Skip to content

Commit 65cc3d0

Browse files
committed
fix panic on gmail (imap_disabled=true)
1 parent 5ef4139 commit 65cc3d0

3 files changed

Lines changed: 97 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
# 2026-05-04
4+
- **Fix: send from `imap_disabled = true` account no longer panics** — sending an email from an account configured as send-only (typically Gmail with `imap_disabled = true`) crashed the TUI with `nil pointer dereference` in `tokenSourceFor` because the helper called `.TokenSource()` on the intentionally-nil IMAP client. The same nil-deref existed in `imapCli`, `imapCliForAccount`, and `primaryIMAPClient` — any code path that resolved an IMAP client for a send-only account would crash. All four helpers now skip nil entries (and fall back to the first non-nil client where appropriate); `sendEmailCmd` also guards `cli.SaveSent` and `replyCli.MarkAnswered` so a fully send-only configuration silently skips the Sent-folder copy instead of panicking. Four regression tests added in `internal/ui/imap_client_helpers_test.go`
45
- **Fix: notify state key keeps IMAP folder name (preserves baselines across upgrades)** — yesterday's label-normalisation fix accidentally changed the persisted state key from `Personal|INBOX` to `Personal|Inbox`, which would have re-baselined every existing user on upgrade and silently swallowed one round of notifications. `MaybeNotify` now takes both the IMAP name (used for the state key) and the UI label (used only for the allowlist comparison) so existing `notify_state.json` files keep working untouched. Regression test added
56
- **Fix: `[notifications].folders` allowlist now matches custom IMAP folder names** — the allowlist compared user-configured labels (e.g. `"PaperTrail"`) against runtime IMAP folder names (e.g. `"HEY/Paper Trail"` or `"[Gmail]/All Mail"`) and silently dropped notifications when the two differed. Folders are now normalised to their UI label via a new `FoldersConfig.LabelFor()` helper before the allowlist check, so users with non-default IMAP folder names get notifications correctly. Regression test added in `internal/config/config_test.go`
67
- **Fix: notifier can no longer freeze the TUI**`Send` now runs `notify-send` (or whatever `[notifications].command` points to) under a 2-second `context.WithTimeout` plus a 500 ms `cmd.WaitDelay`. A hung notification daemon (broken DBus, mako restarting, …) returns a clear `notify-send: timed out after 2s` status instead of blocking the bubbletea Update loop. Test exercises the timeout path with a script that sleeps 60 s

internal/ui/model.go

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -682,10 +682,12 @@ func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mail
682682
}
683683

