From c165d014f21ab69c1438d66011ee1f347677e20b Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 09:25:07 -0700 Subject: [PATCH 01/84] start on tableLabel approach --- mail/actions.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 9cf4bdc2..4f7d3e32 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -50,18 +50,25 @@ func (a *App) actionLabels(f func(c *imapclient.Client, label Label)) { }) } +// tableLabel is used for displaying labels in a table +// for user selection. +type tableLabel struct { + On bool `display:"checkbox"` + Label string +} + // 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{true, label.Name} } ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true) ch.OnChange(func(e events.Event) { - labels = append(labels, ch.CurrentItem.Value.(string)) + labels = append(labels, tableLabel{true, ch.CurrentItem.Value.(string)}) }) - core.NewList(d).SetSlice(&labels) + core.NewTable(d).SetSlice(&labels) d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Save") From 67e7526694392da775942f3cece718215152b1ee Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 09:27:39 -0700 Subject: [PATCH 02/84] populate cached available labels directly in caching --- mail/app.go | 17 +++++------------ mail/cache.go | 4 ++++ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/mail/app.go b/mail/app.go index 73b586b1..4dcdb100 100644 --- a/mail/app.go +++ b/mail/app.go @@ -14,8 +14,6 @@ import ( "slices" "sync" - "golang.org/x/exp/maps" - "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/icons" @@ -69,10 +67,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 @@ -91,7 +88,7 @@ func (a *App) Init() { 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.labels = map[string][]string{} a.showLabel = "INBOX" a.Styler(func(s *styles.Style) { s.Grow.Set(1, 1) @@ -104,11 +101,8 @@ 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 { + for _, label := range a.labels[email] { tree.AddAt(p, label, func(w *core.Tree) { w.SetText(friendlyLabelName(label)) w.OnSelect(func(e events.Event) { @@ -130,7 +124,6 @@ func (a *App) Init() { 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 diff --git a/mail/cache.go b/mail/cache.go index 1f373549..3a1f5ff7 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -116,6 +116,10 @@ func (a *App) CacheMessagesForAccount(email string) error { return fmt.Errorf("getting mailboxes: %w", err) } + for _, mailbox := range mailboxes { + a.labels[email] = append(a.labels[email], mailbox.Mailbox) + } + for _, mailbox := range mailboxes { if strings.HasPrefix(mailbox.Mailbox, "[Gmail]") { continue // TODO: skipping for now until we figure out a good way to handle From bbf13960039855841417f1b2cb5dd95e561200a9 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 09:29:18 -0700 Subject: [PATCH 03/84] use cached labels as chooser items for label dialog --- mail/actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/actions.go b/mail/actions.go index 4f7d3e32..ef49dc79 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -64,7 +64,7 @@ func (a *App) Label() { //types:add for i, label := range a.readMessage.Labels { labels[i] = tableLabel{true, label.Name} } - ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true) + ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true).SetStrings(a.labels[a.currentEmail]...) ch.OnChange(func(e events.Event) { labels = append(labels, tableLabel{true, ch.CurrentItem.Value.(string)}) }) From c31d0e19492b3d8d7483b98c30f6ed4b91524a73 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 09:32:46 -0700 Subject: [PATCH 04/84] get mail label table updating working better --- mail/actions.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mail/actions.go b/mail/actions.go index ef49dc79..8cb2ced4 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -64,11 +64,15 @@ func (a *App) Label() { //types:add for i, label := range a.readMessage.Labels { labels[i] = tableLabel{true, label.Name} } + var tb *core.Table ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true).SetStrings(a.labels[a.currentEmail]...) ch.OnChange(func(e events.Event) { labels = append(labels, tableLabel{true, ch.CurrentItem.Value.(string)}) + ch.SetCurrentValue("") + ch.Update() + tb.Update() }) - core.NewTable(d).SetSlice(&labels) + tb = core.NewTable(d).SetSlice(&labels) d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Save") From a7411c6c656c57cbbf7bd650aef0f18e53aee0ea Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 11:57:56 -0700 Subject: [PATCH 05/84] improve mail label text --- mail/actions.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 8cb2ced4..ee86d7ed 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -53,8 +53,9 @@ func (a *App) actionLabels(f func(c *imapclient.Client, label Label)) { // tableLabel is used for displaying labels in a table // for user selection. type tableLabel struct { - On bool `display:"checkbox"` - Label string + 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. @@ -62,12 +63,12 @@ func (a *App) Label() { //types:add d := core.NewBody("Label") labels := make([]tableLabel, len(a.readMessage.Labels)) for i, label := range a.readMessage.Labels { - labels[i] = tableLabel{true, label.Name} + labels[i] = tableLabel{label.Name, true, friendlyLabelName(label.Name)} } var tb *core.Table ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true).SetStrings(a.labels[a.currentEmail]...) ch.OnChange(func(e events.Event) { - labels = append(labels, tableLabel{true, ch.CurrentItem.Value.(string)}) + labels = append(labels, tableLabel{ch.CurrentItem.Value.(string), true, ch.CurrentItem.GetText()}) ch.SetCurrentValue("") ch.Update() tb.Update() From 83e58728fc8a2ef1a244166cb019cd1185371b3a Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 12:00:40 -0700 Subject: [PATCH 06/84] more friendly labeling for mail label dialog --- mail/actions.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index ee86d7ed..c03612f3 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -63,12 +63,15 @@ func (a *App) Label() { //types:add d := core.NewBody("Label") labels := make([]tableLabel, len(a.readMessage.Labels)) for i, label := range a.readMessage.Labels { - labels[i] = tableLabel{label.Name, true, friendlyLabelName(label.Name)} + labels[i] = tableLabel{name: label.Name, On: true, Label: friendlyLabelName(label.Name)} } var tb *core.Table - ch := core.NewChooser(d).SetEditable(true).SetAllowNew(true).SetStrings(a.labels[a.currentEmail]...) + 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, tableLabel{ch.CurrentItem.Value.(string), true, ch.CurrentItem.GetText()}) + labels = append(labels, tableLabel{name: ch.CurrentItem.Value.(string), On: true, Label: ch.CurrentItem.Text}) ch.SetCurrentValue("") ch.Update() tb.Update() From 983e74697673ceb898ead6c2100b084142efafbe Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 12:05:49 -0700 Subject: [PATCH 07/84] get automatic chooser resetting after label adding working --- mail/actions.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index c03612f3..c1437968 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -72,10 +72,12 @@ func (a *App) Label() { //types:add } ch.OnChange(func(e events.Event) { labels = append(labels, tableLabel{name: ch.CurrentItem.Value.(string), On: true, Label: ch.CurrentItem.Text}) - ch.SetCurrentValue("") - ch.Update() tb.Update() }) + ch.OnFinal(events.Change, func(e events.Event) { + ch.CurrentItem = core.ChooserItem{} + ch.SetCurrentValue("") + }) tb = core.NewTable(d).SetSlice(&labels) d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) From e5b56335532343d541ae0465b1aded4a1149cfd3 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 12:09:34 -0700 Subject: [PATCH 08/84] more mail label dialog work --- mail/actions.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index c1437968..e40162cd 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -5,6 +5,7 @@ package mail import ( + "fmt" "slices" "strings" @@ -63,7 +64,7 @@ func (a *App) Label() { //types:add d := core.NewBody("Label") labels := make([]tableLabel, len(a.readMessage.Labels)) for i, label := range a.readMessage.Labels { - labels[i] = tableLabel{name: label.Name, On: true, Label: friendlyLabelName(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) @@ -81,7 +82,9 @@ func (a *App) Label() { //types:add 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) { + fmt.Println("current", a.readMessage.Labels, "new", labels) + }) }) d.RunDialog(a) // TODO: Move needs to be redesigned with the new many-to-many labeling paradigm. From 7d260c0b98f9c90b96e46d84d0e5ebc81ff236c3 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 12:13:09 -0700 Subject: [PATCH 09/84] finish new label logic in mail --- mail/actions.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mail/actions.go b/mail/actions.go index e40162cd..4ef1bda2 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -83,7 +83,13 @@ func (a *App) Label() { //types:add d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Save").OnClick(func(e events.Event) { - fmt.Println("current", a.readMessage.Labels, "new", labels) + newLabels := []string{} + for _, label := range labels { + if label.On { + newLabels = append(newLabels, label.name) + } + } + fmt.Println("current", a.readMessage.Labels, "new", newLabels) }) }) d.RunDialog(a) From d052c336cfb5ed5b56ecb03a7c9608043ad59df7 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 18 Sep 2024 12:21:30 -0700 Subject: [PATCH 10/84] start on label updating contains logic --- mail/actions.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mail/actions.go b/mail/actions.go index 4ef1bda2..05f9c743 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -89,7 +89,20 @@ func (a *App) Label() { //types:add newLabels = append(newLabels, label.name) } } - fmt.Println("current", a.readMessage.Labels, "new", newLabels) + a.actionLabels(func(c *imapclient.Client, label Label) { + if slices.Contains(newLabels, label.Name) { + return + } + fmt.Println("remove", label.Name) + }) + for _, newLabel := range newLabels { + if slices.ContainsFunc(a.readMessage.Labels, func(label Label) bool { + return label.Name == newLabel + }) { + continue + } + fmt.Println("add", newLabel) + } }) }) d.RunDialog(a) From 114d80f3a1f69aa5974a336eae4b5916348a7a49 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 19 Sep 2024 09:19:17 -0700 Subject: [PATCH 11/84] only create mail caching files in CacheMessagesForAccount instead of CacheMessagesForMailbox; should fix issue where it resets the entire cache once it gets to another mailbox --- mail/cache.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index 3a1f5ff7..b7e04f74 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -111,6 +111,25 @@ func (a *App) CacheMessagesForAccount(email string) error { return fmt.Errorf("authenticating: %w", err) } + dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) + 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]*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] = cached + mailboxes, err := c.List("", "*", nil).Collect() if err != nil { return fmt.Errorf("getting mailboxes: %w", err) @@ -124,7 +143,7 @@ func (a *App) CacheMessagesForAccount(email string) error { 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) + err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached, cacheFile) if err != nil { return fmt.Errorf("caching messages for mailbox %q: %w", mailbox.Mailbox, err) } @@ -135,27 +154,8 @@ func (a *App) CacheMessagesForAccount(email string) error { // 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) - if err != nil { - return err - } - - cacheFile := a.cacheFilename(email) - err = os.MkdirAll(filepath.Dir(cacheFile), 0700) - if err != nil { - return 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) - } - a.cache[email] = cached - - err = a.selectMailbox(c, email, mailbox) +func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheData, cacheFile string) error { + err := a.selectMailbox(c, email, mailbox) if err != nil { return err } From 9cc6ea3de13e77eb781799e315b27bb29d5dc2d5 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 19 Sep 2024 09:27:17 -0700 Subject: [PATCH 12/84] don't add new label in mail dialog if it is blank --- mail/actions.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mail/actions.go b/mail/actions.go index 05f9c743..53db3c51 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -76,6 +76,9 @@ func (a *App) Label() { //types:add tb.Update() }) ch.OnFinal(events.Change, func(e events.Event) { + if ch.CurrentItem.Text == "" { + return + } ch.CurrentItem = core.ChooserItem{} ch.SetCurrentValue("") }) From 85ee5c50f1dde72fc58a85d1eb31422684fbf6a4 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 10:02:35 -0700 Subject: [PATCH 13/84] implement mail labeling copy conditions --- mail/actions.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 53db3c51..9659057c 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -92,20 +92,25 @@ func (a *App) Label() { //types:add newLabels = append(newLabels, label.name) } } + first := true a.actionLabels(func(c *imapclient.Client, label Label) { + 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. + } + fmt.Println("copy from", label, "to", newLabel) + } + } if slices.Contains(newLabels, label.Name) { - return + return // Still have this label. } fmt.Println("remove", label.Name) }) - for _, newLabel := range newLabels { - if slices.ContainsFunc(a.readMessage.Labels, func(label Label) bool { - return label.Name == newLabel - }) { - continue - } - fmt.Println("add", newLabel) - } + }) }) d.RunDialog(a) From 5c44d6dd29006db01b6c572612ba54856b5f53f6 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 10:08:22 -0700 Subject: [PATCH 14/84] get message copying to new labels working in mail --- mail/actions.go | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 9659057c..38607bcd 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -20,14 +20,15 @@ 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)) { +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 = jsonx.Save(a.cache[a.currentEmail], a.cacheFilename(a.currentEmail)) core.ErrorSnackbar(a, err, "Error saving cache") mu.Unlock() a.AsyncLock() @@ -38,16 +39,19 @@ 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) { +func (a *App) actionLabels(f func(c *imapclient.Client, label Label) error) { + 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) } + return nil }) } @@ -93,7 +97,9 @@ func (a *App) Label() { //types:add } } first := true - a.actionLabels(func(c *imapclient.Client, label Label) { + a.actionLabels(func(c *imapclient.Client, label Label) error { + // We copy the existing message to all of the new labels + // and remove it from all of the old labels. if first { first = false for _, newLabel := range newLabels { @@ -102,13 +108,19 @@ func (a *App) Label() { //types:add }) { continue // Already have this label. } - fmt.Println("copy from", label, "to", newLabel) + uidset := imap.UIDSet{} + uidset.AddNum(label.UID) + _, err := c.Copy(uidset, newLabel).Wait() + if err != nil { + return err + } } } if slices.Contains(newLabels, label.Name) { - return // Still have this label. + return nil // Still have this label. } fmt.Println("remove", label.Name) + return nil }) }) @@ -210,21 +222,19 @@ func (a *App) markSeen(seen bool) { // Already set correctly. return } - a.actionLabels(func(c *imapclient.Client, label Label) { + a.actionLabels(func(c *imapclient.Client, label Label) error { uidset := imap.UIDSet{} uidset.AddNum(label.UID) op := imap.StoreFlagsDel if seen { op = imap.StoreFlagsAdd } - cmd := c.Store(uidset, &imap.StoreFlags{ + err := c.Store(uidset, &imap.StoreFlags{ Op: op, Flags: []imap.Flag{imap.FlagSeen}, - }, nil) - err := cmd.Wait() + }, 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 @@ -235,5 +245,6 @@ func (a *App) markSeen(seen bool) { return flag == imap.FlagSeen }) } + return nil }) } From a4d2ca649600053767505e8921e4009a0deff13d Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 10:17:31 -0700 Subject: [PATCH 15/84] implement expunge for old labels in mail label --- mail/actions.go | 29 ++++++++++++++++------------- mail/cache.go | 7 +++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 38607bcd..5715d0fa 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -5,7 +5,6 @@ package mail import ( - "fmt" "slices" "strings" @@ -98,8 +97,7 @@ func (a *App) Label() { //types:add } first := true a.actionLabels(func(c *imapclient.Client, label Label) error { - // We copy the existing message to all of the new labels - // and remove it from all of the old labels. + // We copy the existing message to all of the new labels. if first { first = false for _, newLabel := range newLabels { @@ -108,19 +106,25 @@ func (a *App) Label() { //types:add }) { continue // Already have this label. } - uidset := imap.UIDSet{} - uidset.AddNum(label.UID) - _, err := c.Copy(uidset, newLabel).Wait() + _, err := c.Copy(label.UIDSet(), newLabel).Wait() if err != nil { return err } } } + // We remove the existing message from each old label. if slices.Contains(newLabels, label.Name) { return nil // Still have this label. } - fmt.Println("remove", label.Name) - return nil + err := c.Store(label.UIDSet(), &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Silent: true, + Flags: []imap.Flag{imap.FlagDeleted}, + }, nil).Wait() + if err != nil { + return err + } + return c.UIDExpunge(label.UIDSet()).Wait() }) }) @@ -223,15 +227,14 @@ func (a *App) markSeen(seen bool) { return } a.actionLabels(func(c *imapclient.Client, label Label) error { - uidset := imap.UIDSet{} - uidset.AddNum(label.UID) op := imap.StoreFlagsDel if seen { op = imap.StoreFlagsAdd } - err := c.Store(uidset, &imap.StoreFlags{ - Op: op, - Flags: []imap.Flag{imap.FlagSeen}, + err := c.Store(label.UIDSet(), &imap.StoreFlags{ + Op: op, + Silent: true, + Flags: []imap.Flag{imap.FlagSeen}, }, nil).Wait() if err != nil { return err diff --git a/mail/cache.go b/mail/cache.go index b7e04f74..15fb1085 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -43,6 +43,13 @@ type Label struct { UID imap.UID } +// UIDSet returns an [imap.UIDSet] that contains just this label's UID. +func (lb *Label) UIDSet() imap.UIDSet { + uidset := imap.UIDSet{} + uidset.AddNum(lb.UID) + return uidset +} + // ToMessage converts the [CacheData] to a [ReadMessage]. func (cd *CacheData) ToMessage() *ReadMessage { if cd == nil { From 2b8a7aac776330bac6be6a37af6329c4fe3fc021 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:13:44 -0700 Subject: [PATCH 16/84] update cache based on mail labeling --- mail/actions.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 5715d0fa..2e4dd7ec 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -106,10 +106,12 @@ func (a *App) Label() { //types:add }) { continue // Already have this label. } - _, err := c.Copy(label.UIDSet(), newLabel).Wait() + cd, err := c.Copy(label.UIDSet(), newLabel).Wait() if err != nil { return err } + // Add this new label to the cache. + a.readMessage.Labels = append(a.readMessage.Labels, Label{newLabel, cd.DestUIDs[0].Start}) } } // We remove the existing message from each old label. @@ -124,7 +126,15 @@ func (a *App) Label() { //types:add if err != nil { return err } - return c.UIDExpunge(label.UIDSet()).Wait() + err = c.UIDExpunge(label.UIDSet()).Wait() + if err != nil { + return err + } + // Also remove this label from the cache. + a.readMessage.Labels = slices.DeleteFunc(a.readMessage.Labels, func(l Label) bool { + return l == label + }) + return nil }) }) @@ -240,7 +250,7 @@ func (a *App) markSeen(seen bool) { 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 { From a36f0d9d31d27e8155dbc3dcfe9e3a8e84af762e Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:18:37 -0700 Subject: [PATCH 17/84] try using OnClick in mail tree --- mail/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 4dcdb100..1cd1929d 100644 --- a/mail/app.go +++ b/mail/app.go @@ -105,7 +105,7 @@ func (a *App) Init() { for _, label := range a.labels[email] { tree.AddAt(p, label, func(w *core.Tree) { w.SetText(friendlyLabelName(label)) - w.OnSelect(func(e events.Event) { + w.OnClick(func(e events.Event) { a.showLabel = label a.Update() }) From 0f21170d16eb4e0a635bca6887935ac34f96dee4 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:20:07 -0700 Subject: [PATCH 18/84] clean up old mail move code --- mail/actions.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 2e4dd7ec..e5c8e5a6 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -140,14 +140,6 @@ func (a *App) Label() { //types:add }) }) 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. From 84f15ba4151cf136dc273f4d4ba04c3ee7c5a735 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:22:29 -0700 Subject: [PATCH 19/84] rename CacheData to CacheMessage; much clearer --- mail/app.go | 8 ++++---- mail/cache.go | 23 ++++++++++++----------- mail/typegen.go | 6 +++--- mail/values.go | 6 +++--- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/mail/app.go b/mail/app.go index 1cd1929d..f2c3197f 100644 --- a/mail/app.go +++ b/mail/app.go @@ -45,15 +45,15 @@ 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 + readMessage *CacheMessage // readMessageReferences is the References header of the current readMessage. readMessageReferences []string @@ -130,7 +130,7 @@ func (a *App) Init() { } } } - slices.SortFunc(a.listCache, func(a, b *CacheData) int { + slices.SortFunc(a.listCache, func(a, b *CacheMessage) int { return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) }) }) diff --git a/mail/cache.go b/mail/cache.go index 15fb1085..f4a7ee7e 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -22,10 +22,10 @@ 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 Flags []imap.Flag @@ -43,15 +43,16 @@ type Label struct { UID imap.UID } -// UIDSet returns an [imap.UIDSet] that contains just this label's UID. +// 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 } -// ToMessage converts the [CacheData] to a [ReadMessage]. -func (cd *CacheData) ToMessage() *ReadMessage { +// ToMessage converts the [CacheMessage] to a [ReadMessage]. +func (cd *CacheMessage) ToMessage() *ReadMessage { if cd == nil { return nil } @@ -79,7 +80,7 @@ func IMAPToMailAddresses(as []imap.Address) []*mail.Address { // 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{} + a.cache = map[string]map[string]*CacheMessage{} } if a.imapClient == nil { a.imapClient = map[string]*imapclient.Client{} @@ -101,7 +102,7 @@ func (a *App) CacheMessages() error { // 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{} + a.cache[email] = map[string]*CacheMessage{} } c, err := imapclient.DialTLS("imap.gmail.com:993", nil) @@ -130,7 +131,7 @@ func (a *App) CacheMessagesForAccount(email string) error { return err } - cached := map[string]*CacheData{} + 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) @@ -161,7 +162,7 @@ func (a *App) CacheMessagesForAccount(email string) error { // 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, dir string, cached map[string]*CacheData, cacheFile string) error { +func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheMessage, cacheFile string) error { err := a.selectMailbox(c, email, mailbox) if err != nil { return err @@ -204,7 +205,7 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo // 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, cacheFile string) error { for len(uids) > 0 { num := min(5, len(uids)) cuids := uids[len(uids)-num:] // the current batch of UIDs @@ -257,7 +258,7 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai } else { // Otherwise, we add it as a new entry to the cache // and save the content to a file. - cached[mdata.Envelope.MessageID] = &CacheData{ + cached[mdata.Envelope.MessageID] = &CacheMessage{ Envelope: *mdata.Envelope, Flags: mdata.Flags, Labels: []Label{{mailbox, mdata.UID}}, diff --git a/mail/typegen.go b/mail/typegen.go index 890f9fbc..ae5a3c95 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -16,16 +16,16 @@ 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"}}}) +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: "Data"}}}) // 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 } +func (t *MessageListItem) SetData(v *CacheMessage) *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"}}}) diff --git a/mail/values.go b/mail/values.go index 8622b1c1..74f510ae 100644 --- a/mail/values.go +++ b/mail/values.go @@ -21,14 +21,14 @@ import ( ) func init() { - core.AddValueType[CacheData, MessageListItem]() + core.AddValueType[CacheMessage, MessageListItem]() core.AddValueType[mail.Address, AddressTextField]() } -// 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 + Data *CacheMessage } func (mi *MessageListItem) WidgetValue() any { return &mi.Data } From be4ec9d25de0e973d28b1b1cde076eef30770730 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:34:49 -0700 Subject: [PATCH 20/84] use a separate resultantLabels copy of a.readMessage.Labels to prevent issues with modifying a.readMessage.Labels while looping over it and checking it --- mail/actions.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index e5c8e5a6..cf07238b 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -89,12 +89,19 @@ func (a *App) Label() { //types:add d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Save").OnClick(func(e events.Event) { + // newLabels are the labels we want to end up with, in contrast + // to the old labels we started with, which are a.readMessage.Labels. newLabels := []string{} for _, label := range labels { if label.On { newLabels = append(newLabels, label.name) } } + // 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. @@ -111,7 +118,7 @@ func (a *App) Label() { //types:add return err } // Add this new label to the cache. - a.readMessage.Labels = append(a.readMessage.Labels, Label{newLabel, cd.DestUIDs[0].Start}) + resultantLabels = append(resultantLabels, Label{newLabel, cd.DestUIDs[0].Start}) } } // We remove the existing message from each old label. @@ -130,13 +137,14 @@ func (a *App) Label() { //types:add if err != nil { return err } - // Also remove this label from the cache. - a.readMessage.Labels = slices.DeleteFunc(a.readMessage.Labels, func(l Label) bool { + // Remove this old label from the cache. + resultantLabels = slices.DeleteFunc(resultantLabels, func(l Label) bool { return l == label }) return nil }) - + // Now that we are done, we can save resultantLabels to the cache. + a.readMessage.Labels = resultantLabels }) }) d.RunDialog(a) From b4c66e64fb5f259b9b2e92c84fe857b6834333e1 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:43:33 -0700 Subject: [PATCH 21/84] add after func for mail actionLabels to fix concurrency for resultantLabels saving; labeling now fully working --- mail/actions.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index cf07238b..451d0a14 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -37,8 +37,10 @@ func (a *App) action(f func(c *imapclient.Client) error) { } // 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) error) { +// 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) @@ -50,6 +52,9 @@ func (a *App) actionLabels(f func(c *imapclient.Client, label Label) error) { return err } } + if len(after) > 0 { + after[0]() + } return nil }) } @@ -142,9 +147,10 @@ func (a *App) Label() { //types:add return l == label }) return nil + }, func() { + // Now that we are done, we can save resultantLabels to the cache. + a.readMessage.Labels = resultantLabels }) - // Now that we are done, we can save resultantLabels to the cache. - a.readMessage.Labels = resultantLabels }) }) d.RunDialog(a) From c64a988b128795876473fb18271292b9be276bbc Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:46:16 -0700 Subject: [PATCH 22/84] add error if no labels are specified in mail --- mail/actions.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mail/actions.go b/mail/actions.go index 451d0a14..6726ac8e 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -5,6 +5,7 @@ package mail import ( + "fmt" "slices" "strings" @@ -102,6 +103,10 @@ func (a *App) Label() { //types:add newLabels = append(newLabels, label.name) } } + if len(newLabels) == 0 { + core.ErrorSnackbar(a, fmt.Errorf("specify at least one label")) + return + } // 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. From 92d41653c72464f3dd80af79740eec9e2ffd90a1 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:53:01 -0700 Subject: [PATCH 23/84] start on text for current label name and number of messages --- mail/app.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/mail/app.go b/mail/app.go index f2c3197f..aaae3db3 100644 --- a/mail/app.go +++ b/mail/app.go @@ -116,9 +116,10 @@ func (a *App) Init() { } }) }) - 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 mp := a.cache[a.currentEmail] @@ -134,6 +135,22 @@ func (a *App) Init() { return cmp.Compare(b.Date.UnixNano(), a.Date.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.SetText(fmt.Sprintf("%d messages", len(a.listCache))) + }) + }) + 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) { From 70359bcdbe7bf1622697921c2a85fcc89351530c Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:53:57 -0700 Subject: [PATCH 24/84] rename cd to cm for CacheMessage local variables in mail --- mail/app.go | 6 +++--- mail/cache.go | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mail/app.go b/mail/app.go index aaae3db3..e93b2ce9 100644 --- a/mail/app.go +++ b/mail/app.go @@ -123,10 +123,10 @@ func (a *App) Init() { w.Updater(func() { a.listCache = nil mp := a.cache[a.currentEmail] - for _, cd := range mp { - for _, label := range cd.Labels { + for _, cm := range mp { + for _, label := range cm.Labels { if label.Name == a.showLabel { - a.listCache = append(a.listCache, cd) + a.listCache = append(a.listCache, cm) break } } diff --git a/mail/cache.go b/mail/cache.go index f4a7ee7e..fcac20c3 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -52,15 +52,15 @@ func (lb *Label) UIDSet() imap.UIDSet { } // ToMessage converts the [CacheMessage] to a [ReadMessage]. -func (cd *CacheMessage) ToMessage() *ReadMessage { - if cd == nil { +func (cm *CacheMessage) ToMessage() *ReadMessage { + if cm == nil { return nil } return &ReadMessage{ - From: IMAPToMailAddresses(cd.From), - To: IMAPToMailAddresses(cd.To), - Subject: cd.Subject, - Date: cd.Date.Local(), + From: IMAPToMailAddresses(cm.From), + To: IMAPToMailAddresses(cm.To), + Subject: cm.Subject, + Date: cm.Date.Local(), } } @@ -172,8 +172,8 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo criteria := &imap.SearchCriteria{} if len(cached) > 0 { uidset := imap.UIDSet{} - for _, cd := range cached { - for _, label := range cd.Labels { + for _, cm := range cached { + for _, label := range cm.Labels { if label.Name == mailbox { uidset.AddNum(label.UID) } @@ -249,11 +249,11 @@ 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 From dea98a1cbd88dea7466ea5a0559e24fa7c90f5ec Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 15:57:40 -0700 Subject: [PATCH 25/84] add unread messages count to mail --- mail/app.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mail/app.go b/mail/app.go index e93b2ce9..f5e50399 100644 --- a/mail/app.go +++ b/mail/app.go @@ -20,6 +20,7 @@ import ( "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" @@ -52,6 +53,10 @@ type App struct { // be used for any other purpose. listCache []*CacheMessage + // unreadMessages is the number of unread messages for the current email account + // and labels, used for displaying a count. + unreadMessages int + // readMessage is the current message we are reading readMessage *CacheMessage @@ -122,11 +127,15 @@ func (a *App) Init() { }) w.Updater(func() { a.listCache = nil + a.unreadMessages = 0 mp := a.cache[a.currentEmail] for _, cm := range mp { for _, label := range cm.Labels { if label.Name == a.showLabel { a.listCache = append(a.listCache, cm) + if !slices.Contains(cm.Flags, imap.FlagSeen) { + a.unreadMessages++ + } break } } @@ -144,6 +153,9 @@ func (a *App) Init() { tree.AddChild(w, func(w *core.Text) { w.Updater(func() { w.SetText(fmt.Sprintf("%d messages", len(a.listCache))) + if a.unreadMessages > 0 { + w.Text += fmt.Sprintf(", %d unread", a.unreadMessages) + } }) }) tree.AddChild(w, func(w *core.Separator) {}) From 30b50d0241c08bb18bda0290141796be900a9d50 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 16:23:21 -0700 Subject: [PATCH 26/84] get initial mail attachment sending logic working --- mail/send.go | 58 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/mail/send.go b/mail/send.go index ec347412..55ca3c2e 100644 --- a/mail/send.go +++ b/mail/send.go @@ -7,7 +7,9 @@ package mail import ( "bytes" "fmt" + "io" "log/slog" + "os" "time" "cogentcore.org/core/base/fileinfo" @@ -22,11 +24,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 +87,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) + 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) + hw, err := iw.CreatePart(hh) if err != nil { return err } @@ -108,9 +114,39 @@ 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 { + ah := mail.AttachmentHeader{} + ah.SetFilename(string(at)) + aw, err := mw.CreateAttachment(ah) + if err != nil { + return err + } + f, err := os.Open(string(at)) + 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 { From 29e1886b7cf6a344a51357f1aec5f0625bc4f7fe Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 16:51:36 -0700 Subject: [PATCH 27/84] use filepath.Base for attachment filename --- mail/send.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mail/send.go b/mail/send.go index 55ca3c2e..2520db5e 100644 --- a/mail/send.go +++ b/mail/send.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "os" + "path/filepath" "time" "cogentcore.org/core/base/fileinfo" @@ -125,7 +126,7 @@ func (a *App) Send() error { //types:add for _, at := range a.composeMessage.Attachments { ah := mail.AttachmentHeader{} - ah.SetFilename(string(at)) + ah.SetFilename(filepath.Base(string(at))) aw, err := mw.CreateAttachment(ah) if err != nil { return err From 9fa7f35c3bdf44e08bb36fd0404c95a731d340ce Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:01:33 -0700 Subject: [PATCH 28/84] set Content-Type header for mail attachment; now can be viewed in other mail clients correctly --- mail/send.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mail/send.go b/mail/send.go index 2520db5e..4e5f3b69 100644 --- a/mail/send.go +++ b/mail/send.go @@ -94,7 +94,7 @@ func (a *App) Send() error { //types:add } var ph mail.InlineHeader - ph.Set("Content-Type", "text/plain") + ph.SetContentType("text/plain", nil) pw, err := iw.CreatePart(ph) if err != nil { return err @@ -106,7 +106,7 @@ func (a *App) Send() error { //types:add } var hh mail.InlineHeader - hh.Set("Content-Type", "text/html") + hh.SetContentType("text/html", nil) hw, err := iw.CreatePart(hh) if err != nil { return err @@ -125,13 +125,19 @@ func (a *App) Send() error { //types:add } for _, at := range a.composeMessage.Attachments { + fname := string(at) ah := mail.AttachmentHeader{} - ah.SetFilename(filepath.Base(string(at))) + 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(string(at)) + f, err := os.Open(fname) if err != nil { return err } From 53f87ef20edbccffb46a70ec8708d34684385fd2 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:16:55 -0700 Subject: [PATCH 29/84] start on attachment reading in mail --- mail/read.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mail/read.go b/mail/read.go index 8d3a9158..b21cc7e2 100644 --- a/mail/read.go +++ b/mail/read.go @@ -5,6 +5,7 @@ package mail import ( + "fmt" "io" "os" "path/filepath" @@ -86,6 +87,8 @@ func (a *App) updateReadMessage(w *core.Frame) error { } gotHTML = true } + case *mail.AttachmentHeader: + fmt.Println(p.Header) } } From 7012cc6db5128f5bc0e815564b35111e15708bd3 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:18:55 -0700 Subject: [PATCH 30/84] clarify read/display message naming --- mail/app.go | 4 ++-- mail/cache.go | 6 +++--- mail/read.go | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mail/app.go b/mail/app.go index f5e50399..b61a26f7 100644 --- a/mail/app.go +++ b/mail/app.go @@ -171,7 +171,7 @@ func (a *App) Init() { tree.AddChild(w, func(w *core.Form) { w.SetReadOnly(true) w.Updater(func() { - w.SetStruct(a.readMessage.ToMessage()) + w.SetStruct(a.readMessage.ToDisplay()) }) }) tree.AddChild(w, func(w *core.Frame) { @@ -180,7 +180,7 @@ func (a *App) Init() { s.Grow.Set(1, 0) }) w.Updater(func() { - core.ErrorSnackbar(w, a.updateReadMessage(w), "Error reading message") + core.ErrorSnackbar(w, a.displayMessageContents(w), "Error reading message") }) }) }) diff --git a/mail/cache.go b/mail/cache.go index fcac20c3..3be3148a 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -51,12 +51,12 @@ func (lb *Label) UIDSet() imap.UIDSet { return uidset } -// ToMessage converts the [CacheMessage] to a [ReadMessage]. -func (cm *CacheMessage) ToMessage() *ReadMessage { +// ToDisplay converts the [CacheMessage] to a [displayMessage]. +func (cm *CacheMessage) ToDisplay() *displayMessage { if cm == nil { return nil } - return &ReadMessage{ + return &displayMessage{ From: IMAPToMailAddresses(cm.From), To: IMAPToMailAddresses(cm.To), Subject: cm.Subject, diff --git a/mail/read.go b/mail/read.go index b21cc7e2..5b49e7d2 100644 --- a/mail/read.go +++ b/mail/read.go @@ -16,18 +16,18 @@ import ( "github.com/emersion/go-message/mail" ) -// ReadMessage represents the data necessary to display a message -// for the user to read. -type ReadMessage struct { +// 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 } -// updateReadMessage updates the given frame to display the contents of +// 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 { +func (a *App) displayMessageContents(w *core.Frame) error { if a.readMessage == w.Property("readMessage") { return nil } From 91fa685f9e76f41805f1857223b7493b92678cd0 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:22:07 -0700 Subject: [PATCH 31/84] add readMessageParsed to reduce ambiguity and clutter in mail --- mail/actions.go | 4 ++-- mail/app.go | 9 +++------ mail/read.go | 16 +++++++++++++--- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 6726ac8e..4934433c 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -209,7 +209,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.readMessageParsed.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 { @@ -227,7 +227,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.readMessageParsed.plain, "\n", "\n> ") a.compose(title) } diff --git a/mail/app.go b/mail/app.go index b61a26f7..fcd6008c 100644 --- a/mail/app.go +++ b/mail/app.go @@ -57,14 +57,11 @@ type App struct { // and labels, used for displaying a count. unreadMessages int - // readMessage is the current message we are reading + // readMessage is the current message we are reading. readMessage *CacheMessage - // readMessageReferences is the References header of the current readMessage. - readMessageReferences []string - - // readMessagePlain is the plain text body of the current readMessage. - readMessagePlain string + // readMessageParsed contains data parsed from the current message we are reading. + readMessageParsed readMessageParsed // currentEmail is the current email account. currentEmail string diff --git a/mail/read.go b/mail/read.go index 5b49e7d2..3510351d 100644 --- a/mail/read.go +++ b/mail/read.go @@ -25,6 +25,16 @@ type displayMessage struct { Date time.Time } +// 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 +} + // displayMessageContents updates the given frame to display the contents of // the current message, if it does not already. func (a *App) displayMessageContents(w *core.Frame) error { @@ -54,7 +64,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageReferences = refs + a.readMessageParsed.references = refs var gotHTML bool @@ -79,7 +89,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessagePlain = string(b) + a.readMessageParsed.plain = string(b) case "text/html": err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { @@ -94,7 +104,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { // 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, a.readMessageParsed.plain) if err != nil { return err } From d2fb706aaf5e3be0f02962e9c55f6872a8aa7953 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:24:58 -0700 Subject: [PATCH 32/84] gather attachments list --- mail/read.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mail/read.go b/mail/read.go index 3510351d..8e69cb1c 100644 --- a/mail/read.go +++ b/mail/read.go @@ -5,7 +5,6 @@ package mail import ( - "fmt" "io" "os" "path/filepath" @@ -33,6 +32,9 @@ type readMessageParsed struct { // plain is the plain text body. plain string + + // attachments are the attachments. + attachments []string } // displayMessageContents updates the given frame to display the contents of @@ -66,8 +68,8 @@ func (a *App) displayMessageContents(w *core.Frame) error { } a.readMessageParsed.references = refs - var gotHTML bool - + a.readMessageParsed.attachments = nil + gotHTML := false for { p, err := mr.NextPart() if err == io.EOF { @@ -98,7 +100,11 @@ func (a *App) displayMessageContents(w *core.Frame) error { gotHTML = true } case *mail.AttachmentHeader: - fmt.Println(p.Header) + fname, err := h.Filename() + if err != nil { + return err + } + a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, fname) } } From d1cdf229705138e4e3f062bf01bc4909a680682f Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:29:39 -0700 Subject: [PATCH 33/84] start on displaying mail attachments in message reading --- mail/app.go | 2 +- mail/cache.go | 14 ++++++++------ mail/read.go | 9 +++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/mail/app.go b/mail/app.go index fcd6008c..9a58cc27 100644 --- a/mail/app.go +++ b/mail/app.go @@ -168,7 +168,7 @@ func (a *App) Init() { tree.AddChild(w, func(w *core.Form) { w.SetReadOnly(true) w.Updater(func() { - w.SetStruct(a.readMessage.ToDisplay()) + w.SetStruct(a.readMessage.ToDisplay(&a.readMessageParsed)) }) }) tree.AddChild(w, func(w *core.Frame) { diff --git a/mail/cache.go b/mail/cache.go index 3be3148a..c1107fe4 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -51,16 +51,18 @@ func (lb *Label) UIDSet() imap.UIDSet { return uidset } -// ToDisplay converts the [CacheMessage] to a [displayMessage]. -func (cm *CacheMessage) ToDisplay() *displayMessage { +// ToDisplay converts the [CacheMessage] to a [displayMessage] +// with the given additional [readMessageParsed] data. +func (cm *CacheMessage) ToDisplay(rmp *readMessageParsed) *displayMessage { if cm == nil { return nil } return &displayMessage{ - From: IMAPToMailAddresses(cm.From), - To: IMAPToMailAddresses(cm.To), - Subject: cm.Subject, - Date: cm.Date.Local(), + From: IMAPToMailAddresses(cm.From), + To: IMAPToMailAddresses(cm.To), + Subject: cm.Subject, + Date: cm.Date.Local(), + Attachments: rmp.attachments, } } diff --git a/mail/read.go b/mail/read.go index 8e69cb1c..e58f9d66 100644 --- a/mail/read.go +++ b/mail/read.go @@ -18,10 +18,11 @@ import ( // 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 + From []*mail.Address `display:"inline"` + To []*mail.Address `display:"inline"` + Subject string + Date time.Time + Attachments []string `display:"inline"` } // readMessageParsed contains data parsed from the current message we are reading. From 21c9aec53f6a2f25f4d0d8c7cf11cbc09215246d Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:36:02 -0700 Subject: [PATCH 34/84] start on AttachmentButton --- mail/read.go | 6 +++--- mail/typegen.go | 2 +- mail/values.go | 34 ++++++++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/mail/read.go b/mail/read.go index e58f9d66..f674f2fa 100644 --- a/mail/read.go +++ b/mail/read.go @@ -22,7 +22,7 @@ type displayMessage struct { To []*mail.Address `display:"inline"` Subject string Date time.Time - Attachments []string `display:"inline"` + Attachments []*Attachment `display:"inline"` } // readMessageParsed contains data parsed from the current message we are reading. @@ -35,7 +35,7 @@ type readMessageParsed struct { plain string // attachments are the attachments. - attachments []string + attachments []*Attachment } // displayMessageContents updates the given frame to display the contents of @@ -105,7 +105,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, fname) + a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, &Attachment{fname}) } } diff --git a/mail/typegen.go b/mail/typegen.go index ae5a3c95..381116a0 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -25,7 +25,7 @@ func NewMessageListItem(parent ...tree.Node) *MessageListItem { } // SetData sets the [MessageListItem.Data] -func (t *MessageListItem) SetData(v *CacheMessage) *MessageListItem { t.Data = v; return t } +func (t *MessageListItem) SetData(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"}}}) diff --git a/mail/values.go b/mail/values.go index 74f510ae..4bb21d3c 100644 --- a/mail/values.go +++ b/mail/values.go @@ -23,15 +23,16 @@ import ( func init() { core.AddValueType[CacheMessage, MessageListItem]() core.AddValueType[mail.Address, AddressTextField]() + core.AddValueType[Attachment, AttachmentButton]() } // MessageListItem represents a [CacheMessage] with a [core.Frame] for the message list. type MessageListItem struct { core.Frame - Data *CacheMessage + 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 +43,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 +56,10 @@ func (mi *MessageListItem) Init() { }) w.Updater(func() { text := "" - if !slices.Contains(mi.Data.Flags, imap.FlagSeen) { + if !slices.Contains(mi.Message.Flags, imap.FlagSeen) { text = fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) } - for _, f := range mi.Data.From { + for _, f := range mi.Message.From { if f.Name != "" { text += f.Name + " " } else { @@ -75,7 +76,7 @@ func (mi *MessageListItem) Init() { s.SetTextWrap(false) }) w.Updater(func() { - w.SetText(mi.Data.Subject) + w.SetText(mi.Message.Subject) }) }) } @@ -106,3 +107,24 @@ func (at *AddressTextField) Init() { return nil }) } + +// Attachment represents an email attachment. +type Attachment struct { + Filename string +} + +// 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.SetType(core.ButtonTonal) + ab.Updater(func() { + ab.SetText(ab.Attachment.Filename) + }) +} From a7db025ccd2d5378c4e8ef68640f3931cc577019 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:36:31 -0700 Subject: [PATCH 35/84] update mail generated code --- mail/typegen.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/mail/typegen.go b/mail/typegen.go index 381116a0..83dbab52 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -8,7 +8,7 @@ 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: "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: "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. @@ -16,7 +16,7 @@ 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 [CacheMessage] 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 [CacheMessage] with a [core.Frame] for the message list. @@ -24,8 +24,8 @@ func NewMessageListItem(parent ...tree.Node) *MessageListItem { return tree.New[MessageListItem](parent...) } -// SetData sets the [MessageListItem.Data] -func (t *MessageListItem) SetData(v *CacheMessage) *MessageListItem { t.Message = 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 +37,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 } From 45e7a1715530eb7682625d237a392b246ce13f7f Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:38:38 -0700 Subject: [PATCH 36/84] improve AttachmentButton formatting --- mail/values.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mail/values.go b/mail/values.go index 4bb21d3c..237d0b05 100644 --- a/mail/values.go +++ b/mail/values.go @@ -14,6 +14,7 @@ import ( "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" @@ -123,8 +124,11 @@ func (ab *AttachmentButton) WidgetValue() any { return &ab.Attachment } func (ab *AttachmentButton) Init() { ab.Button.Init() - ab.SetType(core.ButtonTonal) + ab.SetIcon(icons.Download).SetType(core.ButtonTonal) ab.Updater(func() { ab.SetText(ab.Attachment.Filename) }) + ab.OnClick(func(e events.Event) { + fmt.Println("download", ab.Attachment.Filename) + }) } From a9bd637acf54cb78c02a2b3abfc94fc31fa84a40 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:40:25 -0700 Subject: [PATCH 37/84] start on attachment data reading --- mail/read.go | 9 ++++++++- mail/values.go | 7 ++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mail/read.go b/mail/read.go index f674f2fa..906c1132 100644 --- a/mail/read.go +++ b/mail/read.go @@ -38,6 +38,12 @@ type readMessageParsed struct { attachments []*Attachment } +// Attachment represents an email attachment when reading a message. +type Attachment struct { + Filename string + Data io.Reader +} + // displayMessageContents updates the given frame to display the contents of // the current message, if it does not already. func (a *App) displayMessageContents(w *core.Frame) error { @@ -105,7 +111,8 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, &Attachment{fname}) + at := &Attachment{Filename: fname, Data: p.Body} + a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, at) } } diff --git a/mail/values.go b/mail/values.go index 237d0b05..2e16b220 100644 --- a/mail/values.go +++ b/mail/values.go @@ -6,6 +6,7 @@ package mail import ( "fmt" + "io" "net/mail" "slices" "strings" @@ -109,11 +110,6 @@ func (at *AddressTextField) Init() { }) } -// Attachment represents an email attachment. -type Attachment struct { - Filename string -} - // AttachmentButton represents an [Attachment] with a [core.Button]. type AttachmentButton struct { core.Button @@ -130,5 +126,6 @@ func (ab *AttachmentButton) Init() { }) ab.OnClick(func(e events.Event) { fmt.Println("download", ab.Attachment.Filename) + fmt.Println(io.ReadAll(ab.Attachment.Data)) }) } From 60157a0ad4fac458442f9e9c3e637fc925a6317c Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:43:29 -0700 Subject: [PATCH 38/84] implement initial attachment downloading --- mail/read.go | 8 ++++++-- mail/values.go | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mail/read.go b/mail/read.go index 906c1132..538575a2 100644 --- a/mail/read.go +++ b/mail/read.go @@ -41,7 +41,7 @@ type readMessageParsed struct { // Attachment represents an email attachment when reading a message. type Attachment struct { Filename string - Data io.Reader + Data []byte } // displayMessageContents updates the given frame to display the contents of @@ -111,7 +111,11 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - at := &Attachment{Filename: fname, Data: p.Body} + at := &Attachment{Filename: fname} + at.Data, err = io.ReadAll(p.Body) + if err != nil { + return err + } a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, at) } } diff --git a/mail/values.go b/mail/values.go index 2e16b220..998cea21 100644 --- a/mail/values.go +++ b/mail/values.go @@ -6,8 +6,8 @@ package mail import ( "fmt" - "io" "net/mail" + "os" "slices" "strings" @@ -125,7 +125,7 @@ func (ab *AttachmentButton) Init() { ab.SetText(ab.Attachment.Filename) }) ab.OnClick(func(e events.Event) { - fmt.Println("download", ab.Attachment.Filename) - fmt.Println(io.ReadAll(ab.Attachment.Data)) + err := os.WriteFile(ab.Attachment.Filename, ab.Attachment.Data, 0666) + core.ErrorSnackbar(ab, err, "Error downloading attachment") }) } From e702cb800dcc3a50b8e10dadc33dbba9f9aba078 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:50:11 -0700 Subject: [PATCH 39/84] use SoloFuncButton to prompt user for attachment download location in mail --- mail/values.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mail/values.go b/mail/values.go index 998cea21..6a6dcf3a 100644 --- a/mail/values.go +++ b/mail/values.go @@ -125,7 +125,10 @@ func (ab *AttachmentButton) Init() { ab.SetText(ab.Attachment.Filename) }) ab.OnClick(func(e events.Event) { - err := os.WriteFile(ab.Attachment.Filename, ab.Attachment.Data, 0666) - core.ErrorSnackbar(ab, err, "Error downloading attachment") + 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(ab.Attachment.Filename) + fb.CallFunc() }) } From 023bf8e67b99bb3b65fc4e3e1dd5c791d1bf8178 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 20 Sep 2024 17:53:54 -0700 Subject: [PATCH 40/84] set default read mail download location to downloads folder with attachment filename --- mail/values.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mail/values.go b/mail/values.go index 6a6dcf3a..018f31fc 100644 --- a/mail/values.go +++ b/mail/values.go @@ -8,9 +8,11 @@ import ( "fmt" "net/mail" "os" + "path/filepath" "slices" "strings" + "cogentcore.org/core/base/errors" "cogentcore.org/core/colors" "cogentcore.org/core/core" "cogentcore.org/core/cursors" @@ -20,6 +22,7 @@ import ( "cogentcore.org/core/styles/abilities" "cogentcore.org/core/tree" "github.com/emersion/go-imap/v2" + "github.com/mitchellh/go-homedir" ) func init() { @@ -128,7 +131,7 @@ func (ab *AttachmentButton) Init() { 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(ab.Attachment.Filename) + fb.Args[0].Value = core.Filename(filepath.Join(errors.Log1(homedir.Dir()), "Downloads", ab.Attachment.Filename)) fb.CallFunc() }) } From 1a5d0d0393cd0fd5c8ed5e4e33a3eaf04481f38c Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 10:18:15 -0700 Subject: [PATCH 41/84] use temporary file and add AsyncLock protection for cache saving in mail to prevent cache file wiping --- mail/cache.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index c1107fe4..c16649cf 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -296,15 +296,23 @@ 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 save it through a temporary + // file to avoid truncating it without writing it if we quit during the process. + // We also start the AsyncLock here so that we cannot quit from the GUI while + // saving the file. + a.AsyncLock() + err = jsonx.Save(&cached, cacheFile+".tmp") if err != nil { a.imapMu[email].Unlock() return fmt.Errorf("saving cache list: %w", err) } + err = os.Rename(cacheFile+".tmp", cacheFile) + if err != nil { + a.imapMu[email].Unlock() + return err + } a.cache[email] = cached - a.AsyncLock() a.Update() a.AsyncUnlock() } From 041ef543a186ff0f57ab075353047fab2000ff3c Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 10:20:17 -0700 Subject: [PATCH 42/84] make a backup of the old cache file in mail first for extra safety --- mail/cache.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mail/cache.go b/mail/cache.go index c16649cf..e3e67237 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -299,8 +299,14 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai // we get interrupted or have an error. We save it through a temporary // file to avoid truncating it without writing it if we quit during the process. // We also start the AsyncLock here so that we cannot quit from the GUI while - // saving the file. + // saving the file. We also first make a backup of the old cache file for extra + // safety. a.AsyncLock() + err = os.Rename(cacheFile, cacheFile+".backup") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + a.imapMu[email].Unlock() + return err + } err = jsonx.Save(&cached, cacheFile+".tmp") if err != nil { a.imapMu[email].Unlock() From a2039107137e9040755c33adf91f9c36608fb9b9 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 10:27:59 -0700 Subject: [PATCH 43/84] Revert "make a backup of the old cache file in mail first for extra safety"; this actually makes things less safe and is not necessary; we can figure out some better solution later if we really need it This reverts commit 041ef543a186ff0f57ab075353047fab2000ff3c. --- mail/cache.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index e3e67237..c16649cf 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -299,14 +299,8 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai // we get interrupted or have an error. We save it through a temporary // file to avoid truncating it without writing it if we quit during the process. // We also start the AsyncLock here so that we cannot quit from the GUI while - // saving the file. We also first make a backup of the old cache file for extra - // safety. + // saving the file. a.AsyncLock() - err = os.Rename(cacheFile, cacheFile+".backup") - if err != nil && !errors.Is(err, fs.ErrNotExist) { - a.imapMu[email].Unlock() - return err - } err = jsonx.Save(&cached, cacheFile+".tmp") if err != nil { a.imapMu[email].Unlock() From ee8376b11c4dea14818222fd3285b0accd6070d5 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 12:09:24 -0700 Subject: [PATCH 44/84] add uniqute messageFilename logic --- mail/cache.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index c16649cf..e75b9b4f 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -15,6 +15,8 @@ import ( "slices" "strings" "sync" + "sync/atomic" + "time" "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" @@ -326,7 +328,11 @@ 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)) } From 7d8a0b47c62f7d81256ff4a965a0264e63b407a2 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 12:12:27 -0700 Subject: [PATCH 45/84] use unique filename for storing cached mail and save it in the cache list --- mail/cache.go | 9 ++++++++- mail/read.go | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index e75b9b4f..e391f69b 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -29,6 +29,11 @@ import ( // mail list in the GUI. 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. @@ -262,13 +267,15 @@ func (a *App) CacheUIDs(uids []imap.UID, c *imapclient.Client, email string, mai } else { // Otherwise, we add it as a new entry to the cache // and save the content to a file. + 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 diff --git a/mail/read.go b/mail/read.go index 538575a2..4bbf104e 100644 --- a/mail/read.go +++ b/mail/read.go @@ -58,7 +58,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { bemail := FilenameBase32(a.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, a.readMessage.Filename)) if err != nil { return err } From 35f68472a737c44a42671254d06641c771224f29 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 13:51:21 -0700 Subject: [PATCH 46/84] only display attachments in display message if they exist --- mail/read.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mail/read.go b/mail/read.go index 4bbf104e..1be633bc 100644 --- a/mail/read.go +++ b/mail/read.go @@ -25,6 +25,14 @@ type displayMessage struct { Attachments []*Attachment `display:"inline"` } +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 { From 1e04b24f7c7d5516915860cb0ea6098a9e67a400 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 16:09:44 -0700 Subject: [PATCH 47/84] only add criteria if there are UIDs; multi-mailbox mail download is finally working now --- mail/cache.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index e391f69b..5e0d1c18 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -189,9 +189,13 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo } } - nc := imap.SearchCriteria{} - nc.UID = []imap.UIDSet{uidset} - criteria.Not = append(criteria.Not, nc) + // Only add the criteria if there are UIDs; otherwise, it will + // generate an unclear "unexpected EOF" error. + if len(uidset) > 0 { + nc := imap.SearchCriteria{} + nc.UID = []imap.UIDSet{uidset} + criteria.Not = append(criteria.Not, nc) + } } // these are the UIDs of the new messages From bd73ecddb82ead858982a95831d983c1ccfbaf5c Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 16:16:09 -0700 Subject: [PATCH 48/84] use OnSelect for mailbox tree; now fully working with recent core changes --- mail/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 9a58cc27..d03ae07f 100644 --- a/mail/app.go +++ b/mail/app.go @@ -107,7 +107,7 @@ func (a *App) Init() { for _, label := range a.labels[email] { tree.AddAt(p, label, func(w *core.Tree) { w.SetText(friendlyLabelName(label)) - w.OnClick(func(e events.Event) { + w.OnSelect(func(e events.Event) { a.showLabel = label a.Update() }) From b6c940406b679d0062bd79c0900c6da8a9703cda Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 21 Sep 2024 17:04:57 -0700 Subject: [PATCH 49/84] stop doing Logout for now --- mail/cache.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/cache.go b/mail/cache.go index 5e0d1c18..17347d74 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -118,7 +118,7 @@ func (a *App) CacheMessagesForAccount(email string) error { if err != nil { return fmt.Errorf("TLS dialing: %w", err) } - defer c.Logout() + // defer c.Logout() // TODO: Logout in QuitClean or something similar a.imapClient[email] = c a.imapMu[email] = &sync.Mutex{} From 82c3be8fbfbae3318db84336dba049752da14a54 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:15:18 -0700 Subject: [PATCH 50/84] move actual mail labeling logic into a separate function --- mail/actions.go | 101 +++++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 4934433c..19a82b68 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -95,8 +95,6 @@ func (a *App) Label() { //types:add d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Save").OnClick(func(e events.Event) { - // newLabels are the labels we want to end up with, in contrast - // to the old labels we started with, which are a.readMessage.Labels. newLabels := []string{} for _, label := range labels { if label.On { @@ -107,58 +105,65 @@ func (a *App) Label() { //types:add core.ErrorSnackbar(a, fmt.Errorf("specify at least one label")) return } - // 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 + a.label(newLabels) + }) + }) + d.RunDialog(a) +} + +// 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. } - err = c.UIDExpunge(label.UIDSet()).Wait() + cd, err := c.Copy(label.UIDSet(), newLabel).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 - }) + // 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 }) - d.RunDialog(a) } // Reply opens a dialog to reply to the current message. From 0d4deaa50604a0b6d2efc0aff051cd34d6a6ac0b Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:18:58 -0700 Subject: [PATCH 51/84] add Delete button to mail --- mail/actions.go | 5 +++++ mail/app.go | 3 +++ mail/typegen.go | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mail/actions.go b/mail/actions.go index 19a82b68..594e2ef5 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -166,6 +166,11 @@ func (a *App) label(newLabels []string) { }) } +// 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. func (a *App) Reply() { //types:add a.composeMessage = &SendMessage{} diff --git a/mail/app.go b/mail/app.go index d03ae07f..d0326258 100644 --- a/mail/app.go +++ b/mail/app.go @@ -194,6 +194,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) }) diff --git a/mail/typegen.go b/mail/typegen.go index 83dbab52..d669d24d 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -8,7 +8,7 @@ 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: "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."}}}) +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: "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. From 18bd00a6ea377ea8dbca56582d6c894beeb15dda Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:19:58 -0700 Subject: [PATCH 52/84] remove gmail prefix from mail labels --- mail/settings.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mail/settings.go b/mail/settings.go index 129ad484..c1932b75 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -7,6 +7,7 @@ package mail import ( "path/filepath" "slices" + "strings" "cogentcore.org/core/core" ) @@ -36,6 +37,7 @@ func friendlyLabelName(name string) string { if f, ok := friendlyLabelNames[name]; ok { return f } + name = strings.TrimPrefix(name, "[Gmail]/") return name } From a66e3b858258fa57fb81d64658d08ab6cb0aec02 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:25:13 -0700 Subject: [PATCH 53/84] add temporary skipLabels for mail --- mail/app.go | 3 +++ mail/cache.go | 5 ++--- mail/settings.go | 9 +++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/mail/app.go b/mail/app.go index d0326258..1fb4dd72 100644 --- a/mail/app.go +++ b/mail/app.go @@ -105,6 +105,9 @@ func (a *App) Init() { tree.AddAt(p, email, func(w *core.Tree) { w.Maker(func(p *tree.Plan) { for _, label := range a.labels[email] { + if skipLabels[label] { + continue + } tree.AddAt(p, label, func(w *core.Tree) { w.SetText(friendlyLabelName(label)) w.OnSelect(func(e events.Event) { diff --git a/mail/cache.go b/mail/cache.go index 17347d74..3dff0353 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "slices" - "strings" "sync" "sync/atomic" "time" @@ -157,8 +156,8 @@ 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 + if skipLabels[mailbox.Mailbox] { + continue } err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached, cacheFile) if err != nil { diff --git a/mail/settings.go b/mail/settings.go index c1932b75..02200ac6 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -44,3 +44,12 @@ func friendlyLabelName(name string) string { var friendlyLabelNames = map[string]string{ "INBOX": "Inbox", } + +// 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, +} From 33272e4d653abe9ff8fa006daea46338fb9fb0ed Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:31:46 -0700 Subject: [PATCH 54/84] improve sent label formatting --- mail/settings.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mail/settings.go b/mail/settings.go index 02200ac6..8554762c 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -42,7 +42,8 @@ func friendlyLabelName(name string) string { } var friendlyLabelNames = map[string]string{ - "INBOX": "Inbox", + "INBOX": "Inbox", + "[Gmail]/Sent Mail": "Sent", } // skipLabels are a temporary set of labels that should not be cached or displayed. From 47910720335bcd45182d2d0a2f3c4e4fc13aa96a Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:37:44 -0700 Subject: [PATCH 55/84] implement initial mailbox tree icons --- mail/app.go | 9 ++++++++- mail/settings.go | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mail/app.go b/mail/app.go index 1fb4dd72..d6264571 100644 --- a/mail/app.go +++ b/mail/app.go @@ -109,7 +109,14 @@ func (a *App) Init() { continue } tree.AddAt(p, label, func(w *core.Tree) { - w.SetText(friendlyLabelName(label)) + w.Updater(func() { + w.SetText(friendlyLabelName(label)) + 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() diff --git a/mail/settings.go b/mail/settings.go index 8554762c..dd86ec6c 100644 --- a/mail/settings.go +++ b/mail/settings.go @@ -10,6 +10,7 @@ import ( "strings" "cogentcore.org/core/core" + "cogentcore.org/core/icons" ) func init() { @@ -34,16 +35,16 @@ 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 } - name = strings.TrimPrefix(name, "[Gmail]/") return name } var friendlyLabelNames = map[string]string{ - "INBOX": "Inbox", - "[Gmail]/Sent Mail": "Sent", + "INBOX": "Inbox", + "Sent Mail": "Sent", } // skipLabels are a temporary set of labels that should not be cached or displayed. @@ -54,3 +55,12 @@ var skipLabels = map[string]bool{ "[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, +} From 850c82fa763c1be8876b945a534fb58be860b90a Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 11:38:58 -0700 Subject: [PATCH 56/84] use IconLeaf for label icons in mail tree --- mail/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mail/app.go b/mail/app.go index d6264571..7b757509 100644 --- a/mail/app.go +++ b/mail/app.go @@ -112,9 +112,9 @@ func (a *App) Init() { w.Updater(func() { w.SetText(friendlyLabelName(label)) if ic, ok := labelIcons[w.Text]; ok { - w.SetIcon(ic) + w.SetIconLeaf(ic) } else { - w.SetIcon(icons.Label) + w.SetIconLeaf(icons.Label) } }) w.OnSelect(func(e events.Event) { From 45b26e1bc5a69691873b8df4ba17786275cfe695 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 17:12:20 -0700 Subject: [PATCH 57/84] initial implementation of label tree nesting in mail --- mail/app.go | 53 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/mail/app.go b/mail/app.go index 7b757509..7ec85182 100644 --- a/mail/app.go +++ b/mail/app.go @@ -12,6 +12,7 @@ import ( "fmt" "path/filepath" "slices" + "strings" "sync" "cogentcore.org/core/core" @@ -103,27 +104,7 @@ func (a *App) Init() { w.Maker(func(p *tree.Plan) { for _, email := range Settings.Accounts { tree.AddAt(p, email, func(w *core.Tree) { - w.Maker(func(p *tree.Plan) { - for _, label := range a.labels[email] { - if skipLabels[label] { - continue - } - tree.AddAt(p, label, func(w *core.Tree) { - w.Updater(func() { - w.SetText(friendlyLabelName(label)) - if ic, ok := labelIcons[w.Text]; ok { - w.SetIconLeaf(ic) - } else { - w.SetIconLeaf(icons.Label) - } - }) - w.OnSelect(func(e events.Event) { - a.showLabel = label - a.Update() - }) - }) - } - }) + a.initLabelTree(w, email, "") }) } }) @@ -194,6 +175,36 @@ func (a *App) Init() { }) } +func (a *App) initLabelTree(w *core.Tree, email, parentLabel string) { + friendlyParentLabel := friendlyLabelName(parentLabel) + w.Maker(func(p *tree.Plan) { + for _, label := range a.labels[email] { + if skipLabels[label] { + continue + } + friendlyLabel := friendlyLabelName(label) + if (parentLabel == "" && strings.Contains(friendlyLabel, "/")) || (parentLabel != "" && !strings.HasPrefix(friendlyLabel, friendlyParentLabel+"/")) { + continue + } + tree.AddAt(p, label, func(w *core.Tree) { + a.initLabelTree(w, email, label) + w.Updater(func() { + w.SetText(strings.TrimPrefix(friendlyLabelName(label), friendlyParentLabel+"/")) + if ic, ok := labelIcons[w.Text]; ok { + w.SetIconLeaf(ic) + } else { + w.SetIconLeaf(icons.Label) + } + }) + w.OnSelect(func(e events.Event) { + a.showLabel = label + a.Update() + }) + }) + } + }) +} + func (a *App) MakeToolbar(p *tree.Plan) { tree.Add(p, func(w *core.FuncButton) { w.SetFunc(a.Compose).SetIcon(icons.Send).SetKey(keymap.New) From 83585258341054da68abef6c74087640e761a381 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 17:24:17 -0700 Subject: [PATCH 58/84] clean up makeLabelTree in mail --- mail/app.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/mail/app.go b/mail/app.go index 7ec85182..772475c4 100644 --- a/mail/app.go +++ b/mail/app.go @@ -104,7 +104,7 @@ func (a *App) Init() { w.Maker(func(p *tree.Plan) { for _, email := range Settings.Accounts { tree.AddAt(p, email, func(w *core.Tree) { - a.initLabelTree(w, email, "") + a.makeLabelTree(w, email, "") }) } }) @@ -175,7 +175,8 @@ func (a *App) Init() { }) } -func (a *App) initLabelTree(w *core.Tree, email, parentLabel string) { +// 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) { friendlyParentLabel := friendlyLabelName(parentLabel) w.Maker(func(p *tree.Plan) { for _, label := range a.labels[email] { @@ -183,11 +184,17 @@ func (a *App) initLabelTree(w *core.Tree, email, parentLabel string) { continue } friendlyLabel := friendlyLabelName(label) - if (parentLabel == "" && strings.Contains(friendlyLabel, "/")) || (parentLabel != "" && !strings.HasPrefix(friendlyLabel, friendlyParentLabel+"/")) { + // 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.initLabelTree(w, email, label) + a.makeLabelTree(w, email, label) w.Updater(func() { w.SetText(strings.TrimPrefix(friendlyLabelName(label), friendlyParentLabel+"/")) if ic, ok := labelIcons[w.Text]; ok { From ca7dec7680e4d9852f88ed7dc52e372df7e1cc48 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sun, 22 Sep 2024 17:30:17 -0700 Subject: [PATCH 59/84] recompute the friendly labels in every Updater/Maker in case they have changed --- mail/app.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mail/app.go b/mail/app.go index 772475c4..ae0af079 100644 --- a/mail/app.go +++ b/mail/app.go @@ -177,8 +177,8 @@ func (a *App) Init() { // 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) { - friendlyParentLabel := friendlyLabelName(parentLabel) w.Maker(func(p *tree.Plan) { + friendlyParentLabel := friendlyLabelName(parentLabel) for _, label := range a.labels[email] { if skipLabels[label] { continue @@ -196,7 +196,8 @@ func (a *App) makeLabelTree(w *core.Tree, email, parentLabel string) { tree.AddAt(p, label, func(w *core.Tree) { a.makeLabelTree(w, email, label) w.Updater(func() { - w.SetText(strings.TrimPrefix(friendlyLabelName(label), friendlyParentLabel+"/")) + // 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.SetIconLeaf(ic) } else { From 021d0c3ec48bd909fb9980aa35026117fa2ee3e2 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Tue, 24 Sep 2024 18:07:13 -0700 Subject: [PATCH 60/84] implement a cache cleaning function that clears removed message labels; improve cache file saving safety in mail --- mail/actions.go | 3 +- mail/app.go | 21 ++++++++++-- mail/cache.go | 80 +++++++++++++++++++++++-------------------- mail/cmd/mail/mail.go | 3 +- 4 files changed, 64 insertions(+), 43 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 594e2ef5..8ecad04c 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -9,7 +9,6 @@ import ( "slices" "strings" - "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" "cogentcore.org/core/events" "github.com/emersion/go-imap/v2" @@ -28,7 +27,7 @@ func (a *App) action(f func(c *imapclient.Client) error) { mu.Lock() err := f(a.imapClient[a.currentEmail]) core.ErrorSnackbar(a, err, "Error performing action") - err = jsonx.Save(a.cache[a.currentEmail], a.cacheFilename(a.currentEmail)) + err = a.saveCacheFile(a.cache[a.currentEmail], a.currentEmail) core.ErrorSnackbar(a, err, "Error saving cache") mu.Unlock() a.AsyncLock() diff --git a/mail/app.go b/mail/app.go index ae0af079..a0bdc42a 100644 --- a/mail/app.go +++ b/mail/app.go @@ -10,11 +10,13 @@ package mail import ( "cmp" "fmt" + "os" "path/filepath" "slices" "strings" "sync" + "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/icons" @@ -241,7 +243,7 @@ func (a *App) MakeToolbar(p *tree.Plan) { } } -func (a *App) GetMail() error { +func (a *App) GetMail() { go func() { err := a.Auth() if err != nil { @@ -253,7 +255,6 @@ func (a *App) GetMail() error { core.ErrorDialog(a, err, "Error caching messages") } }() - return nil } // selectMailbox selects the given mailbox for the given email for the given client. @@ -275,3 +276,19 @@ func (a *App) selectMailbox(c *imapclient.Client, email string, mailbox string) func (a *App) cacheFilename(email string) string { 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 +} diff --git a/mail/cache.go b/mail/cache.go index 3dff0353..807f1391 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -159,7 +159,7 @@ func (a *App) CacheMessagesForAccount(email string) error { if skipLabels[mailbox.Mailbox] { continue } - err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached, cacheFile) + err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached) if err != nil { return fmt.Errorf("caching messages for mailbox %q: %w", mailbox.Mailbox, err) } @@ -170,39 +170,18 @@ func (a *App) CacheMessagesForAccount(email string) error { // 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, dir string, cached map[string]*CacheMessage, cacheFile string) error { +func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheMessage) error { err := a.selectMailbox(c, email, mailbox) 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 _, cm := range cached { - for _, label := range cm.Labels { - if label.Name == mailbox { - uidset.AddNum(label.UID) - } - } - } - - // Only add the criteria if there are UIDs; otherwise, it will - // generate an unclear "unexpected EOF" error. - if len(uidset) > 0 { - 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() + uidsData, err := c.UIDSearch(&imap.SearchCriteria{}, nil).Wait() if err != nil { return fmt.Errorf("searching for uids: %w", err) } + // These are all of the UIDs, including those we have already cached. uids := uidsData.AllUIDs() if len(uids) == 0 { a.AsyncLock() @@ -210,14 +189,48 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo a.AsyncUnlock() return nil } - return a.CacheUIDs(uids, c, email, mailbox, dir, cached, cacheFile) + + err = a.cleanCache(cached, email, mailbox, uids) + if err != nil { + return err + } + + // We filter out the UIDs that are already cached. + alreadyHave := map[imap.UID]bool{} + for _, cm := range cached { + for _, label := range cm.Labels { + if label.Name == mailbox { + alreadyHave[label.UID] = true + } + } + } + uids = slices.DeleteFunc(uids, func(uid imap.UID) bool { + return alreadyHave[uid] + }) + + 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) + } + } + return a.saveCacheFile(cached, email) } // 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]*CacheMessage, 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 @@ -308,17 +321,10 @@ 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. We save it through a temporary - // file to avoid truncating it without writing it if we quit during the process. - // We also start the AsyncLock here so that we cannot quit from the GUI while - // saving the file. + // 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 = jsonx.Save(&cached, cacheFile+".tmp") - if err != nil { - a.imapMu[email].Unlock() - return fmt.Errorf("saving cache list: %w", err) - } - err = os.Rename(cacheFile+".tmp", cacheFile) + err = a.saveCacheFile(cached, email) if err != nil { 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() } From 3fa477b914e22bcba4b9bf55504f0abd9472536f Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 25 Sep 2024 09:50:52 -0700 Subject: [PATCH 61/84] unexport mail cache functions --- mail/cache.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index 807f1391..e47816e4 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -97,7 +97,7 @@ func (a *App) CacheMessages() error { a.imapMu = map[string]*sync.Mutex{} } 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) } @@ -108,7 +108,7 @@ 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]*CacheMessage{} } @@ -159,7 +159,7 @@ func (a *App) CacheMessagesForAccount(email string) error { if skipLabels[mailbox.Mailbox] { continue } - err := a.CacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached) + err := a.cacheMessagesForMailbox(c, email, mailbox.Mailbox, dir, cached) if err != nil { return fmt.Errorf("caching messages for mailbox %q: %w", mailbox.Mailbox, err) } @@ -167,10 +167,10 @@ 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, dir string, cached map[string]*CacheMessage) error { +func (a *App) cacheMessagesForMailbox(c *imapclient.Client, email string, mailbox string, dir string, cached map[string]*CacheMessage) error { err := a.selectMailbox(c, email, mailbox) if err != nil { return err @@ -208,7 +208,7 @@ func (a *App) CacheMessagesForMailbox(c *imapclient.Client, email string, mailbo return alreadyHave[uid] }) - return a.CacheUIDs(uids, c, email, mailbox, dir, cached) + return a.cacheUIDs(uids, c, email, mailbox, dir, cached) } // cleanCache removes cached messages from the given mailbox if @@ -226,11 +226,11 @@ func (a *App) cleanCache(cached map[string]*CacheMessage, email string, mailbox return a.saveCacheFile(cached, email) } -// 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]*CacheMessage) 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 From 83411807ec9549e3ab054cb820e12397904c3e04 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 25 Sep 2024 10:10:56 -0700 Subject: [PATCH 62/84] use Tree.Icon instead of Tree.IconLeaf with new improved styling --- mail/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mail/app.go b/mail/app.go index a0bdc42a..9776dfac 100644 --- a/mail/app.go +++ b/mail/app.go @@ -201,9 +201,9 @@ func (a *App) makeLabelTree(w *core.Tree, email, parentLabel string) { // 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.SetIconLeaf(ic) + w.SetIcon(ic) } else { - w.SetIconLeaf(icons.Label) + w.SetIcon(icons.Label) } }) w.OnSelect(func(e events.Event) { From 325437712a6d98369e81bb3effd394f21dabb589 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 26 Sep 2024 08:39:50 -0700 Subject: [PATCH 63/84] cache mail messages in baches of the last 100 UIDs per mailbox --- mail/cache.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mail/cache.go b/mail/cache.go index e47816e4..664e32f9 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -207,6 +207,12 @@ func (a *App) cacheMessagesForMailbox(c *imapclient.Client, email string, mailbo uids = slices.DeleteFunc(uids, func(uid imap.UID) bool { return alreadyHave[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) } From b49b76deeecc4228647e170afd3cc20c676c5cef Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 26 Sep 2024 09:18:21 -0700 Subject: [PATCH 64/84] start on repeated mail caching --- mail/app.go | 9 +++++--- mail/cache.go | 59 ++++++++++++++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/mail/app.go b/mail/app.go index 9776dfac..e21b48a9 100644 --- a/mail/app.go +++ b/mail/app.go @@ -250,9 +250,12 @@ func (a *App) GetMail() { 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") + } } }() } diff --git a/mail/cache.go b/mail/cache.go index 664e32f9..d1f51c68 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -113,44 +113,51 @@ func (a *App) cacheMessagesForAccount(email string) error { 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) - } - // defer c.Logout() // TODO: Logout in QuitClean or something similar + dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) + cached := a.cache[email] - a.imapClient[email] = c - a.imapMu[email] = &sync.Mutex{} + 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 - err = c.Authenticate(a.authClient[email]) - if err != nil { - return fmt.Errorf("authenticating: %w", err) - } + a.imapClient[email] = c + a.imapMu[email] = &sync.Mutex{} - dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) - err = os.MkdirAll(string(dir), 0700) - if err != nil { - return err - } + err = c.Authenticate(a.authClient[email]) + if err != nil { + return fmt.Errorf("authenticating: %w", err) + } - cacheFile := a.cacheFilename(email) - err = os.MkdirAll(filepath.Dir(cacheFile), 0700) - if err != nil { - return 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) + 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.cache[email] = cached mailboxes, err := c.List("", "*", nil).Collect() if err != nil { return fmt.Errorf("getting mailboxes: %w", err) } + a.labels[email] = []string{} for _, mailbox := range mailboxes { a.labels[email] = append(a.labels[email], mailbox.Mailbox) } From aa4f5bf03c74a4f8ebd19d63d5000af4c4adce66 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 26 Sep 2024 09:29:37 -0700 Subject: [PATCH 65/84] improve mutex protection for mail caching --- mail/cache.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mail/cache.go b/mail/cache.go index d1f51c68..1927afb8 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -152,15 +152,19 @@ func (a *App) cacheMessagesForAccount(email string) error { 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 skipLabels[mailbox.Mailbox] { @@ -178,12 +182,15 @@ 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, 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 } uidsData, err := c.UIDSearch(&imap.SearchCriteria{}, nil).Wait() + a.imapMu[email].Unlock() if err != nil { return fmt.Errorf("searching for uids: %w", err) } @@ -236,7 +243,10 @@ func (a *App) cleanCache(cached map[string]*CacheMessage, email string, mailbox delete(cached, id) } } - return a.saveCacheFile(cached, email) + 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 From 3d26b60d4f4db87f805f01f0bed8da3aab8a5ff7 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 26 Sep 2024 09:37:46 -0700 Subject: [PATCH 66/84] unexport more things in mail --- mail/app.go | 6 +++--- mail/auth.go | 16 ++++++++-------- mail/cache.go | 6 +++--- mail/read.go | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mail/app.go b/mail/app.go index e21b48a9..d5d3b737 100644 --- a/mail/app.go +++ b/mail/app.go @@ -245,14 +245,14 @@ func (a *App) MakeToolbar(p *tree.Plan) { func (a *App) GetMail() { go func() { - err := a.Auth() + err := a.auth() if err != nil { core.ErrorDialog(a, err, "Error authorizing") return } // We keep caching messages forever to stay in sync. for { - err = a.CacheMessages() + err = a.cacheMessages() if err != nil { core.ErrorDialog(a, err, "Error caching messages") } @@ -277,7 +277,7 @@ func (a *App) selectMailbox(c *imapclient.Client, email string, mailbox string) // 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 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 1927afb8..ab5af703 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -84,9 +84,9 @@ 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 { +func (a *App) cacheMessages() error { if a.cache == nil { a.cache = map[string]map[string]*CacheMessage{} } @@ -113,7 +113,7 @@ func (a *App) cacheMessagesForAccount(email string) error { a.cache[email] = map[string]*CacheMessage{} } - dir := filepath.Join(core.TheApp.AppDataDir(), "mail", FilenameBase32(email)) + dir := filepath.Join(core.TheApp.AppDataDir(), "mail", filenameBase32(email)) cached := a.cache[email] var err error diff --git a/mail/read.go b/mail/read.go index 1be633bc..3b76fd62 100644 --- a/mail/read.go +++ b/mail/read.go @@ -64,7 +64,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { return nil } - bemail := FilenameBase32(a.currentEmail) + bemail := filenameBase32(a.currentEmail) f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, a.readMessage.Filename)) if err != nil { From 657c64df19ee24286e69db844a079a64e5f092d2 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Thu, 26 Sep 2024 12:53:17 -0700 Subject: [PATCH 67/84] start on mail syncFlags --- mail/cache.go | 45 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index ab5af703..cf311a9f 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -209,17 +209,26 @@ func (a *App) cacheMessagesForMailbox(c *imapclient.Client, email string, mailbo return err } - // We filter out the UIDs that are already cached. - alreadyHave := map[imap.UID]bool{} + alreadyHaveSlice := []imap.UID{} + alreadyHaveMap := map[imap.UID]bool{} for _, cm := range cached { for _, label := range cm.Labels { if label.Name == mailbox { - alreadyHave[label.UID] = true + alreadyHaveSlice = append(alreadyHaveSlice, label.UID) + alreadyHaveMap[label.UID] = true } } } + + // We sync the flags of all UIDs we have already cached. + err = a.syncFlags(alreadyHaveSlice, c, email, mailbox) + if err != nil { + return err + } + + // We filter out the UIDs that are already cached. uids = slices.DeleteFunc(uids, func(uid imap.UID) bool { - return alreadyHave[uid] + 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 @@ -278,6 +287,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) @@ -375,3 +385,30 @@ var messageFilenameCounter uint64 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) error { + if len(uids) == 0 { + return nil + } + uidset := imap.UIDSet{} + uidset.AddNum(uids...) + + fetchOptions := &imap.FetchOptions{Flags: 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 + } + data, err := c.Fetch(uidset, fetchOptions).Collect() + a.imapMu[email].Unlock() + if err != nil { + return err + } + fmt.Println(data) + return nil +} From 4dec34ccfc1eee6a4b5d1ab4638649794eab2dd3 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 10:19:58 -0700 Subject: [PATCH 68/84] get basic mail syncFlags working --- mail/cache.go | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index cf311a9f..dcb1d6ed 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -221,7 +221,7 @@ func (a *App) cacheMessagesForMailbox(c *imapclient.Client, email string, mailbo } // We sync the flags of all UIDs we have already cached. - err = a.syncFlags(alreadyHaveSlice, c, email, mailbox) + err = a.syncFlags(alreadyHaveSlice, c, email, mailbox, cached) if err != nil { return err } @@ -387,14 +387,24 @@ func messageFilename() string { } // 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) error { +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} + 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 @@ -404,11 +414,21 @@ func (a *App) syncFlags(uids []imap.UID, c *imapclient.Client, email string, mai a.imapMu[email].Unlock() return err } - data, err := c.Fetch(uidset, fetchOptions).Collect() - a.imapMu[email].Unlock() - if err != nil { - 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 } - fmt.Println(data) - return nil + err = a.saveCacheFile(cached, email) + a.imapMu[email].Unlock() + return err } From a76af1d86a432a9930fe82d45def540e3d6b5432 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 10:58:06 -0700 Subject: [PATCH 69/84] add total messages text in mail --- mail/app.go | 12 ++++++++++-- mail/cache.go | 12 +++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mail/app.go b/mail/app.go index d5d3b737..ad10c8f1 100644 --- a/mail/app.go +++ b/mail/app.go @@ -56,6 +56,9 @@ type App struct { // be used for any other purpose. listCache []*CacheMessage + // totalMessages is the total number of messages for each email account and label. + totalMessages map[string]map[string]int + // unreadMessages is the number of unread messages for the current email account // and labels, used for displaying a count. unreadMessages int @@ -92,6 +95,10 @@ 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][]string{} a.showLabel = "INBOX" @@ -142,7 +149,7 @@ func (a *App) Init() { }) tree.AddChild(w, func(w *core.Text) { w.Updater(func() { - w.SetText(fmt.Sprintf("%d messages", len(a.listCache))) + w.SetText(fmt.Sprintf("%d messages, %d downloaded", a.totalMessages[a.currentEmail][a.showLabel], len(a.listCache))) if a.unreadMessages > 0 { w.Text += fmt.Sprintf(", %d unread", a.unreadMessages) } @@ -266,11 +273,12 @@ 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 } diff --git a/mail/cache.go b/mail/cache.go index dcb1d6ed..0a0bff90 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -87,15 +87,6 @@ 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]*CacheMessage{} - } - if a.imapClient == nil { - a.imapClient = map[string]*imapclient.Client{} - } - if a.imapMu == nil { - a.imapMu = map[string]*sync.Mutex{} - } for _, account := range Settings.Accounts { err := a.cacheMessagesForAccount(account) if err != nil { @@ -112,6 +103,9 @@ func (a *App) cacheMessagesForAccount(email string) error { if a.cache[email] == nil { a.cache[email] = map[string]*CacheMessage{} } + if a.totalMessages[email] == nil { + a.totalMessages[email] = map[string]int{} + } dir := filepath.Join(core.TheApp.AppDataDir(), "mail", filenameBase32(email)) cached := a.cache[email] From 726a81bbfd002f606b4c45602f7ce08aab621b2a Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 11:09:39 -0700 Subject: [PATCH 70/84] use guard statement for label checking in mail listCache updater --- mail/app.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/mail/app.go b/mail/app.go index ad10c8f1..5c0a16c1 100644 --- a/mail/app.go +++ b/mail/app.go @@ -128,13 +128,14 @@ func (a *App) Init() { mp := a.cache[a.currentEmail] for _, cm := range mp { for _, label := range cm.Labels { - if label.Name == a.showLabel { - a.listCache = append(a.listCache, cm) - if !slices.Contains(cm.Flags, imap.FlagSeen) { - a.unreadMessages++ - } - break + 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 *CacheMessage) int { From 0b5f9c0693b6fea8cb9c50d95fb7ea0c5afc181f Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 12:26:54 -0700 Subject: [PATCH 71/84] only show downloaded messages text if not everything downloaded --- mail/app.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 5c0a16c1..31195d4b 100644 --- a/mail/app.go +++ b/mail/app.go @@ -150,7 +150,11 @@ func (a *App) Init() { }) tree.AddChild(w, func(w *core.Text) { w.Updater(func() { - w.SetText(fmt.Sprintf("%d messages, %d downloaded", a.totalMessages[a.currentEmail][a.showLabel], len(a.listCache))) + total := a.totalMessages[a.currentEmail][a.showLabel] + w.SetText(fmt.Sprintf("%d messages", total)) + if len(a.listCache) < total { + w.Text += fmt.Sprintf(", %d downloaded", len(a.listCache)) + } if a.unreadMessages > 0 { w.Text += fmt.Sprintf(", %d unread", a.unreadMessages) } From ada44455daa7328a168ecd5985f12179040699bb Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 12:31:20 -0700 Subject: [PATCH 72/84] improve format for downloaded messages text --- mail/app.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mail/app.go b/mail/app.go index 31195d4b..608f4892 100644 --- a/mail/app.go +++ b/mail/app.go @@ -150,11 +150,12 @@ func (a *App) Init() { }) tree.AddChild(w, func(w *core.Text) { w.Updater(func() { + w.Text = "" total := a.totalMessages[a.currentEmail][a.showLabel] - w.SetText(fmt.Sprintf("%d messages", total)) if len(a.listCache) < total { - w.Text += fmt.Sprintf(", %d downloaded", len(a.listCache)) + 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) } From 0aada19909ee2c066c142a1ba0b9fc493352f390 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 12:42:55 -0700 Subject: [PATCH 73/84] start on conversation combination / threading in mail --- mail/app.go | 7 +++++++ mail/cache.go | 5 +++++ mail/values.go | 5 ++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 608f4892..41ac3246 100644 --- a/mail/app.go +++ b/mail/app.go @@ -127,6 +127,13 @@ func (a *App) Init() { a.unreadMessages = 0 mp := a.cache[a.currentEmail] for _, cm := range mp { + if len(cm.InReplyTo) > 0 { + irt := cm.InReplyTo[0] + if irtcm, ok := mp[irt]; ok { + irtcm.replies = append(irtcm.replies, cm) + continue + } + } for _, label := range cm.Labels { if label.Name != a.showLabel { continue diff --git a/mail/cache.go b/mail/cache.go index 0a0bff90..4e3df2a9 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -39,6 +39,11 @@ type CacheMessage struct { // 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 } // Label represents a Label associated with a message. diff --git a/mail/values.go b/mail/values.go index 018f31fc..789ac302 100644 --- a/mail/values.go +++ b/mail/values.go @@ -62,7 +62,10 @@ func (mi *MessageListItem) Init() { w.Updater(func() { text := "" if !slices.Contains(mi.Message.Flags, imap.FlagSeen) { - text = fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) + text += fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) + } + if len(mi.Message.replies) > 0 { + text += fmt.Sprintf(`%d `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base)), len(mi.Message.replies)) } for _, f := range mi.Message.From { if f.Name != "" { From c6e10dd1257fe8259e5758c4433d681266a018c5 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 12:47:28 -0700 Subject: [PATCH 74/84] only add message to replies if it isn't already there in mail threading --- mail/app.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 41ac3246..cc861004 100644 --- a/mail/app.go +++ b/mail/app.go @@ -130,7 +130,9 @@ func (a *App) Init() { if len(cm.InReplyTo) > 0 { irt := cm.InReplyTo[0] if irtcm, ok := mp[irt]; ok { - irtcm.replies = append(irtcm.replies, cm) + if !slices.Contains(irtcm.replies, cm) { + irtcm.replies = append(irtcm.replies, cm) + } continue } } From 0d10bfbcaaa7b3daff95b3ba3c3c3def34b95318 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Fri, 27 Sep 2024 15:39:44 -0700 Subject: [PATCH 75/84] add 1 to number of replies in mail --- mail/values.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/values.go b/mail/values.go index 789ac302..06cb4534 100644 --- a/mail/values.go +++ b/mail/values.go @@ -65,7 +65,7 @@ func (mi *MessageListItem) Init() { text += fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) } if len(mi.Message.replies) > 0 { - text += fmt.Sprintf(`%d `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base)), len(mi.Message.replies)) + 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 != "" { From e5c92ec7254ade0ed3f6b86132f2a95a3588fd8b Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 28 Sep 2024 15:53:37 -0700 Subject: [PATCH 76/84] start on more extensible DisplayMessageFrame paradigm in mail --- mail/app.go | 21 +++------------------ mail/read.go | 33 +++++++++++++++++++++++++++++++++ mail/typegen.go | 16 +++++++++++++++- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/mail/app.go b/mail/app.go index cc861004..2a695a6b 100644 --- a/mail/app.go +++ b/mail/app.go @@ -176,24 +176,9 @@ func (a *App) Init() { 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.ToDisplay(&a.readMessageParsed)) - }) - }) - 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.displayMessageContents(w), "Error reading message") - }) + tree.AddChild(w, func(w *DisplayMessageFrame) { + w.Updater(func() { + w.SetMessage(a.readMessage) }) }) }) diff --git a/mail/read.go b/mail/read.go index 3b76fd62..9a0e5091 100644 --- a/mail/read.go +++ b/mail/read.go @@ -12,6 +12,8 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/htmlcore" + "cogentcore.org/core/styles" + "cogentcore.org/core/tree" "github.com/emersion/go-message/mail" ) @@ -52,6 +54,37 @@ type Attachment struct { 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(&theApp.readMessageParsed)) + }) + }) + 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, theApp.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) displayMessageContents(w *core.Frame) error { diff --git a/mail/typegen.go b/mail/typegen.go index d669d24d..15ae83bc 100644 --- a/mail/typegen.go +++ b/mail/typegen.go @@ -8,12 +8,26 @@ 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: "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: "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."}}}) +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 [CacheMessage] with a [core.Frame] for the message list.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Message"}}}) From 67878fdab5d1c768325c7328c27489efc2a71d06 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 28 Sep 2024 16:00:02 -0700 Subject: [PATCH 77/84] get initial display of multiple DisplayMessageFrame widgets working for conversation threading --- mail/app.go | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/mail/app.go b/mail/app.go index 2a695a6b..29fe6a2d 100644 --- a/mail/app.go +++ b/mail/app.go @@ -176,9 +176,28 @@ func (a *App) Init() { w.SetReadOnly(true) }) }) - tree.AddChild(w, func(w *DisplayMessageFrame) { - w.Updater(func() { - w.SetMessage(a.readMessage) + tree.AddChild(w, func(w *core.Frame) { + w.Styler(func(s *styles.Style) { + s.Direction = styles.Column + }) + 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 _, reply := range a.readMessage.replies { + add(reply) + } + add(a.readMessage) }) }) }) From 61bc402abe8c71178cf4bb0b625e0281f6839e4a Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 28 Sep 2024 16:03:29 -0700 Subject: [PATCH 78/84] get initial display of actual message contents of thread working --- mail/app.go | 2 +- mail/read.go | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/mail/app.go b/mail/app.go index 29fe6a2d..c739670e 100644 --- a/mail/app.go +++ b/mail/app.go @@ -67,7 +67,7 @@ type App struct { readMessage *CacheMessage // readMessageParsed contains data parsed from the current message we are reading. - readMessageParsed readMessageParsed + readMessageParsed readMessageParsed // TODO: move into CacheMessage // currentEmail is the current email account. currentEmail string diff --git a/mail/read.go b/mail/read.go index 9a0e5091..bf65fdf2 100644 --- a/mail/read.go +++ b/mail/read.go @@ -80,26 +80,23 @@ func (dmf *DisplayMessageFrame) Init() { s.Grow.Set(1, 0) }) w.Updater(func() { - core.ErrorSnackbar(w, theApp.displayMessageContents(w), "Error reading message") + 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) displayMessageContents(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, a.readMessage.Filename)) + f, err := os.Open(filepath.Join(core.TheApp.AppDataDir(), "mail", bemail, dmf.Message.Filename)) if err != nil { return err } @@ -114,9 +111,9 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageParsed.references = refs + theApp.readMessageParsed.references = refs - a.readMessageParsed.attachments = nil + theApp.readMessageParsed.attachments = nil gotHTML := false for { p, err := mr.NextPart() @@ -139,7 +136,7 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageParsed.plain = string(b) + theApp.readMessageParsed.plain = string(b) case "text/html": err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { @@ -157,13 +154,13 @@ func (a *App) displayMessageContents(w *core.Frame) error { if err != nil { return err } - a.readMessageParsed.attachments = append(a.readMessageParsed.attachments, at) + theApp.readMessageParsed.attachments = append(theApp.readMessageParsed.attachments, at) } } // we only handle the plain version if there is no HTML version if !gotHTML { - err := htmlcore.ReadMDString(htmlcore.NewContext(), w, a.readMessageParsed.plain) + err := htmlcore.ReadMDString(htmlcore.NewContext(), w, theApp.readMessageParsed.plain) if err != nil { return err } From adbad2c5371d1cea06c1f075caa80ca66b2e3a9e Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 28 Sep 2024 16:05:48 -0700 Subject: [PATCH 79/84] add separators between conversation messages --- mail/app.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index c739670e..336c1dc1 100644 --- a/mail/app.go +++ b/mail/app.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "sync" @@ -194,8 +195,9 @@ func (a *App) Init() { slices.SortFunc(a.readMessage.replies, func(a, b *CacheMessage) int { return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) }) - for _, reply := range a.readMessage.replies { + for i, reply := range a.readMessage.replies { add(reply) + tree.AddAt(p, "separator"+strconv.Itoa(i), func(w *core.Separator) {}) } add(a.readMessage) }) From dbbc8bcdc189bdbadc09e25dc0afbd386c67902d Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Sat, 28 Sep 2024 16:59:41 -0700 Subject: [PATCH 80/84] get better conversation thread determining working in Cogent Mail with new conversationStart function --- mail/app.go | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/mail/app.go b/mail/app.go index 336c1dc1..2fb4c1f5 100644 --- a/mail/app.go +++ b/mail/app.go @@ -128,14 +128,11 @@ func (a *App) Init() { a.unreadMessages = 0 mp := a.cache[a.currentEmail] for _, cm := range mp { - if len(cm.InReplyTo) > 0 { - irt := cm.InReplyTo[0] - if irtcm, ok := mp[irt]; ok { - if !slices.Contains(irtcm.replies, cm) { - irtcm.replies = append(irtcm.replies, cm) - } - continue + 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 { @@ -324,3 +321,19 @@ func (a *App) saveCacheFile(cached map[string]*CacheMessage, email string) error } 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 + } +} From 1bac5e918e0a3c68fd8ba56ffff925262ed0a602 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 2 Oct 2024 10:02:30 -0700 Subject: [PATCH 81/84] add mail.CacheMessage.latestDate to sort messages in the list cache by most recent date instead of first date --- mail/app.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mail/app.go b/mail/app.go index 2fb4c1f5..d82cc4f6 100644 --- a/mail/app.go +++ b/mail/app.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "time" "cogentcore.org/core/base/iox/jsonx" "cogentcore.org/core/core" @@ -146,7 +147,7 @@ func (a *App) Init() { } } slices.SortFunc(a.listCache, func(a, b *CacheMessage) int { - return cmp.Compare(b.Date.UnixNano(), a.Date.UnixNano()) + return cmp.Compare(b.latestDate().UnixNano(), a.latestDate().UnixNano()) }) }) tree.AddChild(w, func(w *core.Text) { @@ -337,3 +338,14 @@ func (a *App) conversationStart(mp map[string]*CacheMessage, cm *CacheMessage) * 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 +} From 73d3ad4f774729cead7d5b09e40172b051334152 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 2 Oct 2024 10:11:59 -0700 Subject: [PATCH 82/84] add isRead function such that a message is treated as unread if it or any of its replies are unread --- mail/actions.go | 2 +- mail/app.go | 13 +++++++++++++ mail/values.go | 4 +--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 8ecad04c..6d38d70e 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -252,7 +252,7 @@ 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 } diff --git a/mail/app.go b/mail/app.go index d82cc4f6..a2446a1a 100644 --- a/mail/app.go +++ b/mail/app.go @@ -349,3 +349,16 @@ func (cm *CacheMessage) latestDate() time.Time { } 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/values.go b/mail/values.go index 06cb4534..72ecaacd 100644 --- a/mail/values.go +++ b/mail/values.go @@ -9,7 +9,6 @@ import ( "net/mail" "os" "path/filepath" - "slices" "strings" "cogentcore.org/core/base/errors" @@ -21,7 +20,6 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/tree" - "github.com/emersion/go-imap/v2" "github.com/mitchellh/go-homedir" ) @@ -61,7 +59,7 @@ func (mi *MessageListItem) Init() { }) w.Updater(func() { text := "" - if !slices.Contains(mi.Message.Flags, imap.FlagSeen) { + if !mi.Message.isRead() { text += fmt.Sprintf(` `, colors.AsHex(colors.ToUniform(colors.Scheme.Primary.Base))) } if len(mi.Message.replies) > 0 { From 1d9a3ed0e5b06a718f4e1317cdf10525badacc0e Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 2 Oct 2024 10:16:53 -0700 Subject: [PATCH 83/84] fold readMessageParsed into CacheMessage --- mail/actions.go | 5 +++-- mail/app.go | 3 --- mail/cache.go | 4 ++++ mail/read.go | 12 ++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/mail/actions.go b/mail/actions.go index 6d38d70e..21edb263 100644 --- a/mail/actions.go +++ b/mail/actions.go @@ -19,6 +19,7 @@ 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. +// 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. @@ -218,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.readMessageParsed.references, 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 { @@ -236,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.readMessageParsed.plain, "\n", "\n> ") + a.composeMessage.body += strings.ReplaceAll(a.readMessage.parsed.plain, "\n", "\n> ") a.compose(title) } diff --git a/mail/app.go b/mail/app.go index a2446a1a..0d276b15 100644 --- a/mail/app.go +++ b/mail/app.go @@ -68,9 +68,6 @@ type App struct { // readMessage is the current message we are reading. readMessage *CacheMessage - // readMessageParsed contains data parsed from the current message we are reading. - readMessageParsed readMessageParsed // TODO: move into CacheMessage - // currentEmail is the current email account. currentEmail string diff --git a/mail/cache.go b/mail/cache.go index 4e3df2a9..f71c5175 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -44,6 +44,10 @@ type CacheMessage struct { // 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. diff --git a/mail/read.go b/mail/read.go index bf65fdf2..a67fd804 100644 --- a/mail/read.go +++ b/mail/read.go @@ -71,7 +71,7 @@ func (dmf *DisplayMessageFrame) Init() { tree.AddChild(dmf, func(w *core.Form) { w.SetReadOnly(true) w.Updater(func() { - w.SetStruct(dmf.Message.ToDisplay(&theApp.readMessageParsed)) + w.SetStruct(dmf.Message.ToDisplay(&theApp.readMessage.parsed)) }) }) tree.AddChild(dmf, func(w *core.Frame) { @@ -111,9 +111,9 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessageParsed.references = refs + theApp.readMessage.parsed.references = refs - theApp.readMessageParsed.attachments = nil + theApp.readMessage.parsed.attachments = nil gotHTML := false for { p, err := mr.NextPart() @@ -136,7 +136,7 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessageParsed.plain = string(b) + theApp.readMessage.parsed.plain = string(b) case "text/html": err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { @@ -154,13 +154,13 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessageParsed.attachments = append(theApp.readMessageParsed.attachments, at) + theApp.readMessage.parsed.attachments = append(theApp.readMessage.parsed.attachments, at) } } // we only handle the plain version if there is no HTML version if !gotHTML { - err := htmlcore.ReadMDString(htmlcore.NewContext(), w, theApp.readMessageParsed.plain) + err := htmlcore.ReadMDString(htmlcore.NewContext(), w, theApp.readMessage.parsed.plain) if err != nil { return err } From 620c9a7889db45966f8eeb4fab930186827ec1b0 Mon Sep 17 00:00:00 2001 From: Kai O'Reilly Date: Wed, 2 Oct 2024 10:18:35 -0700 Subject: [PATCH 84/84] use correct message for parsed info in DisplayMessageFrame in mail --- mail/cache.go | 4 ++-- mail/read.go | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mail/cache.go b/mail/cache.go index f71c5175..c643aacd 100644 --- a/mail/cache.go +++ b/mail/cache.go @@ -68,7 +68,7 @@ func (lb *Label) UIDSet() imap.UIDSet { // ToDisplay converts the [CacheMessage] to a [displayMessage] // with the given additional [readMessageParsed] data. -func (cm *CacheMessage) ToDisplay(rmp *readMessageParsed) *displayMessage { +func (cm *CacheMessage) ToDisplay() *displayMessage { if cm == nil { return nil } @@ -77,7 +77,7 @@ func (cm *CacheMessage) ToDisplay(rmp *readMessageParsed) *displayMessage { To: IMAPToMailAddresses(cm.To), Subject: cm.Subject, Date: cm.Date.Local(), - Attachments: rmp.attachments, + Attachments: cm.parsed.attachments, } } diff --git a/mail/read.go b/mail/read.go index a67fd804..1c89c517 100644 --- a/mail/read.go +++ b/mail/read.go @@ -71,7 +71,7 @@ func (dmf *DisplayMessageFrame) Init() { tree.AddChild(dmf, func(w *core.Form) { w.SetReadOnly(true) w.Updater(func() { - w.SetStruct(dmf.Message.ToDisplay(&theApp.readMessage.parsed)) + w.SetStruct(dmf.Message.ToDisplay()) }) }) tree.AddChild(dmf, func(w *core.Frame) { @@ -111,9 +111,9 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessage.parsed.references = refs + dmf.Message.parsed.references = refs - theApp.readMessage.parsed.attachments = nil + dmf.Message.parsed.attachments = nil gotHTML := false for { p, err := mr.NextPart() @@ -136,7 +136,7 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessage.parsed.plain = string(b) + dmf.Message.parsed.plain = string(b) case "text/html": err := htmlcore.ReadHTML(htmlcore.NewContext(), w, p.Body) if err != nil { @@ -154,13 +154,13 @@ func (dmf *DisplayMessageFrame) displayMessageContents(w *core.Frame) error { if err != nil { return err } - theApp.readMessage.parsed.attachments = append(theApp.readMessage.parsed.attachments, at) + 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, theApp.readMessage.parsed.plain) + err := htmlcore.ReadMDString(htmlcore.NewContext(), w, dmf.Message.parsed.plain) if err != nil { return err }