diff --git a/mail/actions.go b/mail/actions.go index 53b7fbed..9cf4bdc2 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -5,20 +5,188 @@ 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" + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-message/mail" ) -// MoveMessage moves the current message to the given mailbox. -func (a *App) MoveMessage(mailbox string) error { //types:add - c := a.IMAPClient[a.CurrentEmail] - uidset := imap.UIDSet{} - uidset.AddNum(a.ReadMessage.UID) - fmt.Println(uidset) - mc := c.Move(uidset, mailbox) - fmt.Println("mc", mc) - md, err := mc.Wait() - fmt.Println("md", md, err) - return err +// 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)) { + // 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)) + core.ErrorSnackbar(a, err, "Error saving cache") + mu.Unlock() + a.AsyncLock() + a.Update() + a.AsyncUnlock() + }() +} + +// 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) { + for _, label := range a.readMessage.Labels { + err := a.selectMailbox(c, a.currentEmail, label.Name) + if err != nil { + core.ErrorSnackbar(a, err) + return + } + f(c, label) + } + }) +} + +// 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)) + for i, label := range a.readMessage.Labels { + labels[i] = label.Name + } + ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true) + ch.OnChange(func(e events.Event) { + labels = append(labels, ch.CurrentItem.Value.(string)) + }) + core.NewList(d).SetSlice(&labels) + d.AddBottomBar(func(bar *core.Frame) { + d.AddCancel(bar) + d.AddOK(bar).SetText("Save") + }) + 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") + // }) +} + +// Reply opens a dialog to reply to the current message. +func (a *App) Reply() { //types:add + a.composeMessage = &SendMessage{} + a.composeMessage.To = IMAPToMailAddresses(a.readMessage.From) + // If we sent the original message, reply to the original receiver instead of ourself. + if a.composeMessage.To[0].Address == a.currentEmail { + a.composeMessage.To = IMAPToMailAddresses(a.readMessage.To) + } + a.reply("Reply", false) +} + +// ReplyAll opens a dialog to reply to all people involved in the current message. +func (a *App) ReplyAll() { //types:add + a.composeMessage = &SendMessage{} + a.composeMessage.To = append(IMAPToMailAddresses(a.readMessage.From), IMAPToMailAddresses(a.readMessage.To)...) + a.reply("Reply all", false) +} + +// Forward opens a dialog to forward the current message to others. +func (a *App) Forward() { //types:add + a.composeMessage = &SendMessage{} + a.composeMessage.To = []*mail.Address{{}} + a.reply("Forward", true) +} + +// reply is the implementation of the email reply dialog, +// used by other higher-level functions. forward is whether +// this is actually a forward instead of a reply. +func (a *App) reply(title string, forward bool) { + // If we have more than one receiver, then we should not be one of them. + if len(a.composeMessage.To) > 1 { + a.composeMessage.To = slices.DeleteFunc(a.composeMessage.To, func(ma *mail.Address) bool { + return ma.Address == a.currentEmail + }) + // If all of the receivers were us, then we should reply to ourself. + if len(a.composeMessage.To) == 0 { + a.composeMessage.To = []*mail.Address{{Address: a.currentEmail}} + } + } + a.composeMessage.Subject = a.readMessage.Subject + prefix := "Re: " + if forward { + prefix = "Fwd: " + } + if !strings.HasPrefix(a.composeMessage.Subject, prefix) { + a.composeMessage.Subject = prefix + a.composeMessage.Subject + } + a.composeMessage.inReplyTo = a.readMessage.MessageID + a.composeMessage.references = append(a.readMessageReferences, 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 { + a.composeMessage.body = "\n\n> Begin forwarded message:\n>" + a.composeMessage.body += "\n> From: " + from + // Need 2 spaces to create a newline in markdown. + a.composeMessage.body += " \n> Subject: " + a.readMessage.Subject + a.composeMessage.body += " \n> Date: " + date + to := make([]string, len(a.readMessage.To)) + for i, addr := range IMAPToMailAddresses(a.readMessage.To) { + to[i] = addr.String() + } + a.composeMessage.body += " \n> To: " + strings.Join(to, ", ") + } else { + a.composeMessage.body = "\n\n> On " + date + ", " + from + " wrote:" + } + a.composeMessage.body += "\n>\n> " + a.composeMessage.body += strings.ReplaceAll(a.readMessagePlain, "\n", "\n> ") + a.compose(title) +} + +// MarkAsRead marks the current message as read. +func (a *App) MarkAsRead() { //types:add + a.markSeen(true) +} + +// MarkAsUnread marks the current message as unread. +func (a *App) MarkAsUnread() { //types:add + a.markSeen(false) +} + +// 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 { + // Already set correctly. + return + } + a.actionLabels(func(c *imapclient.Client, label Label) { + uidset := imap.UIDSet{} + uidset.AddNum(label.UID) + 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() + if err != nil { + core.ErrorSnackbar(a, err, "Error marking message as read") + return + } + // Also directly update the cache: + flags := &a.cache[a.currentEmail][a.readMessage.MessageID].Flags + if seen && !slices.Contains(*flags, imap.FlagSeen) { + *flags = append(*flags, imap.FlagSeen) + } else if !seen { + *flags = slices.DeleteFunc(*flags, func(flag imap.Flag) bool { + return flag == imap.FlagSeen + }) + } + }) } diff --git a/mail/app.go b/mail/app.go index d1ce9b53..73b586b1 100644 --- a/mail/app.go +++ b/mail/app.go @@ -8,8 +8,18 @@ package mail //go:generate core generate import ( + "cmp" + "fmt" + "path/filepath" + "slices" + "sync" + + "golang.org/x/exp/maps" + "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/imapclient" @@ -21,75 +31,163 @@ import ( type App struct { core.Frame - // AuthToken contains the [oauth2.Token] for each account. - AuthToken map[string]*oauth2.Token `set:"-"` + // authToken contains the [oauth2.Token] for each account. + authToken map[string]*oauth2.Token + + // authClient contains the [sasl.Client] authentication for sending messages for each account. + authClient map[string]sasl.Client + + // imapClient contains the imap clients for each account. + imapClient map[string]*imapclient.Client + + // imapMu contains the imap client mutexes for each account. + imapMu map[string]*sync.Mutex + + // composeMessage is the current message we are editing + composeMessage *SendMessage + + // cache contains the cached message data, keyed by account and then MessageID. + cache map[string]map[string]*CacheData + + // 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 - // AuthClient contains the [sasl.Client] authentication for sending messages for each account. - AuthClient map[string]sasl.Client `set:"-"` + // readMessage is the current message we are reading + readMessage *CacheData - // IMAPCLient contains the imap clients for each account. - IMAPClient map[string]*imapclient.Client `set:"-"` + // readMessageReferences is the References header of the current readMessage. + readMessageReferences []string - // ComposeMessage is the current message we are editing - ComposeMessage *SendMessage `set:"-"` + // readMessagePlain is the plain text body of the current readMessage. + readMessagePlain string - // Cache contains the cache data, keyed by account and then mailbox. - Cache map[string]map[string][]*CacheData `set:"-"` + // currentEmail is the current email account. + currentEmail string - // ReadMessage is the current message we are reading - ReadMessage *CacheData `set:"-"` + // selectedMailbox is the currently selected mailbox for each email account in IMAP. + selectedMailbox map[string]string - // The current email account - CurrentEmail string `set:"-"` + // 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 - // The current mailbox - CurrentMailbox string `set:"-"` + // showLabel is the current label to show messages for. + showLabel string } // needed for interface import var _ tree.Node = (*App)(nil) +// theApp is the current app instance. +// TODO: ideally we could remove this. +var theApp *App + func (a *App) Init() { + theApp = a a.Frame.Init() - a.AuthToken = map[string]*oauth2.Token{} - a.AuthClient = map[string]sasl.Client{} + a.authToken = map[string]*oauth2.Token{} + a.authClient = map[string]sasl.Client{} + a.selectedMailbox = map[string]string{} + a.labels = map[string]map[string]bool{} + a.showLabel = "INBOX" a.Styler(func(s *styles.Style) { s.Grow.Set(1, 1) }) - tree.AddChildAt(a, "splits", func(w *core.Splits) { - tree.AddChildAt(w, "mbox", func(w *core.Tree) { - w.SetText("Mailboxes") + tree.AddChild(a, func(w *core.Splits) { + w.SetSplits(0.1, 0.2, 0.7) + tree.AddChild(w, func(w *core.Tree) { + w.SetText("Accounts") + 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() + }) + }) + } + }) + }) + } + }) }) - tree.AddChildAt(w, "list", func(w *core.Frame) { - w.Styler(func(s *styles.Style) { - s.Direction = styles.Column + tree.AddChild(w, func(w *core.List) { + w.SetSlice(&a.listCache) + w.SetReadOnly(true) + w.Updater(func() { + a.listCache = nil + 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 + } + } + } + slices.SortFunc(a.listCache, func(a, b *CacheData) int { + return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) + }) }) }) - tree.AddChildAt(w, "mail", func(w *core.Frame) { + tree.AddChild(w, func(w *core.Frame) { w.Styler(func(s *styles.Style) { s.Direction = styles.Column }) - tree.AddChildAt(w, "msv", func(w *core.Form) { + tree.AddChild(w, func(w *core.Form) { w.SetReadOnly(true) + w.Updater(func() { + w.SetStruct(a.readMessage.ToMessage()) + }) }) - tree.AddChildAt(w, "mb", func(w *core.Frame) { + tree.AddChild(w, 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, a.updateReadMessage(w), "Error reading message") }) }) }) - w.SetSplits(0.1, 0.2, 0.7) - }) - a.Updater(func() { - // a.UpdateReadMessage(ml, msv, mb) }) } func (a *App) MakeToolbar(p *tree.Plan) { tree.Add(p, func(w *core.FuncButton) { - w.SetFunc(a.Compose).SetIcon(icons.Send) + w.SetFunc(a.Compose).SetIcon(icons.Send).SetKey(keymap.New) }) + + if a.readMessage != nil { + tree.Add(p, func(w *core.Separator) {}) + 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.Reply).SetIcon(icons.Reply).SetKey(keymap.Replace) + }) + tree.Add(p, func(w *core.FuncButton) { + w.SetFunc(a.ReplyAll).SetIcon(icons.ReplyAll) + }) + tree.Add(p, func(w *core.FuncButton) { + w.SetFunc(a.Forward).SetIcon(icons.Forward) + }) + tree.Add(p, func(w *core.FuncButton) { + w.SetFunc(a.MarkAsUnread).SetIcon(icons.MarkAsUnread) + }) + } } func (a *App) GetMail() error { @@ -106,3 +204,23 @@ func (a *App) GetMail() error { }() return nil } + +// selectMailbox selects the given mailbox for the given email for the given client. +// It does nothing if the given mailbox is already selected. +func (a *App) selectMailbox(c *imapclient.Client, email string, mailbox string) error { + if a.selectedMailbox[email] == mailbox { + return nil // already selected + } + _, err := c.Select(mailbox, nil).Wait() + if err != nil { + return fmt.Errorf("selecting mailbox: %w", err) + } + a.selectedMailbox[email] = mailbox + 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") +} diff --git a/mail/auth.go b/mail/auth.go index 5de7836e..f59ab8b1 100644 --- a/mail/auth.go +++ b/mail/auth.go @@ -25,7 +25,7 @@ func (a *App) Auth() error { return err } - a.AuthClient[email] = xoauth2.NewXoauth2Client(email, a.AuthToken[email].AccessToken) + a.authClient[email] = xoauth2.NewXoauth2Client(email, a.authToken[email].AccessToken) return nil } @@ -39,8 +39,8 @@ func (a *App) SignIn() (string, error) { Settings.Accounts = append(Settings.Accounts, userInfo.Email) errors.Log(core.SaveSettings(Settings)) } - a.CurrentEmail = userInfo.Email - a.AuthToken[userInfo.Email] = token + a.currentEmail = userInfo.Email + a.authToken[userInfo.Email] = token d.Close() email <- userInfo.Email } diff --git a/mail/cache.go b/mail/cache.go index 4d8be215..1f373549 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -12,12 +12,12 @@ import ( "net/mail" "os" "path/filepath" - "strconv" + "slices" "strings" + "sync" "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" - "cogentcore.org/core/events" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" ) @@ -27,12 +27,27 @@ import ( // mail list in the GUI. type CacheData struct { imap.Envelope - UID imap.UID - Filename string + 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 +} + +// Label represents a Label associated with a message. +// It contains the name of the Label and the UID of the message +// in the IMAP mailbox corresponding to the Label. +type Label struct { + Name string + UID imap.UID } // ToMessage converts the [CacheData] to a [ReadMessage]. func (cd *CacheData) ToMessage() *ReadMessage { + if cd == nil { + return nil + } return &ReadMessage{ From: IMAPToMailAddresses(cd.From), To: IMAPToMailAddresses(cd.To), @@ -56,16 +71,15 @@ func IMAPToMailAddresses(as []imap.Address) []*mail.Address { // 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.cache == nil { + a.cache = map[string]map[string]*CacheData{} } - if a.IMAPClient == nil { - a.IMAPClient = map[string]*imapclient.Client{} + if a.imapClient == nil { + a.imapClient = map[string]*imapclient.Client{} + } + if a.imapMu == nil { + a.imapMu = map[string]*sync.Mutex{} } - mbox := a.FindPath("splits/mbox").(*core.Tree) - mbox.AsyncLock() - mbox.DeleteChildren() - mbox.AsyncUnlock() for _, account := range Settings.Accounts { err := a.CacheMessagesForAccount(account) if err != nil { @@ -79,8 +93,8 @@ func (a *App) CacheMessages() error { // 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 { - if a.Cache[email] == nil { - a.Cache[email] = map[string][]*CacheData{} + if a.cache[email] == nil { + a.cache[email] = map[string]*CacheData{} } c, err := imapclient.DialTLS("imap.gmail.com:993", nil) @@ -89,9 +103,10 @@ func (a *App) CacheMessagesForAccount(email string) error { } defer c.Logout() - a.IMAPClient[email] = c + a.imapClient[email] = c + a.imapMu[email] = &sync.Mutex{} - err = c.Authenticate(a.AuthClient[email]) + err = c.Authenticate(a.authClient[email]) if err != nil { return fmt.Errorf("authenticating: %w", err) } @@ -102,6 +117,9 @@ func (a *App) CacheMessagesForAccount(email string) error { } for _, mailbox := range mailboxes { + if strings.HasPrefix(mailbox.Mailbox, "[Gmail]") { + continue // TODO: skipping for now until we figure out a good way to handle + } err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox) if err != nil { return fmt.Errorf("caching messages for mailbox %q: %w", mailbox.Mailbox, err) @@ -114,55 +132,40 @@ func (a *App) CacheMessagesForAccount(email string) error { // 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 { - if a.CurrentMailbox == "" { - a.CurrentMailbox = mailbox - } - - bemail := FilenameBase32(email) - - a.AsyncLock() - mbox := a.FindPath("splits/mbox").(*core.Tree) - embox := mbox.ChildByName(bemail) - if embox == nil { - embox = core.NewTree(mbox).SetText(email) // TODO(config) - embox.AsTree().SetName(bemail) - } - core.NewTree(embox).SetText(mailbox).OnClick(func(e events.Event) { - a.CurrentMailbox = mailbox - a.UpdateMessageList() - }) - a.AsyncUnlock() - - dir := filepath.Join(core.TheApp.AppDataDir(), "mail", bemail) + dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) err := os.MkdirAll(string(dir), 0700) if err != nil { return err } - cachedFile := filepath.Join(core.TheApp.AppDataDir(), "caching", bemail, "cached-messages.json") - err = os.MkdirAll(filepath.Dir(cachedFile), 0700) + cacheFile := a.cacheFilename(email) + err = os.MkdirAll(filepath.Dir(cacheFile), 0700) if err != nil { return err } - var cached []*CacheData - err = jsonx.Open(&cached, cachedFile) + 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) } - a.Cache[email][mailbox] = cached + a.cache[email] = cached - _, err = c.Select(mailbox, nil).Wait() + err = a.selectMailbox(c, email, mailbox) if err != nil { - return fmt.Errorf("opening mailbox: %w", err) + return err } - // we want messages with UIDs not in the list we already cached + // We want messages in this mailbox with UIDs we haven't already cached. criteria := &imap.SearchCriteria{} if len(cached) > 0 { uidset := imap.UIDSet{} - for _, c := range cached { - uidset.AddNum(c.UID) + for _, cd := range cached { + for _, label := range cd.Labels { + if label.Name == mailbox { + uidset.AddNum(label.UID) + } + } } nc := imap.SearchCriteria{} @@ -178,17 +181,19 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo uids := uidsData.AllUIDs() if len(uids) == 0 { - a.UpdateMessageList() + a.AsyncLock() + a.Update() + a.AsyncUnlock() return nil } - return a.CacheUIDs(uids, c, email, mailbox, dir, cached, cachedFile) + return a.CacheUIDs(uids, c, email, mailbox, dir, cached, cacheFile) } // 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 []*CacheData, cachedFile string) error { +func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheData, cacheFile string) error { for len(uids) > 0 { num := min(5, len(uids)) cuids := uids[len(uids)-num:] // the current batch of UIDs @@ -199,13 +204,22 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai fetchOptions := &imap.FetchOptions{ Envelope: true, + Flags: true, UID: true, BodySection: []*imap.FetchItemBodySection{ - {Specifier: imap.PartSpecifierHeader}, - {Specifier: imap.PartSpecifierText}, + {Specifier: imap.PartSpecifierHeader, Peek: true}, + {Specifier: imap.PartSpecifierText, Peek: 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 { + return err + } mcmd := c.Fetch(fuidset, fetchOptions) for { @@ -216,58 +230,81 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai mdata, err := msg.Collect() if err != nil { + a.imapMu[email].Unlock() return err } - filename := strconv.FormatUint(uint64(mdata.UID), 32) - filename = strings.Repeat("0", 7-len(filename)) + filename - f, err := os.Create(filepath.Join(dir, filename)) - if err != nil { - return err - } - - var header, text []byte + // 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 { + return label.Name == mailbox + }) { + cd.Labels = append(cd.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{ + Envelope: *mdata.Envelope, + Flags: mdata.Flags, + Labels: []Label{{mailbox, mdata.UID}}, + } - for k, v := range mdata.BodySection { - if k.Specifier == imap.PartSpecifierHeader { - header = v - } else if k.Specifier == imap.PartSpecifierText { - text = v + f, err := os.Create(filepath.Join(dir, messageFilename(mdata.Envelope))) + if err != nil { + a.imapMu[email].Unlock() + return err } - } - _, err = f.Write(append(header, text...)) - if err != nil { - return fmt.Errorf("writing message: %w", err) - } + var header, text []byte - err = f.Close() - if err != nil { - return fmt.Errorf("closing message: %w", err) - } + for k, v := range mdata.BodySection { + if k.Specifier == imap.PartSpecifierHeader { + header = v + } else if k.Specifier == imap.PartSpecifierText { + text = v + } + } + + _, err = f.Write(append(header, text...)) + if err != nil { + a.imapMu[email].Unlock() + return fmt.Errorf("writing message: %w", err) + } - cd := &CacheData{ - Envelope: *mdata.Envelope, - UID: mdata.UID, - Filename: filename, + err = f.Close() + if err != nil { + a.imapMu[email].Unlock() + return fmt.Errorf("closing message: %w", err) + } } - // we need to save the list of cached messages every time in case - // we get interrupted or have an error - cached = append(cached, cd) - err = jsonx.Save(&cached, cachedFile) + // 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) if err != nil { + a.imapMu[email].Unlock() return fmt.Errorf("saving cache list: %w", err) } - a.Cache[email][mailbox] = cached - a.UpdateMessageList() + a.cache[email] = cached + a.AsyncLock() + a.Update() + a.AsyncUnlock() } - err := mcmd.Close() + err = mcmd.Close() + a.imapMu[email].Unlock() if err != nil { return fmt.Errorf("fetching messages: %w", err) } } 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" +} diff --git a/mail/read.go b/mail/read.go index b798bcb1..8d3a9158 100644 --- a/mail/read.go +++ b/mail/read.go @@ -5,21 +5,13 @@ package mail import ( - "cmp" "io" "os" "path/filepath" - "slices" "time" - "cogentcore.org/core/base/errors" "cogentcore.org/core/core" - "cogentcore.org/core/cursors" - "cogentcore.org/core/events" "cogentcore.org/core/htmlcore" - "cogentcore.org/core/icons" - "cogentcore.org/core/styles" - "cogentcore.org/core/styles/abilities" "github.com/emersion/go-message/mail" ) @@ -32,83 +24,21 @@ type ReadMessage struct { Date time.Time } -// UpdateMessageList updates the message list from [App.Cache]. -func (a *App) UpdateMessageList() { - cached := a.Cache[a.CurrentEmail][a.CurrentMailbox] - - a.AsyncLock() - defer a.AsyncUnlock() - - list := a.FindPath("splits/list").(*core.Frame) - - if list.NumChildren() > 100 { - return +// updateReadMessage 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") { + return nil } - - list.DeleteChildren() // TODO(config) - - slices.SortFunc(cached, func(a, b *CacheData) int { - return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) - }) - - for i, cd := range cached { - cd := cd - - if i > 100 { - break - } - - fr := core.NewFrame(list) - fr.Styler(func(s *styles.Style) { - s.Direction = styles.Column - }) - - fr.Styler(func(s *styles.Style) { - s.SetAbilities(true, abilities.Activatable, abilities.Hoverable) - s.Cursor = cursors.Pointer - }) - fr.OnClick(func(e events.Event) { - a.ReadMessage = cd - errors.Log(a.UpdateReadMessage()) - }) - fr.AddContextMenu(func(m *core.Scene) { - a.ReadMessage = cd - core.NewFuncButton(m).SetFunc(a.MoveMessage).SetIcon(icons.Move).SetText("Move") - }) - - ftxt := "" - for _, f := range cd.From { - if f.Name != "" { - ftxt += f.Name + " " - } else { - ftxt += f.Addr() + " " - } - } - - from := core.NewText(fr).SetType(core.TextTitleMedium).SetText(ftxt) - from.Styler(func(s *styles.Style) { - s.SetNonSelectable() - }) - subject := core.NewText(fr).SetType(core.TextBodyMedium).SetText(cd.Subject) - subject.Styler(func(s *styles.Style) { - s.SetNonSelectable() - }) + w.SetProperty("readMessage", a.readMessage) + w.DeleteChildren() + if a.readMessage == nil { + return nil } - list.Update() -} - -// UpdateReadMessage updates the view of the message currently being read. -func (a *App) UpdateReadMessage() error { - msv := a.FindPath("splits/mail/msv").(*core.Form) - msv.SetStruct(a.ReadMessage.ToMessage()) - - mb := a.FindPath("splits/mail/mb").(*core.Frame) - mb.DeleteChildren() + bemail := FilenameBase32(a.currentEmail) - bemail := FilenameBase32(a.CurrentEmail) - - f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, a.ReadMessage.Filename)) + f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, messageFilename(&a.readMessage.Envelope))) if err != nil { return err } @@ -119,7 +49,12 @@ func (a *App) UpdateReadMessage() error { return err } - var plain *mail.Part + refs, err := mr.Header.MsgIDList("References") + if err != nil { + return err + } + a.readMessageReferences = refs + var gotHTML bool for { @@ -139,9 +74,13 @@ func (a *App) UpdateReadMessage() error { switch ct { case "text/plain": - plain = p + b, err := io.ReadAll(p.Body) + if err != nil { + return err + } + a.readMessagePlain = string(b) case "text/html": - err := htmlcore.ReadHTML(htmlcore.NewContext(), mb, p.Body) + err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { return err } @@ -151,13 +90,11 @@ func (a *App) UpdateReadMessage() error { } // we only handle the plain version if there is no HTML version - if !gotHTML && plain != nil { - err := htmlcore.ReadMD(htmlcore.NewContext(), mb, errors.Log1(io.ReadAll(plain.Body))) + if !gotHTML { + err := htmlcore.ReadMDString(htmlcore.NewContext(), w, a.readMessagePlain) if err != nil { return err } } - - mb.Update() return nil } diff --git a/mail/send.go b/mail/send.go index e93b89eb..ec347412 100644 --- a/mail/send.go +++ b/mail/send.go @@ -25,46 +25,59 @@ type SendMessage struct { From []*mail.Address `display:"inline"` To []*mail.Address `display:"inline"` Subject string - Body string `display:"-"` + body string + + inReplyTo string + references []string } -// Compose pulls up a dialog to send a new message +// Compose opens a dialog to send a new message. func (a *App) Compose() { //types:add - a.ComposeMessage = &SendMessage{} - a.ComposeMessage.From = []*mail.Address{{Address: Settings.Accounts[0]}} - a.ComposeMessage.To = []*mail.Address{{}} - b := core.NewBody("Send message") - core.NewForm(b).SetStruct(a.ComposeMessage) + a.composeMessage = &SendMessage{} + a.composeMessage.To = []*mail.Address{{}} + a.compose("Compose") +} + +// compose is the implementation of the email comoposition dialog, +// which is called by other higher-level functions. +func (a *App) compose(title string) { + a.composeMessage.From = []*mail.Address{{Address: Settings.Accounts[0]}} + b := core.NewBody(title) + core.NewForm(b).SetStruct(a.composeMessage) ed := texteditor.NewEditor(b) + core.Bind(&a.composeMessage.body, ed) ed.Buffer.SetLanguage(fileinfo.Markdown) ed.Buffer.Options.LineNumbers = false ed.Styler(func(s *styles.Style) { s.SetMono(false) + s.Grow.Set(1, 1) }) b.AddBottomBar(func(bar *core.Frame) { b.AddCancel(bar) b.AddOK(bar).SetText("Send").OnClick(func(e events.Event) { - a.ComposeMessage.Body = ed.Buffer.String() - a.SendMessage() + a.composeMessage.body = ed.Buffer.String() + a.Send() }) }) - b.RunFullDialog(a) + b.RunWindowDialog(a) } -// SendMessage sends the current message -func (a *App) SendMessage() error { //types:add - if len(a.ComposeMessage.From) != 1 { - return fmt.Errorf("expected 1 sender, but got %d", len(a.ComposeMessage.From)) +// Send sends the current message +func (a *App) Send() error { //types:add + if len(a.composeMessage.From) != 1 { + return fmt.Errorf("expected 1 sender, but got %d", len(a.composeMessage.From)) } - email := a.ComposeMessage.From[0].Address + email := a.composeMessage.From[0].Address var b bytes.Buffer var h mail.Header h.SetDate(time.Now()) - h.SetAddressList("From", a.ComposeMessage.From) - h.SetAddressList("To", a.ComposeMessage.To) - h.SetSubject(a.ComposeMessage.Subject) + h.SetAddressList("From", a.composeMessage.From) + h.SetAddressList("To", a.composeMessage.To) + h.SetSubject(a.composeMessage.Subject) + h.SetMsgIDList("In-Reply-To", []string{a.composeMessage.inReplyTo}) + h.SetMsgIDList("References", a.composeMessage.references) mw, err := mail.CreateWriter(&b, h) if err != nil { @@ -75,7 +88,6 @@ func (a *App) SendMessage() error { //types:add if err != nil { return err } - defer tw.Close() var ph mail.InlineHeader ph.Set("Content-Type", "text/plain") @@ -83,7 +95,7 @@ func (a *App) SendMessage() error { //types:add if err != nil { return err } - pw.Write([]byte(a.ComposeMessage.Body)) + pw.Write([]byte(a.composeMessage.body)) pw.Close() var hh mail.InlineHeader @@ -92,20 +104,22 @@ func (a *App) SendMessage() error { //types:add if err != nil { return err } - err = goldmark.Convert([]byte(a.ComposeMessage.Body), hw) + err = goldmark.Convert([]byte(a.composeMessage.body), hw) if err != nil { return err } hw.Close() + tw.Close() + mw.Close() - to := make([]string, len(a.ComposeMessage.To)) - for i, t := range a.ComposeMessage.To { + to := make([]string, len(a.composeMessage.To)) + for i, t := range a.composeMessage.To { to[i] = t.Address } err = smtp.SendMail( "smtp.gmail.com:587", - a.AuthClient[email], + a.authClient[email], email, to, &b, diff --git a/mail/settings.go b/mail/settings.go index 3536fdd7..129ad484 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -30,3 +30,15 @@ type SettingsData struct { //types:add // Accounts are the email accounts the user is signed into. Accounts []string } + +// friendlyLabelName converts the given label name to a user-friendly version. +func friendlyLabelName(name string) string { + if f, ok := friendlyLabelNames[name]; ok { + return f + } + return name +} + +var friendlyLabelNames = map[string]string{ + "INBOX": "Inbox", +} diff --git a/mail/typegen.go b/mail/typegen.go index 9664fc5c..890f9fbc 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -3,13 +3,12 @@ package mail import ( - "net/mail" - "cogentcore.org/core/tree" "cogentcore.org/core/types" + "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: "MoveMessage", Doc: "MoveMessage moves the current message to the given mailbox.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"mailbox"}, Returns: []string{"error"}}, {Name: "Compose", Doc: "Compose pulls up a dialog to send a new message", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SendMessage", Doc: "SendMessage 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: "ComposeMessage", Doc: "ComposeMessage is the current message we are editing"}, {Name: "Cache", Doc: "Cache contains the cache data, keyed by account and then mailbox."}, {Name: "ReadMessage", Doc: "ReadMessage is the current message we are reading"}, {Name: "CurrentEmail", Doc: "The current email account"}, {Name: "CurrentMailbox", Doc: "The current mailbox"}}}) +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."}}}) // NewApp returns a new [App] with the given optional parent: // App is an email client app. @@ -17,6 +16,17 @@ func NewApp(parent ...tree.Node) *App { return tree.New[App](parent...) } 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"}}}) + +// NewMessageListItem returns a new [MessageListItem] with the given optional parent: +// MessageListItem represents a [CacheData] 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 } + 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"}}}) // NewAddressTextField returns a new [AddressTextField] with the given optional parent: diff --git a/mail/values.go b/mail/values.go index eed7b6db..8622b1c1 100644 --- a/mail/values.go +++ b/mail/values.go @@ -7,14 +7,79 @@ package mail import ( "fmt" "net/mail" + "slices" + "strings" + "cogentcore.org/core/colors" "cogentcore.org/core/core" + "cogentcore.org/core/cursors" + "cogentcore.org/core/events" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/abilities" + "cogentcore.org/core/tree" + "github.com/emersion/go-imap/v2" ) func init() { + core.AddValueType[CacheData, MessageListItem]() core.AddValueType[mail.Address, AddressTextField]() } +// MessageListItem represents a [CacheData] with a [core.Frame] for the message list. +type MessageListItem struct { + core.Frame + Data *CacheData +} + +func (mi *MessageListItem) WidgetValue() any { return &mi.Data } + +func (mi *MessageListItem) Init() { + mi.Frame.Init() + mi.Styler(func(s *styles.Style) { + s.SetAbilities(true, abilities.Activatable, abilities.Hoverable) + s.Cursor = cursors.Pointer + s.Direction = styles.Column + s.Grow.Set(1, 0) + }) + mi.OnClick(func(e events.Event) { + theApp.readMessage = mi.Data + theApp.MarkAsRead() + theApp.Update() + }) + + tree.AddChild(mi, func(w *core.Text) { + w.SetType(core.TextTitleMedium) + w.Styler(func(s *styles.Style) { + s.SetNonSelectable() + s.SetTextWrap(false) + }) + w.Updater(func() { + text := "" + if !slices.Contains(mi.Data.Flags, imap.FlagSeen) { + text = fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) + } + for _, f := range mi.Data.From { + if f.Name != "" { + text += f.Name + " " + } else { + text += f.Addr() + " " + } + } + w.SetText(text) + }) + }) + tree.AddChild(mi, func(w *core.Text) { + w.SetType(core.TextBodyMedium) + w.Styler(func(s *styles.Style) { + s.SetNonSelectable() + s.SetTextWrap(false) + }) + w.Updater(func() { + w.SetText(mi.Data.Subject) + }) + }) +} + // AddressTextField represents a [mail.Address] with a [core.TextField]. type AddressTextField struct { core.TextField @@ -32,4 +97,12 @@ func (at *AddressTextField) Init() { } at.SetText(at.Address.Address) }) + at.SetValidator(func() error { + text := at.Text() + if !strings.Contains(text, "@") && !strings.Contains(text, ".") { + return fmt.Errorf("invalid email address") + } + at.Address.Address = text + return nil + }) } diff --git a/marbles/graph.go b/marbles/graph.go index d0239ab9..32ff1304 100644 --- a/marbles/graph.go +++ b/marbles/graph.go @@ -84,28 +84,28 @@ type Params struct { //types:add MarbleStartY Expr // Starting horizontal velocity of the marbles - StartVelocityY Param `view:"inline" label:"Starting velocity y"` + StartVelocityY Param `display:"inline" label:"Starting velocity y"` // Starting vertical velocity of the marbles - StartVelocityX Param `view:"inline" label:"Starting velocity x"` + StartVelocityX Param `display:"inline" label:"Starting velocity x"` // how fast to move along velocity vector -- lower = smoother, more slow-mo - UpdateRate Param `view:"inline"` + UpdateRate Param `display:"inline"` // how fast time increases - TimeStep Param `view:"inline"` + TimeStep Param `display:"inline"` // how fast it accelerates down - YForce Param `view:"inline" label:"Y force (Gravity)"` + YForce Param `display:"inline" label:"Y force (Gravity)"` // how fast the marbles move side to side without collisions, set to 0 for no movement - XForce Param `view:"inline" label:"X force (Wind)"` + XForce Param `display:"inline" label:"X force (Wind)"` // the center point of the graph, x - CenterX Param `view:"inline" label:"Graph center x"` + CenterX Param `display:"inline" label:"Graph center x"` // the center point of the graph, y - CenterY Param `view:"inline" label:"Graph center y"` + CenterY Param `display:"inline" label:"Graph center y"` TrackingSettings TrackingSettings } diff --git a/marbles/settings.go b/marbles/settings.go index 75c69d49..8cd2904c 100644 --- a/marbles/settings.go +++ b/marbles/settings.go @@ -10,11 +10,11 @@ import ( // Settings are the settings the app has type Settings struct { - LineDefaults LineDefaults `view:"no-inline" label:"Line Defaults"` + LineDefaults LineDefaults `display:"no-inline" label:"Line Defaults"` - GraphDefaults Params `view:"no-inline" label:"Graph Param Defaults"` + GraphDefaults Params `display:"no-inline" label:"Graph Param Defaults"` - MarbleSettings MarbleSettings `view:"inline" label:"Marble Settings"` + MarbleSettings MarbleSettings `display:"inline" label:"Marble Settings"` GraphSize int `label:"Graph Size" min:"100" max:"800"`