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}