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