fetcher.go

  1package fetcher
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"io"
  7	"io/ioutil"
  8	"log"
  9	"mime"
 10	"strings"
 11	"time"
 12
 13	"github.com/andrinoff/email-cli/config"
 14	"github.com/emersion/go-imap"
 15	"github.com/emersion/go-imap/client"
 16	"github.com/emersion/go-message/mail"
 17	"golang.org/x/text/encoding/ianaindex"
 18	"golang.org/x/text/transform"
 19)
 20
 21// Attachment holds data for an email attachment.
 22type Attachment struct {
 23	Filename string
 24	Data     []byte
 25}
 26
 27type Email struct {
 28	UID         uint32
 29	From        string
 30	To          []string
 31	Subject     string
 32	Body        string
 33	Date        time.Time
 34	MessageID   string
 35	References  []string
 36	Attachments []Attachment
 37}
 38
 39func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 40	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 41	if err != nil {
 42		body, _ := ioutil.ReadAll(reader)
 43		return string(body), nil
 44	}
 45
 46	charset := "utf-8"
 47	if params["charset"] != "" {
 48		charset = strings.ToLower(params["charset"])
 49	}
 50
 51	encoding, err := ianaindex.IANA.Encoding(charset)
 52	if err != nil || encoding == nil {
 53		encoding, _ = ianaindex.IANA.Encoding("utf-8")
 54	}
 55
 56	transformReader := transform.NewReader(reader, encoding.NewDecoder())
 57	decodedBody, err := ioutil.ReadAll(transformReader)
 58	if err != nil {
 59		return "", err
 60	}
 61
 62	if strings.HasPrefix(mediaType, "multipart/") {
 63		return "[This is a multipart message]", nil
 64	}
 65
 66	return string(decodedBody), nil
 67}
 68
 69func decodeHeader(header string) string {
 70	dec := new(mime.WordDecoder)
 71	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
 72		encoding, err := ianaindex.IANA.Encoding(charset)
 73		if err != nil {
 74			return nil, err
 75		}
 76		return transform.NewReader(input, encoding.NewDecoder()), nil
 77	}
 78	decoded, err := dec.DecodeHeader(header)
 79	if err != nil {
 80		return header
 81	}
 82	return decoded
 83}
 84
 85func connect(cfg *config.Config) (*client.Client, error) {
 86	var imapServer string
 87	var imapPort int
 88
 89	switch cfg.ServiceProvider {
 90	case "gmail":
 91		imapServer = "imap.gmail.com"
 92		imapPort = 993
 93	case "icloud":
 94		imapServer = "imap.mail.me.com"
 95		imapPort = 993
 96	default:
 97		return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider)
 98	}
 99
