1package fetcher
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "io/ioutil"
9 "mime"
10 "mime/quotedprintable"
11 "strings"
12 "time"
13
14 "github.com/emersion/go-imap"
15 "github.com/emersion/go-imap/client"
16 "github.com/emersion/go-message/mail"
17 "github.com/floatpane/matcha/config"
18 "golang.org/x/text/encoding/ianaindex"
19 "golang.org/x/text/transform"
20)
21
22// Attachment holds data for an email attachment.
23type Attachment struct {
24 Filename string
25 PartID string // Keep PartID to fetch on demand
26 Data []byte
27 Encoding string // Store encoding for proper decoding
28}
29
30type Email struct {
31 UID uint32
32 From string
33 To []string
34 Subject string
35 Body string
36 Date time.Time
37 MessageID string
38 References []string
39 Attachments []Attachment
40 AccountID string // ID of the account this email belongs to
41}
42
43func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
44 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
45 if err != nil {
46 body, _ := ioutil.ReadAll(reader)
47 return string(body), nil
48 }
49
50 charset := "utf-8"
51 if params["charset"] != "" {
52 charset = strings.ToLower(params["charset"])
53 }
54
55 encoding, err := ianaindex.IANA.Encoding(charset)
56 if err != nil || encoding == nil {
57 encoding, _ = ianaindex.IANA.Encoding("utf-8")
58 }
59
60 transformReader := transform.NewReader(reader, encoding.NewDecoder())
61 decodedBody, err := ioutil.ReadAll(transformReader)
62 if err != nil {
63 return "", err
64 }
65
66 if strings.HasPrefix(mediaType, "multipart/") {
67 return "[This is a multipart message]", nil
68 }
69
70 return string(decodedBody), nil
71}
72
73func decodeHeader(header string) string {
74 dec := new(mime.WordDecoder)
75 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
76 encoding, err := ianaindex.IANA.Encoding(charset)
77 if err != nil {
78 return nil, err
79 }
80 return transform.NewReader(input, encoding.NewDecoder()), nil
81 }
82 decoded, err := dec.DecodeHeader(header)
83 if err != nil {
84 return header
85 }
86 return decoded
87}
88
89func connect(account *config.Account) (*client.Client, error) {
90 imapServer := account.GetIMAPServer()
91 imapPort := account.GetIMAPPort()
92
93 if imapServer == "" {
94 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
95 }
96
97 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
98 c, err := client.DialTLS(addr, nil)
99 if err != nil {
100 return nil, err
101 }
102
103 if err := c.Login(account.Email, account.Password); err != nil {
104 return nil, err
105 }
106
107 return c, nil
108}
109
110func getSentMailbox(account *config.Account) string {
111 switch account.ServiceProvider {
112 case "gmail":
113 return "[Gmail]/Sent Mail"
114 case "icloud":
115 return "Sent Messages"
116 default:
117 return "Sent"
118 }
119}
120
121func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
122 c, err := connect(account)
123 if err != nil {
124 return nil, err
125 }
126 defer c.Logout()
127
128 mbox, err := c.Select(mailbox, false)
129 if err != nil {
130 return nil, err
131 }
132
133 if mbox.Messages == 0 {
134 return []Email{}, nil
135 }
136
137 to := mbox.Messages - offset
138 from := uint32(1)
139 if to > limit {
140 from = to - limit + 1
141 }
142
143 if to < 1 {
144 return []Email{}, nil
145 }
146
147 seqset := new(imap.SeqSet)
148 seqset.AddRange(from, to)
149
150 messages := make(chan *imap.Message, limit)
151 done := make(chan error, 1)
152 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
153 go func() {
154 done <- c.Fetch(seqset, fetchItems, messages)
155 }()
156
157 var msgs []*imap.Message
158 for msg := range messages {
159 msgs = append(msgs, msg)
160 }
161
162 if err := <-done; err != nil {
163 return nil, err
164 }
165
166 var emails []Email
167 for _, msg := range msgs {
168 if msg == nil || msg.Envelope == nil {
169 continue
170 }
171
172 var fromAddr string
173 if len(msg.Envelope.From) > 0 {
174 fromAddr = msg.Envelope.From[0].Address()
175 }
176
177 var toAddrList []string
178 // Build recipient list from To and Cc for matching and display
179 for _, addr := range msg.Envelope.To {
180 toAddrList = append(toAddrList, addr.Address())
181 }
182 for _, addr := range msg.Envelope.Cc {
183 toAddrList = append(toAddrList, addr.Address())
184 }
185
186 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
187 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
188 if fetchEmail == "" {
189 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
190 }
191
192 // Check if any recipient matches the fetchEmail
193 matched := false
194 for _, r := range toAddrList {
195 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
196 matched = true
197 break
198 }
199 }
200
201 if !matched {
202 // Skip messages not addressed to the configured fetch email
203 continue
204 }
205
206 emails = append(emails, Email{
207 UID: msg.Uid,
208 From: fromAddr,
209 To: toAddrList,
210 Subject: decodeHeader(msg.Envelope.Subject),
211 Date: msg.Envelope.Date,
212 AccountID: account.ID,
213 })
214 }
215
216 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
217 emails[i], emails[j] = emails[j], emails[i]
218 }
219
220 return emails, nil
221}
222
223func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
224 c, err := connect(account)
225 if err != nil {
226 return "", nil, err
227 }
228 defer c.Logout()
229
230 if _, err := c.Select(mailbox, false); err != nil {
231 return "", nil, err
232 }
233
234 seqset := new(imap.SeqSet)
235 seqset.AddNum(uid)
236
237 messages := make(chan *imap.Message, 1)
238 done := make(chan error, 1)
239 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
240 go func() {
241 done <- c.UidFetch(seqset, fetchItems, messages)
242 }()
243
244 if err := <-done; err != nil {
245 return "", nil, err
246 }
247
248 msg := <-messages
249 if msg == nil || msg.BodyStructure == nil {
250 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
251 }
252
253 var textPartID string
254 var attachments []Attachment
255 var checkPart func(part *imap.BodyStructure, partID string)
256 checkPart = func(part *imap.BodyStructure, partID string) {
257 // Check for text content
258 if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
259 textPartID = partID
260 }
261
262 // Check for attachments using multiple methods
263 filename := ""
264 // First try the Filename() method which handles various cases
265 if fn, err := part.Filename(); err == nil && fn != "" {
266 filename = fn
267 }
268 // Fallback: check DispositionParams
269 if filename == "" {
270 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
271 filename = fn
272 }
273 }
274 // Fallback: check Params (for name parameter)
275 if filename == "" {
276 if fn, ok := part.Params["name"]; ok && fn != "" {
277 filename = fn
278 }
279 }
280 // Fallback: check Params for filename
281 if filename == "" {
282 if fn, ok := part.Params["filename"]; ok && fn != "" {
283 filename = fn
284 }
285 }
286
287 // Add as attachment if it has a disposition or a filename (and not just plain text)
288 if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
289 attachments = append(attachments, Attachment{
290 Filename: filename,
291 PartID: partID,
292 Encoding: part.Encoding, // Store encoding for proper decoding
293 })
294 }
295 }
296
297 var findParts func(*imap.BodyStructure, string)
298 findParts = func(bs *imap.BodyStructure, prefix string) {
299 // If this is a non-multipart message, check the body structure itself
300 if len(bs.Parts) == 0 {
301 partID := prefix
302 if partID == "" {
303 partID = "1"
304 }
305 checkPart(bs, partID)
306 return
307 }
308
309 // Iterate through parts
310 for i, part := range bs.Parts {
311 partID := fmt.Sprintf("%d", i+1)
312 if prefix != "" {
313 partID = fmt.Sprintf("%s.%d", prefix, i+1)
314 }
315
316 checkPart(part, partID)
317
318 if len(part.Parts) > 0 {
319 findParts(part, partID)
320 }
321 }
322 }
323 findParts(msg.BodyStructure, "")
324
325 var body string
326 if textPartID != "" {
327 partMessages := make(chan *imap.Message, 1)
328 partDone := make(chan error, 1)
329
330 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
331 section, err := imap.ParseBodySectionName(fetchItem)
332 if err != nil {
333 return "", nil, err
334 }
335
336 go func() {
337 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
338 }()
339
340 if err := <-partDone; err != nil {
341 return "", nil, err
342 }
343
344 partMsg := <-partMessages
345 if partMsg != nil {
346 literal := partMsg.GetBody(section)
347 if literal != nil {
348 // The new decoding logic starts here
349 buf, _ := ioutil.ReadAll(literal)
350 mr, err := mail.CreateReader(bytes.NewReader(buf))
351 if err != nil {
352 body = string(buf)
353 } else {
354 p, err := mr.NextPart()
355 if err != nil {
356 body = string(buf)
357 } else {
358 encoding := p.Header.Get("Content-Transfer-Encoding")
359 bodyBytes, _ := ioutil.ReadAll(p.Body)
360
361 switch strings.ToLower(encoding) {
362 case "base64":
363 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
364 if err == nil {
365 body = string(decoded)
366 } else {
367 body = string(bodyBytes)
368 }
369 case "quoted-printable":
370 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
371 if err == nil {
372 body = string(decoded)
373 } else {
374 body = string(bodyBytes)
375 }
376 default:
377 body = string(bodyBytes)
378 }
379 }
380 }
381 }
382 }
383 }
384
385 return body, attachments, nil
386}
387
388func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
389 c, err := connect(account)
390 if err != nil {
391 return nil, err
392 }
393 defer c.Logout()
394
395 if _, err := c.Select(mailbox, false); err != nil {
396 return nil, err
397 }
398
399 seqset := new(imap.SeqSet)
400 seqset.AddNum(uid)
401
402 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
403 section, err := imap.ParseBodySectionName(fetchItem)
404 if err != nil {
405 return nil, err
406 }
407
408 messages := make(chan *imap.Message, 1)
409 done := make(chan error, 1)
410 go func() {
411 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
412 }()
413
414 if err := <-done; err != nil {
415 return nil, err
416 }
417
418 msg := <-messages
419 if msg == nil {
420 return nil, fmt.Errorf("could not fetch attachment")
421 }
422
423 literal := msg.GetBody(section)
424 if literal == nil {
425 return nil, fmt.Errorf("could not get attachment body")
426 }
427
428 rawBytes, err := ioutil.ReadAll(literal)
429 if err != nil {
430 return nil, err
431 }
432
433 switch strings.ToLower(encoding) {
434 case "base64":
435 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
436 decoded, err := ioutil.ReadAll(decoder)
437 if err == nil {
438 return decoded, nil
439 }
440 return rawBytes, nil
441 case "quoted-printable":
442 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
443 if err == nil {
444 return decoded, nil
445 }
446 return rawBytes, nil
447 default:
448 return rawBytes, nil
449 }
450}
451
452func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
453 c, err := connect(account)
454 if err != nil {
455 return err
456 }
457 defer c.Logout()
458
459 if _, err := c.Select(sourceMailbox, false); err != nil {
460 return err
461 }
462
463 seqSet := new(imap.SeqSet)
464 seqSet.AddNum(uid)
465
466 return c.UidMove(seqSet, destMailbox)
467}
468
469func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
470 c, err := connect(account)
471 if err != nil {
472 return err
473 }
474 defer c.Logout()
475
476 if _, err := c.Select(mailbox, false); err != nil {
477 return err
478 }
479
480 seqSet := new(imap.SeqSet)
481 seqSet.AddNum(uid)
482
483 item := imap.FormatFlagsOp(imap.AddFlags, true)
484 flags := []interface{}{imap.DeletedFlag}
485
486 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
487 return err
488 }
489
490 return c.Expunge(nil)
491}
492
493func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
494 var archiveMailbox string
495 switch account.ServiceProvider {
496 case "gmail":
497 archiveMailbox = "[Gmail]/All Mail"
498 default:
499 archiveMailbox = "Archive"
500 }
501 return moveEmail(account, uid, mailbox, archiveMailbox)
502}
503
504// Convenience wrappers defaulting to INBOX for existing call sites.
505
506func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
507 return FetchMailboxEmails(account, "INBOX", limit, offset)
508}
509
510func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
511 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
512}
513
514func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
515 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
516}
517
518func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
519 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
520}
521
522func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
523 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
524}
525
526func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
527 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
528}
529
530func DeleteEmail(account *config.Account, uid uint32) error {
531 return DeleteEmailFromMailbox(account, "INBOX", uid)
532}
533
534func DeleteSentEmail(account *config.Account, uid uint32) error {
535 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
536}
537
538func ArchiveEmail(account *config.Account, uid uint32) error {
539 return ArchiveEmailFromMailbox(account, "INBOX", uid)
540}
541
542func ArchiveSentEmail(account *config.Account, uid uint32) error {
543 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
544}