From 08d28cfb047aa281a73d4607dc2e9cd8fd19cd68 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Tue, 23 Jun 2026 08:25:20 +0000 Subject: [PATCH 1/5] feat(config): warn on unknown keys in config file Emit a warning via log.Printf listing unrecognized config keys at startup. This helps users catch typos in their config.json. Closes #1521 --- config/config.go | 76 +++++++++++++++++++++++++++++++++++++++++++ config/config_test.go | 17 ++++++++++ 2 files changed, 93 insertions(+) diff --git a/config/config.go b/config/config.go index 2fb1b087..0033e83f 100644 --- a/config/config.go +++ b/config/config.go @@ -651,6 +651,8 @@ func LoadConfig() (*Config, error) { return nil, err } + warnUnknownConfigKeys(data) + secureMode := GetSessionKey() != nil var config Config @@ -957,3 +959,77 @@ func EnsurePGPDir() error { pgpDir := filepath.Join(dir, "pgp") return os.MkdirAll(pgpDir, 0700) } + +var knownConfigKeys = map[string]bool{ + "accounts": true, + "disable_images": true, + "hide_tips": true, + "disable_notifications": true, + "enable_split_pane": true, + "enable_threaded": true, + "enable_detailed_dates": true, + "theme": true, + "mailing_lists": true, + "date_format": true, + "language": true, + "body_cache_threshold_mb": true, + "plugin_settings": true, +} + +var knownAccountKeys = map[string]bool{ + "id": true, + "name": true, + "email": true, + "password": true, + "service_provider": true, + "fetch_email": true, + "send_as_email": true, + "imap_server": true, + "imap_port": true, + "smtp_server": true, + "smtp_port": true, + "insecure": true, + "smime_cert": true, + "smime_key": true, + "smime_sign_by_default": true, + "pgp_public_key": true, + "pgp_private_key": true, + "pgp_key_source": true, + "pgp_pin": true, + "pgp_sign_by_default": true, + "auth_method": true, + "protocol": true, + "jmap_endpoint": true, + "pop3_server": true, + "pop3_port": true, + "catch_all": true, +} + +func warnUnknownConfigKeys(data []byte) { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return + } + for key := range raw { + if !knownConfigKeys[key] { + log.Printf("matcha: unknown config key %q", key) + } + if key == "accounts" { + accounts, ok := raw["accounts"].([]interface{}) + if !ok { + continue + } + for i, acc := range accounts { + accMap, ok := acc.(map[string]interface{}) + if !ok { + continue + } + for accKey := range accMap { + if !knownAccountKeys[accKey] { + log.Printf("matcha: unknown config key in accounts[%d]: %q", i, accKey) + } + } + } + } + } +} diff --git a/config/config_test.go b/config/config_test.go index 036bb854..b5589c38 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -711,3 +711,20 @@ func TestPassCmd(t *testing.T) { t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password) } } + +func TestWarnUnknownConfigKeys(t *testing.T) { + tests := []struct { + name string + json string + }{ + {"no unknown keys", `{"accounts": [{"name": "test", "email": "a@b.com", "service_provider": "gmail"}], "theme": "dark"}`}, + {"empty object", `{}`}, + {"invalid json", `not json`}, + {"nested with unknown", `{"unknown_top": true, "accounts": [{"name": "test", "email": "a@b.com", "unknown_field": true}]}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + warnUnknownConfigKeys([]byte(tc.json)) + }) + } +} From bd2b9371e98dfe6efd01b5d4f1172c3d5420b6d7 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Tue, 23 Jun 2026 08:27:43 +0000 Subject: [PATCH 2/5] fix: add pass_cmd to known account keys --- config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.go b/config/config.go index 0033e83f..89481bee 100644 --- a/config/config.go +++ b/config/config.go @@ -1003,6 +1003,7 @@ var knownAccountKeys = map[string]bool{ "pop3_server": true, "pop3_port": true, "catch_all": true, + "pass_cmd": true, } func warnUnknownConfigKeys(data []byte) { From 3ac50d5506eadb2980b4a5ffe19c60c7151a31dc Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Tue, 23 Jun 2026 09:46:39 +0000 Subject: [PATCH 3/5] style: fix gofmt alignment in config.go --- config/config.go | 54 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/config/config.go b/config/config.go index 89481bee..c7d52e6c 100644 --- a/config/config.go +++ b/config/config.go @@ -977,33 +977,33 @@ var knownConfigKeys = map[string]bool{ } var knownAccountKeys = map[string]bool{ - "id": true, - "name": true, - "email": true, - "password": true, - "service_provider": true, - "fetch_email": true, - "send_as_email": true, - "imap_server": true, - "imap_port": true, - "smtp_server": true, - "smtp_port": true, - "insecure": true, - "smime_cert": true, - "smime_key": true, - "smime_sign_by_default": true, - "pgp_public_key": true, - "pgp_private_key": true, - "pgp_key_source": true, - "pgp_pin": true, - "pgp_sign_by_default": true, - "auth_method": true, - "protocol": true, - "jmap_endpoint": true, - "pop3_server": true, - "pop3_port": true, - "catch_all": true, - "pass_cmd": true, + "id": true, + "name": true, + "email": true, + "password": true, + "service_provider": true, + "fetch_email": true, + "send_as_email": true, + "imap_server": true, + "imap_port": true, + "smtp_server": true, + "smtp_port": true, + "insecure": true, + "smime_cert": true, + "smime_key": true, + "smime_sign_by_default": true, + "pgp_public_key": true, + "pgp_private_key": true, + "pgp_key_source": true, + "pgp_pin": true, + "pgp_sign_by_default": true, + "auth_method": true, + "protocol": true, + "jmap_endpoint": true, + "pop3_server": true, + "pop3_port": true, + "catch_all": true, + "pass_cmd": true, } func warnUnknownConfigKeys(data []byte) { From ca4ebdb9163799e1af2036c0f2ce3ecc92a21c25 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Wed, 24 Jun 2026 00:57:19 +0000 Subject: [PATCH 4/5] refactor: use struct reflection for known config keys Replace hardcoded knownConfigKeys and knownAccountKeys maps with dynamic extraction from Config and Account struct tags via reflection. This eliminates the maintenance burden of keeping the key lists in sync with the struct definitions. --- config/config.go | 68 ++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/config/config.go b/config/config.go index c7d52e6c..1063a90a 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "sync" @@ -960,53 +961,36 @@ func EnsurePGPDir() error { return os.MkdirAll(pgpDir, 0700) } -var knownConfigKeys = map[string]bool{ - "accounts": true, - "disable_images": true, - "hide_tips": true, - "disable_notifications": true, - "enable_split_pane": true, - "enable_threaded": true, - "enable_detailed_dates": true, - "theme": true, - "mailing_lists": true, - "date_format": true, - "language": true, - "body_cache_threshold_mb": true, - "plugin_settings": true, +var knownConfigKeys map[string]bool +var knownAccountKeys map[string]bool +var knownKeysOnce sync.Once + +func initKnownKeys() { + knownConfigKeys = structJSONKeys(Config{}) + knownAccountKeys = structJSONKeys(Account{}) } -var knownAccountKeys = map[string]bool{ - "id": true, - "name": true, - "email": true, - "password": true, - "service_provider": true, - "fetch_email": true, - "send_as_email": true, - "imap_server": true, - "imap_port": true, - "smtp_server": true, - "smtp_port": true, - "insecure": true, - "smime_cert": true, - "smime_key": true, - "smime_sign_by_default": true, - "pgp_public_key": true, - "pgp_private_key": true, - "pgp_key_source": true, - "pgp_pin": true, - "pgp_sign_by_default": true, - "auth_method": true, - "protocol": true, - "jmap_endpoint": true, - "pop3_server": true, - "pop3_port": true, - "catch_all": true, - "pass_cmd": true, +func structJSONKeys(v any) map[string]bool { + keys := make(map[string]bool) + t := reflect.TypeOf(v) + for i := range t.NumField() { + f := t.Field(i) + tag := f.Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + name = strings.ToLower(f.Name) + } + keys[name] = true + } + return keys } func warnUnknownConfigKeys(data []byte) { + knownKeysOnce.Do(initKnownKeys) + var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { return From 71f1f829b20faf60328898d3b3db07c85dbd98b8 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Thu, 25 Jun 2026 00:02:34 +0000 Subject: [PATCH 5/5] refactor: use diskConfig/rawAccount for key reflection, fix tests --- config/config.go | 119 +++++++++++++++++++++--------------------- config/config_test.go | 54 ++++++++++++++++--- 2 files changed, 108 insertions(+), 65 deletions(-) diff --git a/config/config.go b/config/config.go index 1063a90a..1ef8ee2b 100644 --- a/config/config.go +++ b/config/config.go @@ -659,63 +659,6 @@ func LoadConfig() (*Config, error) { var config Config var needsMigration bool - type rawAccount struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - 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"` - SMTPPort int `json:"smtp_port,omitempty"` - Insecure bool `json:"insecure,omitempty"` - SMTPUsername string `json:"smtp_username,omitempty"` - SMTPPassword string `json:"smtp_password,omitempty"` - SMIMECert string `json:"smime_cert,omitempty"` - SMIMEKey string `json:"smime_key,omitempty"` - SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` - PGPPublicKey string `json:"pgp_public_key,omitempty"` - PGPPrivateKey string `json:"pgp_private_key,omitempty"` - PGPKeySource string `json:"pgp_key_source,omitempty"` - PGPPIN string `json:"pgp_pin,omitempty"` - PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` - AuthMethod string `json:"auth_method,omitempty"` - PassCmd string `json:"pass_cmd,omitempty"` - Protocol string `json:"protocol,omitempty"` - JMAPEndpoint string `json:"jmap_endpoint,omitempty"` - POP3Server string `json:"pop3_server,omitempty"` - POP3Port int `json:"pop3_port,omitempty"` - MaildirPath string `json:"maildir_path,omitempty"` - CatchAll bool `json:"catch_all,omitempty"` - } - type diskConfig struct { - Accounts []rawAccount `json:"accounts"` - DisableImages bool `json:"disable_images,omitempty"` - HideTips bool `json:"hide_tips,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - DisableDaemon bool `json:"disable_daemon,omitempty"` - EnableSplitPane bool `json:"enable_split_pane,omitempty"` - SplitPaneOrientation string `json:"split_pane_orientation,omitempty"` - EnableThreaded bool `json:"enable_threaded,omitempty"` - EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` - DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` - DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` - Theme string `json:"theme,omitempty"` - MailingLists []MailingList `json:"mailing_lists,omitempty"` - DateFormat string `json:"date_format,omitempty"` - Language string `json:"language,omitempty"` - BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` - UndoDelaySeconds int `json:"undo_delay_seconds,omitempty"` - PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` - HasSeenSetupGuide bool `json:"has_seen_setup_guide,omitempty"` - MouseEnabled *bool `json:"mouse_enabled,omitempty"` - ShowOriginalOnReply bool `json:"show_original_on_reply,omitempty"` - ShowCcBccByDefault bool `json:"show_cc_bcc_by_default,omitempty"` - } - var raw diskConfig if err := json.Unmarshal(data, &raw); err != nil { var legacyConfig legacyConfigFormat @@ -961,13 +904,71 @@ func EnsurePGPDir() error { return os.MkdirAll(pgpDir, 0700) } +type rawAccount struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + 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"` + SMTPPort int `json:"smtp_port,omitempty"` + Insecure bool `json:"insecure,omitempty"` + SMTPUsername string `json:"smtp_username,omitempty"` + SMTPPassword string `json:"smtp_password,omitempty"` + SMIMECert string `json:"smime_cert,omitempty"` + SMIMEKey string `json:"smime_key,omitempty"` + SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` + PGPPublicKey string `json:"pgp_public_key,omitempty"` + PGPPrivateKey string `json:"pgp_private_key,omitempty"` + PGPKeySource string `json:"pgp_key_source,omitempty"` + PGPPIN string `json:"pgp_pin,omitempty"` + PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + PassCmd string `json:"pass_cmd,omitempty"` + Protocol string `json:"protocol,omitempty"` + JMAPEndpoint string `json:"jmap_endpoint,omitempty"` + POP3Server string `json:"pop3_server,omitempty"` + POP3Port int `json:"pop3_port,omitempty"` + MaildirPath string `json:"maildir_path,omitempty"` + CatchAll bool `json:"catch_all,omitempty"` +} + +type diskConfig struct { + Accounts []rawAccount `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + DisableDaemon bool `json:"disable_daemon,omitempty"` + EnableSplitPane bool `json:"enable_split_pane,omitempty"` + SplitPaneOrientation string `json:"split_pane_orientation,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` + EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"` + DisableSpellcheck bool `json:"disable_spellcheck,omitempty"` + DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"` + Theme string `json:"theme,omitempty"` + MailingLists []MailingList `json:"mailing_lists,omitempty"` + DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` + BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"` + UndoDelaySeconds int `json:"undo_delay_seconds,omitempty"` + PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"` + HasSeenSetupGuide bool `json:"has_seen_setup_guide,omitempty"` + MouseEnabled *bool `json:"mouse_enabled,omitempty"` + ShowOriginalOnReply bool `json:"show_original_on_reply,omitempty"` + ShowCcBccByDefault bool `json:"show_cc_bcc_by_default,omitempty"` +} + var knownConfigKeys map[string]bool var knownAccountKeys map[string]bool var knownKeysOnce sync.Once func initKnownKeys() { - knownConfigKeys = structJSONKeys(Config{}) - knownAccountKeys = structJSONKeys(Account{}) + knownConfigKeys = structJSONKeys(diskConfig{}) + knownAccountKeys = structJSONKeys(rawAccount{}) } func structJSONKeys(v any) map[string]bool { diff --git a/config/config_test.go b/config/config_test.go index b5589c38..54a4ed81 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2,9 +2,11 @@ package config import ( "encoding/json" + "log" "os" "path/filepath" "reflect" + "strings" "testing" "time" @@ -714,17 +716,57 @@ func TestPassCmd(t *testing.T) { func TestWarnUnknownConfigKeys(t *testing.T) { tests := []struct { - name string - json string + name string + json string + wantsLog []string // substrings we expect in log output }{ - {"no unknown keys", `{"accounts": [{"name": "test", "email": "a@b.com", "service_provider": "gmail"}], "theme": "dark"}`}, - {"empty object", `{}`}, - {"invalid json", `not json`}, - {"nested with unknown", `{"unknown_top": true, "accounts": [{"name": "test", "email": "a@b.com", "unknown_field": true}]}`}, + { + "no unknown keys", + `{"accounts": [{"name": "test", "email": "a@b.com", "service_provider": "gmail"}], "theme": "dark"}`, + nil, + }, + { + "empty object", + `{}`, + nil, + }, + { + "invalid json", + `not json`, + nil, + }, + { + "unknown top-level key", + `{"unknown_top": true}`, + []string{"unknown config key"}, + }, + { + "unknown account key", + `{"accounts": [{"name": "test", "email": "a@b.com", "service_provider": "gmail", "unknown_field": true}]}`, + []string{"unknown config key", "accounts[0]"}, + }, + { + "known keys produce no warnings", + `{"accounts": [{"name": "test", "email": "a@b.com", "service_provider": "gmail", "password": "s3cret"}], "theme": "dark"}`, + nil, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + var buf strings.Builder + log.SetOutput(&buf) warnUnknownConfigKeys([]byte(tc.json)) + log.SetOutput(os.Stderr) + + got := buf.String() + for _, want := range tc.wantsLog { + if !strings.Contains(got, want) { + t.Errorf("expected log to contain %q, got: %s", want, got) + } + } + if len(tc.wantsLog) == 0 && got != "" { + t.Errorf("expected no log output, got: %s", got) + } }) } }