Detailed changes
@@ -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 {
@@ -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",
@@ -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,
@@ -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)
+ }
}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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"`
@@ -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/`:
@@ -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",
@@ -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}
}
}
@@ -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