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