100	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
101	c, err := client.DialTLS(addr, nil)
102	if err != nil {
103		return nil, err
104	}
105
106	if err := c.Login(cfg.Email, cfg.Password); err != nil {
107		return nil, err
108	}
109
110	return c, nil
111}
112
113func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
114	c, err := connect(cfg)
115	if err != nil {
116		return nil, err
117	}
118	defer c.Logout()
119
120	mbox, err := c.Select("INBOX", false)
121	if err != nil {
122		return nil, err
123	}
124
125	if mbox.Messages == 0 {
126		return []Email{}, nil
127	}
128
129	to := mbox.Messages - offset
130	from := uint32(1)
131	if to > limit {
132		from = to - limit + 1
133	}
134
135	if to < 1 {
136		return []Email{}, nil
137	}
138
139	seqset := new(imap.SeqSet)
140	seqset.AddRange(from, to)
141
142	messages := make(chan *imap.Message, limit)
143	done := make(chan error, 1)
144	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")}
145	go func() {
146		done <- c.Fetch(seqset, fetchItems, messages)
147	}()
148
149	var emails []Email
150	for msg := range messages {
151		if msg == nil {
152			continue
153		}
154
155		bodyLiteral := msg.GetBody(&imap.BodySectionName{})
156		if bodyLiteral == nil {
157			log.Println("Could not get message body")
158			continue
159		}
160
161		mr, err := mail.CreateReader(bodyLiteral)
162		if err != nil {
163			log.Printf("Error creating mail reader: %v", err)
164			continue
165		}
166
167		header := mr.Header
168		fromAddrs, _ := header.AddressList("From")
169		toAddrs, _ := header.AddressList("To")
170		subject := decodeHeader(header.Get("Subject"))
171		date, _ := header.Date()
172		messageID := header.Get("Message-ID")
173		references := header.Get("References")
174
175		var fromAddr string
176		if len(fromAddrs) > 0 {
177			fromAddr = fromAddrs[0].Address
178		}
179
180		var toAddrList []string
181		for _, addr := range toAddrs {
182			toAddrList = append(toAddrList, addr.Address)
183		}
184
185		var body string
186		var attachments []Attachment
187		for {
188			p, err := mr.NextPart()
189			if err == io.EOF {
190				break
191			} else if err != nil {
192				log.Printf("Error getting next part: %v", err)
193				break
194			}
195
196			// Correctly parse Content-Disposition
197			cdHeader := p.Header.Get("Content-Disposition")
198			if cdHeader != "" {
199				disposition, params, err := mime.ParseMediaType(cdHeader)
200				if err == nil && (disposition == "attachment" || disposition == "inline") {
201					filename := params["filename"]
202					if filename != "" {
203						partBody, _ := ioutil.ReadAll(p.Body)
204						encoding := p.Header.Get("Content-Transfer-Encoding")
205						if strings.ToLower(encoding) == "base64" {
206							decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody))
207							if decodeErr == nil {
208								partBody = decoded
209							}
210						}
211						attachments = append(attachments, Attachment{Filename: filename, Data: partBody})
212						continue // Skip to next part
213					}
214				}
215			}
216
217			// Process body part if not an attachment
218			mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
219			if (mediaType == "text/plain" || mediaType == "text/html") && body == "" {
220				decodedPart, decodeErr := decodePart(p.Body, p.Header)
221				if decodeErr == nil {
222					body = decodedPart
223				}
224			}
225		}
226
227		emails = append(emails, Email{
228			UID:         msg.Uid,
229			From:        fromAddr,
230			To:          toAddrList,
231			Subject:     subject,
232			Body:        body,
233			Date:        date,
234			MessageID:   messageID,
235			References:  strings.Fields(references),
236			Attachments: attachments,
237		})
238	}
239
240	if err := <-done; err != nil {
241		return nil, err
242	}
243
244	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
245		emails[i], emails[j] = emails[j], emails[i]
246	}
247
248	return emails, nil
249}
250
251func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
252	c, err := connect(cfg)
253	if err != nil {
254		return err
255	}
256	defer c.Logout()
257
258	if _, err := c.Select("INBOX", false); err != nil {
259		return err
260	}
261
262	seqSet := new(imap.SeqSet)
263	seqSet.AddNum(uid)
264
265	return c.UidMove(seqSet, destMailbox)
266}
267
268func DeleteEmail(cfg *config.Config, uid uint32) error {
269    c, err := connect(cfg)
270    if err != nil {
271        return err
272    }
273    defer c.Logout()
274
275    if _, err := c.Select("INBOX", false); err != nil {
276        return err
277    }
278
279    seqSet := new(imap.SeqSet)
280    seqSet.AddNum(uid)
281
282    item := imap.FormatFlagsOp(imap.AddFlags, true)
283    flags := []interface{}{imap.DeletedFlag}
284
285    if err := c.UidStore(seqSet, item, flags, nil); err != nil {
286        return err
287    }
288
289    return c.Expunge(nil)
290}
291
292func ArchiveEmail(cfg *config.Config, uid uint32) error {
293	var archiveMailbox string
294	switch cfg.ServiceProvider {
295	case "gmail":
296		archiveMailbox = "[Gmail]/All Mail"
297	default:
298		archiveMailbox = "Archive"
299	}
300	return moveEmail(cfg, uid, archiveMailbox)
301}