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	"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, 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, string, []backend.Attachment, error) {
356	mr, err := gomail.CreateReader(r)
357	if err != nil {
358		// Not a multipart message — read body directly. We don't know the
359		// content type at this layer; surface empty so the renderer falls
360		// back to its legacy markdown→HTML path.
361		body, err := io.ReadAll(r)
362		if err != nil {
363			return "", "", nil, err
364		}
365		return string(body), "", nil, nil
366	}
367
368	var bodyText string
369	var htmlBody string
370	var attachments []backend.Attachment
371	partIdx := 0
372
373	for {
374		part, err := mr.NextPart()
375		if err == io.EOF {
376			break
377		}
378		if err != nil {
379			break
380		}
381		partIdx++
382
383		contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
384		disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
385
386		data, readErr := io.ReadAll(part.Body)
387		if readErr != nil {
388			continue
389		}
390
391		if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
392			filename := dParams["filename"]
393			if filename == "" {
394				_, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
395				filename = cp["name"]
396			}
397			att := backend.Attachment{
398				Filename: filename,
399				PartID:   fmt.Sprintf("%d", partIdx),
400				Data:     data,
401				MIMEType: contentType,
402				Inline:   disposition == "inline",
403			}
404			if cid := part.Header.Get("Content-ID"); cid != "" {
405				att.ContentID = strings.Trim(cid, "<>")
406			}
407			attachments = append(attachments, att)
408		} else if contentType == "text/html" {
409			htmlBody = string(data)
410		} else if contentType == "text/plain" && bodyText == "" {
411			bodyText = string(data)
412		}
413	}
414
415	if htmlBody != "" {
416		return htmlBody, "text/html", attachments, nil
417	}
418	return bodyText, "text/plain", attachments, nil
419}
420
421// findAttachmentData walks a raw message to find attachment data by partID.
422func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
423	mr, err := gomail.CreateReader(r)
424	if err != nil {
425		return nil, fmt.Errorf("not a multipart message")
426	}
427
428	partIdx := 0
429	for {
430		part, err := mr.NextPart()
431		if err == io.EOF {
432			break
433		}
434		if err != nil {
435			break
436		}
437		partIdx++
438
439		if fmt.Sprintf("%d", partIdx) == targetPartID {
440			return io.ReadAll(part.Body)
441		}
442	}
443
444	return nil, fmt.Errorf("pop3: attachment part %s not found", targetPartID)
445}