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