684684
// tokenSourceFor returns the OAuth2 token source for the account with the
685-
// given name, or nil if the account uses plain password authentication.
685+
// given name, or nil if the account uses plain password authentication or
686+
// is configured as send-only (imap_disabled — its client is nil because
687+
// neomd never opened an IMAP connection).
686688
func (m Model) tokenSourceFor(accountName string) func() (string, error) {
687689
for i, acc := range m.accounts {
688-
if acc.Name == accountName && i < len(m.clients) {
690+
if acc.Name == accountName && i < len(m.clients) && m.clients[i] != nil {
689691
return m.clients[i].TokenSource()
690692
}
691693
}
@@ -748,18 +750,24 @@ func (m Model) presendSMTPAccount() config.AccountConfig {
748750

749751
func (m Model) imapCliForAccount(accountName string) *imap.Client {
750752
for i, a := range m.accounts {
751-
if strings.EqualFold(a.Name, accountName) && i < len(m.clients) {
753+
if strings.EqualFold(a.Name, accountName) && i < len(m.clients) && m.clients[i] != nil {
752754
return m.clients[i]
753755
}
754756
}
757+
// Account not found, or its client is nil because imap_disabled — fall
758+
// back to whatever imapCli / primaryIMAPClient resolves to.
755759
return m.imapCli()
756760
}
757761

758762
func (m Model) primaryIMAPClient() *imap.Client {
759-
if len(m.clients) > 0 {
760-
return m.clients[0]
763+
// Pick the first non-nil client — accounts with imap_disabled = true
764+
// have a nil entry by design, so we cannot blindly use index 0.
765+
for _, c := range m.clients {
766+
if c != nil {
767+
return c
768+
}
761769
}
762-
return m.imapCli()
770+
return nil
763771
}
764772

765773
func (m Model) sentDraftsIMAPClient() *imap.Client {
@@ -779,12 +787,13 @@ func (m *Model) applyEditedFrom(from string) {
779787
}
780788
}
781789

782-
// imapCli returns the IMAP client for the active account.
790+
// imapCli returns the IMAP client for the active account, falling back to
791+
// the first non-nil client if the active account is imap_disabled.
783792
func (m Model) imapCli() *imap.Client {
784-
if m.accountI < len(m.clients) {
793+
if m.accountI < len(m.clients) && m.clients[m.accountI] != nil {
785794
return m.clients[m.accountI]
786795
}
787-
return m.clients[0]
796+
return m.primaryIMAPClient()
788797
}
789798

790799
func (m Model) Init() tea.Cmd {
@@ -890,11 +899,16 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su
890899
return sendDoneMsg{err: err}
891900
}
892901
// Save copy to Sent; non-fatal if it fails, but warn user.
893-
if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil {
894-
return sendDoneMsg{warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), replyToUID: replyToUID, replyToFolder: replyToFolder}
902+
// cli can be nil when every configured account has imap_disabled = true
903+
// (send-only setups) — in that case there is no IMAP target for the
904+
// Sent copy, so we skip silently.
905+
if cli != nil {
906+
if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil {
907+
return sendDoneMsg{warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), replyToUID: replyToUID, replyToFolder: replyToFolder}
908+
}
895909
}
896910
// Mark original email as \Answered (non-fatal).
897-
if replyToUID > 0 && replyToFolder != "" {
911+
if replyToUID > 0 && replyToFolder != "" && replyCli != nil {
898912
_ = replyCli.MarkAnswered(nil, replyToFolder, replyToUID)
899913
}
900914
return sendDoneMsg{replyToUID: replyToUID, replyToFolder: replyToFolder}

internal/ui/model_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,76 @@ func TestSentDraftsIMAPClient_FollowsSendingAccountWhenEnabled(t *testing.T) {
287287
}
288288
}
289289

290+
// imap_disabled = true accounts (typically a Gmail set up for SMTP-only)
291+
// produce a nil entry in m.clients by design (cmd/neomd/main.go appends nil
292+
// for those accounts). Every helper that walks m.clients[i] must skip the
293+
// nil entries — historically several didn't, and a perfectly normal "send
294+
// from Gmail" flow crashed the TUI with `nil pointer dereference` in
295+
// tokenSourceFor. These tests pin all four resolver helpers so that
296+
// regression cannot recur silently.
297+
298+
func TestTokenSourceFor_NilClientReturnsNil(t *testing.T) {
299+
cfg := &config.Config{
300+
Accounts: []config.AccountConfig{
301+
{Name: "Personal"},
302+
{Name: "Gmail", IMAPDisabled: true},
303+
},
304+
}
305+
m := Model{
306+
cfg: cfg,
307+
accounts: cfg.ActiveAccounts(),
308+
clients: []*imap.Client{imap.New(imap.Config{Host: "personal"}), nil},
309+
}
310+
defer func() {
311+
if r := recover(); r != nil {
312+
t.Fatalf("tokenSourceFor panicked on nil client: %v", r)
313+
}
314+
}()
315+
if m.tokenSourceFor("Gmail") != nil {
316+
t.Error("tokenSourceFor(send-only Gmail) should return nil, not a token source from nil client")
317+
}
318+
}
319+
320+
func TestImapCliForAccount_FallsBackWhenClientNil(t *testing.T) {
321+
cfg := &config.Config{
322+
Accounts: []config.AccountConfig{
323+
{Name: "Personal"},
324+
{Name: "Gmail", IMAPDisabled: true},
325+
},
326+
}
327+
personal := imap.New(imap.Config{Host: "personal"})
328+
m := Model{cfg: cfg, accounts: cfg.ActiveAccounts(), clients: []*imap.Client{personal, nil}}
329+
if got := m.imapCliForAccount("Gmail"); got != personal {
330+
t.Error("imapCliForAccount(Gmail) should fall back to the first non-nil client")
331+
}
332+
}
333+
334+
func TestPrimaryIMAPClient_SkipsNilEntries(t *testing.T) {
335+
// Reverse the layout — nil at index 0, real client at index 1.
336+
personal := imap.New(imap.Config{Host: "personal"})
337+
m := Model{
338+
cfg: &config.Config{Accounts: []config.AccountConfig{{Name: "Gmail", IMAPDisabled: true}, {Name: "Personal"}}},
339+
accounts: []config.AccountConfig{{Name: "Gmail", IMAPDisabled: true}, {Name: "Personal"}},
340+
clients: []*imap.Client{nil, personal},
341+
}
342+
if got := m.primaryIMAPClient(); got != personal {
343+
t.Error("primaryIMAPClient should walk past nil entries to the first real client")
344+
}
345+
}
346+
347+
func TestImapCli_FallsBackWhenActiveAccountIMAPDisabled(t *testing.T) {
348+
personal := imap.New(imap.Config{Host: "personal"})
349+
m := Model{
350+
cfg: &config.Config{Accounts: []config.AccountConfig{{Name: "Personal"}, {Name: "Gmail", IMAPDisabled: true}}},
351+
accounts: []config.AccountConfig{{Name: "Personal"}, {Name: "Gmail", IMAPDisabled: true}},
352+
clients: []*imap.Client{personal, nil},
353+
accountI: 1, // active = Gmail (nil client)
354+
}
355+
if got := m.imapCli(); got != personal {
356+
t.Error("imapCli should fall back to a non-nil client when the active account is imap_disabled")
357+
}
358+
}
359+
290360
func TestMatchFromAddress(t *testing.T) {
291361
cfg := &config.Config{
292362
Accounts: []config.AccountConfig{

0 commit comments

Comments
 (0)