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()
+ })
+}