pop3.go

  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}