feat: notifications (#416)

Drew Smirnoff created

Change summary

config/config.go | 23 +++++++++++++----------
main.go          | 12 ++++++++++++
notify/notify.go | 21 +++++++++++++++++++++
tui/settings.go  | 29 ++++++++++++++++++++++++-----
4 files changed, 70 insertions(+), 15 deletions(-)

Detailed changes

config/config.go 🔗

@@ -46,11 +46,12 @@ type MailingList struct {
 
 // Config stores the user's email configuration with multiple accounts.
 type Config struct {
-	Accounts      []Account     `json:"accounts"`
-	DisableImages bool          `json:"disable_images,omitempty"`
-	HideTips      bool          `json:"hide_tips,omitempty"`
-	Theme         string        `json:"theme,omitempty"`
-	MailingLists  []MailingList `json:"mailing_lists,omitempty"`
+	Accounts             []Account     `json:"accounts"`
+	DisableImages        bool          `json:"disable_images,omitempty"`
+	HideTips             bool          `json:"hide_tips,omitempty"`
+	DisableNotifications bool          `json:"disable_notifications,omitempty"`
+	Theme                string        `json:"theme,omitempty"`
+	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 }
 
 // GetIMAPServer returns the IMAP server address for the account.
@@ -192,11 +193,12 @@ func LoadConfig() (*Config, error) {
 		AuthMethod         string `json:"auth_method,omitempty"`
 	}
 	type diskConfig struct {
-		Accounts      []rawAccount  `json:"accounts"`
-		DisableImages bool          `json:"disable_images,omitempty"`
-		HideTips      bool          `json:"hide_tips,omitempty"`
-		Theme         string        `json:"theme,omitempty"`
-		MailingLists  []MailingList `json:"mailing_lists,omitempty"`
+		Accounts             []rawAccount  `json:"accounts"`
+		DisableImages        bool          `json:"disable_images,omitempty"`
+		HideTips             bool          `json:"hide_tips,omitempty"`
+		DisableNotifications bool          `json:"disable_notifications,omitempty"`
+		Theme                string        `json:"theme,omitempty"`
+		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 	}
 
 	var raw diskConfig
@@ -226,6 +228,7 @@ func LoadConfig() (*Config, error) {
 
 	config.DisableImages = raw.DisableImages
 	config.HideTips = raw.HideTips
+	config.DisableNotifications = raw.DisableNotifications
 	config.Theme = raw.Theme
 	config.MailingLists = raw.MailingLists
 	for _, rawAcc := range raw.Accounts {

main.go 🔗

@@ -23,6 +23,7 @@ import (
 	"github.com/floatpane/matcha/clib"
 	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/notify"
 	"github.com/floatpane/matcha/plugin"
 	"github.com/floatpane/matcha/sender"
 	"github.com/floatpane/matcha/theme"
@@ -485,6 +486,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
 
 	case tui.IdleNewMailMsg:
+		// Send desktop notification for new mail (if enabled)
+		if m.config == nil || !m.config.DisableNotifications {
+			accountName := msg.AccountID
+			if m.config != nil {
+				if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
+					accountName = acc.Email
+				}
+			}
+			go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
+		}
+
 		// IDLE detected new mail — refetch the folder if we're viewing it
 		if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
 			return m, tea.Batch(

notify/notify.go 🔗

@@ -0,0 +1,21 @@
+package notify
+
+import (
+	"fmt"
+	"os/exec"
+	"runtime"
+)
+
+// Send delivers a desktop notification with the given title and body.
+// On macOS it uses osascript; on Linux it uses notify-send.
+func Send(title, body string) error {
+	switch runtime.GOOS {
+	case "darwin":
+		script := fmt.Sprintf(`display notification %q with title %q sound name "default"`, body, title)
+		return exec.Command("osascript", "-e", script).Run()
+	case "linux":
+		return exec.Command("notify-send", title, body).Run()
+	default:
+		return nil
+	}
+}

tui/settings.go 🔗

@@ -128,8 +128,8 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.cursor--
 		}
 	case "down", "j":
-		// Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Mailing Lists
-		if m.cursor < 5 {
+		// Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists
+		if m.cursor < 6 {
 			m.cursor++
 		}
 	case "enter":
@@ -160,7 +160,11 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			m.cfg.HideTips = !m.cfg.HideTips
 			_ = config.SaveConfig(m.cfg)
 			return m, nil
-		case 5: // Mailing Lists
+		case 5: // Desktop Notifications
+			m.cfg.DisableNotifications = !m.cfg.DisableNotifications
+			_ = config.SaveConfig(m.cfg)
+			return m, nil
+		case 6: // Mailing Lists
 			m.state = SettingsMailingLists
 			m.cursor = 0
 			return m, nil
@@ -443,9 +447,22 @@ func (m *Settings) viewMain() string {
 	}
 	b.WriteString("\n")
 
-	// Option 5: Mailing Lists
-	mailingListsText := "Mailing Lists"
+	// Option 5: Desktop Notifications
+	notifStatus := "ON"
+	if m.cfg.DisableNotifications {
+		notifStatus = "OFF"
+	}
+	notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
 	if m.cursor == 5 {
+		b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
+	} else {
+		b.WriteString(accountItemStyle.Render("  " + notifText))
+	}
+	b.WriteString("\n")
+
+	// Option 6: Mailing Lists
+	mailingListsText := "Mailing Lists"
+	if m.cursor == 6 {
 		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
 	} else {
 		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
@@ -466,6 +483,8 @@ func (m *Settings) viewMain() string {
 		case 4:
 			tip = "Toggle displaying helpful contextual tips like this one."
 		case 5:
+			tip = "Toggle desktop notifications when new mail arrives."
+		case 6:
 			tip = "Manage groups of email addresses to quickly send to multiple people."
 		}
 		if tip != "" {