diff --git a/mail/actions.go b/mail/actions.go index 9cf4bdc2..21edb263 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -5,10 +5,10 @@ package mail import ( + "fmt" "slices" "strings" - "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" "cogentcore.org/core/events" "github.com/emersion/go-imap/v2" @@ -19,14 +19,16 @@ import ( // action executes the given function in a goroutine with proper locking. // This should be used for any user action that interacts with a message in IMAP. // It also automatically saves the cache after the action is completed. -func (a *App) action(f func(c *imapclient.Client)) { +// It calls the function for the current a.readMessage and all of its replies. +func (a *App) action(f func(c *imapclient.Client) error) { // Use a goroutine to prevent GUI freezing and a double mutex deadlock // with a combination of the renderContext mutex and the imapMu. go func() { mu := a.imapMu[a.currentEmail] mu.Lock() - f(a.imapClient[a.currentEmail]) - err := jsonx.Save(a.cache[a.currentEmail], a.cacheFilename(a.currentEmail)) + err := f(a.imapClient[a.currentEmail]) + core.ErrorSnackbar(a, err, "Error performing action") + err = a.saveCacheFile(a.cache[a.currentEmail], a.currentEmail) core.ErrorSnackbar(a, err, "Error saving cache") mu.Unlock() a.AsyncLock() @@ -36,45 +38,137 @@ func (a *App) action(f func(c *imapclient.Client)) { } // actionLabels executes the given function for each label of the current message, -// selecting the mailbox for each one first. -func (a *App) actionLabels(f func(c *imapclient.Client, label Label)) { - a.action(func(c *imapclient.Client) { +// selecting the mailbox for each one first. It does so in a goroutine with proper +// locking. It takes an optional function to call while still in the protected +// goroutine after all of the labels have been processed. +func (a *App) actionLabels(f func(c *imapclient.Client, label Label) error, after ...func()) { + a.action(func(c *imapclient.Client) error { for _, label := range a.readMessage.Labels { err := a.selectMailbox(c, a.currentEmail, label.Name) if err != nil { - core.ErrorSnackbar(a, err) - return + return err + } + err = f(c, label) + if err != nil { + return err } - f(c, label) } + if len(after) > 0 { + after[0]() + } + return nil }) } +// tableLabel is used for displaying labels in a table +// for user selection. +type tableLabel struct { + name string // the true underlying name + On bool `display:"checkbox"` + Label string `edit:"-"` // the friendly label name +} + // Label opens a dialog for changing the labels (mailboxes) of the current message. func (a *App) Label() { //types:add d := core.NewBody("Label") - labels := make([]string, len(a.readMessage.Labels)) + labels := make([]tableLabel, len(a.readMessage.Labels)) for i, label := range a.readMessage.Labels { - labels[i] = label.Name + labels[i] = tableLabel{name: label.Name, On: label.Name != "INBOX", Label: friendlyLabelName(label.Name)} } + var tb *core.Table ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true) + for _, label := range a.labels[a.currentEmail] { + ch.Items = append(ch.Items, core.ChooserItem{Value: label, Text: friendlyLabelName(label)}) + } ch.OnChange(func(e events.Event) { - labels = append(labels, ch.CurrentItem.Value.(string)) + labels = append(labels, tableLabel{name: ch.CurrentItem.Value.(string), On: true, Label: ch.CurrentItem.Text}) + tb.Update() }) - core.NewList(d).SetSlice(&labels) + ch.OnFinal(events.Change, func(e events.Event) { + if ch.CurrentItem.Text == "" { + return + } + ch.CurrentItem = core.ChooserItem{} + ch.SetCurrentValue("") + }) + tb = core.NewTable(d).SetSlice(&labels) d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) - d.AddOK(bar).SetText("Save") + d.AddOK(bar).SetText("Save").OnClick(func(e events.Event) { + newLabels := []string{} + for _, label := range labels { + if label.On { + newLabels = append(newLabels, label.name) + } + } + if len(newLabels) == 0 { + core.ErrorSnackbar(a, fmt.Errorf("specify at least one label")) + return + } + a.label(newLabels) + }) }) d.RunDialog(a) - // TODO: Move needs to be redesigned with the new many-to-many labeling paradigm. - // a.actionLabels(func(c *imapclient.Client, label Label) { - // uidset := imap.UIDSet{} - // uidset.AddNum(label.UID) - // mc := c.Move(uidset, mailbox) - // _, err := mc.Wait() - // core.ErrorSnackbar(a, err, "Error moving message") - // }) +} + +// label changes the labels of the current message to the given labels. +// newLabels are the labels we want to end up with, in contrast +// to the old labels we started with, which are a.readMessage.Labels. +func (a *App) label(newLabels []string) { + // resultantLabels are the labels we apply to a.readMessage.Labels after + // the process is over. This needs to be a copy of a.readMessage.Labels + // since we can't modify it while looping over it and checking it. + resultantLabels := make([]Label, len(a.readMessage.Labels)) + copy(resultantLabels, a.readMessage.Labels) + first := true + a.actionLabels(func(c *imapclient.Client, label Label) error { + // We copy the existing message to all of the new labels. + if first { + first = false + for _, newLabel := range newLabels { + if slices.ContainsFunc(a.readMessage.Labels, func(label Label) bool { + return label.Name == newLabel + }) { + continue // Already have this label. + } + cd, err := c.Copy(label.UIDSet(), newLabel).Wait() + if err != nil { + return err + } + // Add this new label to the cache. + resultantLabels = append(resultantLabels, Label{newLabel, cd.DestUIDs[0].Start}) + } + } + // We remove the existing message from each old label. + if slices.Contains(newLabels, label.Name) { + return nil // Still have this label. + } + err := c.Store(label.UIDSet(), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil).Wait() + if err != nil { + return err + } + err = c.UIDExpunge(label.UIDSet()).Wait() + if err != nil { + return err + } + // Remove this old label from the cache. + resultantLabels = slices.DeleteFunc(resultantLabels, func(l Label) bool { + return l == label + }) + return nil + }, func() { + // Now that we are done, we can save resultantLabels to the cache. + a.readMessage.Labels = resultantLabels + }) +} + +// Delete moves the current message to the trash. +func (a *App) Delete() { //types:add + a.label([]string{"[Gmail]/Trash"}) // TODO: support other trash mailboxes } // Reply opens a dialog to reply to the current message. @@ -125,7 +219,7 @@ func (a *App) reply(title string, forward bool) { a.composeMessage.Subject = prefix + a.composeMessage.Subject } a.composeMessage.inReplyTo = a.readMessage.MessageID - a.composeMessage.references = append(a.readMessageReferences, a.readMessage.MessageID) + a.composeMessage.references = append(a.readMessage.parsed.references, a.readMessage.MessageID) from := IMAPToMailAddresses(a.readMessage.From)[0].String() date := a.readMessage.Date.Format("Mon, Jan 2, 2006 at 3:04 PM") if forward { @@ -143,7 +237,7 @@ func (a *App) reply(title string, forward bool) { a.composeMessage.body = "\n\n> On " + date + ", " + from + " wrote:" } a.composeMessage.body += "\n>\n> " - a.composeMessage.body += strings.ReplaceAll(a.readMessagePlain, "\n", "\n> ") + a.composeMessage.body += strings.ReplaceAll(a.readMessage.parsed.plain, "\n", "\n> ") a.compose(title) } @@ -159,28 +253,25 @@ func (a *App) MarkAsUnread() { //types:add // markSeen sets the [imap.FlagSeen] flag of the current message. func (a *App) markSeen(seen bool) { - if slices.Contains(a.readMessage.Flags, imap.FlagSeen) == seen { + if a.readMessage.isRead() == seen { // Already set correctly. return } - a.actionLabels(func(c *imapclient.Client, label Label) { - uidset := imap.UIDSet{} - uidset.AddNum(label.UID) + a.actionLabels(func(c *imapclient.Client, label Label) error { op := imap.StoreFlagsDel if seen { op = imap.StoreFlagsAdd } - cmd := c.Store(uidset, &imap.StoreFlags{ - Op: op, - Flags: []imap.Flag{imap.FlagSeen}, - }, nil) - err := cmd.Wait() + err := c.Store(label.UIDSet(), &imap.StoreFlags{ + Op: op, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, + }, nil).Wait() if err != nil { - core.ErrorSnackbar(a, err, "Error marking message as read") - return + return err } // Also directly update the cache: - flags := &a.cache[a.currentEmail][a.readMessage.MessageID].Flags + flags := &a.readMessage.Flags if seen && !slices.Contains(*flags, imap.FlagSeen) { *flags = append(*flags, imap.FlagSeen) } else if !seen { @@ -188,5 +279,6 @@ func (a *App) markSeen(seen bool) { return flag == imap.FlagSeen }) } + return nil }) } diff --git a/mail/app.go b/mail/app.go index 73b586b1..0d276b15 100644 --- a/mail/app.go +++ b/mail/app.go @@ -10,18 +10,22 @@ package mail import ( "cmp" "fmt" + "os" "path/filepath" "slices" + "strconv" + "strings" "sync" + "time" - "golang.org/x/exp/maps" - + "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/keymap" "cogentcore.org/core/styles" "cogentcore.org/core/tree" + "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-sasl" "golang.org/x/oauth2" @@ -47,21 +51,22 @@ type App struct { composeMessage *SendMessage // cache contains the cached message data, keyed by account and then MessageID. - cache map[string]map[string]*CacheData + cache map[string]map[string]*CacheMessage // listCache is a sorted view of [App.cache] for the current email account // and labels, used for displaying a [core.List] of messages. It should not // be used for any other purpose. - listCache []*CacheData + listCache []*CacheMessage - // readMessage is the current message we are reading - readMessage *CacheData + // totalMessages is the total number of messages for each email account and label. + totalMessages map[string]map[string]int - // readMessageReferences is the References header of the current readMessage. - readMessageReferences []string + // unreadMessages is the number of unread messages for the current email account + // and labels, used for displaying a count. + unreadMessages int - // readMessagePlain is the plain text body of the current readMessage. - readMessagePlain string + // readMessage is the current message we are reading. + readMessage *CacheMessage // currentEmail is the current email account. currentEmail string @@ -69,10 +74,9 @@ type App struct { // selectedMailbox is the currently selected mailbox for each email account in IMAP. selectedMailbox map[string]string - // labels are all of the possible labels that messages have. - // The first key is the account for which the labels are stored, - // and the second key is for each label name. - labels map[string]map[string]bool + // labels are all of the possible labels that messages can have in + // each email account. + labels map[string][]string // showLabel is the current label to show messages for. showLabel string @@ -90,8 +94,12 @@ func (a *App) Init() { a.Frame.Init() a.authToken = map[string]*oauth2.Token{} a.authClient = map[string]sasl.Client{} + a.imapClient = map[string]*imapclient.Client{} + a.imapMu = map[string]*sync.Mutex{} + a.cache = map[string]map[string]*CacheMessage{} + a.totalMessages = map[string]map[string]int{} a.selectedMailbox = map[string]string{} - a.labels = map[string]map[string]bool{} + a.labels = map[string][]string{} a.showLabel = "INBOX" a.Styler(func(s *styles.Style) { s.Grow.Set(1, 1) @@ -104,64 +112,129 @@ func (a *App) Init() { w.Maker(func(p *tree.Plan) { for _, email := range Settings.Accounts { tree.AddAt(p, email, func(w *core.Tree) { - a.labels[email] = map[string]bool{} - w.Maker(func(p *tree.Plan) { - labels := maps.Keys(a.labels[email]) - slices.Sort(labels) - for _, label := range labels { - tree.AddAt(p, label, func(w *core.Tree) { - w.SetText(friendlyLabelName(label)) - w.OnSelect(func(e events.Event) { - a.showLabel = label - a.Update() - }) - }) - } - }) + a.makeLabelTree(w, email, "") }) } }) }) - tree.AddChild(w, func(w *core.List) { - w.SetSlice(&a.listCache) - w.SetReadOnly(true) + tree.AddChild(w, func(w *core.Frame) { + w.Styler(func(s *styles.Style) { + s.Direction = styles.Column + }) w.Updater(func() { a.listCache = nil + a.unreadMessages = 0 mp := a.cache[a.currentEmail] - for _, cd := range mp { - for _, label := range cd.Labels { - a.labels[a.currentEmail][label.Name] = true - if label.Name == a.showLabel { - a.listCache = append(a.listCache, cd) - break + for _, cm := range mp { + if start := a.conversationStart(mp, cm); start != cm { + if !slices.Contains(start.replies, cm) { + start.replies = append(start.replies, cm) + } + continue + } + for _, label := range cm.Labels { + if label.Name != a.showLabel { + continue } + a.listCache = append(a.listCache, cm) + if !slices.Contains(cm.Flags, imap.FlagSeen) { + a.unreadMessages++ + } + break } } - slices.SortFunc(a.listCache, func(a, b *CacheData) int { - return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) + slices.SortFunc(a.listCache, func(a, b *CacheMessage) int { + return cmp.Compare(b.latestDate().UnixNano(), a.latestDate().UnixNano()) + }) + }) + tree.AddChild(w, func(w *core.Text) { + w.SetType(core.TextTitleMedium) + w.Updater(func() { + w.SetText(friendlyLabelName(a.showLabel)) + }) + }) + tree.AddChild(w, func(w *core.Text) { + w.Updater(func() { + w.Text = "" + total := a.totalMessages[a.currentEmail][a.showLabel] + if len(a.listCache) < total { + w.Text += fmt.Sprintf("%d of ", len(a.listCache)) + } + w.Text += fmt.Sprintf("%d messages", total) + if a.unreadMessages > 0 { + w.Text += fmt.Sprintf(", %d unread", a.unreadMessages) + } }) }) + tree.AddChild(w, func(w *core.Separator) {}) + tree.AddChild(w, func(w *core.List) { + w.SetSlice(&a.listCache) + w.SetReadOnly(true) + }) }) tree.AddChild(w, func(w *core.Frame) { w.Styler(func(s *styles.Style) { s.Direction = styles.Column }) - tree.AddChild(w, func(w *core.Form) { - w.SetReadOnly(true) - w.Updater(func() { - w.SetStruct(a.readMessage.ToMessage()) + w.Maker(func(p *tree.Plan) { + if a.readMessage == nil { + return + } + add := func(cm *CacheMessage) { + tree.AddAt(p, cm.Filename, func(w *DisplayMessageFrame) { + w.Updater(func() { + w.SetMessage(cm) + }) + }) + } + slices.SortFunc(a.readMessage.replies, func(a, b *CacheMessage) int { + return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) }) + for i, reply := range a.readMessage.replies { + add(reply) + tree.AddAt(p, "separator"+strconv.Itoa(i), func(w *core.Separator) {}) + } + add(a.readMessage) }) - tree.AddChild(w, func(w *core.Frame) { - w.Styler(func(s *styles.Style) { - s.Direction = styles.Column - s.Grow.Set(1, 0) - }) + }) + }) +} + +// makeLabelTree recursively adds a Maker to the given tree to form a nested tree of labels. +func (a *App) makeLabelTree(w *core.Tree, email, parentLabel string) { + w.Maker(func(p *tree.Plan) { + friendlyParentLabel := friendlyLabelName(parentLabel) + for _, label := range a.labels[email] { + if skipLabels[label] { + continue + } + friendlyLabel := friendlyLabelName(label) + // Skip labels that are not directly nested under the parent label. + if parentLabel == "" && strings.Contains(friendlyLabel, "/") { + continue + } else if parentLabel != "" { + if !strings.HasPrefix(friendlyLabel, friendlyParentLabel+"/") || + strings.Count(friendlyLabel, "/") > strings.Count(friendlyParentLabel, "/")+1 { + continue + } + } + tree.AddAt(p, label, func(w *core.Tree) { + a.makeLabelTree(w, email, label) w.Updater(func() { - core.ErrorSnackbar(w, a.updateReadMessage(w), "Error reading message") + // Recompute the friendly labels in case they have changed. + w.SetText(strings.TrimPrefix(friendlyLabelName(label), friendlyLabelName(parentLabel)+"/")) + if ic, ok := labelIcons[w.Text]; ok { + w.SetIcon(ic) + } else { + w.SetIcon(icons.Label) + } + }) + w.OnSelect(func(e events.Event) { + a.showLabel = label + a.Update() }) }) - }) + } }) } @@ -175,6 +248,9 @@ func (a *App) MakeToolbar(p *tree.Plan) { tree.Add(p, func(w *core.FuncButton) { w.SetFunc(a.Label).SetIcon(icons.DriveFileMove).SetKey(keymap.Save) }) + tree.Add(p, func(w *core.FuncButton) { + w.SetFunc(a.Delete).SetIcon(icons.Delete).SetKey(keymap.Delete) + }) tree.Add(p, func(w *core.FuncButton) { w.SetFunc(a.Reply).SetIcon(icons.Reply).SetKey(keymap.Replace) }) @@ -190,19 +266,21 @@ func (a *App) MakeToolbar(p *tree.Plan) { } } -func (a *App) GetMail() error { +func (a *App) GetMail() { go func() { - err := a.Auth() + err := a.auth() if err != nil { core.ErrorDialog(a, err, "Error authorizing") return } - err = a.CacheMessages() - if err != nil { - core.ErrorDialog(a, err, "Error caching messages") + // We keep caching messages forever to stay in sync. + for { + err = a.cacheMessages() + if err != nil { + core.ErrorDialog(a, err, "Error caching messages") + } } }() - return nil } // selectMailbox selects the given mailbox for the given email for the given client. @@ -211,16 +289,73 @@ func (a *App) selectMailbox(c *imapclient.Client, email string, mailbox string) if a.selectedMailbox[email] == mailbox { return nil // already selected } - _, err := c.Select(mailbox, nil).Wait() + sd, err := c.Select(mailbox, nil).Wait() if err != nil { return fmt.Errorf("selecting mailbox: %w", err) } a.selectedMailbox[email] = mailbox + a.totalMessages[email][mailbox] = int(sd.NumMessages) return nil } // cacheFilename returns the filename for the cached messages JSON file // for the given email address. func (a *App) cacheFilename(email string) string { - return filepath.Join(core.TheApp.AppDataDir(), "caching", FilenameBase32(email), "cached-messages.json") + return filepath.Join(core.TheApp.AppDataDir(), "caching", filenameBase32(email), "cached-messages.json") +} + +// saveCacheFile safely saves the given cache data for the +// given email account by going through a temporary file to +// avoid truncating it without writing it if we quit during the process. +func (a *App) saveCacheFile(cached map[string]*CacheMessage, email string) error { + fname := a.cacheFilename(email) + err := jsonx.Save(&cached, fname+".tmp") + if err != nil { + return fmt.Errorf("saving cache list: %w", err) + } + err = os.Rename(fname+".tmp", fname) + if err != nil { + return err + } + return nil +} + +// conversationStart returns the first message in the conversation +// of the given message using [CacheMessage.InReplyTo] and the given +// cache map. +func (a *App) conversationStart(mp map[string]*CacheMessage, cm *CacheMessage) *CacheMessage { + for { + if len(cm.InReplyTo) == 0 { + return cm + } + new, ok := mp[cm.InReplyTo[0]] + if !ok { + return cm + } + cm = new + } +} + +// latestDate returns the latest date/time of the message and all of its replies. +func (cm *CacheMessage) latestDate() time.Time { + res := cm.Date + for _, reply := range cm.replies { + if reply.Date.After(res) { + res = reply.Date + } + } + return res +} + +// isRead returns whether the message and all of its replies are marked as read. +func (cm *CacheMessage) isRead() bool { + if !slices.Contains(cm.Flags, imap.FlagSeen) { + return false + } + for _, reply := range cm.replies { + if !slices.Contains(reply.Flags, imap.FlagSeen) { + return false + } + } + return true } diff --git a/mail/auth.go b/mail/auth.go index f59ab8b1..47ee44c5 100644 --- a/mail/auth.go +++ b/mail/auth.go @@ -17,10 +17,10 @@ import ( "golang.org/x/oauth2" ) -// Auth authorizes access to the user's mail and sets [App.AuthClient]. +// auth authorizes access to the user's mail and sets [App.AuthClient]. // If the user does not already have a saved auth token, it calls [SignIn]. -func (a *App) Auth() error { - email, err := a.SignIn() +func (a *App) auth() error { + email, err := a.signIn() if err != nil { return err } @@ -29,9 +29,9 @@ func (a *App) Auth() error { return nil } -// SignIn displays a dialog for the user to sign in with the platform of their choice. +// signIn displays a dialog for the user to sign in with the platform of their choice. // It returns the user's email address. -func (a *App) SignIn() (string, error) { +func (a *App) signIn() (string, error) { d := core.NewBody("Sign in") email := make(chan string) fun := func(token *oauth2.Token, userInfo *oidc.UserInfo) { @@ -47,7 +47,7 @@ func (a *App) SignIn() (string, error) { auth.Buttons(d, &auth.ButtonsConfig{ SuccessFunc: fun, TokenFile: func(provider, email string) string { - return filepath.Join(core.TheApp.AppDataDir(), "auth", FilenameBase32(email), provider+"-token.json") + return filepath.Join(core.TheApp.AppDataDir(), "auth", filenameBase32(email), provider+"-token.json") }, Accounts: Settings.Accounts, Scopes: map[string][]string{ @@ -58,7 +58,7 @@ func (a *App) SignIn() (string, error) { return <-email, nil } -// FilenameBase32 converts the given string to a filename-safe base32 version. -func FilenameBase32(s string) string { +// filenameBase32 converts the given string to a filename-safe base32 version. +func filenameBase32(s string) string { return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(s)) } diff --git a/mail/cache.go b/mail/cache.go index 1f373549..c643aacd 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -13,8 +13,9 @@ import ( "os" "path/filepath" "slices" - "strings" "sync" + "sync/atomic" + "time" "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" @@ -22,17 +23,31 @@ import ( "github.com/emersion/go-imap/v2/imapclient" ) -// CacheData contains the data stored for a cached message in the cached messages file. +// CacheMessage contains the data stored for a cached message in the cached messages file. // It contains basic information about the message so that it can be displayed in the // mail list in the GUI. -type CacheData struct { +type CacheMessage struct { imap.Envelope + + // Filename is the unique filename of the cached message contents. + Filename string + + // Flags are the IMAP flags associated with the message. Flags []imap.Flag // Labels are the labels associated with the message. // Labels are many-to-many, similar to gmail. All labels // also correspond to IMAP mailboxes. Labels []Label + + // replies are other messages that are replies to this message. + // They are not stored in the cache file or computed ahead of time; + // rather, they are used for conversation combination in the list GUI. + replies []*CacheMessage + + // parsed contains data parsed from this message. This is populated live + // and not stored in the cache file. + parsed readMessageParsed } // Label represents a Label associated with a message. @@ -43,16 +58,26 @@ type Label struct { UID imap.UID } -// ToMessage converts the [CacheData] to a [ReadMessage]. -func (cd *CacheData) ToMessage() *ReadMessage { - if cd == nil { +// UIDSet returns an [imap.UIDSet] that contains just the UID +// of the message in the IMAP mailbox corresponding to the [Label]. +func (lb *Label) UIDSet() imap.UIDSet { + uidset := imap.UIDSet{} + uidset.AddNum(lb.UID) + return uidset +} + +// ToDisplay converts the [CacheMessage] to a [displayMessage] +// with the given additional [readMessageParsed] data. +func (cm *CacheMessage) ToDisplay() *displayMessage { + if cm == nil { return nil } - return &ReadMessage{ - From: IMAPToMailAddresses(cd.From), - To: IMAPToMailAddresses(cd.To), - Subject: cd.Subject, - Date: cd.Date.Local(), + return &displayMessage{ + From: IMAPToMailAddresses(cm.From), + To: IMAPToMailAddresses(cm.To), + Subject: cm.Subject, + Date: cm.Date.Local(), + Attachments: cm.parsed.attachments, } } @@ -68,20 +93,11 @@ func IMAPToMailAddresses(as []imap.Address) []*mail.Address { return res } -// CacheMessages caches all of the messages from the server that +// cacheMessages caches all of the messages from the server that // have not already been cached. It caches them in the app's data directory. -func (a *App) CacheMessages() error { - if a.cache == nil { - a.cache = map[string]map[string]*CacheData{} - } - if a.imapClient == nil { - a.imapClient = map[string]*imapclient.Client{} - } - if a.imapMu == nil { - a.imapMu = map[string]*sync.Mutex{} - } +func (a *App) cacheMessages() error { for _, account := range Settings.Accounts { - err := a.CacheMessagesForAccount(account) + err := a.cacheMessagesForAccount(account) if err != nil { return fmt.Errorf("caching messages for account %q: %w", account, err) } @@ -92,35 +108,72 @@ func (a *App) CacheMessages() error { // CacheMessages caches all of the messages from the server that // have not already been cached for the given email account. It // caches them in the app's data directory. -func (a *App) CacheMessagesForAccount(email string) error { +func (a *App) cacheMessagesForAccount(email string) error { if a.cache[email] == nil { - a.cache[email] = map[string]*CacheData{} + a.cache[email] = map[string]*CacheMessage{} } - - c, err := imapclient.DialTLS("imap.gmail.com:993", nil) - if err != nil { - return fmt.Errorf("TLS dialing: %w", err) + if a.totalMessages[email] == nil { + a.totalMessages[email] = map[string]int{} } - defer c.Logout() - a.imapClient[email] = c - a.imapMu[email] = &sync.Mutex{} + dir := filepath.Join(core.TheApp.AppDataDir(), "mail", filenameBase32(email)) + cached := a.cache[email] - err = c.Authenticate(a.authClient[email]) - if err != nil { - return fmt.Errorf("authenticating: %w", err) + var err error + c := a.imapClient[email] + if c == nil { + c, err = imapclient.DialTLS("imap.gmail.com:993", nil) + if err != nil { + return fmt.Errorf("TLS dialing: %w", err) + } + // defer c.Logout() // TODO: Logout in QuitClean or something similar + + a.imapClient[email] = c + a.imapMu[email] = &sync.Mutex{} + + err = c.Authenticate(a.authClient[email]) + if err != nil { + return fmt.Errorf("authenticating: %w", err) + } + + err = os.MkdirAll(string(dir), 0700) + if err != nil { + return err + } + + cacheFile := a.cacheFilename(email) + err = os.MkdirAll(filepath.Dir(cacheFile), 0700) + if err != nil { + return err + } + + cached = map[string]*CacheMessage{} + err = jsonx.Open(&cached, cacheFile) + if err != nil && !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, io.EOF) { + return fmt.Errorf("opening cache list: %w", err) + } + a.cache[email] = cached } + a.imapMu[email].Lock() mailboxes, err := c.List("", "*", nil).Collect() + a.imapMu[email].Unlock() if err != nil { return fmt.Errorf("getting mailboxes: %w", err) } + a.AsyncLock() + a.labels[email] = []string{} + for _, mailbox := range mailboxes { + a.labels[email] = append(a.labels[email], mailbox.Mailbox) + } + a.AsyncUnlock() + for _, mailbox := range mailboxes { - if strings.HasPrefix(mailbox.Mailbox, "[Gmail]") { - continue // TODO: skipping for now until we figure out a good way to handle + if skipLabels[mailbox.Mailbox] { + continue } - err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox) + err := a.cacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached) if err != nil { return fmt.Errorf("caching messages for mailbox %q: %w", mailbox.Mailbox, err) } @@ -128,72 +181,91 @@ func (a *App) CacheMessagesForAccount(email string) error { return nil } -// CacheMessagesForMailbox caches all of the messages from the server +// cacheMessagesForMailbox caches all of the messages from the server // that have not already been cached for the given email account and mailbox. // It caches them in the app's data directory. -func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string) error { - dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) - err := os.MkdirAll(string(dir), 0700) +func (a *App) cacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheMessage) error { + a.imapMu[email].Lock() + err := a.selectMailbox(c, email, mailbox) if err != nil { + a.imapMu[email].Unlock() return err } - cacheFile := a.cacheFilename(email) - err = os.MkdirAll(filepath.Dir(cacheFile), 0700) + uidsData, err := c.UIDSearch(&imap.SearchCriteria{}, nil).Wait() + a.imapMu[email].Unlock() if err != nil { - return err + return fmt.Errorf("searching for uids: %w", err) } - cached := map[string]*CacheData{} - err = jsonx.Open(&cached, cacheFile) - if err != nil && !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, io.EOF) { - return fmt.Errorf("opening cache list: %w", err) + // These are all of the UIDs, including those we have already cached. + uids := uidsData.AllUIDs() + if len(uids) == 0 { + a.AsyncLock() + a.Update() + a.AsyncUnlock() + return nil } - a.cache[email] = cached - err = a.selectMailbox(c, email, mailbox) + err = a.cleanCache(cached, email, mailbox, uids) if err != nil { return err } - // We want messages in this mailbox with UIDs we haven't already cached. - criteria := &imap.SearchCriteria{} - if len(cached) > 0 { - uidset := imap.UIDSet{} - for _, cd := range cached { - for _, label := range cd.Labels { - if label.Name == mailbox { - uidset.AddNum(label.UID) - } + alreadyHaveSlice := []imap.UID{} + alreadyHaveMap := map[imap.UID]bool{} + for _, cm := range cached { + for _, label := range cm.Labels { + if label.Name == mailbox { + alreadyHaveSlice = append(alreadyHaveSlice, label.UID) + alreadyHaveMap[label.UID] = true } } - - nc := imap.SearchCriteria{} - nc.UID = []imap.UIDSet{uidset} - criteria.Not = append(criteria.Not, nc) } - // these are the UIDs of the new messages - uidsData, err := c.UIDSearch(criteria, nil).Wait() + // We sync the flags of all UIDs we have already cached. + err = a.syncFlags(alreadyHaveSlice, c, email, mailbox, cached) if err != nil { - return fmt.Errorf("searching for uids: %w", err) + return err } - uids := uidsData.AllUIDs() - if len(uids) == 0 { - a.AsyncLock() - a.Update() - a.AsyncUnlock() - return nil + // We filter out the UIDs that are already cached. + uids = slices.DeleteFunc(uids, func(uid imap.UID) bool { + return alreadyHaveMap[uid] + }) + // We only cache in baches of 100 UIDs per mailbox to allow us to + // get to multiple mailboxes quickly. We want the last UIDs since + // those are typically the most recent messages. + if len(uids) > 100 { + uids = uids[len(uids)-100:] } - return a.CacheUIDs(uids, c, email, mailbox, dir, cached, cacheFile) + + return a.cacheUIDs(uids, c, email, mailbox, dir, cached) +} + +// cleanCache removes cached messages from the given mailbox if +// they are not part of the given list of UIDs. +func (a *App) cleanCache(cached map[string]*CacheMessage, email string, mailbox string, uids []imap.UID) error { + for id, cm := range cached { + cm.Labels = slices.DeleteFunc(cm.Labels, func(label Label) bool { + return label.Name == mailbox && !slices.Contains(uids, label.UID) + }) + // We can remove the message since it is removed from all mailboxes. + if len(cm.Labels) == 0 { + delete(cached, id) + } + } + a.imapMu[email].Lock() + err := a.saveCacheFile(cached, email) + a.imapMu[email].Unlock() + return err } -// CacheUIDs caches the messages with the given UIDs in the context of the +// cacheUIDs caches the messages with the given UIDs in the context of the // other given values, using an iterative batched approach that fetches the // five next most recent messages at a time, allowing for concurrent mail // modifiation operations and correct ordering. -func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheData, cacheFile string) error { +func (a *App) cacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheMessage) error { for len(uids) > 0 { num := min(5, len(uids)) cuids := uids[len(uids)-num:] // the current batch of UIDs @@ -218,6 +290,7 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai // already selected. err := a.selectMailbox(c, email, mailbox) if err != nil { + a.imapMu[email].Unlock() return err } mcmd := c.Fetch(fuidset, fetchOptions) @@ -237,22 +310,24 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai // If the message is already cached (likely in another mailbox), // we update its labels to include this mailbox if it doesn't already. if _, already := cached[mdata.Envelope.MessageID]; already { - cd := cached[mdata.Envelope.MessageID] - if !slices.ContainsFunc(cd.Labels, func(label Label) bool { + cm := cached[mdata.Envelope.MessageID] + if !slices.ContainsFunc(cm.Labels, func(label Label) bool { return label.Name == mailbox }) { - cd.Labels = append(cd.Labels, Label{mailbox, mdata.UID}) + cm.Labels = append(cm.Labels, Label{mailbox, mdata.UID}) } } else { // Otherwise, we add it as a new entry to the cache // and save the content to a file. - cached[mdata.Envelope.MessageID] = &CacheData{ + filename := messageFilename() + cached[mdata.Envelope.MessageID] = &CacheMessage{ Envelope: *mdata.Envelope, + Filename: filename, Flags: mdata.Flags, Labels: []Label{{mailbox, mdata.UID}}, } - f, err := os.Create(filepath.Join(dir, messageFilename(mdata.Envelope))) + f, err := os.Create(filepath.Join(dir, filename)) if err != nil { a.imapMu[email].Unlock() return err @@ -282,15 +357,16 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai } // We need to save the list of cached messages every time in case - // we get interrupted or have an error. - err = jsonx.Save(&cached, cacheFile) + // we get interrupted or have an error. We also start the AsyncLock + // here so that we cannot quit from the GUI while saving the file. + a.AsyncLock() + err = a.saveCacheFile(cached, email) if err != nil { a.imapMu[email].Unlock() - return fmt.Errorf("saving cache list: %w", err) + return err } a.cache[email] = cached - a.AsyncLock() a.Update() a.AsyncUnlock() } @@ -304,7 +380,58 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai return nil } -// messageFilename returns the filename for storing the message with the given envelope. -func messageFilename(env *imap.Envelope) string { - return FilenameBase32(env.MessageID) + ".eml" +var messageFilenameCounter uint64 + +// messageFilename returns a unique filename for storing a message +// based on the current time, the current process ID, and an atomic counter, +// which ensures uniqueness. +func messageFilename() string { + return fmt.Sprintf("%d%d%d", time.Now().UnixMilli(), os.Getpid(), atomic.AddUint64(&messageFilenameCounter, 1)) +} + +// syncFlags updates the IMAP flags of cached messages to match those on the server. +func (a *App) syncFlags(uids []imap.UID, c *imapclient.Client, email string, mailbox string, cached map[string]*CacheMessage) error { + if len(uids) == 0 { + return nil + } + + uidToMessage := map[imap.UID]*CacheMessage{} + for _, cm := range cached { + for _, label := range cm.Labels { + if label.Name == mailbox { + uidToMessage[label.UID] = cm + } + } + } + + uidset := imap.UIDSet{} + uidset.AddNum(uids...) + + fetchOptions := &imap.FetchOptions{Flags: true, UID: true} + a.imapMu[email].Lock() + // We must reselect the mailbox in case the user has changed it + // by doing actions in another mailbox. This is a no-op if it is + // already selected. + err := a.selectMailbox(c, email, mailbox) + if err != nil { + a.imapMu[email].Unlock() + return err + } + cmd := c.Fetch(uidset, fetchOptions) + for { + msg := cmd.Next() + if msg == nil { + break + } + + mdata, err := msg.Collect() + if err != nil { + a.imapMu[email].Unlock() + return err + } + uidToMessage[mdata.UID].Flags = mdata.Flags + } + err = a.saveCacheFile(cached, email) + a.imapMu[email].Unlock() + return err } diff --git a/mail/cmd/mail/mail.go b/mail/cmd/mail/mail.go index 74458ef2..d78c7d46 100644 --- a/mail/cmd/mail/mail.go +++ b/mail/cmd/mail/mail.go @@ -6,7 +6,6 @@ package main import ( "cogentcore.org/cogent/mail" - "cogentcore.org/core/base/errors" "cogentcore.org/core/core" "cogentcore.org/core/events" ) @@ -18,7 +17,7 @@ func main() { core.NewToolbar(bar).Maker(a.MakeToolbar) }) b.OnShow(func(e events.Event) { - errors.Log(a.GetMail()) + a.GetMail() }) b.RunMainWindow() } diff --git a/mail/read.go b/mail/read.go index 8d3a9158..1c89c517 100644 --- a/mail/read.go +++ b/mail/read.go @@ -12,33 +12,91 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/htmlcore" + "cogentcore.org/core/styles" + "cogentcore.org/core/tree" "github.com/emersion/go-message/mail" ) -// ReadMessage represents the data necessary to display a message -// for the user to read. -type ReadMessage struct { - From []*mail.Address `display:"inline"` - To []*mail.Address `display:"inline"` - Subject string - Date time.Time +// displayMessage represents the metadata necessary to display a message +// for the user to read. It does not contain the actual message contents. +type displayMessage struct { + From []*mail.Address `display:"inline"` + To []*mail.Address `display:"inline"` + Subject string + Date time.Time + Attachments []*Attachment `display:"inline"` } -// updateReadMessage updates the given frame to display the contents of +func (dm *displayMessage) ShouldDisplay(field string) bool { + switch field { + case "Attachments": + return len(dm.Attachments) > 0 + } + return true +} + +// readMessageParsed contains data parsed from the current message we are reading. +type readMessageParsed struct { + + // references is the References header. + references []string + + // plain is the plain text body. + plain string + + // attachments are the attachments. + attachments []*Attachment +} + +// Attachment represents an email attachment when reading a message. +type Attachment struct { + Filename string + Data []byte +} + +// DisplayMessageFrame is a frame that displays the metadata and contents of a message. +type DisplayMessageFrame struct { + core.Frame + Message *CacheMessage +} + +func (dmf *DisplayMessageFrame) WidgetValue() any { return &dmf.Message } + +func (dmf *DisplayMessageFrame) Init() { + dmf.Frame.Init() + dmf.Styler(func(s *styles.Style) { + s.Grow.Set(1, 0) + s.Direction = styles.Column + }) + tree.AddChild(dmf, func(w *core.Form) { + w.SetReadOnly(true) + w.Updater(func() { + w.SetStruct(dmf.Message.ToDisplay()) + }) + }) + tree.AddChild(dmf, func(w *core.Frame) { + w.Styler(func(s *styles.Style) { + s.Direction = styles.Column + s.Grow.Set(1, 0) + }) + w.Updater(func() { + core.ErrorSnackbar(w, dmf.displayMessageContents(w), "Error reading message") + }) + }) +} + +// displayMessageContents updates the given frame to display the contents of // the current message, if it does not already. -func (a *App) updateReadMessage(w *core.Frame) error { - if a.readMessage == w.Property("readMessage") { +func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { + if dmf.Message == w.Property("readMessage") { return nil } - w.SetProperty("readMessage", a.readMessage) + w.SetProperty("readMessage", dmf.Message) w.DeleteChildren() - if a.readMessage == nil { - return nil - } - bemail := FilenameBase32(a.currentEmail) + bemail := filenameBase32(theApp.currentEmail) - f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, messageFilename(&a.readMessage.Envelope))) + f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, dmf.Message.Filename)) if err != nil { return err } @@ -53,10 +111,10 @@ func (a *App) updateReadMessage(w *core.Frame) error { if err != nil { return err } - a.readMessageReferences = refs - - var gotHTML bool + dmf.Message.parsed.references = refs + dmf.Message.parsed.attachments = nil + gotHTML := false for { p, err := mr.NextPart() if err == io.EOF { @@ -78,7 +136,7 @@ func (a *App) updateReadMessage(w *core.Frame) error { if err != nil { return err } - a.readMessagePlain = string(b) + dmf.Message.parsed.plain = string(b) case "text/html": err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { @@ -86,12 +144,23 @@ func (a *App) updateReadMessage(w *core.Frame) error { } gotHTML = true } + case *mail.AttachmentHeader: + fname, err := h.Filename() + if err != nil { + return err + } + at := &Attachment{Filename: fname} + at.Data, err = io.ReadAll(p.Body) + if err != nil { + return err + } + dmf.Message.parsed.attachments = append(dmf.Message.parsed.attachments, at) } } // we only handle the plain version if there is no HTML version if !gotHTML { - err := htmlcore.ReadMDString(htmlcore.NewContext(), w, a.readMessagePlain) + err := htmlcore.ReadMDString(htmlcore.NewContext(), w, dmf.Message.parsed.plain) if err != nil { return err } diff --git a/mail/send.go b/mail/send.go index ec347412..4e5f3b69 100644 --- a/mail/send.go +++ b/mail/send.go @@ -7,7 +7,10 @@ package mail import ( "bytes" "fmt" + "io" "log/slog" + "os" + "path/filepath" "time" "cogentcore.org/core/base/fileinfo" @@ -22,11 +25,12 @@ import ( // SendMessage represents the data necessary for the user to send a message. type SendMessage struct { - From []*mail.Address `display:"inline"` - To []*mail.Address `display:"inline"` - Subject string - body string + From []*mail.Address `display:"inline"` + To []*mail.Address `display:"inline"` + Subject string + Attachments []core.Filename `display:"inline"` + body string inReplyTo string references []string } @@ -84,23 +88,26 @@ func (a *App) Send() error { //types:add return err } - tw, err := mw.CreateInline() + iw, err := mw.CreateInline() if err != nil { return err } var ph mail.InlineHeader - ph.Set("Content-Type", "text/plain") - pw, err := tw.CreatePart(ph) + ph.SetContentType("text/plain", nil) + pw, err := iw.CreatePart(ph) if err != nil { return err } pw.Write([]byte(a.composeMessage.body)) - pw.Close() + err = pw.Close() + if err != nil { + return err + } var hh mail.InlineHeader - hh.Set("Content-Type", "text/html") - hw, err := tw.CreatePart(hh) + hh.SetContentType("text/html", nil) + hw, err := iw.CreatePart(hh) if err != nil { return err } @@ -108,9 +115,45 @@ func (a *App) Send() error { //types:add if err != nil { return err } - hw.Close() - tw.Close() - mw.Close() + err = hw.Close() + if err != nil { + return err + } + err = iw.Close() + if err != nil { + return err + } + + for _, at := range a.composeMessage.Attachments { + fname := string(at) + ah := mail.AttachmentHeader{} + ah.SetFilename(filepath.Base(fname)) + fi, err := fileinfo.NewFileInfo(fname) + if err != nil { + return err + } + ah.SetContentType(fi.Mime, nil) + aw, err := mw.CreateAttachment(ah) + if err != nil { + return err + } + f, err := os.Open(fname) + if err != nil { + return err + } + _, err = io.Copy(aw, f) + if err != nil { + return err + } + err = aw.Close() + if err != nil { + return err + } + } + err = mw.Close() + if err != nil { + return err + } to := make([]string, len(a.composeMessage.To)) for i, t := range a.composeMessage.To { diff --git a/mail/settings.go b/mail/settings.go index 129ad484..dd86ec6c 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -7,8 +7,10 @@ package mail import ( "path/filepath" "slices" + "strings" "cogentcore.org/core/core" + "cogentcore.org/core/icons" ) func init() { @@ -33,6 +35,7 @@ type SettingsData struct { //types:add // friendlyLabelName converts the given label name to a user-friendly version. func friendlyLabelName(name string) string { + name = strings.TrimPrefix(name, "[Gmail]/") if f, ok := friendlyLabelNames[name]; ok { return f } @@ -40,5 +43,24 @@ func friendlyLabelName(name string) string { } var friendlyLabelNames = map[string]string{ - "INBOX": "Inbox", + "INBOX": "Inbox", + "Sent Mail": "Sent", +} + +// skipLabels are a temporary set of labels that should not be cached or displayed. +// TODO: figure out a better approach to this. +var skipLabels = map[string]bool{ + "[Gmail]": true, + "[Gmail]/All Mail": true, + "[Gmail]/Important": true, + "[Gmail]/Starred": true, +} + +// labelIcons are the icons for each friendly label name. +var labelIcons = map[string]icons.Icon{ + "Inbox": icons.Inbox, + "Drafts": icons.Draft, + "Sent": icons.Send, + "Spam": icons.Report, + "Trash": icons.Delete, } diff --git a/mail/typegen.go b/mail/typegen.go index 890f9fbc..15ae83bc 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -8,24 +8,38 @@ import ( "github.com/emersion/go-message/mail" ) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.App", IDName: "app", Doc: "App is an email client app.", Methods: []types.Method{{Name: "Label", Doc: "Label opens a dialog for changing the labels (mailboxes) of the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Reply", Doc: "Reply opens a dialog to reply to the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "ReplyAll", Doc: "ReplyAll opens a dialog to reply to all people involved in the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Forward", Doc: "Forward opens a dialog to forward the current message to others.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "MarkAsRead", Doc: "MarkAsRead marks the current message as read.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "MarkAsUnread", Doc: "MarkAsUnread marks the current message as unread.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Compose", Doc: "Compose opens a dialog to send a new message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Send", Doc: "Send sends the current message", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"error"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "authToken", Doc: "authToken contains the [oauth2.Token] for each account."}, {Name: "authClient", Doc: "authClient contains the [sasl.Client] authentication for sending messages for each account."}, {Name: "imapClient", Doc: "imapClient contains the imap clients for each account."}, {Name: "imapMu", Doc: "imapMu contains the imap client mutexes for each account."}, {Name: "composeMessage", Doc: "composeMessage is the current message we are editing"}, {Name: "cache", Doc: "cache contains the cached message data, keyed by account and then MessageID."}, {Name: "listCache", Doc: "listCache is a sorted view of [App.cache] for the current email account\nand labels, used for displaying a [core.List] of messages. It should not\nbe used for any other purpose."}, {Name: "readMessage", Doc: "readMessage is the current message we are reading"}, {Name: "readMessageReferences", Doc: "readMessageReferences is the References header of the current readMessage."}, {Name: "readMessagePlain", Doc: "readMessagePlain is the plain text body of the current readMessage."}, {Name: "currentEmail", Doc: "currentEmail is the current email account."}, {Name: "selectedMailbox", Doc: "selectedMailbox is the currently selected mailbox for each email account in IMAP."}, {Name: "labels", Doc: "labels are all of the possible labels that messages have.\nThe first key is the account for which the labels are stored,\nand the second key is for each label name."}, {Name: "showLabel", Doc: "showLabel is the current label to show messages for."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.App", IDName: "app", Doc: "App is an email client app.", Methods: []types.Method{{Name: "Label", Doc: "Label opens a dialog for changing the labels (mailboxes) of the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Delete", Doc: "Delete moves the current message to the trash.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Reply", Doc: "Reply opens a dialog to reply to the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "ReplyAll", Doc: "ReplyAll opens a dialog to reply to all people involved in the current message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Forward", Doc: "Forward opens a dialog to forward the current message to others.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "MarkAsRead", Doc: "MarkAsRead marks the current message as read.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "MarkAsUnread", Doc: "MarkAsUnread marks the current message as unread.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Compose", Doc: "Compose opens a dialog to send a new message.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Send", Doc: "Send sends the current message", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"error"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "authToken", Doc: "authToken contains the [oauth2.Token] for each account."}, {Name: "authClient", Doc: "authClient contains the [sasl.Client] authentication for sending messages for each account."}, {Name: "imapClient", Doc: "imapClient contains the imap clients for each account."}, {Name: "imapMu", Doc: "imapMu contains the imap client mutexes for each account."}, {Name: "composeMessage", Doc: "composeMessage is the current message we are editing"}, {Name: "cache", Doc: "cache contains the cached message data, keyed by account and then MessageID."}, {Name: "listCache", Doc: "listCache is a sorted view of [App.cache] for the current email account\nand labels, used for displaying a [core.List] of messages. It should not\nbe used for any other purpose."}, {Name: "totalMessages", Doc: "totalMessages is the total number of messages for each email account and label."}, {Name: "unreadMessages", Doc: "unreadMessages is the number of unread messages for the current email account\nand labels, used for displaying a count."}, {Name: "readMessage", Doc: "readMessage is the current message we are reading."}, {Name: "readMessageParsed", Doc: "readMessageParsed contains data parsed from the current message we are reading."}, {Name: "currentEmail", Doc: "currentEmail is the current email account."}, {Name: "selectedMailbox", Doc: "selectedMailbox is the currently selected mailbox for each email account in IMAP."}, {Name: "labels", Doc: "labels are all of the possible labels that messages can have in\neach email account."}, {Name: "showLabel", Doc: "showLabel is the current label to show messages for."}}}) // NewApp returns a new [App] with the given optional parent: // App is an email client app. func NewApp(parent ...tree.Node) *App { return tree.New[App](parent...) } +var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.DisplayMessageFrame", IDName: "display-message-frame", Doc: "DisplayMessageFrame is a frame that displays the metadata and contents of a message.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Message"}}}) + +// NewDisplayMessageFrame returns a new [DisplayMessageFrame] with the given optional parent: +// DisplayMessageFrame is a frame that displays the metadata and contents of a message. +func NewDisplayMessageFrame(parent ...tree.Node) *DisplayMessageFrame { + return tree.New[DisplayMessageFrame](parent...) +} + +// SetMessage sets the [DisplayMessageFrame.Message] +func (t *DisplayMessageFrame) SetMessage(v *CacheMessage) *DisplayMessageFrame { + t.Message = v + return t +} + var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.SettingsData", IDName: "settings-data", Doc: "SettingsData is the data type for the global Cogent Mail settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Accounts", Doc: "Accounts are the email accounts the user is signed into."}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.MessageListItem", IDName: "message-list-item", Doc: "MessageListItem represents a [CacheData] with a [core.Frame] for the message list.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Data"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.MessageListItem", IDName: "message-list-item", Doc: "MessageListItem represents a [CacheMessage] with a [core.Frame] for the message list.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Message"}}}) // NewMessageListItem returns a new [MessageListItem] with the given optional parent: -// MessageListItem represents a [CacheData] with a [core.Frame] for the message list. +// MessageListItem represents a [CacheMessage] with a [core.Frame] for the message list. func NewMessageListItem(parent ...tree.Node) *MessageListItem { return tree.New[MessageListItem](parent...) } -// SetData sets the [MessageListItem.Data] -func (t *MessageListItem) SetData(v *CacheData) *MessageListItem { t.Data = v; return t } +// SetMessage sets the [MessageListItem.Message] +func (t *MessageListItem) SetMessage(v *CacheMessage) *MessageListItem { t.Message = v; return t } var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.AddressTextField", IDName: "address-text-field", Doc: "AddressTextField represents a [mail.Address] with a [core.TextField].", Embeds: []types.Field{{Name: "TextField"}}, Fields: []types.Field{{Name: "Address"}}}) @@ -37,3 +51,14 @@ func NewAddressTextField(parent ...tree.Node) *AddressTextField { // SetAddress sets the [AddressTextField.Address] func (t *AddressTextField) SetAddress(v mail.Address) *AddressTextField { t.Address = v; return t } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/cogent/mail.AttachmentButton", IDName: "attachment-button", Doc: "AttachmentButton represents an [Attachment] with a [core.Button].", Embeds: []types.Field{{Name: "Button"}}, Fields: []types.Field{{Name: "Attachment"}}}) + +// NewAttachmentButton returns a new [AttachmentButton] with the given optional parent: +// AttachmentButton represents an [Attachment] with a [core.Button]. +func NewAttachmentButton(parent ...tree.Node) *AttachmentButton { + return tree.New[AttachmentButton](parent...) +} + +// SetAttachment sets the [AttachmentButton.Attachment] +func (t *AttachmentButton) SetAttachment(v *Attachment) *AttachmentButton { t.Attachment = v; return t } diff --git a/mail/values.go b/mail/values.go index 8622b1c1..72ecaacd 100644 --- a/mail/values.go +++ b/mail/values.go @@ -7,31 +7,35 @@ package mail import ( "fmt" "net/mail" - "slices" + "os" + "path/filepath" "strings" + "cogentcore.org/core/base/errors" "cogentcore.org/core/colors" "cogentcore.org/core/core" "cogentcore.org/core/cursors" "cogentcore.org/core/events" + "cogentcore.org/core/icons" "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/tree" - "github.com/emersion/go-imap/v2" + "github.com/mitchellh/go-homedir" ) func init() { - core.AddValueType[CacheData, MessageListItem]() + core.AddValueType[CacheMessage, MessageListItem]() core.AddValueType[mail.Address, AddressTextField]() + core.AddValueType[Attachment, AttachmentButton]() } -// MessageListItem represents a [CacheData] with a [core.Frame] for the message list. +// MessageListItem represents a [CacheMessage] with a [core.Frame] for the message list. type MessageListItem struct { core.Frame - Data *CacheData + Message *CacheMessage } -func (mi *MessageListItem) WidgetValue() any { return &mi.Data } +func (mi *MessageListItem) WidgetValue() any { return &mi.Message } func (mi *MessageListItem) Init() { mi.Frame.Init() @@ -42,7 +46,7 @@ func (mi *MessageListItem) Init() { s.Grow.Set(1, 0) }) mi.OnClick(func(e events.Event) { - theApp.readMessage = mi.Data + theApp.readMessage = mi.Message theApp.MarkAsRead() theApp.Update() }) @@ -55,10 +59,13 @@ func (mi *MessageListItem) Init() { }) w.Updater(func() { text := "" - if !slices.Contains(mi.Data.Flags, imap.FlagSeen) { - text = fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) + if !mi.Message.isRead() { + text += fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) } - for _, f := range mi.Data.From { + if len(mi.Message.replies) > 0 { + text += fmt.Sprintf(`%d `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base)), len(mi.Message.replies)+1) + } + for _, f := range mi.Message.From { if f.Name != "" { text += f.Name + " " } else { @@ -75,7 +82,7 @@ func (mi *MessageListItem) Init() { s.SetTextWrap(false) }) w.Updater(func() { - w.SetText(mi.Data.Subject) + w.SetText(mi.Message.Subject) }) }) } @@ -106,3 +113,26 @@ func (at *AddressTextField) Init() { return nil }) } + +// AttachmentButton represents an [Attachment] with a [core.Button]. +type AttachmentButton struct { + core.Button + Attachment *Attachment +} + +func (ab *AttachmentButton) WidgetValue() any { return &ab.Attachment } + +func (ab *AttachmentButton) Init() { + ab.Button.Init() + ab.SetIcon(icons.Download).SetType(core.ButtonTonal) + ab.Updater(func() { + ab.SetText(ab.Attachment.Filename) + }) + ab.OnClick(func(e events.Event) { + fb := core.NewSoloFuncButton(ab).SetFunc(func(filename core.Filename) error { + return os.WriteFile(string(filename), ab.Attachment.Data, 0666) + }) + fb.Args[0].Value = core.Filename(filepath.Join(errors.Log1(homedir.Dir()), "Downloads", ab.Attachment.Filename)) + fb.CallFunc() + }) +}