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}