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}