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(_ context.Context, _ string, uid uint32) error {
192	conn, err := p.connect()
193	if err != nil {
194		return err
195	}
196
197	msgID, err := p.findMessageByUID(conn, uid)
198	if err != nil {
199		conn.Quit()
200		return err
201	}
202
203	if err := conn.Dele(msgID); err != nil {
204		conn.Quit()
205		return fmt.Errorf("pop3 dele: %w", err)
206	}
207
208	// Quit commits the deletion
209	return conn.Quit()
210}
211
212func (p *Provider) ArchiveEmail(_ context.Context, _ string, _ uint32) error {
213	return backend.ErrNotSupported
214}
215
216func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error {
217	return backend.ErrNotSupported
218}
219
220func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
221	// POP3 doesn't support batch - loop through individual operations
222	for _, uid := range uids {
223		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
224			return err
225		}
226	}
227	return nil
228}
229
230func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error {
231	return backend.ErrNotSupported
232}
233
234func (p *Provider) MoveEmails(_ context.Context, _ []uint32, _, _ string) error {
235	return backend.ErrNotSupported
236}
237
238func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
239	_, err := sender.SendEmail(
240		p.account, msg.To, msg.Cc, msg.Bcc,
241		msg.Subject, msg.PlainBody, msg.HTMLBody,
242		msg.Images, msg.Attachments,
243		msg.InReplyTo, msg.References,
244		msg.SignSMIME, msg.EncryptSMIME,
245		msg.SignPGP, msg.EncryptPGP,
246	)
247	return err
248}
249
250func (p *Provider) FetchFolders(_ context.Context) ([]backend.Folder, error) {
251	return []backend.Folder{
252		{Name: "INBOX", Delimiter: "/"},
253	}, nil
254}
255
256func (p *Provider) Watch(_ context.Context, _ string) (<-chan backend.NotifyEvent, func(), error) {
257	return nil, nil, backend.ErrNotSupported
258}
259
260func (p *Provider) Close() error {
261	return nil
262}
263
264// Verify interface compliance at compile time.
265var _ backend.Provider = (*Provider)(nil)
266
267// findMessageByUID finds a POP3 message ID by matching the UID hash.
268func (p *Provider) findMessageByUID(conn *pop3client.Conn, uid 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 0, fmt.Errorf("pop3 list: %w", err)
274		}
275		for _, m := range msgs {
276			if hashUID(fmt.Sprintf("%d", m.ID)) == uid {
277				return m.ID, nil
278			}
279		}
280		return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
281	}
282
283	for _, m := range msgs {
284		if hashUID(m.UID) == uid {
285			return m.ID, nil
286		}
287	}
288	return 0, fmt.Errorf("pop3: message with UID %d not found", uid)
289}
290
291// hashUID converts a POP3 UIDL string to a uint32 hash.
292func hashUID(uidl string) uint32 {
293	var hash uint32
294	for _, c := range uidl {
295		hash = hash*31 + uint32(c)
296	}
297	if hash == 0 {
298		hash = 1
299	}
300	return hash
301}
302
303// entityToEmail converts message headers to a backend.Email.
304func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, accountID string) backend.Email {
305	from := header.Get("From")
306	subject := header.Get("Subject")
307	dateStr := header.Get("Date")
308	messageID := header.Get("Message-ID")
309	inReplyTo := firstMessageID(header.Get("In-Reply-To"))
310	references := messageIDList(header.Get("References"))
311
312	var to []string
313	if toHeader := header.Get("To"); toHeader != "" {
314		if addrs, err := mail.ParseAddressList(toHeader); err == nil {
315			for _, addr := range addrs {
316				to = append(to, addr.Address)
317			}
318		}
319	}
320
321	var replyTo []string
322	if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
323		if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
324			for _, addr := range addrs {
325				replyTo = append(replyTo, addr.Address)
326			}
327		}
328	}
329
330	var date time.Time
331	if dateStr != "" {
332		if parsed, err := mail.ParseDate(dateStr); err == nil {
333			date = parsed
334		}
335	}
336
337	// Decode MIME-encoded headers
338	dec := new(mime.WordDecoder)
339	if decoded, err := dec.DecodeHeader(subject); err == nil {
340		subject = decoded
341	}
342	if decoded, err := dec.DecodeHeader(from); err == nil {
343		from = decoded
344	}
345
346	uidStr := msgInfo.UID
347	if uidStr == "" {
348		uidStr = fmt.Sprintf("%d", msgInfo.ID)
349	}
350
351	return backend.Email{
352		UID:        hashUID(uidStr),
353		From:       from,
354		To:         to,
355		ReplyTo:    replyTo,
356		Subject:    subject,
357		Date:       date,
358		IsRead:     false,
359		MessageID:  messageID,
360		InReplyTo:  inReplyTo,
361		References: references,
362		AccountID:  accountID,
363	}
364}
365
366func firstMessageID(value string) string {
367	ids := messageIDList(value)
368	if len(ids) == 0 {
369		return ""
370	}
371	return ids[0]
372}
373
374func messageIDList(value string) []string {
375	matches := pop3MessageIDRE.FindAllString(value, -1)
376	if len(matches) == 0 {
377		return strings.Fields(value)
378	}
379	return matches
380}
381
382// parseMessageBody extracts the body text and attachments from a raw message.
383func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) {
384	mr, err := gomail.CreateReader(r)
385	if err != nil {
386		// Not a multipart message — read body directly. We don't know the
387		// content type at this layer; surface empty so the renderer falls
388		// back to its legacy markdown→HTML path.
389		body, err := io.ReadAll(r)
390		if err != nil {
391			return "", "", nil, err
392		}
393		return string(body), "", nil, nil
394	}
395
396	var bodyText string
397	var htmlBody string
398	var attachments []backend.Attachment
399	partIdx := 0
400
401	for {
402		part, err := mr.NextPart()
403		if err == io.EOF {
404			break
405		}
406		if err != nil {
407			break
408		}
409		partIdx++
410
411		contentType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
412		disposition, dParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
413
414		data, readErr := io.ReadAll(part.Body)
415		if readErr != nil {
416			continue
417		}
418
419		if disposition == "attachment" || (disposition == "inline" && !strings.HasPrefix(contentType, "text/")) {
420			filename := dParams["filename"]
421			if filename == "" {
422				_, cp, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
423				filename = cp["name"]
424			}
425			att := backend.Attachment{
426				Filename: filename,
427				PartID:   fmt.Sprintf("%d", partIdx),
428				Data:     data,
429				MIMEType: contentType,
430				Inline:   disposition == "inline",
431			}
432			if cid := part.Header.Get("Content-ID"); cid != "" {
433				att.ContentID = strings.Trim(cid, "<>")
434			}
435			attachments = append(attachments, att)
436		} else if contentType == "text/html" {
437			htmlBody = string(data)
438		} else if contentType == "text/plain" && bodyText == "" {
439			bodyText = string(data)
440		}
441	}
442
443	if htmlBody != "" {
444		return htmlBody, "text/html", attachments, nil
445	}
446	return bodyText, "text/plain", attachments, nil
447}
448
449// findAttachmentData walks a raw message to find attachment data by partID.
450func findAttachmentData(r io.Reader, targetPartID string) ([]byte, error) {
451	mr, err := gomail.CreateReader(r)
452	if err != nil {
453		return nil, fmt.Errorf("not a multipart message")
454	}
455
456	partIdx := 0
457	for {
458		part, err := mr.NextPart()
459		if err == io.EOF {
460			break
461		}
462		if err != nil {
463			break
464		}
465		partIdx++
466
467		if fmt.Sprintf("%d", partIdx) == targetPartID {
468			return io.ReadAll(part.Body)
469		}
470	}
471
472	return nil, fmt.Errorf("pop3: attachment part %s not found", targetPartID)
473}