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