package mail import ( "fmt" "io" "sort" "strings" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" ) type IMAPConfig struct { Host string Port int Security string // tls | starttls Username string Password string } type Client struct { c *client.Client } func Dial(cfg IMAPConfig) (*Client, error) { addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) var ( ic *client.Client err error ) switch cfg.Security { case "tls": ic, err = client.DialTLS(addr, nil) case "starttls": ic, err = client.Dial(addr) if err == nil { err = ic.StartTLS(nil) } default: return nil, fmt.Errorf("unknown imap security %q", cfg.Security) } if err != nil { return nil, err } if err := ic.Login(cfg.Username, cfg.Password); err != nil { ic.Logout() return nil, err } return &Client{c: ic}, nil } func (c *Client) Logout() error { return c.c.Logout() } func (c *Client) SelectFolder(folder string) (uint32, uint32, error) { mbox, err := c.c.Select(folder, true) // read-only select if err != nil { return 0, 0, err } var maxUID uint32 if mbox.Messages > 0 { // UIDNext-1 is an upper bound for the highest existing UID. if mbox.UidNext > 0 { maxUID = mbox.UidNext - 1 } } return mbox.UidValidity, maxUID, nil } // hasAttachment reports whether a BODYSTRUCTURE tree contains a part marked // Content-Disposition: attachment — the same notion of "attachment" that // ParseMessage uses (go-message routes attachment-disposition parts to // AttachmentHeader). This lets list/search report has_attachments without // downloading any body. func hasAttachment(bs *imap.BodyStructure) bool { if bs == nil { return false } if strings.EqualFold(bs.Disposition, "attachment") { return true } for _, p := range bs.Parts { if hasAttachment(p) { return true } } return false } // fetchHeadersByUIDSet fetches only message headers (BODY.PEEK[HEADER]) plus the // BODYSTRUCTURE, never the body, so listing is cheap on large mailboxes. func (c *Client) fetchHeadersByUIDSet(folder string, set *imap.SeqSet) ([]Header, error) { if _, err := c.c.Select(folder, true); err != nil { return nil, err } section := &imap.BodySectionName{ BodyPartName: imap.BodyPartName{Specifier: imap.HeaderSpecifier}, Peek: true, // BODY.PEEK[HEADER] — headers only, no \Seen side-effect } items := []imap.FetchItem{imap.FetchUid, imap.FetchBodyStructure, section.FetchItem()} msgCh := make(chan *imap.Message, 16) done := make(chan error, 1) go func() { done <- c.c.UidFetch(set, items, msgCh) }() var out []Header for m := range msgCh { r := m.GetBody(section) if r == nil { continue } raw, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("uid %d: read header: %w", m.Uid, err) } h, err := ParseHeaderBytes(m.Uid, raw) if err != nil { return nil, err } h.HasAttachments = hasAttachment(m.BodyStructure) out = append(out, h) } if err := <-done; err != nil { return nil, err } sort.Slice(out, func(i, j int) bool { return out[i].UID > out[j].UID }) return out, nil } // fetchFullByUID fetches one message in full (body + attachments). func (c *Client) fetchFullByUID(folder string, uid uint32) (Message, error) { if _, err := c.c.Select(folder, true); err != nil { return Message{}, err } section := &imap.BodySectionName{Peek: true} // entire message, no \Seen side-effect items := []imap.FetchItem{imap.FetchUid, section.FetchItem()} set := new(imap.SeqSet) set.AddNum(uid) msgCh := make(chan *imap.Message, 1) done := make(chan error, 1) go func() { done <- c.c.UidFetch(set, items, msgCh) }() var ( msg Message found bool ) for m := range msgCh { r := m.GetBody(section) if r == nil { continue } raw, err := io.ReadAll(r) if err != nil { return Message{}, fmt.Errorf("uid %d: read body: %w", m.Uid, err) } parsed, err := ParseMessage(m.Uid, raw) if err != nil { return Message{}, err } msg, found = parsed, true } if err := <-done; err != nil { return Message{}, err } if !found { return Message{}, fmt.Errorf("uid %d not found in %s", uid, folder) } return msg, nil } func (c *Client) FetchHeaders(folder string, uids []uint32) ([]Header, error) { set := new(imap.SeqSet) if len(uids) == 0 { set.AddRange(1, 0) // 1:* — all } else { for _, u := range uids { set.AddNum(u) } } return c.fetchHeadersByUIDSet(folder, set) } func (c *Client) FetchHeadersRange(folder string, sinceUID, beforeUID uint32, limit int) ([]Header, error) { set := new(imap.SeqSet) lo := sinceUID + 1 if sinceUID == 0 { lo = 1 } hi := uint32(0) // '*' if beforeUID > 1 { hi = beforeUID - 1 } set.AddRange(lo, hi) h, err := c.fetchHeadersByUIDSet(folder, set) if err != nil { return nil, err } if limit > 0 && len(h) > limit { h = h[:limit] } return h, nil } func (c *Client) FetchFull(folder string, uid uint32) (Message, error) { return c.fetchFullByUID(folder, uid) } type SearchCriteria struct { From string SubjectContains string Text string Since time.Time Before time.Time } func (c *Client) Search(folder string, sc SearchCriteria, limit int) ([]Header, error) { if _, err := c.c.Select(folder, true); err != nil { return nil, err } crit := imap.NewSearchCriteria() if sc.From != "" { crit.Header.Add("From", sc.From) } if sc.SubjectContains != "" { crit.Header.Add("Subject", sc.SubjectContains) } if sc.Text != "" { crit.Text = []string{sc.Text} } if !sc.Since.IsZero() { crit.Since = sc.Since } if !sc.Before.IsZero() { crit.Before = sc.Before } uids, err := c.c.UidSearch(crit) if err != nil { return nil, err } if len(uids) == 0 { return nil, nil } if limit > 0 && len(uids) > limit { uids = uids[len(uids)-limit:] // keep highest (most recent) UIDs } return c.FetchHeaders(folder, uids) }