Detailed changes
@@ -260,6 +260,7 @@ type Draft struct {
Body string `json:"body"`
AttachmentPaths []string `json:"attachment_paths,omitempty"`
AccountID string `json:"account_id"`
+ FromOverride string `json:"from_override,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
References []string `json:"references,omitempty"`
QuotedText string `json:"quoted_text,omitempty"`
@@ -42,6 +42,9 @@ type Account struct {
// SendAsEmail controls the visible From header on outgoing mail.
// If empty, it defaults to FetchEmail, then Email.
SendAsEmail string `json:"send_as_email,omitempty"`
+ // CatchAll skips per-address filtering so all inbox messages are shown,
+ // regardless of which address they were delivered to.
+ CatchAll bool `json:"catch_all,omitempty"`
// Custom server settings (used when ServiceProvider is "custom")
IMAPServer string `json:"imap_server,omitempty"`
@@ -243,6 +246,9 @@ func (a *Account) GetSendAsEmail() string {
// FormatFromHeader returns the display-ready From header value.
func (a *Account) FormatFromHeader() string {
sendAs := a.GetSendAsEmail()
+ if strings.Contains(sendAs, "<") && strings.Contains(sendAs, ">") {
+ return sendAs
+ }
if a.Name != "" && sendAs != "" {
return fmt.Sprintf("%s <%s>", a.Name, sendAs)
}
@@ -373,6 +379,7 @@ type secureDiskAccount struct {
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
POP3Server string `json:"pop3_server,omitempty"`
POP3Port int `json:"pop3_port,omitempty"`
+ CatchAll bool `json:"catch_all,omitempty"`
}
type secureDiskConfig struct {
@@ -456,6 +463,7 @@ func SaveConfig(config *Config) error {
JMAPEndpoint: acc.JMAPEndpoint,
POP3Server: acc.POP3Server,
POP3Port: acc.POP3Port,
+ CatchAll: acc.CatchAll,
})
}
data, err = json.MarshalIndent(sdc, "", " ")
@@ -517,6 +525,7 @@ func LoadConfig() (*Config, error) {
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
POP3Server string `json:"pop3_server,omitempty"`
POP3Port int `json:"pop3_port,omitempty"`
+ CatchAll bool `json:"catch_all,omitempty"`
}
type diskConfig struct {
Accounts []rawAccount `json:"accounts"`
@@ -588,6 +597,7 @@ func LoadConfig() (*Config, error) {
JMAPEndpoint: rawAcc.JMAPEndpoint,
POP3Server: rawAcc.POP3Server,
POP3Port: rawAcc.POP3Port,
+ CatchAll: rawAcc.CatchAll,
}
// Validate PGPKeySource
@@ -41,6 +41,7 @@ func TestSaveAndLoadConfig(t *testing.T) {
IMAPPort: 993,
SMTPServer: "smtp.custom.com",
SMTPPort: 587,
+ CatchAll: true,
},
},
}
@@ -312,6 +313,16 @@ func TestAccountSendIdentityHelpers(t *testing.T) {
t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com")
}
})
+
+ t.Run("format from header avoids double wrapping", func(t *testing.T) {
+ account := Account{
+ Name: "Account Name",
+ SendAsEmail: "Custom Name <custom@example.com>",
+ }
+ if got := account.FormatFromHeader(); got != "Custom Name <custom@example.com>" {
+ t.Fatalf("FormatFromHeader() = %q, want %q", got, "Custom Name <custom@example.com>")
+ }
+ })
}
func TestTranslateDateFormat(t *testing.T) {
@@ -498,7 +498,9 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
}
matched := false
- if isSentMailbox {
+ if account.CatchAll {
+ matched = true
+ } else if isSentMailbox {
var senderEmail string
if len(msg.Envelope.From) > 0 {
senderEmail = msg.Envelope.From[0].Addr()
@@ -1518,23 +1520,27 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
// For archive/All Mail, match emails where user is sender OR recipient
matched := false
- // Check if user is the sender
- if addressMatches(fromAddr, fetchEmail, account) {
+ if account.CatchAll {
matched = true
- }
- // Check if user is a recipient
- if !matched {
- for _, r := range toAddrList {
- if addressMatches(r, fetchEmail, account) {
- matched = true
- break
+ } else {
+ // Check if user is the sender
+ if addressMatches(fromAddr, fetchEmail, account) {
+ matched = true
+ }
+ // Check if user is a recipient
+ if !matched {
+ for _, r := range toAddrList {
+ if addressMatches(r, fetchEmail, account) {
+ matched = true
+ break
+ }
}
}
- }
- // Check delivery headers for auto-forwarded emails
- if !matched {
- headerData := msg.FindBodySection(deliveryHeaderSection)
- matched = deliveryHeadersMatch(headerData, fetchEmail, account)
+ // Check delivery headers for auto-forwarded emails
+ if !matched {
+ headerData := msg.FindBodySection(deliveryHeaderSection)
+ matched = deliveryHeadersMatch(headerData, fetchEmail, account)
+ }
}
if !matched {
@@ -345,6 +345,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ServiceProvider: msg.Provider,
FetchEmail: fetchEmails[0],
SendAsEmail: msg.SendAsEmail,
+ CatchAll: msg.CatchAll,
AuthMethod: msg.AuthMethod,
Protocol: msg.Protocol,
Insecure: msg.Insecure,
@@ -389,6 +390,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ServiceProvider: msg.Provider,
FetchEmail: fe,
SendAsEmail: msg.SendAsEmail,
+ CatchAll: msg.CatchAll,
AuthMethod: msg.AuthMethod,
Protocol: msg.Protocol,
JMAPEndpoint: msg.JMAPEndpoint,
@@ -1066,7 +1068,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.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, 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.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port, msg.CatchAll)
m.current = login
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
@@ -2409,6 +2411,13 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
}
+ // Apply custom From address for catch-all accounts.
+ if msg.FromOverride != "" {
+ acc := *account
+ acc.SendAsEmail = msg.FromOverride
+ account = &acc
+ }
+
recipients := splitEmails(msg.To)
cc := splitEmails(msg.Cc)
bcc := splitEmails(msg.Bcc)
@@ -64,6 +64,7 @@ type Composer struct {
accounts []config.Account
selectedAccountIdx int
showAccountPicker bool
+ fromInput textinput.Model // editable From when account is catch-all
// Contact suggestions
suggestions []config.Contact
@@ -141,6 +142,12 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
m.signatureInput.SetStyles(taStyles)
m.updateSignature()
+ m.fromInput = textinput.New()
+ m.fromInput.Placeholder = t("composer.from_placeholder")
+ m.fromInput.Prompt = "> "
+ m.fromInput.CharLimit = 256
+ m.fromInput.SetStyles(tiStyles)
+
// Start focus on To field (From is selectable but not a text input)
m.focusIndex = focusTo
m.toInput.Focus()
@@ -151,10 +158,17 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
// updateSignature updates the signature input based on the current selected account.
func (m *Composer) updateSignature() {
if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
- if sig, err := config.LoadSignatureForAccount(&m.accounts[m.selectedAccountIdx]); err == nil && sig != "" {
+ acc := &m.accounts[m.selectedAccountIdx]
+ if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
m.signatureInput.SetValue(sig)
- return
+ } else if sig, err := config.LoadSignature(); err == nil && sig != "" {
+ m.signatureInput.SetValue(sig)
+ } else {
+ m.signatureInput.SetValue("")
}
+ // Seed the editable From address for catch-all accounts.
+ m.fromInput.SetValue(acc.FormatFromHeader())
+ return
}
if sig, err := config.LoadSignature(); err == nil && sig != "" {
@@ -197,6 +211,13 @@ func (m *Composer) getFromAddress() string {
return ""
}
+func (m *Composer) isCatchAllAccount() bool {
+ if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
+ return m.accounts[m.selectedAccountIdx].CatchAll
+ }
+ return false
+}
+
func (m *Composer) getSelectedAccount() *config.Account {
if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
return &m.accounts[m.selectedAccountIdx]
@@ -385,8 +406,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
maxFocus := focusSend
minFocus := focusFrom
- // Skip From field if only one account (nothing to switch)
- if len(m.accounts) <= 1 {
+ // Skip From field if only one non-catch-all account (nothing to switch or edit)
+ if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
minFocus = focusTo
}
@@ -396,6 +417,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.focusIndex = maxFocus
}
+ m.fromInput.Blur()
m.toInput.Blur()
m.ccInput.Blur()
m.bccInput.Blur()
@@ -404,6 +426,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.signatureInput.Blur()
switch m.focusIndex {
+ case focusFrom:
+ if m.isCatchAllAccount() {
+ cmds = append(cmds, m.fromInput.Focus())
+ }
case focusTo:
cmds = append(cmds, m.toInput.Focus())
case focusCc:
@@ -428,8 +454,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter", " ":
switch m.focusIndex {
case focusFrom:
- if len(m.accounts) > 1 && msg.String() == "enter" {
+ if msg.String() == "enter" && len(m.accounts) > 1 {
m.showAccountPicker = true
+ return m, nil
+ }
+ if m.isCatchAllAccount() && msg.String() == " " {
+ break
}
return m, nil
case focusAttachment:
@@ -449,6 +479,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if acc != nil {
accountID = acc.ID
}
+ fromOverride := ""
+ if m.isCatchAllAccount() {
+ fromOverride = m.fromInput.Value()
+ }
return m, func() tea.Msg {
return SendEmailMsg{
To: m.toInput.Value(),
@@ -458,6 +492,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Body: m.bodyInput.Value(),
AttachmentPaths: m.attachmentPaths,
AccountID: accountID,
+ FromOverride: fromOverride,
QuotedText: m.quotedText,
InReplyTo: m.inReplyTo,
References: m.references,
@@ -473,6 +508,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
switch m.focusIndex {
+ case focusFrom:
+ if m.isCatchAllAccount() {
+ m.fromInput, cmd = m.fromInput.Update(msg)
+ cmds = append(cmds, cmd)
+ }
case focusTo:
m.toInput, cmd = m.toInput.Update(msg)
cmds = append(cmds, cmd)
@@ -528,7 +568,18 @@ func (m *Composer) View() tea.View {
// From field with account selector
fromAddr := m.getFromAddress()
var fromField string
- if len(m.accounts) > 1 {
+ if m.isCatchAllAccount() {
+ fromAddrView := m.fromInput.View()
+ if len(m.accounts) > 1 {
+ if m.focusIndex == focusFrom {
+ fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
+ } else {
+ fromField = blurredStyle.Render(fmt.Sprintf(" %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
+ }
+ } else {
+ fromField = " " + t("composer.from") + " " + fromAddrView
+ }
+ } else if len(m.accounts) > 1 {
if m.focusIndex == focusFrom {
fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
} else {
@@ -876,6 +927,7 @@ func (m *Composer) ToDraft() config.Draft {
Body: m.bodyInput.Value(),
AttachmentPaths: m.attachmentPaths,
AccountID: m.GetSelectedAccountID(),
+ FromOverride: m.fromInput.Value(),
InReplyTo: m.inReplyTo,
References: m.references,
QuotedText: m.quotedText,
@@ -889,6 +941,9 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip
m.bccInput.SetValue(draft.Bcc)
m.draftID = draft.ID
m.attachmentPaths = draft.AttachmentPaths
+ if m.isCatchAllAccount() && draft.FromOverride != "" {
+ m.fromInput.SetValue(draft.FromOverride)
+ }
m.inReplyTo = draft.InReplyTo
m.references = draft.References
m.quotedText = draft.QuotedText
@@ -389,16 +389,18 @@ func (m *Inbox) updateList() {
currentIndex := m.list.Index()
displayEmails := m.displayEmails()
- var showAccountLabel bool
+ m.emailsCount = len(displayEmails)
+ var showAccountLabel bool
if m.searchActive {
- showAccountLabel = !(len(m.accounts) <= 1)
+ showAccountLabel = len(m.accounts) > 1
} else if m.currentAccountID == "" {
- // "ALL" view - show all emails sorted by date
- showAccountLabel = !(len(m.accounts) <= 1)
+ showAccountLabel = len(m.accounts) > 1
}
- m.emailsCount = len(displayEmails)
+ if !showAccountLabel && len(m.accounts) == 1 && m.accounts[0].CatchAll {
+ showAccountLabel = true
+ }
items := make([]list.Item, len(displayEmails))
for i, email := range displayEmails {
@@ -500,6 +502,18 @@ func (m *Inbox) filteredSearchResults() []fetcher.Email {
}
func (m *Inbox) accountLabelForEmail(email fetcher.Email) string {
+ var owningAcc *config.Account
+ for i := range m.accounts {
+ if m.accounts[i].ID == email.AccountID {
+ owningAcc = &m.accounts[i]
+ break
+ }
+ }
+
+ if owningAcc != nil && owningAcc.CatchAll && len(email.To) > 0 {
+ return extractEmailAddress(email.To[0])
+ }
+
for _, acc := range m.accounts {
fetchEmail := accountDisplayEmail(acc)
for _, recipient := range email.To {
@@ -508,10 +522,9 @@ func (m *Inbox) accountLabelForEmail(email fetcher.Email) string {
}
}
}
- for _, acc := range m.accounts {
- if acc.ID == email.AccountID {
- return accountDisplayEmail(acc)
- }
+
+ if owningAcc != nil {
+ return accountDisplayEmail(*owningAcc)
}
return ""
}
@@ -35,6 +35,7 @@ const (
inputSMTPServer
inputSMTPPort
inputInsecure
+ inputCatchAll // "true/false" — show all inbox messages regardless of To address
inputJMAPEndpoint // JMAP session URL
inputPOP3Server
inputPOP3Port
@@ -97,6 +98,9 @@ func NewLogin(hideTips bool) *Login {
case inputInsecure:
t.Placeholder = "Insecure (true/false) - Skip TLS verification"
t.Prompt = "🔓 > "
+ case inputCatchAll:
+ t.Placeholder = "Catch-All (true/false) - Show all inbox messages"
+ t.Prompt = "📬 > "
case inputJMAPEndpoint:
t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
t.Prompt = "🔗 > "
@@ -139,14 +143,14 @@ func (m *Login) visibleFields() []int {
switch proto {
case "jmap":
// JMAP: no provider selector, just endpoint + common fields
- fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword, inputJMAPEndpoint)
+ fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword, inputJMAPEndpoint)
case "pop3":
// POP3: custom server fields + SMTP for sending
- fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword,
+ fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll, inputPassword,
inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure)
default:
// IMAP (default): existing flow
- fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail)
+ fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputCatchAll)
if hasOAuth {
fields = append(fields, inputAuthMethod)
}
@@ -284,6 +288,7 @@ func (m *Login) submitForm() func() tea.Msg {
proto := m.protocol()
insecure := m.inputs[inputInsecure].Value() == "true"
+ catchAll := m.inputs[inputCatchAll].Value() == "true"
return func() tea.Msg {
return Credentials{
@@ -293,6 +298,7 @@ func (m *Login) submitForm() func() tea.Msg {
Host: m.inputs[inputEmail].Value(),
FetchEmail: m.inputs[inputFetchEmail].Value(),
SendAsEmail: m.inputs[inputSendAsEmail].Value(),
+ CatchAll: catchAll,
Password: m.inputs[inputPassword].Value(),
IMAPServer: m.inputs[inputIMAPServer].Value(),
IMAPPort: imapPort,
@@ -344,6 +350,8 @@ func (m *Login) View() tea.View {
tip = "The port for the SMTP server (usually 587 for TLS)."
case inputInsecure:
tip = "Type 'true' to disable TLS certificate verification (not recommended)."
+ case inputCatchAll:
+ tip = "Type 'true' to show all inbox messages regardless of To address (useful for catch-all domains)."
case inputJMAPEndpoint:
tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
case inputPOP3Server:
@@ -366,6 +374,7 @@ func (m *Login) View() tea.View {
m.inputs[inputEmail].View(),
m.inputs[inputFetchEmail].View(),
m.inputs[inputSendAsEmail].View(),
+ m.inputs[inputCatchAll].View(),
m.inputs[inputPassword].View(),
"",
listHeader.Render("JMAP Settings:"),
@@ -377,6 +386,7 @@ func (m *Login) View() tea.View {
m.inputs[inputEmail].View(),
m.inputs[inputFetchEmail].View(),
m.inputs[inputSendAsEmail].View(),
+ m.inputs[inputCatchAll].View(),
m.inputs[inputPassword].View(),
"",
listHeader.Render("POP3 Server Settings:"),
@@ -398,6 +408,7 @@ func (m *Login) View() tea.View {
m.inputs[inputEmail].View(),
m.inputs[inputFetchEmail].View(),
m.inputs[inputSendAsEmail].View(),
+ m.inputs[inputCatchAll].View(),
)
if hasOAuth {
@@ -438,7 +449,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, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int) {
+func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int, catchAll bool) {
m.isEditMode = true
m.accountID = accountID
@@ -451,6 +462,11 @@ func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEma
m.inputs[inputEmail].SetValue(email)
m.inputs[inputFetchEmail].SetValue(fetchEmail)
m.inputs[inputSendAsEmail].SetValue(sendAsEmail)
+ if catchAll {
+ m.inputs[inputCatchAll].SetValue("true")
+ } else {
+ m.inputs[inputCatchAll].SetValue("false")
+ }
m.showCustom = provider == "custom"
if m.showCustom {
@@ -35,6 +35,7 @@ type SendEmailMsg struct {
InReplyTo string
References []string
AccountID string // ID of the account to send from
+ FromOverride string // Custom From address (used when account is catch-all)
QuotedText string // Hidden quoted text appended when sending
Signature string // Signature to append to email body
SignSMIME bool // Whether to sign the email using S/MIME
@@ -48,6 +49,7 @@ type Credentials struct {
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.
+ CatchAll bool // Show all inbox messages regardless of To address.
Password string
IMAPServer string
IMAPPort int
@@ -277,6 +279,7 @@ type GoToEditAccountMsg struct {
Email string
FetchEmail string
SendAsEmail string
+ CatchAll bool
IMAPServer string
IMAPPort int
SMTPServer string
@@ -59,6 +59,7 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
Email: acc.Email,
FetchEmail: acc.FetchEmail,
SendAsEmail: acc.SendAsEmail,
+ CatchAll: acc.CatchAll,
IMAPServer: acc.IMAPServer,
IMAPPort: acc.IMAPPort,
SMTPServer: acc.SMTPServer,
@@ -147,6 +148,9 @@ func (m *Settings) viewAccounts() string {
if config.HasAccountSignature(&account) {
providerInfo += " [Signature]"
}
+ if account.CatchAll {
+ providerInfo += " [Catch-All]"
+ }
line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))