diff --git a/config/config.go b/config/config.go index 24e28e073bcb7edeeb789f1fa88c0d8fd0d7a2db..ca2f21b332957a14ce1c977e9497d2c08eb815f9 100644 --- a/config/config.go +++ b/config/config.go @@ -129,6 +129,7 @@ type Config struct { DateFormat string `json:"date_format,omitempty"` Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de") BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` + UndoDelaySeconds int `json:"undo_delay_seconds,omitempty"` // PluginSettings stores user-configurable values for installed plugins, // keyed by plugin name then setting key. Values are JSON-native types // (bool, float64, string) matching the plugin's declared schema. @@ -144,6 +145,13 @@ func (c *Config) GetBodyCacheThreshold() int { return c.BodyCacheThresholdMB * 1024 * 1024 } +func (c *Config) GetUndoDelaySeconds() int { + if c.UndoDelaySeconds <= 0 { + return 5 + } + return c.UndoDelaySeconds +} + // GetDateFormat returns the Go time reference layout translated from the // user's configured human-readable format. Defaults to EU when unset. func (c *Config) GetDateFormat() string { @@ -612,6 +620,7 @@ func LoadConfig() (*Config, error) { DateFormat string `json:"date_format,omitempty"` Language string `json:"language,omitempty"` BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` + UndoDelaySeconds int `json:"undo_delay_seconds,omitempty"` PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` } @@ -654,6 +663,7 @@ func LoadConfig() (*Config, error) { config.DateFormat = raw.DateFormat config.Language = raw.Language config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB + config.UndoDelaySeconds = raw.UndoDelaySeconds config.PluginSettings = raw.PluginSettings for _, rawAcc := range raw.Accounts { diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 2e4e8385b041c1571de682c07ac26290943e7a27..4c13dceeae0f23af0128520a3bb5be480715bb51 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -36,7 +36,8 @@ "spell_next": "ctrl+n", "spell_prev": "ctrl+p", "spell_accept": "tab", - "spell_dismiss": "esc" + "spell_dismiss": "esc", + "undo_send": "u" }, "folder": { "next_folder": "tab", diff --git a/config/keybinds.go b/config/keybinds.go index bb53d13979569f3b78a2914ea9f27905aef84eb4..7853c6ac90528861e9e0b944aba91490fcb971fa 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -67,6 +67,7 @@ type ComposerKeys struct { SpellPrev string `json:"spell_prev"` SpellAccept string `json:"spell_accept"` SpellDismiss string `json:"spell_dismiss"` + UndoSend string `json:"undo_send"` } type FolderKeys struct { @@ -136,6 +137,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "focus_attachments": kb.Email.FocusAttachments, }, "composer": { + "undo_send": kb.Composer.UndoSend, "external_editor": kb.Composer.ExternalEditor, "next_field": kb.Composer.NextField, "prev_field": kb.Composer.PrevField, diff --git a/config/keybinds_test.go b/config/keybinds_test.go index 23a86438ef24827abcc9d3562571302290080cea..0f3d71169a8adee54419aefbece2a2269b4a2851 100644 --- a/config/keybinds_test.go +++ b/config/keybinds_test.go @@ -73,7 +73,7 @@ func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) { } // Override inbox delete key - custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}` + custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab","undo_send":"u"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}` if err := os.WriteFile(filepath.Join(dir, "keybinds.json"), []byte(custom), 0600); err != nil { t.Fatalf("write custom: %v", err) } @@ -83,4 +83,7 @@ func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) { if Keybinds.Inbox.Delete != "x" { t.Errorf("expected inbox.delete=x, got %q", Keybinds.Inbox.Delete) } + if Keybinds.Composer.UndoSend != "u" { + t.Errorf("expected composer.undo_send=u, got %q", Keybinds.Composer.UndoSend) + } } diff --git a/daemon/daemon.go b/daemon/daemon.go index e478638eb90d367fb937c036bdc9119dd98d6975..231f7de7441a6916b3d8d5c0d78ce4c136e7df01 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -16,6 +16,7 @@ import ( "github.com/floatpane/matcha/daemonrpc" "github.com/floatpane/matcha/fetcher" "github.com/floatpane/matcha/notify" + "github.com/floatpane/matcha/sender" ) const inboxFolder = "INBOX" @@ -46,6 +47,15 @@ type Daemon struct { shutdown chan struct{} done chan struct{} + + outbox map[string]*OutboxEntry + outboxMu sync.Mutex +} + +type OutboxEntry struct { + ID string + Params daemonrpc.SendEmailParams + SendAt time.Time } // New creates a daemon with the given config. @@ -59,6 +69,7 @@ func New(cfg *config.Config) *Daemon { idleUpdates: idleUpdates, shutdown: make(chan struct{}), done: make(chan struct{}), + outbox: make(map[string]*OutboxEntry), } d.server = udsrpc.NewServer() @@ -92,6 +103,8 @@ func (d *Daemon) registerHandlers() { d.server.Handle(daemonrpc.MethodRefreshFolder, d.handleRefreshFolder) d.server.Handle(daemonrpc.MethodSubscribe, d.handleSubscribe) d.server.Handle(daemonrpc.MethodUnsubscribe, d.handleUnsubscribe) + d.server.Handle(daemonrpc.MethodQueueEmail, d.handleQueueEmail) + d.server.Handle(daemonrpc.MethodCancelEmail, d.handleCancelEmail) } // Run starts the daemon: creates providers, starts the socket listener, @@ -157,6 +170,8 @@ func (d *Daemon) Run() error { d.syncCancel = cancel go d.backgroundSync(ctx) + go d.processOutbox(ctx) + // Serve client connections via the shared RPC server. Canceling serveCtx // closes the listener and unblocks Serve. serveCtx, serveCancel := context.WithCancel(context.Background()) @@ -506,3 +521,62 @@ func (d *Daemon) updateFolderCache(folderName, accountID string, newEmails []con // Save merged cache return config.SaveFolderEmailCache(folderName, merged) } + +func (d *Daemon) processOutbox(ctx context.Context) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.outboxMu.Lock() + for id, entry := range d.outbox { + if time.Now().After(entry.SendAt) { + delete(d.outbox, id) + go d.sendOutboxEntry(entry) + } + } + d.outboxMu.Unlock() + } + } +} + +func (d *Daemon) sendOutboxEntry(entry *OutboxEntry) { + acct := d.getAccount(entry.Params.AccountID) + if acct == nil { + log.Printf("daemon: outbox send failed, no account for %s", entry.Params.AccountID) + return + } + + rawMsg, err := sender.SendEmail( + acct, + entry.Params.To, + entry.Params.Cc, + entry.Params.Bcc, + entry.Params.Subject, + entry.Params.Body, + entry.Params.HTMLBody, + entry.Params.Images, + entry.Params.Attachments, + entry.Params.InReplyTo, + entry.Params.References, + entry.Params.SignSMIME, + entry.Params.EncryptSMIME, + entry.Params.SignPGP, + entry.Params.EncryptPGP, + ) + if err != nil { + log.Printf("daemon: outbox send failed for %s: %v", entry.ID, err) + return + } + + if acct.ServiceProvider != "gmail" { + if err := fetcher.AppendToSentMailbox(acct, rawMsg); err != nil { + log.Printf("daemon: append to sent failed for %s: %v", entry.ID, err) + } + } + + log.Printf("daemon: outbox sent email %s", entry.ID) +} diff --git a/daemon/handler.go b/daemon/handler.go index df43fd2b1d3a975e995c1ea08f72c0bf982a4095..bb1d64e5bc72acca0c035bdde2781a5ed4176fa2 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -9,6 +9,7 @@ import ( "time" "github.com/floatpane/matcha/daemonrpc" + "github.com/google/uuid" ) // Per-handler timeouts. fetchTimeout covers reads against the upstream IMAP @@ -338,3 +339,46 @@ func (d *Daemon) handleUnsubscribe(_ context.Context, conn *daemonrpc.Conn, para return true, nil } + +func (d *Daemon) handleQueueEmail(_ context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.QueueEmailParams](params) + if err != nil { + return nil, parseError(err) + } + + id := uuid.New().String() + entry := &OutboxEntry{ + ID: id, + Params: args.Email, + SendAt: time.Now().Add(time.Duration(args.DelaySeconds) * time.Second), + } + + d.outboxMu.Lock() + d.outbox[id] = entry + d.outboxMu.Unlock() + + log.Printf("daemon: queued email %s, sending in %ds", id, args.DelaySeconds) + + return daemonrpc.QueueEmailResult{JobID: id}, nil +} + +func (d *Daemon) handleCancelEmail(_ context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.CancelEmailParams](params) + if err != nil { + return nil, parseError(err) + } + + d.outboxMu.Lock() + _, exists := d.outbox[args.JobID] + if exists { + delete(d.outbox, args.JobID) + } + d.outboxMu.Unlock() + + if !exists { + return nil, fmt.Errorf("job %s not found", args.JobID) + } + + log.Printf("daemon: cancelled email %s", args.JobID) + return true, nil +} diff --git a/daemonclient/service.go b/daemonclient/service.go index 9c17977dcdd1ebfb47ee514b819d7d5063552c27..63d48bab1e4dc8d3a59647298344ab63cc14873b 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -2,6 +2,7 @@ package daemonclient import ( "context" + "fmt" "log" "os" "os/exec" @@ -12,6 +13,8 @@ import ( _ "github.com/floatpane/matcha/backend/maildir" // register maildir backend for directService "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/daemonrpc" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/sender" ) // Service abstracts daemon-backed vs direct email operations. @@ -26,6 +29,8 @@ type Service interface { MoveEmails(accountID string, uids []uint32, src, dst string) error MarkRead(accountID, folder string, uids []uint32) error MarkUnread(accountID, folder string, uids []uint32) error + QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error) + CancelEmail(jobID string) error FetchFolders(accountID string) ([]backend.Folder, error) RefreshFolder(accountID, folder string) error Subscribe(accountID, folder string) error @@ -173,6 +178,37 @@ func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) erro }, nil) } +func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error) { + var result daemonrpc.QueueEmailResult + err := s.client.Call(daemonrpc.MethodQueueEmail, daemonrpc.QueueEmailParams{ + Email: daemonrpc.SendEmailParams{ + AccountID: accountID, + To: to, + Cc: cc, + Bcc: bcc, + Subject: subject, + Body: body, + HTMLBody: htmlBody, + Images: images, + Attachments: attachments, + InReplyTo: inReplyTo, + References: references, + SignSMIME: signSMIME, + EncryptSMIME: encryptSMIME, + SignPGP: signPGP, + EncryptPGP: encryptPGP, + }, + DelaySeconds: delaySeconds, + }, &result) + return result.JobID, err +} + +func (s *daemonService) CancelEmail(jobID string) error { + return s.client.Call(daemonrpc.MethodCancelEmail, daemonrpc.CancelEmailParams{ + JobID: jobID, + }, nil) +} + func (s *daemonService) FetchFolders(accountID string) ([]backend.Folder, error) { var folders []backend.Folder err := s.client.Call(daemonrpc.MethodFetchFolders, daemonrpc.FetchFoldersParams{ @@ -368,3 +404,43 @@ func (s *directService) Close() error { close(s.events) return nil } + +func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int) (string, error) { + acct := s.cfg.GetAccountByID(accountID) + if acct == nil { + return "", fmt.Errorf("no account for %s", accountID) + } + + rawMsg, err := sender.SendEmail( + acct, + to, + cc, + bcc, + subject, + body, + htmlBody, + images, + attachments, + inReplyTo, + references, + signSMIME, + encryptSMIME, + signPGP, + encryptPGP, + ) + if err != nil { + return "", err + } + + if acct.ServiceProvider != "gmail" { + if err := fetcher.AppendToSentMailbox(acct, rawMsg); err != nil { + log.Printf("direct: append to sent failed: %v", err) + } + } + + return "", nil +} + +func (s *directService) CancelEmail(_ string) error { + return nil +} diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index 1713a61206c2ab908aac8d7982b57f7d4c8a7562..2b07f27f2452bb080eb8d2768015f434f3c0a531 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -48,6 +48,8 @@ const ( MethodGetCachedEmails = "GetCachedEmails" MethodGetCachedBody = "GetCachedBody" MethodExportContacts = "ExportContacts" + MethodQueueEmail = "QueueEmail" + MethodCancelEmail = "CancelEmail" ) // Event type names. @@ -93,6 +95,19 @@ type FetchEmailBodyParams struct { UID uint32 `json:"uid"` } +type QueueEmailParams struct { + Email SendEmailParams `json:"email"` + DelaySeconds int `json:"delay_seconds"` +} + +type QueueEmailResult struct { + JobID string `json:"job_id"` +} + +type CancelEmailParams struct { + JobID string `json:"job_id"` +} + type FetchEmailBodyResult struct { Body string `json:"body"` BodyMIMEType string `json:"body_mime_type,omitempty"` @@ -116,6 +131,7 @@ type SendEmailParams struct { Subject string `json:"subject"` Body string `json:"body"` HTMLBody string `json:"html_body,omitempty"` + Images map[string][]byte `json:"images,omitempty"` Attachments map[string][]byte `json:"attachments,omitempty"` InReplyTo string `json:"in_reply_to,omitempty"` References []string `json:"references,omitempty"` diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 5068d2edaebba42a07324d6f0eb22bf6fa6a1a03..e34986b3d76d0c28040b4fce981510fd4a891b3b 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -50,7 +50,8 @@ Configuration is stored in `~/.config/matcha/config.json`. "hide_tips": true, "disable_spellcheck": false, "disable_spell_suggestions": false, - "body_cache_threshold_mb": 100 + "body_cache_threshold_mb": 100, + "undo_delay_seconds": 5 } ``` @@ -66,6 +67,8 @@ Configuration is stored in `~/.config/matcha/config.json`. `body_cache_threshold_mb` sets the maximum size (in megabytes) for the local email body cache. When this limit is reached, least recently accessed cached emails are evicted across all folders to make room for new ones. Defaults to `100` MB if not specified. +`undo_delay_seconds` sets the delay (in seconds) before a sent email is actually delivered, giving you a chance to cancel mistakes. During this window, a countdown shows "Sending in Xs... (u to undo)". Pressing the configured undo key cancels the send. After the delay expires, the email is transmitted and cannot be undone. Set to `0` to send immediately with no undo window. Defaults to `5` seconds if not specified. + ## Data Locations Configuration and persistent data are stored in `~/.config/matcha/`: diff --git a/docs/docs/Features/Keybinds.md b/docs/docs/Features/Keybinds.md index 544697afc9523ba758cb33a24cd981697b462e1a..a4cbfff87985d97236b51bcedf4285ca51a02f91 100644 --- a/docs/docs/Features/Keybinds.md +++ b/docs/docs/Features/Keybinds.md @@ -46,7 +46,8 @@ Plain text, not encrypted. Edit with any text editor. Restart matcha to apply ch "composer": { "external_editor": "ctrl+e", "next_field": "tab", - "prev_field": "shift+tab" + "prev_field": "shift+tab", + "undo_send": "u" }, "folder": { "next_folder": "tab", diff --git a/main.go b/main.go index c8d37bf28c24a2548cda59aa6e7495b98b2e8ec6..58c9444ce4d145fed21789a80f2cd88a9713f29b 100644 --- a/main.go +++ b/main.go @@ -123,6 +123,7 @@ type mainModel struct { showLogPanel bool logCh <-chan logging.Entry logPanel *tui.LogPanel + pendingJobID string } type logEntryMsg struct { @@ -339,6 +340,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo switch msg := msg.(type) { case tea.KeyPressMsg: + if msg.String() == config.Keybinds.Composer.UndoSend { + if m.pendingJobID != "" { + jobID := m.pendingJobID + m.pendingJobID = "" + return m, func() tea.Msg { + if err := m.service.CancelEmail(jobID); err != nil { + return tui.EmailResultMsg{Err: fmt.Errorf("could not undo: email may have already been sent")} + } + return tui.UndoSendMsg{JobID: jobID} + } + } + } if msg.String() == "ctrl+c" { // Persist an in-progress draft so quitting the composer // doesn't discard the user's work. @@ -1695,6 +1708,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo if m.plugins != nil { m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID) } + + m.previousModel = m.current + // Get draft ID before clearing composer (if it's a composer) var draftID string if composer, ok := m.current.(*tui.Composer); ok { @@ -1739,7 +1755,45 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } }() - return m, tea.Batch(m.current.Init(), sendEmail(account, msg)) + return m, tea.Batch(m.current.Init(), m.sendEmailCmd(account, msg)) + + case tui.EmailQueuedMsg: + m.pendingJobID = msg.JobID + m.current = tui.NewStatus(fmt.Sprintf("Message sent (%s to undo)", config.Keybinds.Composer.UndoSend)) + return m, tea.Batch( + m.current.Init(), + tea.Tick( + time.Duration(msg.DelaySeconds)*time.Second, func(t time.Time) tea.Msg { + return tui.EmailDelayExpiredMsg{JobID: msg.JobID} + }), + ) + + case tui.EmailDelayExpiredMsg: + if m.pendingJobID == msg.JobID { + m.pendingJobID = "" + m.previousModel = nil + + if m.plugins != nil { + m.plugins.CallHook(plugin.HookEmailSendAfter) + } + + m.current = tui.NewChoice() + m.current, _ = m.current.Update(m.currentWindowSize()) + return m, m.current.Init() + } + + return m, nil + + case tui.UndoSendMsg: + if m.previousModel != nil { + m.current = m.previousModel + m.previousModel = nil + m.current, _ = m.current.Update(m.currentWindowSize()) + return m, m.current.Init() + } + + m.previousModel = tui.NewChoice() + return m, m.current.Init() case tui.SendRSVPMsg: account := m.config.GetAccountByID(msg.AccountID) @@ -2635,7 +2689,7 @@ func splitEmails(s string) []string { return res } -func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { +func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { return func() tea.Msg { if account == nil { return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")} @@ -2690,20 +2744,15 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { attachments[filename] = fileData } - rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false) + delaySeconds := m.config.GetUndoDelaySeconds() + jobID, err := m.service.QueueEmail(account.ID, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false, delaySeconds) + if err != nil { - log.Printf("Failed to send email: %v", err) + log.Printf("Failed to queue email: %v", err) return tui.EmailResultMsg{Err: err} } - // Append to Sent folder via IMAP (Gmail auto-saves, so skip it) - if account.ServiceProvider != "gmail" { - if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil { - log.Printf("Failed to append sent message to Sent folder: %v", err) - } - } - - return tui.EmailResultMsg{} + return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: delaySeconds} } } diff --git a/tui/messages.go b/tui/messages.go index cd253290ec68e018feb32a650c4d0ea2c121c580..e2f59f3ff5b2ee627ea846e9043d906d69022ea1 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -43,6 +43,19 @@ type SendEmailMsg struct { SignPGP bool // Whether to sign the email using PGP } +type EmailQueuedMsg struct { + JobID string + DelaySeconds int +} + +type EmailDelayExpiredMsg struct { + JobID string +} + +type UndoSendMsg struct { + JobID string +} + type Credentials struct { Provider string Name string