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