feat: send-as email (#492)

Mine13zoom created

## 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 ./...

Change summary

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(-)

Detailed changes

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

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,

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 <alias@example.com>" {
+			t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User <alias@example.com>")
+		}
+	})
+
+	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")
+		}
+	})
+}

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

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.

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()

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 {

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

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 <alias@example.com>"
+		if fromAddr != expected {
+			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
+		}
+	})
+
 	t.Run("No accounts", func(t *testing.T) {
 		composer := NewComposer("", "", "", "", false)
 

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 {

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

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,