1// Package pop3 implements the backend.Provider interface using POP3 for
2// reading email and SMTP for sending.
3//
4// POP3 is inherently limited compared to IMAP/JMAP:
5// - Only supports a single "INBOX" folder
6// - No support for flags (mark as read is a no-op)
7// - No support for moving or archiving emails
8// - No support for push notifications (IDLE)
9// - Delete marks for deletion; executed on Quit()
10package pop3
11
12import (
13 "context"
14 "fmt"
15 "io"
16 "mime"
17 "net/mail"
18 "strings"
19 "time"
20
21 "github.com/emersion/go-message"
22 gomail "github.com/emersion/go-message/mail"
23 pop3client "github.com/knadh/go-pop3"
24
25 "github.com/floatpane/matcha/backend"
26 "github.com/floatpane/matcha/config"
27 "github.com/floatpane/matcha/sender"
28)
29
30func init() {
31 backend.RegisterBackend("pop3", func(account *config.Account) (backend.Provider, error) {
32 return New(account)
33 })
34}
35
36// Provider implements backend.Provider using POP3+SMTP.
37type Provider struct {
38 account *config.Account
39 opt pop3client.Opt
40}
41
42// New creates a new POP3 provider for the given account.
43func New(account *config.Account) (*Provider, error) {
44 server := account.GetPOP3Server()
45 port := account.GetPOP3Port()
46
47 if server == "" {
48 return nil, fmt.Errorf("POP3 server not configured")
49 }
50
51 opt := pop3client.Opt{
52 Host: server,
53 Port: port,
54 TLSEnabled: true,
55 TLSSkipVerify: account.Insecure,
56 }
57
58 // Non-SSL ports use plain connection
59 if port == 110 {
60 opt.TLSEnabled = false
61 }
62
63 return &Provider{
64 account: account,
65 opt: opt,
66 }, nil
67}
68
69// connect creates a new POP3 connection and authenticates.
70func (p *Provider) connect() (*pop3client.Conn, error) {
71 client := pop3client.New(p.opt)
72 conn, err := client.NewConn()
73 if err != nil {
74 return nil, fmt.Errorf("pop3 connect: %w", err)
75 }
76
77 if err := conn.Auth(p.account.Email, p.account.Password); err != nil {
78 conn.Quit()
79 return nil, fmt.Errorf("pop3 auth: %w", err)
80 }
81
82 return conn, nil
83}
84
85func (p *Provider) FetchEmails(_ context.Context, _ string, limit, offset uint32) ([]backend.Email, error) {
86 conn, err := p.connect()
87 if err != nil {
88 return nil, err
89 }
90 defer conn.Quit()
91
92 // Get message list with UIDs
93 msgs, err := conn.Uidl(0)
94 if err != nil {
95 // Fallback to LIST if UIDL not supported
96 msgs, err = conn.List(0)
97 if err != nil {
98 return nil, fmt.Errorf("pop3 list: %w", err)
99 }
100 }
101
102 if len(msgs) == 0 {
103 return []backend.Email{}, nil
104 }
105
106 // POP3 messages are 1-indexed. We want newest first (highest ID first).
107 start := len(msgs) - int(offset)
108 if start <= 0 {
109 return []backend.Email{}, nil
110 }
111
112 end := start - int(limit)
113 if end < 0 {
114 end = 0
115 }
116
117 var emails []backend.Email
118 for i := start; i > end; i-- {
119 msgInfo := msgs[i-1]
120
121 // Fetch headers only using TOP (0 lines of body)
122 entity, err := conn.Top(msgInfo.ID, 0)
123 if err != nil {
124 continue
125 }
126
127 email := entityToEmail(&entity.Header, msgInfo, p.account.ID)
128 emails = append(emails, email)
129 }
130
131 return emails, nil
132}
133
134func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
135 conn, err := p.connect()
136 if err != nil {
137 return "", nil, err
138 }
139 defer conn.Quit()
140
141 msgID, err := p.findMessageByUID(conn, uid)
142 if err != nil {
143 return "", nil, err
144 }
145
146 raw, err := conn.RetrRaw(msgID)
147 if err != nil {
148 return "", nil, fmt.Errorf("pop3 retr: %w", err)
149 }
150
151 return parseMessageBody(raw)
152}
153
154func (p *Provider) FetchAttachment(_ context.Context, _ string, uid uint32, partID, _ string) ([]byte, error) {
155 conn, err := p.connect()
156 if err != nil {
157 return nil, err
158 }
159 defer conn.Quit()
160
161 msgID, err := p.findMessageByUID(conn, uid)
162 if err != nil {
163 return nil, err
164 }
165
166 raw, err := conn.RetrRaw(msgID)
167 if err != nil {
168 return nil, fmt.Errorf("pop3 retr: %w", err)
169 }
170
171 return findAttachmentData(raw, partID)
172}
173
174func (p *Provider) Search(_ context.Context, _ string, _ backend.SearchQuery) ([]backend.Email, error) {
175 return nil, backend.ErrNotSupported
176}
177
178func (p *Provider) MarkAsRead(_ context.Context, _ string, _ uint32) error {
179 // POP3 has no concept of read/unread flags — this is a no-op
180 return nil
181}
182
183func (p *Provider) DeleteEmail(_ context.Context, _ string, uid uint32) error {
184 conn, err := p.connect()
185 if err != nil {
186 return err
187 }
188
189 msgID, err := p.findMessageByUID(conn, uid)
190 if err != nil {
191 conn.Quit()
192 return err
193 }
194
195 if err := conn.Dele(msgID); err != nil {
196 conn.Quit()
197 return fmt.Errorf("pop3 dele: %w", err)
198 }
199
200 // Quit commits the deletion
201 return conn.Quit()
202}
203
204func (p *Provider) ArchiveEmail(_ context.Context, _ string, _ uint32) error {
205 return backend.ErrNotSupported
206}
207
208func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error {
209 return backend.ErrNotSupported
210}
211
212func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
213 // POP3 doesn't support batch - loop through individual operations
214 for _, uid := range uids {
215 if err := p.DeleteEmail(ctx, folder, uid); err != nil {
216 return err
217 }
218 }
219 return nil
220}
221
222func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error {
223 return backend.ErrNotSupported
224}
225
226func (p *Provider) MoveEmails(_ context.Context, _ []uint32, _, _ string) error {
227 return backend.ErrNotSupported
228}
229
230func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
231 _, err := sender.SendEmail(
232 p.account, msg.To, msg.Cc, msg.Bcc,
233 msg.Subject, msg.PlainBody, msg.HTMLBody,
234 msg.Images, msg.Attachments,
235 msg.InReplyTo, msg.References,
236 msg.SignSMIME, msg.EncryptSMIME,
237 msg.SignPGP, msg.EncryptPGP,
238 )
239 return err
240}
241
242func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
243 return []backend.Folder{
244 {Name: "INBOX", Delimiter: "/"},
245 }, nil
246}
247
248func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
249 return nil, nil, backend.ErrNotSupported
250}
251
252func (p *Provider) Close() error {
253 return nil
254}
255
256// Verify interface compliance at compile time.
257var _ backend.Provider = (*Provider)(nil)
258
259// findMessageByUID finds a POP3 message ID by matching the UID hash.
260func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid uint32) (int, error) {
261 msgs, err := conn.Uidl(0)
262 if err != nil {
263 msgs, err = conn.List(0)
264 if err != nil {
265 return 0, fmt.Errorf("pop3 list: %w", err)
266 }
267 for _, m := range msgs {
268 if hashUID(fmt.Sprintf("%d", m.ID)) == uid {
269 return m.ID, nil
270 }
271 }
272 return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
273 }
274
275 for _, m := range msgs {
276 if hashUID(m.UID) == uid {
277 return m.ID, nil
278 }
279 }
280 return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
281}
282
283// hashUID converts a POP3 UIDL string to a uint32 hash.
284func hashUID(uidl string) uint32 {
285 var hash uint32
286 for _, c := range uidl {
287 hash = hash*31 + uint32(c)
288 }
289 if hash == 0 {
290 hash = 1
291 }
292 return hash
293}
294
295// entityToEmail converts message headers to a backend.Email.
296func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, accountID string) backend.Email {
297 from := header.Get("From")
298 subject := header.Get("Subject")
299 dateStr := header.Get("Date")
300 messageID := header.Get("Message-ID")
301
302 var to []string
303 if toHeader := header.Get("To"); toHeader != "" {
304 if addrs, err := mail.ParseAddressList(toHeader); err == nil {
305 for _, addr := range addrs {
306 to = append(to, addr.Address)
307 }
308 }
309 }
310
311 var replyTo []string
312 if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
313 if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
314 for _, addr := range addrs {
315 replyTo = append(replyTo, addr.Address)
316 }
317 }
318 }
319
320 var date time.Time
321 if dateStr != "" {
322 if parsed, err := mail.ParseDate(dateStr); err == nil {
323 date = parsed
324 }
325 }
326
327 // Decode MIME-encoded headers
328 dec := new(mime.WordDecoder)
329 if decoded, err := dec.DecodeHeader(subject); err == nil {
330 subject = decoded
331 }
332 if decoded, err := dec.DecodeHeader(from); err == nil {
333 from = decoded
334 }
335
336 uidStr := msgInfo.UID
337 if uidStr == "" {
338 uidStr = fmt.Sprintf("%d", msgInfo.ID)
339 }
340
341 return backend.Email{
342 UID: hashUID(uidStr),
343 From: from,
344 To: to,
345 ReplyTo: replyTo,
346 Subject: subject,
347 Date: date,
348 IsRead: false,
349 MessageID: messageID,
350 AccountID: accountID,
351 }
352}
353
354// parseMessageBody extracts the body text and attachments from a raw message.
355func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) {
356 mr, err := gomail.CreateReader(r)
357 if err != nil {
358 // Not a multipart message — read body directly
359 body, err := io.ReadAll(r)
360 if err != nil {
361 return "", nil, err
362 }
363 return string(body), nil, nil
364 }
365
366 var bodyText string
367 var htmlBody string
368 var attachments []backend.Attachment
369 partIdx := 0
370
371 for {
372 part, err := mr.NextPart()
373 if err == io.EOF {
374 break
375 }
376 if err != nil {
377 break
378 }
379 partIdx++
380
381 contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
382 disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
383
384 data, readErr := io.ReadAll(part.Body)
385 if readErr != nil {
386 continue
387 }
388
389 if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
390 filename := dParams["filename"]
391 if filename == "" {
392 _, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
393 filename = cp["name"]
394 }
395 att := backend.Attachment{
396 Filename: filename,
397 PartID: fmt.Sprintf("%d", partIdx),
398 Data: data,
399 MIMEType: contentType,
400 Inline: disposition == "inline",
401 }
402 if cid := part.Header.Get("Content-ID"); cid != "" {
403 att.ContentID = strings.Trim(cid, "<>")
404 }
405 attachments = append(attachments, att)
406 } else if contentType == "text/html" {
407 htmlBody = string(data)
408 } else if contentType == "text/plain" && bodyText == "" {
409 bodyText = string(data)
410 }
411 }
412
413 if htmlBody != "" {
414 return htmlBody, attachments, nil
415 }
416 return bodyText, attachments, nil
417}
418
419// findAttachmentData walks a raw message to find attachment data by partID.
420func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
421 mr, err := gomail.CreateReader(r)
422 if err != nil {
423 return nil, fmt.Errorf("not a multipart message")
424 }
425
426 partIdx := 0
427 for {
428 part, err := mr.NextPart()
429 if err == io.EOF {
430 break
431 }
432 if err != nil {
433 break
434 }
435 partIdx++
436
437 if fmt.Sprintf("%d", partIdx) == targetPartID {
438 return io.ReadAll(part.Body)
439 }
440 }
441
442 return nil, fmt.Errorf("pop3: attachment part %s not found", targetPartID)
443}