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}
28
29type Email struct {
30 UID uint32
31 From string
32 To []string
33 Subject string
34 Body string
35 Date time.Time
36 MessageID string
37 References []string
38 Attachments []Attachment
39 AccountID string // ID of the account this email belongs to
40}
41
42func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
43 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
44 if err != nil {
45 body, _ := ioutil.ReadAll(reader)
46 return string(body), nil
47 }
48
49 charset := "utf-8"
50 if params["charset"] != "" {
51 charset = strings.ToLower(params["charset"])
52 }
53
54 encoding, err := ianaindex.IANA.Encoding(charset)
55 if err != nil || encoding == nil {
56 encoding, _ = ianaindex.IANA.Encoding("utf-8")
57 }
58
59 transformReader := transform.NewReader(reader, encoding.NewDecoder())
60 decodedBody, err := ioutil.ReadAll(transformReader)
61 if err != nil {
62 return "", err
63 }
64
65 if strings.HasPrefix(mediaType, "multipart/") {
66 return "[This is a multipart message]", nil
67 }
68
69 return string(decodedBody), nil
70}
71
72func decodeHeader(header string) string {
73 dec := new(mime.WordDecoder)
74 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
75 encoding, err := ianaindex.IANA.Encoding(charset)
76 if err != nil {
77 return nil, err
78 }
79 return transform.NewReader(input, encoding.NewDecoder()), nil
80 }
81 decoded, err := dec.DecodeHeader(header)
82 if err != nil {
83 return header
84 }
85 return decoded
86}
87
88func connect(account *config.Account) (*client.Client, error) {
89 imapServer := account.GetIMAPServer()
90 imapPort := account.GetIMAPPort()
91
92 if imapServer == "" {
93 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
94 }
95
96 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
97 c, err := client.DialTLS(addr, nil)
98 if err != nil {
99 return nil, err
100 }
101
102 if err := c.Login(account.Email, account.Password); err != nil {
103 return nil, err
104 }
105
106 return c, nil
107}
108
109func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
110 c, err := connect(account)
111 if err != nil {
112 return nil, err
113 }
114 defer c.Logout()
115
116 mbox, err := c.Select("INBOX", false)
117 if err != nil {
118 return nil, err
119 }
120
121 if mbox.Messages == 0 {
122 return []Email{}, nil
123 }
124
125 to := mbox.Messages - offset
126 from := uint32(1)
127 if to > limit {
128 from = to - limit + 1
129 }
130
131 if to < 1 {
132 return []Email{}, nil
133 }
134
135 seqset := new(imap.SeqSet)
136 seqset.AddRange(from, to)
137
138 messages := make(chan *imap.Message, limit)
139 done := make(chan error, 1)
140 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
141 go func() {
142 done <- c.Fetch(seqset, fetchItems, messages)
143 }()
144
145 var msgs []*imap.Message
146 for msg := range messages {
147 msgs = append(msgs, msg)
148 }
149
150 if err := <-done; err != nil {
151 return nil, err
152 }
153
154 var emails []Email
155 for _, msg := range msgs {
156 if msg == nil || msg.Envelope == nil {
157 continue
158 }
159
160 var fromAddr string
161 if len(msg.Envelope.From) > 0 {
162 fromAddr = msg.Envelope.From[0].Address()
163 }
164
165 var toAddrList []string
166 for _, addr := range msg.Envelope.To {
167 toAddrList = append(toAddrList, addr.Address())
168 }
169
170 emails = append(emails, Email{
171 UID: msg.Uid,
172 From: fromAddr,
173 To: toAddrList,
174 Subject: decodeHeader(msg.Envelope.Subject),
175 Date: msg.Envelope.Date,
176 AccountID: account.ID,
177 })
178 }
179
180 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
181 emails[i], emails[j] = emails[j], emails[i]
182 }
183
184 return emails, nil
185}
186
187func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
188 c, err := connect(account)
189 if err != nil {
190 return "", nil, err
191 }
192 defer c.Logout()
193
194 if _, err := c.Select("INBOX", false); err != nil {
195 return "", nil, err
196 }
197
198 seqset := new(imap.SeqSet)
199 seqset.AddNum(uid)
200
201 messages := make(chan *imap.Message, 1)
202 done := make(chan error, 1)
203 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
204 go func() {
205 done <- c.UidFetch(seqset, fetchItems, messages)
206 }()
207
208 if err := <-done; err != nil {
209 return "", nil, err
210 }
211
212 msg := <-messages
213 if msg == nil || msg.BodyStructure == nil {
214 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
215 }
216
217 var textPartID string
218 var attachments []Attachment
219 var checkPart func(part *imap.BodyStructure, partID string)
220 checkPart = func(part *imap.BodyStructure, partID string) {
221 // Check for text content
222 if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
223 textPartID = partID
224 }
225
226 // Check for attachments using multiple methods
227 filename := ""
228 // First try the Filename() method which handles various cases
229 if fn, err := part.Filename(); err == nil && fn != "" {
230 filename = fn
231 }
232 // Fallback: check DispositionParams
233 if filename == "" {
234 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
235 filename = fn
236 }
237 }
238 // Fallback: check Params (for name parameter)
239 if filename == "" {
240 if fn, ok := part.Params["name"]; ok && fn != "" {
241 filename = fn
242 }
243 }
244 // Fallback: check Params for filename
245 if filename == "" {
246 if fn, ok := part.Params["filename"]; ok && fn != "" {
247 filename = fn
248 }
249 }
250
251 // Add as attachment if it has a disposition or a filename (and not just plain text)
252 if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
253 attachments = append(attachments, Attachment{Filename: filename, PartID: partID})
254 }
255 }
256
257 var findParts func(*imap.BodyStructure, string)
258 findParts = func(bs *imap.BodyStructure, prefix string) {
259 // If this is a non-multipart message, check the body structure itself
260 if len(bs.Parts) == 0 {
261 partID := prefix
262 if partID == "" {
263 partID = "1"
264 }
265 checkPart(bs, partID)
266 return
267 }
268
269 // Iterate through parts
270 for i, part := range bs.Parts {
271 partID := fmt.Sprintf("%d", i+1)
272 if prefix != "" {
273 partID = fmt.Sprintf("%s.%d", prefix, i+1)
274 }
275
276 checkPart(part, partID)
277
278 if len(part.Parts) > 0 {
279 findParts(part, partID)
280 }
281 }
282 }
283 findParts(msg.BodyStructure, "")
284
285 var body string
286 if textPartID != "" {
287 partMessages := make(chan *imap.Message, 1)
288 partDone := make(chan error, 1)
289
290 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
291 section, err := imap.ParseBodySectionName(fetchItem)
292 if err != nil {
293 return "", nil, err
294 }
295
296 go func() {
297 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
298 }()
299
300 if err := <-partDone; err != nil {
301 return "", nil, err
302 }
303
304 partMsg := <-partMessages
305 if partMsg != nil {
306 literal := partMsg.GetBody(section)
307 if literal != nil {
308 // The new decoding logic starts here
309 buf, _ := ioutil.ReadAll(literal)
310 mr, err := mail.CreateReader(bytes.NewReader(buf))
311 if err != nil {
312 body = string(buf)
313 } else {
314 p, err := mr.NextPart()
315 if err != nil {
316 body = string(buf)
317 } else {
318 encoding := p.Header.Get("Content-Transfer-Encoding")
319 bodyBytes, _ := ioutil.ReadAll(p.Body)
320
321 switch strings.ToLower(encoding) {
322 case "base64":
323 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
324 if err == nil {
325 body = string(decoded)
326 } else {
327 body = string(bodyBytes)
328 }
329 case "quoted-printable":
330 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
331 if err == nil {
332 body = string(decoded)
333 } else {
334 body = string(bodyBytes)
335 }
336 default:
337 body = string(bodyBytes)
338 }
339 }
340 }
341 }
342 }
343 }
344
345 return body, attachments, nil
346}
347
348func FetchAttachment(account *config.Account, uid uint32, partID string) ([]byte, error) {
349 c, err := connect(account)
350 if err != nil {
351 return nil, err
352 }
353 defer c.Logout()
354
355 if _, err := c.Select("INBOX", false); err != nil {
356 return nil, err
357 }
358
359 seqset := new(imap.SeqSet)
360 seqset.AddNum(uid)
361
362 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
363 section, err := imap.ParseBodySectionName(fetchItem)
364 if err != nil {
365 return nil, err
366 }
367
368 messages := make(chan *imap.Message, 1)
369 done := make(chan error, 1)
370 go func() {
371 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
372 }()
373
374 if err := <-done; err != nil {
375 return nil, err
376 }
377
378 msg := <-messages
379 if msg == nil {
380 return nil, fmt.Errorf("could not fetch attachment")
381 }
382
383 literal := msg.GetBody(section)
384 if literal == nil {
385 return nil, fmt.Errorf("could not get attachment body")
386 }
387
388 return ioutil.ReadAll(literal)
389}
390
391func moveEmail(account *config.Account, uid uint32, destMailbox string) error {
392 c, err := connect(account)
393 if err != nil {
394 return err
395 }
396 defer c.Logout()
397
398 if _, err := c.Select("INBOX", false); err != nil {
399 return err
400 }
401
402 seqSet := new(imap.SeqSet)
403 seqSet.AddNum(uid)
404
405 return c.UidMove(seqSet, destMailbox)
406}
407
408func DeleteEmail(account *config.Account, uid uint32) error {
409 c, err := connect(account)
410 if err != nil {
411 return err
412 }
413 defer c.Logout()
414
415 if _, err := c.Select("INBOX", false); err != nil {
416 return err
417 }
418
419 seqSet := new(imap.SeqSet)
420 seqSet.AddNum(uid)
421
422 item := imap.FormatFlagsOp(imap.AddFlags, true)
423 flags := []interface{}{imap.DeletedFlag}
424
425 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
426 return err
427 }
428
429 return c.Expunge(nil)
430}
431
432func ArchiveEmail(account *config.Account, uid uint32) error {
433 var archiveMailbox string
434 switch account.ServiceProvider {
435 case "gmail":
436 archiveMailbox = "[Gmail]/All Mail"
437 default:
438 archiveMailbox = "Archive"
439 }
440 return moveEmail(account, uid, archiveMailbox)
441}