feat: send or unsend matcha messages (#1434)

Mohamed Mahmoud created

## What?

Messages are held for `N` seconds before delivery, with a recall window.

## Why?

Saves users from sending mistakes, just like Gmail/Outlook.


Closes #1139

Change summary

config/config.go               | 10 ++++
config/default_keybinds.json   |  3 
config/keybinds.go             |  2 
config/keybinds_test.go        |  5 +
daemon/daemon.go               | 74 +++++++++++++++++++++++++++++++++++
daemon/handler.go              | 44 ++++++++++++++++++++
daemonclient/service.go        | 76 ++++++++++++++++++++++++++++++++++++
daemonrpc/protocol.go          | 16 +++++++
docs/docs/Configuration.md     |  5 +
docs/docs/Features/Keybinds.md |  3 
main.go                        | 73 ++++++++++++++++++++++++++++-----
tui/messages.go                | 13 ++++++
12 files changed, 308 insertions(+), 16 deletions(-)

Detailed changes

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 {

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",

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,

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

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

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

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

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"`

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/`:

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",

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}
 	}
 }
 

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