From 4ad910f3a6a9e296db05034fa626de55d42b085c Mon Sep 17 00:00:00 2001 From: Mine13zoom Date: Tue, 14 Apr 2026 07:57:59 +0200 Subject: [PATCH] feat: send-as email (#492) ## Summary - add an account-level `send_as_email` field to keep Gmail OAuth2 login separate from the visible sender address - use the send-as address for outgoing `From` headers in the composer, SMTP sender, and JMAP sender while leaving auth tied to the login identity - update docs and add tests covering send-as fallback/display behavior ## Testing - go test ./... --- backend/jmap/jmap.go | 2 +- config/config.go | 33 ++++++++++++++++++++++++++++ config/config_test.go | 38 +++++++++++++++++++++++++++++++++ docs/docs/Configuration.md | 5 ++++- docs/docs/setup-guides/gmail.md | 1 + main.go | 4 +++- sender/sender.go | 9 +++----- tui/composer.go | 10 +++------ tui/composer_test.go | 13 +++++++++++ tui/login.go | 19 +++++++++++++---- tui/messages.go | 2 ++ tui/settings.go | 1 + 12 files changed, 117 insertions(+), 20 deletions(-) diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 0e34d72f992198606693960f78b9e2acb682c508..598a8fa4740a75e5681b4a58360ab25cad76d7ff 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -393,7 +393,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro // Build raw RFC5322 message and upload as blob var buf bytes.Buffer - fmt.Fprintf(&buf, "From: %s\r\n", p.account.Email) + fmt.Fprintf(&buf, "From: %s\r\n", p.account.FormatFromHeader()) fmt.Fprintf(&buf, "To: %s\r\n", strings.Join(msg.To, ", ")) if len(msg.Cc) > 0 { fmt.Fprintf(&buf, "Cc: %s\r\n", strings.Join(msg.Cc, ", ")) diff --git a/config/config.go b/config/config.go index ac7d53e4d6fdf5eea145e83a5d8c6aa26f8b0174..c719db4ae74600b44751328348fe073c6198067c 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" @@ -21,6 +22,9 @@ type Account struct { // FetchEmail is the single email address for which messages should be fetched. // If empty, it will default to `Email` when accounts are added. FetchEmail string `json:"fetch_email,omitempty"` + // SendAsEmail controls the visible From header on outgoing mail. + // If empty, it defaults to FetchEmail, then Email. + SendAsEmail string `json:"send_as_email,omitempty"` // Custom server settings (used when ServiceProvider is "custom") IMAPServer string `json:"imap_server,omitempty"` @@ -125,6 +129,31 @@ func (a *Account) GetSMTPPort() int { } } +// GetFetchEmail returns the configured fetch identity, falling back to Email. +func (a *Account) GetFetchEmail() string { + if a.FetchEmail != "" { + return a.FetchEmail + } + return a.Email +} + +// GetSendAsEmail returns the visible sender address for outgoing mail. +func (a *Account) GetSendAsEmail() string { + if a.SendAsEmail != "" { + return a.SendAsEmail + } + return a.GetFetchEmail() +} + +// FormatFromHeader returns the display-ready From header value. +func (a *Account) FormatFromHeader() string { + sendAs := a.GetSendAsEmail() + if a.Name != "" && sendAs != "" { + return fmt.Sprintf("%s <%s>", a.Name, sendAs) + } + return sendAs +} + // GetPOP3Server returns the POP3 server address for the account. func (a *Account) GetPOP3Server() string { if a.POP3Server != "" { @@ -230,6 +259,7 @@ type secureDiskAccount struct { Password string `json:"password,omitempty"` ServiceProvider string `json:"service_provider"` FetchEmail string `json:"fetch_email,omitempty"` + SendAsEmail string `json:"send_as_email,omitempty"` IMAPServer string `json:"imap_server,omitempty"` IMAPPort int `json:"imap_port,omitempty"` SMTPServer string `json:"smtp_server,omitempty"` @@ -301,6 +331,7 @@ func SaveConfig(config *Config) error { Password: acc.Password, ServiceProvider: acc.ServiceProvider, FetchEmail: acc.FetchEmail, + SendAsEmail: acc.SendAsEmail, IMAPServer: acc.IMAPServer, IMAPPort: acc.IMAPPort, SMTPServer: acc.SMTPServer, @@ -355,6 +386,7 @@ func LoadConfig() (*Config, error) { Password string `json:"password,omitempty"` ServiceProvider string `json:"service_provider"` FetchEmail string `json:"fetch_email,omitempty"` + SendAsEmail string `json:"send_as_email,omitempty"` IMAPServer string `json:"imap_server,omitempty"` IMAPPort int `json:"imap_port,omitempty"` SMTPServer string `json:"smtp_server,omitempty"` @@ -420,6 +452,7 @@ func LoadConfig() (*Config, error) { Email: rawAcc.Email, ServiceProvider: rawAcc.ServiceProvider, FetchEmail: rawAcc.FetchEmail, + SendAsEmail: rawAcc.SendAsEmail, IMAPServer: rawAcc.IMAPServer, IMAPPort: rawAcc.IMAPPort, SMTPServer: rawAcc.SMTPServer, diff --git a/config/config_test.go b/config/config_test.go index b521d2d69a22bb4546393e0f4f8af0e8eb9c8a6e..2b1604b4aad6e42ea3d97d5d7a7389f112f79d02 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -28,6 +28,7 @@ func TestSaveAndLoadConfig(t *testing.T) { Email: "test@example.com", Password: "supersecret", ServiceProvider: "gmail", + SendAsEmail: "alias@example.com", }, { ID: "test-id-2", @@ -246,3 +247,40 @@ func TestAccountGetPorts(t *testing.T) { t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort()) } } + +func TestAccountSendIdentityHelpers(t *testing.T) { + t.Run("send as takes precedence", func(t *testing.T) { + account := Account{ + Name: "Alias User", + Email: "login@gmail.com", + FetchEmail: "inbox@gmail.com", + SendAsEmail: "alias@example.com", + } + + if got := account.GetFetchEmail(); got != "inbox@gmail.com" { + t.Fatalf("GetFetchEmail() = %q, want %q", got, "inbox@gmail.com") + } + if got := account.GetSendAsEmail(); got != "alias@example.com" { + t.Fatalf("GetSendAsEmail() = %q, want %q", got, "alias@example.com") + } + if got := account.FormatFromHeader(); got != "Alias User " { + t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User ") + } + }) + + t.Run("send as falls back to fetch then login", func(t *testing.T) { + account := Account{ + Name: "Fallback User", + Email: "login@gmail.com", + FetchEmail: "inbox@gmail.com", + } + if got := account.GetSendAsEmail(); got != "inbox@gmail.com" { + t.Fatalf("GetSendAsEmail() = %q, want %q", got, "inbox@gmail.com") + } + + account.FetchEmail = "" + if got := account.GetSendAsEmail(); got != "login@gmail.com" { + t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com") + } + }) +} diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index dcf58c3f1a48144bd92562e38135c9d245b0f50c..0af390856f8da7903656202dddc92642b02cadde 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -19,7 +19,8 @@ Configuration is stored in `~/.config/matcha/config.json`. "name": "John Doe", "email": "john@gmail.com", "service_provider": "gmail", - "fetch_email": "john@gmail.com" + "fetch_email": "john@gmail.com", + "send_as_email": "john@alias.example", "smime_cert": "/home/jane/.certs/jane_smime_cert.pem", "smime_key": "/home/jane/.certs/jane_smime_private.pem" }, @@ -47,6 +48,8 @@ Configuration is stored in `~/.config/matcha/config.json`. } ``` +`send_as_email` is optional. When set, Matcha uses it for the outgoing `From` header while continuing to authenticate with the account's login address. + ## Data Locations Configuration and persistent data are stored in `~/.config/matcha/`: diff --git a/docs/docs/setup-guides/gmail.md b/docs/docs/setup-guides/gmail.md index b487b7232780a53320beaa6ab44b386063f954fd..f81c4fcfb2f35ca2761fd3f37cbe43d1ee6a3236 100644 --- a/docs/docs/setup-guides/gmail.md +++ b/docs/docs/setup-guides/gmail.md @@ -99,6 +99,7 @@ From Matcha, open settings and choose to add a new account. Enter: - **Display name**: The name that will appear on emails you send - **Username**: Your Gmail address - **Email Address**: The Gmail address to fetch messages from (usually the same as Username) +- **Send As Email**: Optional. Set this if you want the outgoing `From` header to use a verified Gmail alias instead of your login address - **Auth Method**: oauth2 No password is needed — Matcha will use the tokens from the authorization step. diff --git a/main.go b/main.go index b15b62925a0568b603d24665d4cc229f33e8cca7..c63d30601f28a85e94b2ca567cf62018860d0e43 100644 --- a/main.go +++ b/main.go @@ -265,6 +265,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Password: msg.Password, ServiceProvider: msg.Provider, FetchEmail: fetchEmails[0], + SendAsEmail: msg.SendAsEmail, AuthMethod: msg.AuthMethod, Protocol: msg.Protocol, JMAPEndpoint: msg.JMAPEndpoint, @@ -307,6 +308,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Password: msg.Password, ServiceProvider: msg.Provider, FetchEmail: fe, + SendAsEmail: msg.SendAsEmail, AuthMethod: msg.AuthMethod, Protocol: msg.Protocol, JMAPEndpoint: msg.JMAPEndpoint, @@ -801,7 +803,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { hideTips = m.config.HideTips } login := tui.NewLogin(hideTips) - login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port) + login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port) m.current = login m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() diff --git a/sender/sender.go b/sender/sender.go index 15359179ae1b7094e9eb9969c24a3e0ed9fa0351..c6d3e35b4c83d7099b5365ccc0413f41a96fad20 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -166,10 +166,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer) loginAuthFallback := &loginAuth{username: account.Email, password: account.Password} - fromHeader := account.FetchEmail - if account.Name != "" { - fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail) - } + fromHeader := account.FormatFromHeader() // Set top-level headers (From/To/Subject/Date/etc) headers := map[string]string{ @@ -177,7 +174,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody "To": strings.Join(to, ", "), "Subject": subject, "Date": time.Now().Format(time.RFC1123Z), - "Message-ID": generateMessageID(account.FetchEmail), + "Message-ID": generateMessageID(account.GetSendAsEmail()), "MIME-Version": "1.0", } @@ -694,7 +691,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody } // Send Envelope - if err = c.Mail(account.FetchEmail); err != nil { + if err = c.Mail(account.GetFetchEmail()); err != nil { return err } for _, r := range allRecipients { diff --git a/tui/composer.go b/tui/composer.go index acb5f75e586a3e5f40f70371ba00b361dd68e724..2ce24d393132a1af29a416f8a00536bb6bb00e26 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -180,11 +180,7 @@ func (m *Composer) Init() tea.Cmd { func (m *Composer) getFromAddress() string { if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) { - acc := m.accounts[m.selectedAccountIdx] - if acc.Name != "" { - return fmt.Sprintf("%s <%s>", acc.Name, acc.FetchEmail) - } - return acc.FetchEmail + return m.accounts[m.selectedAccountIdx].FormatFromHeader() } return "" } @@ -667,9 +663,9 @@ func (m *Composer) View() tea.View { var accountList strings.Builder accountList.WriteString("Select Account:\n\n") for i, acc := range m.accounts { - display := acc.FetchEmail + display := acc.GetSendAsEmail() if acc.Name != "" { - display = fmt.Sprintf("%s (%s)", acc.Name, acc.FetchEmail) + display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail()) } if i == m.selectedAccountIdx { accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display))) diff --git a/tui/composer_test.go b/tui/composer_test.go index 9ec3d8d1391135f4d28330c9bf8e71d890afbe8b..f2922ee1912fca12ab1b5369171615135787b240 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -253,6 +253,19 @@ func TestComposerGetFromAddress(t *testing.T) { } }) + t.Run("Send as overrides fetch email", func(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", FetchEmail: "gmail@gmail.com", SendAsEmail: "alias@example.com", Name: "Test User"}, + } + composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false) + + fromAddr := composer.getFromAddress() + expected := "Test User " + if fromAddr != expected { + t.Errorf("Expected from address %q, got %q", expected, fromAddr) + } + }) + t.Run("No accounts", func(t *testing.T) { composer := NewComposer("", "", "", "", false) diff --git a/tui/login.go b/tui/login.go index de69980e893a607c6029fe0ff67271c0831c3c98..6478dfde19e105f0c31489db4132de9eef5f1bda 100644 --- a/tui/login.go +++ b/tui/login.go @@ -27,6 +27,7 @@ const ( inputName inputEmail inputFetchEmail + inputSendAsEmail inputAuthMethod // "password" or "oauth2" (shown for gmail) inputPassword inputIMAPServer @@ -70,6 +71,9 @@ func NewLogin(hideTips bool) *Login { case inputFetchEmail: t.Placeholder = "Email Address (comma-separated for multiple)" t.Prompt = "📧 > " + case inputSendAsEmail: + t.Placeholder = "Send As Email (optional From header override)" + t.Prompt = "✉️ > " case inputAuthMethod: t.Placeholder = "Auth Method (password or oauth2)" t.Prompt = "🔐 > " @@ -131,14 +135,14 @@ func (m *Login) visibleFields() []int { switch proto { case "jmap": // JMAP: no provider selector, just endpoint + common fields - fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword, inputJMAPEndpoint) + fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword, inputJMAPEndpoint) case "pop3": // POP3: custom server fields + SMTP for sending - fields = append(fields, inputName, inputEmail, inputFetchEmail, inputPassword, + fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword, inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort) default: // IMAP (default): existing flow - fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail) + fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail) if isGmail { fields = append(fields, inputAuthMethod) } @@ -271,6 +275,7 @@ func (m *Login) submitForm() func() tea.Msg { Name: m.inputs[inputName].Value(), Host: m.inputs[inputEmail].Value(), FetchEmail: m.inputs[inputFetchEmail].Value(), + SendAsEmail: m.inputs[inputSendAsEmail].Value(), Password: m.inputs[inputPassword].Value(), IMAPServer: m.inputs[inputIMAPServer].Value(), IMAPPort: imapPort, @@ -305,6 +310,8 @@ func (m *Login) View() tea.View { tip = "Your full email address used to log in." case inputFetchEmail: tip = "The email address to fetch messages for (comma-separated for multiple, e.g. me@icloud.com,me@mac.com)." + case inputSendAsEmail: + tip = "Optional From header override for outgoing email. Leave blank to send as the fetched address." case inputAuthMethod: tip = "Type 'oauth2' for Gmail OAuth2 or 'password' for app password." case inputPassword: @@ -338,6 +345,7 @@ func (m *Login) View() tea.View { m.inputs[inputName].View(), m.inputs[inputEmail].View(), m.inputs[inputFetchEmail].View(), + m.inputs[inputSendAsEmail].View(), m.inputs[inputPassword].View(), "", listHeader.Render("JMAP Settings:"), @@ -348,6 +356,7 @@ func (m *Login) View() tea.View { m.inputs[inputName].View(), m.inputs[inputEmail].View(), m.inputs[inputFetchEmail].View(), + m.inputs[inputSendAsEmail].View(), m.inputs[inputPassword].View(), "", listHeader.Render("POP3 Server Settings:"), @@ -366,6 +375,7 @@ func (m *Login) View() tea.View { m.inputs[inputName].View(), m.inputs[inputEmail].View(), m.inputs[inputFetchEmail].View(), + m.inputs[inputSendAsEmail].View(), ) if isGmail { @@ -401,7 +411,7 @@ func (m *Login) View() tea.View { } // SetEditMode sets the login form to edit an existing account. -func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) { +func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) { m.isEditMode = true m.accountID = accountID @@ -413,6 +423,7 @@ func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEma m.inputs[inputName].SetValue(name) m.inputs[inputEmail].SetValue(email) m.inputs[inputFetchEmail].SetValue(fetchEmail) + m.inputs[inputSendAsEmail].SetValue(sendAsEmail) m.showCustom = provider == "custom" if m.showCustom { diff --git a/tui/messages.go b/tui/messages.go index 5494cfdf377a16fcf9f3187723d7f5555a803ea3..0502d0b3bef1f2a63b5ee7eab92616a664630995 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -43,6 +43,7 @@ type Credentials struct { Name string Host string // Host (this was the previous "Email Address" field in the UI) FetchEmail string // Single email address to fetch messages for. If empty, code should default this to Host when creating the account. + SendAsEmail string // Optional From header email. If empty, sending falls back to FetchEmail, then Host. Password string IMAPServer string IMAPPort int @@ -208,6 +209,7 @@ type GoToEditAccountMsg struct { Name string Email string FetchEmail string + SendAsEmail string IMAPServer string IMAPPort int SMTPServer string diff --git a/tui/settings.go b/tui/settings.go index 49a17d41ab9aca0d27aec9b3589fdf265eb810f0..5f0fcd0d260ef33c9d81aa5dc41ac8a9bbb36327 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -328,6 +328,7 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { Name: acc.Name, Email: acc.Email, FetchEmail: acc.FetchEmail, + SendAsEmail: acc.SendAsEmail, IMAPServer: acc.IMAPServer, IMAPPort: acc.IMAPPort, SMTPServer: acc.SMTPServer,