From e695ebd5dbaa5b03e31fc52b8093f5e2f55f7640 Mon Sep 17 00:00:00 2001 From: mavonx Date: Thu, 4 Jun 2026 09:42:56 +0300 Subject: [PATCH 1/7] fix: move send email action to daemon --- daemon/daemon.go | 1 + daemon/handler.go | 47 +++++++++++++++++++++++++++++++ daemonclient/service.go | 61 +++++++++++++++++++++++++++++++++++++++++ main.go | 40 ++++++++++++++++----------- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/daemon/daemon.go b/daemon/daemon.go index e478638e..e412d458 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -88,6 +88,7 @@ func (d *Daemon) registerHandlers() { d.server.Handle(daemonrpc.MethodArchiveEmails, d.handleArchiveEmails) d.server.Handle(daemonrpc.MethodMoveEmails, d.handleMoveEmails) d.server.Handle(daemonrpc.MethodMarkRead, d.handleMarkRead) + d.server.Handle(daemonrpc.MethodSendEmail, d.handleSendEmail) d.server.Handle(daemonrpc.MethodFetchFolders, d.handleFetchFolders) d.server.Handle(daemonrpc.MethodRefreshFolder, d.handleRefreshFolder) d.server.Handle(daemonrpc.MethodSubscribe, d.handleSubscribe) diff --git a/daemon/handler.go b/daemon/handler.go index df43fd2b..904d13a6 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -9,6 +9,8 @@ import ( "time" "github.com/floatpane/matcha/daemonrpc" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/sender" ) // Per-handler timeouts. fetchTimeout covers reads against the upstream IMAP @@ -338,3 +340,48 @@ func (d *Daemon) handleUnsubscribe(_ context.Context, conn *daemonrpc.Conn, para return true, nil } + +func (d *Daemon) handleSendEmail(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { + args, err := decodeParams[daemonrpc.SendEmailParams](params) + if err != nil { + return nil, parseError(err) + } + + acct := d.getAccount(args.AccountID) + if acct == nil { + return nil, fmt.Errorf("no account for %s", args.AccountID) + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + rawMsg, err := sender.SendEmail( + acct, + args.To, + args.Cc, + args.Bcc, + args.Subject, + args.Body, + args.HTMLBody, + nil, + args.Attachments, + args.InReplyTo, + args.References, + args.SignSMIME, + args.EncryptSMIME, + args.SignPGP, + args.EncryptPGP, + ) + + if err != nil { + return nil, err + } + + if acct.ServiceProvider != "gmail" { + if err := fetcher.AppendToSentMailbox(acct, rawMsg); err != nil { + log.Printf("daemon: append to sent failed: %v", err) + } + } + + return true, nil +} diff --git a/daemonclient/service.go b/daemonclient/service.go index 5c7bb684..31c1fd46 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -2,6 +2,7 @@ package daemonclient import ( "context" + "fmt" "log" "os" "os/exec" @@ -10,6 +11,8 @@ import ( "github.com/floatpane/matcha/backend" "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. @@ -24,6 +27,7 @@ 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 + SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) error FetchFolders(accountID string) ([]backend.Folder, error) RefreshFolder(accountID, folder string) error Subscribe(accountID, folder string) error @@ -171,6 +175,25 @@ func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) erro }, nil) } +func (s *daemonService) SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) error { + return s.client.Call(daemonrpc.MethodSendEmail, daemonrpc.SendEmailParams{ + AccountID: accountID, + To: to, + Cc: cc, + Bcc: bcc, + Subject: subject, + Body: body, + HTMLBody: htmlBody, + Attachments: attachments, + InReplyTo: inReplyTo, + References: references, + SignSMIME: signSMIME, + EncryptSMIME: encryptSMIME, + SignPGP: signPGP, + EncryptPGP: encryptPGP, + }, nil) +} + func (s *daemonService) FetchFolders(accountID string) ([]backend.Folder, error) { var folders []backend.Folder err := s.client.Call(daemonrpc.MethodFetchFolders, daemonrpc.FetchFoldersParams{ @@ -366,3 +389,41 @@ func (s *directService) Close() error { close(s.events) return nil } + +func (s *directService) SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) 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, + nil, + 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 +} diff --git a/main.go b/main.go index 07db2972..f6213d75 100644 --- a/main.go +++ b/main.go @@ -1732,7 +1732,7 @@ 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.SendRSVPMsg: account := m.config.GetAccountByID(msg.AccountID) @@ -2628,7 +2628,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")} @@ -2644,21 +2644,19 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { recipients := splitEmails(msg.To) cc := splitEmails(msg.Cc) bcc := splitEmails(msg.Bcc) + body := msg.Body - // Append signature if present if msg.Signature != "" { body = body + "\n\n" + msg.Signature } - // Append quoted text if present (for replies) if msg.QuotedText != "" { body += msg.QuotedText } - images := make(map[string][]byte) - attachments := make(map[string][]byte) + // Process inline images. + images := make(map[string][]byte) re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`) matches := re.FindAllStringSubmatch(body, -1) - for _, match := range matches { imgPath := match[1] imgData, err := os.ReadFile(imgPath) @@ -2671,8 +2669,10 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { body = strings.Replace(body, imgPath, "cid:"+cid, 1) } - htmlBody := markdownToHTML([]byte(body)) + htmlBody := string(markdownToHTML([]byte(body))) + // Read attachment files into memory. + attachments := make(map[string][]byte) for _, attachPath := range msg.AttachmentPaths { fileData, err := os.ReadFile(attachPath) if err != nil { @@ -2683,19 +2683,27 @@ 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) + err := m.service.SendEmail( + account.ID, + recipients, + cc, + bcc, + msg.Subject, + body, + htmlBody, + attachments, + msg.InReplyTo, + msg.References, + msg.SignSMIME, + msg.EncryptSMIME, + msg.SignPGP, + false, + ) if err != nil { log.Printf("Failed to send 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{} } } From 1d7ed791f9923a8a11e550d26bd86edb74d6d180 Mon Sep 17 00:00:00 2001 From: mavonx Date: Thu, 4 Jun 2026 10:01:18 +0300 Subject: [PATCH 2/7] fix: remove unused context --- daemon/handler.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/daemon/handler.go b/daemon/handler.go index 904d13a6..4626fc18 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -341,7 +341,7 @@ func (d *Daemon) handleUnsubscribe(_ context.Context, conn *daemonrpc.Conn, para return true, nil } -func (d *Daemon) handleSendEmail(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { +func (d *Daemon) handleSendEmail(_ context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { args, err := decodeParams[daemonrpc.SendEmailParams](params) if err != nil { return nil, parseError(err) @@ -352,9 +352,6 @@ func (d *Daemon) handleSendEmail(ctx context.Context, _ *daemonrpc.Conn, params return nil, fmt.Errorf("no account for %s", args.AccountID) } - ctx, cancel := context.WithTimeout(ctx, fetchTimeout) - defer cancel() - rawMsg, err := sender.SendEmail( acct, args.To, From ab1a633a6b8fd15b72bd4f4f7f8ae9e3c58915cd Mon Sep 17 00:00:00 2001 From: mavonx Date: Sat, 6 Jun 2026 01:43:57 +0300 Subject: [PATCH 3/7] fix: add email queue and cancellation in daemon --- daemon/daemon.go | 75 ++++++++++++++++++++++++++++++++++++++++- daemon/handler.go | 62 +++++++++++++++++----------------- daemonclient/service.go | 58 +++++++++++++++++++------------ daemonrpc/protocol.go | 15 +++++++++ 4 files changed, 156 insertions(+), 54 deletions(-) diff --git a/daemon/daemon.go b/daemon/daemon.go index e412d458..6db75eee 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() @@ -88,11 +99,12 @@ func (d *Daemon) registerHandlers() { d.server.Handle(daemonrpc.MethodArchiveEmails, d.handleArchiveEmails) d.server.Handle(daemonrpc.MethodMoveEmails, d.handleMoveEmails) d.server.Handle(daemonrpc.MethodMarkRead, d.handleMarkRead) - d.server.Handle(daemonrpc.MethodSendEmail, d.handleSendEmail) d.server.Handle(daemonrpc.MethodFetchFolders, d.handleFetchFolders) 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, @@ -158,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()) @@ -507,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, + nil, + 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 4626fc18..bb1d64e5 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -9,8 +9,7 @@ import ( "time" "github.com/floatpane/matcha/daemonrpc" - "github.com/floatpane/matcha/fetcher" - "github.com/floatpane/matcha/sender" + "github.com/google/uuid" ) // Per-handler timeouts. fetchTimeout covers reads against the upstream IMAP @@ -341,44 +340,45 @@ func (d *Daemon) handleUnsubscribe(_ context.Context, conn *daemonrpc.Conn, para return true, nil } -func (d *Daemon) handleSendEmail(_ context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) { - args, err := decodeParams[daemonrpc.SendEmailParams](params) +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) } - acct := d.getAccount(args.AccountID) - if acct == nil { - return nil, fmt.Errorf("no account for %s", args.AccountID) - } - - rawMsg, err := sender.SendEmail( - acct, - args.To, - args.Cc, - args.Bcc, - args.Subject, - args.Body, - args.HTMLBody, - nil, - args.Attachments, - args.InReplyTo, - args.References, - args.SignSMIME, - args.EncryptSMIME, - args.SignPGP, - args.EncryptPGP, - ) + 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, err + return nil, parseError(err) } - if acct.ServiceProvider != "gmail" { - if err := fetcher.AppendToSentMailbox(acct, rawMsg); err != nil { - log.Printf("daemon: append to sent failed: %v", 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 31c1fd46..2576958f 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -27,7 +27,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 - SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) error + QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, 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 @@ -175,22 +176,33 @@ func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) erro }, nil) } -func (s *daemonService) SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) error { - return s.client.Call(daemonrpc.MethodSendEmail, daemonrpc.SendEmailParams{ - AccountID: accountID, - To: to, - Cc: cc, - Bcc: bcc, - Subject: subject, - Body: body, - HTMLBody: htmlBody, - Attachments: attachments, - InReplyTo: inReplyTo, - References: references, - SignSMIME: signSMIME, - EncryptSMIME: encryptSMIME, - SignPGP: signPGP, - EncryptPGP: encryptPGP, +func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, 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, + 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) } @@ -390,11 +402,10 @@ func (s *directService) Close() error { return nil } -func (s *directService) SendEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool) error { +func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, 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) + return "", fmt.Errorf("no account for %s", accountID) } rawMsg, err := sender.SendEmail( @@ -414,9 +425,8 @@ func (s *directService) SendEmail(accountID string, to, cc, bcc []string, subjec signPGP, encryptPGP, ) - if err != nil { - return err + return "", err } if acct.ServiceProvider != "gmail" { @@ -425,5 +435,9 @@ func (s *directService) SendEmail(accountID string, to, cc, bcc []string, subjec } } + return "", nil +} + +func (s *directService) CancelEmail(_ string) error { return nil } diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index 1713a612..4a2b0b47 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"` From 194514e3ee5ae01712e32cdc5d089d3d05e1cbf2 Mon Sep 17 00:00:00 2001 From: mavonx Date: Sat, 6 Jun 2026 09:49:55 +0300 Subject: [PATCH 4/7] fix: add email queue and cancellation in UI --- main.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++--- tui/messages.go | 14 ++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index f6213d75..82120d5a 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() == "u" { + 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" { m.idleWatcher.StopAll() if m.service != nil { @@ -1688,6 +1701,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 { @@ -1734,6 +1750,42 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return m, tea.Batch(m.current.Init(), m.sendEmailCmd(account, msg)) + case tui.EmailQueuedMsg: + m.pendingJobID = msg.JobID + m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (u to undo)", msg.DelaySeconds)) + return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.DelaySeconds)) + + case tui.UndoSendTickMsg: + if m.pendingJobID == "" { + return m, nil + } + + if msg.SecondsLeft <= 0 { + m.pendingJobID = "" + 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() + } + m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (u to undo)", msg.SecondsLeft)) + return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.SecondsLeft)) + + 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() + m.current = tui.NewStatus("Email cancelled.") + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + case tui.SendRSVPMsg: account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -2683,7 +2735,7 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) attachments[filename] = fileData } - err := m.service.SendEmail( + jobID, err := m.service.QueueEmail( account.ID, recipients, cc, @@ -2698,16 +2750,26 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) msg.EncryptSMIME, msg.SignPGP, false, + 10, ) if err != nil { - log.Printf("Failed to send email: %v", err) + log.Printf("Failed to queue email: %v", err) return tui.EmailResultMsg{Err: err} } - return tui.EmailResultMsg{} + return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: 10} } } +func undoSendTickCmd(jobID string, secondsLeft int) tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tui.UndoSendTickMsg{ + JobID: jobID, + SecondsLeft: secondsLeft - 1, + } + }) +} + func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd { return func() tea.Msg { if account == nil { diff --git a/tui/messages.go b/tui/messages.go index cd253290..d5ba9a93 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -43,6 +43,20 @@ type SendEmailMsg struct { SignPGP bool // Whether to sign the email using PGP } +type EmailQueuedMsg struct { + JobID string + DelaySeconds int +} + +type UndoSendTickMsg struct { + JobID string + SecondsLeft int +} + +type UndoSendMsg struct { + JobID string +} + type Credentials struct { Provider string Name string From 279bd3da76be2e6b4acfb7f4ca931b7ab07575f7 Mon Sep 17 00:00:00 2001 From: mavonx Date: Sat, 6 Jun 2026 20:41:43 +0300 Subject: [PATCH 5/7] fix: inline images, undo keybind, and configurable delay seconds --- config/config.go | 10 +++++++++ config/default_keybinds.json | 3 ++- config/keybinds.go | 2 ++ config/keybinds_test.go | 5 ++++- daemonclient/service.go | 8 +++---- docs/docs/Configuration.md | 5 ++++- docs/docs/Features/Keybinds.md | 3 ++- main.go | 40 +++++++++++----------------------- 8 files changed, 41 insertions(+), 35 deletions(-) diff --git a/config/config.go b/config/config.go index 24e28e07..362e6e96 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 10 + } + 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 2e4e8385..4c13dcee 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 bb53d139..7853c6ac 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 23a86438..0f3d7116 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/daemonclient/service.go b/daemonclient/service.go index 2576958f..1bc5b587 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -27,7 +27,7 @@ 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, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, 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 @@ -176,7 +176,7 @@ 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, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error) { +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{ @@ -402,7 +402,7 @@ func (s *directService) Close() error { return nil } -func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int) (string, error) { +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) @@ -416,7 +416,7 @@ func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subje subject, body, htmlBody, - nil, + images, attachments, inReplyTo, references, diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 5068d2ed..72a2e082 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": 10 } ``` @@ -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 `10` 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 544697af..a4cbfff8 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 82120d5a..b703d421 100644 --- a/main.go +++ b/main.go @@ -340,7 +340,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo switch msg := msg.(type) { case tea.KeyPressMsg: - if msg.String() == "u" { + if msg.String() == config.Keybinds.Composer.UndoSend { if m.pendingJobID != "" { jobID := m.pendingJobID m.pendingJobID = "" @@ -1752,7 +1752,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo case tui.EmailQueuedMsg: m.pendingJobID = msg.JobID - m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (u to undo)", msg.DelaySeconds)) + m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (%s to undo)", msg.DelaySeconds, config.Keybinds.Composer.UndoSend)) return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.DelaySeconds)) case tui.UndoSendTickMsg: @@ -1769,7 +1769,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() } - m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (u to undo)", msg.SecondsLeft)) + m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (%s to undo)", msg.SecondsLeft, config.Keybinds.Composer.UndoSend)) return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.SecondsLeft)) case tui.UndoSendMsg: @@ -2696,19 +2696,21 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) recipients := splitEmails(msg.To) cc := splitEmails(msg.Cc) bcc := splitEmails(msg.Bcc) - body := msg.Body + // Append signature if present if msg.Signature != "" { body = body + "\n\n" + msg.Signature } + // Append quoted text if present (for replies) if msg.QuotedText != "" { body += msg.QuotedText } - - // Process inline images. images := make(map[string][]byte) + attachments := make(map[string][]byte) + re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`) matches := re.FindAllStringSubmatch(body, -1) + for _, match := range matches { imgPath := match[1] imgData, err := os.ReadFile(imgPath) @@ -2721,10 +2723,8 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) body = strings.Replace(body, imgPath, "cid:"+cid, 1) } - htmlBody := string(markdownToHTML([]byte(body))) + htmlBody := markdownToHTML([]byte(body)) - // Read attachment files into memory. - attachments := make(map[string][]byte) for _, attachPath := range msg.AttachmentPaths { fileData, err := os.ReadFile(attachPath) if err != nil { @@ -2735,29 +2735,15 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) attachments[filename] = fileData } - jobID, err := m.service.QueueEmail( - account.ID, - recipients, - cc, - bcc, - msg.Subject, - body, - htmlBody, - attachments, - msg.InReplyTo, - msg.References, - msg.SignSMIME, - msg.EncryptSMIME, - msg.SignPGP, - false, - 10, - ) + 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 queue email: %v", err) return tui.EmailResultMsg{Err: err} } - return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: 10} + return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: delaySeconds} } } From 0c2334d495c8b54c8b305f4dadff23ad227cf7e6 Mon Sep 17 00:00:00 2001 From: mavonx Date: Sat, 6 Jun 2026 22:35:12 +0300 Subject: [PATCH 6/7] fix: remove countdown timer from TUI --- config/config.go | 2 +- docs/docs/Configuration.md | 4 ++-- main.go | 39 ++++++++++++++++---------------------- tui/messages.go | 5 ++--- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/config/config.go b/config/config.go index 362e6e96..ca2f21b3 100644 --- a/config/config.go +++ b/config/config.go @@ -147,7 +147,7 @@ func (c *Config) GetBodyCacheThreshold() int { func (c *Config) GetUndoDelaySeconds() int { if c.UndoDelaySeconds <= 0 { - return 10 + return 5 } return c.UndoDelaySeconds } diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 72a2e082..e34986b3 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -51,7 +51,7 @@ Configuration is stored in `~/.config/matcha/config.json`. "disable_spellcheck": false, "disable_spell_suggestions": false, "body_cache_threshold_mb": 100, - "undo_delay_seconds": 10 + "undo_delay_seconds": 5 } ``` @@ -67,7 +67,7 @@ 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 `10` seconds 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 diff --git a/main.go b/main.go index b703d421..bd37ca5d 100644 --- a/main.go +++ b/main.go @@ -1752,25 +1752,30 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo case tui.EmailQueuedMsg: m.pendingJobID = msg.JobID - m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (%s to undo)", msg.DelaySeconds, config.Keybinds.Composer.UndoSend)) - return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.DelaySeconds)) - - case tui.UndoSendTickMsg: - if m.pendingJobID == "" { - return m, nil - } + 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} + }), + ) - if msg.SecondsLeft <= 0 { + 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() } - m.current = tui.NewStatus(fmt.Sprintf("Sending in %ds... (%s to undo)", msg.SecondsLeft, config.Keybinds.Composer.UndoSend)) - return m, tea.Batch(m.current.Init(), undoSendTickCmd(msg.JobID, msg.SecondsLeft)) + + return m, nil case tui.UndoSendMsg: if m.previousModel != nil { @@ -1781,10 +1786,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo } m.previousModel = tui.NewChoice() - m.current = tui.NewStatus("Email cancelled.") - return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { - return tui.RestoreViewMsg{} - }) + return m, m.current.Init() case tui.SendRSVPMsg: account := m.config.GetAccountByID(msg.AccountID) @@ -2747,15 +2749,6 @@ func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) } } -func undoSendTickCmd(jobID string, secondsLeft int) tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tui.UndoSendTickMsg{ - JobID: jobID, - SecondsLeft: secondsLeft - 1, - } - }) -} - func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd { return func() tea.Msg { if account == nil { diff --git a/tui/messages.go b/tui/messages.go index d5ba9a93..e2f59f3f 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -48,9 +48,8 @@ type EmailQueuedMsg struct { DelaySeconds int } -type UndoSendTickMsg struct { - JobID string - SecondsLeft int +type EmailDelayExpiredMsg struct { + JobID string } type UndoSendMsg struct { From 3d805f616b0ae5d6780ade509acd2c9cca60edd1 Mon Sep 17 00:00:00 2001 From: mavonx Date: Sun, 7 Jun 2026 11:30:26 +0300 Subject: [PATCH 7/7] fix: add images in the SendEmailParams --- daemon/daemon.go | 2 +- daemonclient/service.go | 1 + daemonrpc/protocol.go | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/daemon/daemon.go b/daemon/daemon.go index 6db75eee..231f7de7 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -558,7 +558,7 @@ func (d *Daemon) sendOutboxEntry(entry *OutboxEntry) { entry.Params.Subject, entry.Params.Body, entry.Params.HTMLBody, - nil, + entry.Params.Images, entry.Params.Attachments, entry.Params.InReplyTo, entry.Params.References, diff --git a/daemonclient/service.go b/daemonclient/service.go index 1bc5b587..28a0226b 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -187,6 +187,7 @@ func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subje Subject: subject, Body: body, HTMLBody: htmlBody, + Images: images, Attachments: attachments, InReplyTo: inReplyTo, References: references, diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index 4a2b0b47..2b07f27f 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -131,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"`