| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- package email
- /*
- folders.go
- Mailbox listing, role detection and folder management.
- Roles come from SPECIAL-USE attributes when the server provides them
- (Gmail, Outlook, iCloud, Dovecot) and from well-known names otherwise.
- Folder lists are cached briefly per account because every delete, archive
- or junk action needs to know where the Trash / Archive / Junk folder is.
- */
- import (
- "context"
- "errors"
- "sort"
- "strings"
- "sync"
- "time"
- "github.com/emersion/go-imap/v2"
- "github.com/emersion/go-imap/v2/imapclient"
- )
- const folderCacheTTL = 10 * time.Minute
- type folderCacheEntry struct {
- folders []Folder
- loaded time.Time
- }
- // folderCache remembers each account's folder list for a few minutes.
- type folderCache struct {
- mutex sync.Mutex
- entries map[string]folderCacheEntry
- }
- // specialUseRoles maps lower-cased SPECIAL-USE attributes (attributes are
- // case-insensitive on the wire) to roles.
- var specialUseRoles = map[string]string{
- strings.ToLower(string(imap.MailboxAttrSent)): RoleSent,
- strings.ToLower(string(imap.MailboxAttrDrafts)): RoleDrafts,
- strings.ToLower(string(imap.MailboxAttrTrash)): RoleTrash,
- strings.ToLower(string(imap.MailboxAttrJunk)): RoleJunk,
- strings.ToLower(string(imap.MailboxAttrArchive)): RoleArchive,
- strings.ToLower(string(imap.MailboxAttrAll)): RoleAll,
- strings.ToLower(string(imap.MailboxAttrFlagged)): RoleFlagged,
- strings.ToLower(string(imap.MailboxAttrImportant)): RoleImportant,
- }
- // Folder names used by servers without SPECIAL-USE, keyed by lower case.
- var wellKnownFolderNames = map[string]string{
- "sent": RoleSent, "sent items": RoleSent, "sent messages": RoleSent, "sent mail": RoleSent,
- "sent-mail": RoleSent, "outbox sent": RoleSent, "已发送": RoleSent, "已傳送": RoleSent,
- "已發送": RoleSent, "送信済み": RoleSent, "gesendet": RoleSent, "envoyés": RoleSent,
- "drafts": RoleDrafts, "draft": RoleDrafts, "草稿": RoleDrafts, "草稿箱": RoleDrafts,
- "下書き": RoleDrafts, "entwürfe": RoleDrafts, "brouillons": RoleDrafts,
- "trash": RoleTrash, "deleted items": RoleTrash, "deleted messages": RoleTrash,
- "deleted": RoleTrash, "bin": RoleTrash, "已删除": RoleTrash, "已刪除": RoleTrash,
- "ゴミ箱": RoleTrash, "papierkorb": RoleTrash, "corbeille": RoleTrash,
- "junk": RoleJunk, "junk email": RoleJunk, "junk e-mail": RoleJunk, "spam": RoleJunk,
- "bulk mail": RoleJunk, "bulk": RoleJunk, "垃圾邮件": RoleJunk, "垃圾郵件": RoleJunk,
- "迷惑メール": RoleJunk,
- "archive": RoleArchive, "archives": RoleArchive, "归档": RoleArchive, "封存": RoleArchive,
- "all mail": RoleAll,
- }
- var roleOrder = map[string]int{
- RoleInbox: 0, RoleFlagged: 1, RoleDrafts: 2, RoleSent: 3, RoleArchive: 4,
- RoleAll: 5, RoleImportant: 6, RoleJunk: 7, RoleTrash: 8,
- }
- // Folders lists the mailboxes of an account with message counts.
- func (m *Manager) Folders(ctx context.Context, p Principal, accountID string, refresh bool) ([]Folder, error) {
- account, err := m.account(p, accountID)
- if err != nil {
- return nil, err
- }
- key := poolKey(p.Username, account.ID)
- if !refresh {
- if cached, ok := m.cachedFolders(key); ok {
- return cached, nil
- }
- }
- var folders []Folder
- err = m.withIMAP(ctx, p, account, func(c *imapclient.Client) error {
- var err error
- folders, err = listFolders(c, true)
- return err
- })
- if err != nil {
- return nil, err
- }
- m.clearAuthError(p, account)
- m.storeFolders(key, folders)
- return folders, nil
- }
- // foldersQuick returns the cached folder list or lists without counts, for
- // operations that only need to find a role folder.
- func (m *Manager) foldersQuick(c *imapclient.Client, key string) ([]Folder, error) {
- if cached, ok := m.cachedFolders(key); ok {
- return cached, nil
- }
- folders, err := listFolders(c, false)
- if err != nil {
- return nil, err
- }
- m.storeFolders(key, folders)
- return folders, nil
- }
- func (m *Manager) cachedFolders(key string) ([]Folder, bool) {
- m.folders.mutex.Lock()
- defer m.folders.mutex.Unlock()
- entry, ok := m.folders.entries[key]
- if !ok || time.Since(entry.loaded) > folderCacheTTL {
- return nil, false
- }
- return entry.folders, true
- }
- func (m *Manager) storeFolders(key string, folders []Folder) {
- m.folders.mutex.Lock()
- defer m.folders.mutex.Unlock()
- if m.folders.entries == nil {
- m.folders.entries = map[string]folderCacheEntry{}
- }
- m.folders.entries[key] = folderCacheEntry{folders: folders, loaded: time.Now()}
- }
- func (m *Manager) dropFolderCache(key string) {
- m.folders.mutex.Lock()
- defer m.folders.mutex.Unlock()
- delete(m.folders.entries, key)
- }
- // listFolders runs LIST (and STATUS where needed) on a connection.
- func listFolders(c *imapclient.Client, withCounts bool) ([]Folder, error) {
- caps := c.Caps()
- options := &imap.ListOptions{}
- if caps.Has(imap.CapSpecialUse) && (caps.Has(imap.CapListExtended) || caps.Has(imap.CapIMAP4rev2)) {
- options.ReturnSpecialUse = true
- }
- listStatus := withCounts && (caps.Has(imap.CapListStatus) || caps.Has(imap.CapIMAP4rev2))
- if listStatus {
- options.ReturnStatus = &imap.StatusOptions{NumMessages: true, NumUnseen: true}
- }
- mailboxes, err := c.List("", "*", options).Collect()
- if err != nil && options.ReturnStatus != nil {
- //Some servers advertise LIST-STATUS but choke on it; retry plainly
- options.ReturnStatus = nil
- listStatus = false
- mailboxes, err = c.List("", "*", options).Collect()
- }
- if err != nil {
- return nil, err
- }
- folders := make([]Folder, 0, len(mailboxes))
- for _, mailbox := range mailboxes {
- folder := Folder{
- Name: mailbox.Mailbox,
- Selectable: true,
- Total: -1,
- Unread: -1,
- }
- if mailbox.Delim != 0 {
- folder.Delimiter = string(mailbox.Delim)
- }
- for _, attr := range mailbox.Attrs {
- lower := strings.ToLower(string(attr))
- if lower == strings.ToLower(string(imap.MailboxAttrNoSelect)) || lower == strings.ToLower(string(imap.MailboxAttrNonExistent)) {
- folder.Selectable = false
- }
- if role, ok := specialUseRoles[lower]; ok && folder.Role == "" {
- folder.Role = role
- }
- }
- if strings.EqualFold(folder.Name, "INBOX") {
- folder.Role = RoleInbox
- }
- if mailbox.Status != nil {
- if mailbox.Status.NumMessages != nil {
- folder.Total = int(*mailbox.Status.NumMessages)
- }
- if mailbox.Status.NumUnseen != nil {
- folder.Unread = int(*mailbox.Status.NumUnseen)
- }
- }
- segments := []string{folder.Name}
- if folder.Delimiter != "" {
- segments = strings.Split(folder.Name, folder.Delimiter)
- }
- folder.Display = segments[len(segments)-1]
- folder.Depth = len(segments) - 1
- if len(segments) > 1 {
- folder.Parent = strings.Join(segments[:len(segments)-1], folder.Delimiter)
- }
- if folder.Role == RoleInbox {
- folder.Display = "Inbox"
- }
- folders = append(folders, folder)
- }
- assignWellKnownRoles(folders)
- if withCounts && !listStatus {
- fillFolderCounts(c, folders)
- }
- sortFolders(folders)
- return folders, nil
- }
- // assignWellKnownRoles names folders for servers without SPECIAL-USE. A role
- // is only assigned when no folder carries it yet, preferring shallow folders.
- func assignWellKnownRoles(folders []Folder) {
- taken := map[string]bool{}
- for _, folder := range folders {
- if folder.Role != "" {
- taken[folder.Role] = true
- }
- }
- //Top-level folders first, then "INBOX.Sent" style servers that nest
- //every folder under the inbox
- for depth := 0; depth <= 1; depth++ {
- for i := range folders {
- folder := &folders[i]
- if folder.Role != "" || folder.Depth != depth || !folder.Selectable {
- continue
- }
- if depth == 1 && !strings.EqualFold(folder.Parent, "INBOX") {
- continue
- }
- role, ok := wellKnownFolderNames[strings.ToLower(folder.Display)]
- if !ok || taken[role] {
- continue
- }
- folder.Role = role
- taken[role] = true
- }
- }
- }
- // fillFolderCounts pipelines STATUS for every selectable folder.
- func fillFolderCounts(c *imapclient.Client, folders []Folder) {
- type pending struct {
- index int
- command *imapclient.StatusCommand
- }
- commands := []pending{}
- for i := range folders {
- if !folders[i].Selectable || len(commands) >= 200 {
- continue
- }
- commands = append(commands, pending{index: i, command: c.Status(folders[i].Name, &imap.StatusOptions{NumMessages: true, NumUnseen: true})})
- }
- for _, item := range commands {
- data, err := item.command.Wait()
- if err != nil || data == nil {
- continue
- }
- if data.NumMessages != nil {
- folders[item.index].Total = int(*data.NumMessages)
- }
- if data.NumUnseen != nil {
- folders[item.index].Unread = int(*data.NumUnseen)
- }
- }
- }
- // sortFolders puts special folders first and keeps children under parents.
- func sortFolders(folders []Folder) {
- rank := func(folder Folder) int {
- if order, ok := roleOrder[folder.Role]; ok {
- return order
- }
- return 100
- }
- sort.SliceStable(folders, func(i, j int) bool {
- ri, rj := rank(folders[i]), rank(folders[j])
- if ri != rj && (ri < 100 || rj < 100) {
- return ri < rj
- }
- return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
- })
- }
- // folderByRole finds the folder playing a role.
- func folderByRole(folders []Folder, role string) (Folder, bool) {
- for _, folder := range folders {
- if folder.Role == role && folder.Selectable {
- return folder, true
- }
- }
- return Folder{}, false
- }
- // roleOf returns the role of a folder by name.
- func roleOf(folders []Folder, name string) string {
- for _, folder := range folders {
- if folder.Name == name {
- return folder.Role
- }
- }
- if strings.EqualFold(name, "INBOX") {
- return RoleInbox
- }
- return ""
- }
- // ensureRoleFolder finds the folder of a role, creating a top-level one with
- // a conventional name when the server has none.
- func (m *Manager) ensureRoleFolder(c *imapclient.Client, key string, role string) (string, error) {
- folders, err := m.foldersQuick(c, key)
- if err != nil {
- return "", err
- }
- if folder, ok := folderByRole(folders, role); ok {
- return folder.Name, nil
- }
- names := map[string]string{RoleTrash: "Trash", RoleArchive: "Archive", RoleJunk: "Junk", RoleSent: "Sent", RoleDrafts: "Drafts"}
- name, ok := names[role]
- if !ok {
- return "", errors.New("no folder for " + role)
- }
- //The INBOX.Sent convention: create under INBOX when everything lives there
- for _, folder := range folders {
- if folder.Parent != "" && strings.EqualFold(folder.Parent, "INBOX") && folder.Delimiter != "" {
- name = "INBOX" + folder.Delimiter + name
- break
- }
- }
- createOptions := &imap.CreateOptions{}
- attrs := map[string]imap.MailboxAttr{RoleTrash: imap.MailboxAttrTrash, RoleArchive: imap.MailboxAttrArchive,
- RoleJunk: imap.MailboxAttrJunk, RoleSent: imap.MailboxAttrSent, RoleDrafts: imap.MailboxAttrDrafts}
- if c.Caps().Has(imap.CapCreateSpecialUse) {
- createOptions.SpecialUse = []imap.MailboxAttr{attrs[role]}
- }
- if err := c.Create(name, createOptions).Wait(); err != nil {
- var statusErr *imap.Error
- if !(errors.As(err, &statusErr) && statusErr.Code == imap.ResponseCodeAlreadyExists) {
- return "", err
- }
- }
- m.dropFolderCache(key)
- return name, nil
- }
- // CreateFolder makes a new mailbox, optionally inside parent.
- func (m *Manager) CreateFolder(ctx context.Context, p Principal, accountID string, parent string, name string) (string, error) {
- name = strings.TrimSpace(name)
- if name == "" {
- return "", errors.New("folder name is empty")
- }
- account, err := m.account(p, accountID)
- if err != nil {
- return "", err
- }
- key := poolKey(p.Username, account.ID)
- full := name
- err = m.withIMAP(ctx, p, account, func(c *imapclient.Client) error {
- if parent != "" {
- folders, err := m.foldersQuick(c, key)
- if err != nil {
- return err
- }
- delimiter := "/"
- for _, folder := range folders {
- if folder.Name == parent && folder.Delimiter != "" {
- delimiter = folder.Delimiter
- }
- }
- if strings.Contains(name, delimiter) {
- return errors.New("folder name cannot contain \"" + delimiter + "\"")
- }
- full = parent + delimiter + name
- }
- err := c.Create(full, nil).Wait()
- if err == nil {
- c.Subscribe(full).Wait()
- }
- return err
- })
- m.dropFolderCache(key)
- return full, err
- }
- // RenameFolder renames a mailbox (keeping it under the same parent).
- func (m *Manager) RenameFolder(ctx context.Context, p Principal, accountID string, folder string, newName string) (string, error) {
- newName = strings.TrimSpace(newName)
- if newName == "" {
- return "", errors.New("folder name is empty")
- }
- account, err := m.account(p, accountID)
- if err != nil {
- return "", err
- }
- key := poolKey(p.Username, account.ID)
- full := newName
- err = m.withIMAP(ctx, p, account, func(c *imapclient.Client) error {
- folders, err := m.foldersQuick(c, key)
- if err != nil {
- return err
- }
- var current *Folder
- for i := range folders {
- if folders[i].Name == folder {
- current = &folders[i]
- }
- }
- if current == nil {
- return ErrFolderNotFound
- }
- if current.Role != "" {
- return errors.New("special folders cannot be renamed")
- }
- if current.Parent != "" {
- full = current.Parent + current.Delimiter + newName
- }
- return c.Rename(folder, full, nil).Wait()
- })
- m.dropFolderCache(key)
- return full, err
- }
- // DeleteFolder removes a mailbox and its messages.
- func (m *Manager) DeleteFolder(ctx context.Context, p Principal, accountID string, folder string) error {
- account, err := m.account(p, accountID)
- if err != nil {
- return err
- }
- key := poolKey(p.Username, account.ID)
- err = m.withIMAP(ctx, p, account, func(c *imapclient.Client) error {
- folders, err := m.foldersQuick(c, key)
- if err != nil {
- return err
- }
- if role := roleOf(folders, folder); role != "" {
- return errors.New("special folders cannot be deleted")
- }
- if selected := c.Mailbox(); selected != nil && selected.Name == folder {
- c.Unselect().Wait()
- }
- c.Unsubscribe(folder).Wait()
- return c.Delete(folder).Wait()
- })
- m.dropFolderCache(key)
- return err
- }
|