diff --git a/AGENTS.md b/AGENTS.md index f1740fd..06ba8b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -482,6 +482,84 @@ that conversation; "the test was too strict" is not a decision an agent makes al and never mutates the store. Tests: `TestMergeFileGoogleCSVRealExport`, `TestContactsPickerFilterAndSelect`. +## IMAP Keyword Tags + +- **Tags are IMAP keywords; the registry is only the backup** — the message's own + keywords (parsed into `Email.Keywords` — SORTED, server flag order is map-random — + from the FLAGS every `FetchHeaders` already returns, zero extra round trips) are + the source of truth; `/tags/
/` (`internal/tags` — one folder + per account, keyed by its bare lowercased From address, which is a valid folder + name on every supported platform; one line-based Message-ID list per keyword + inside, screener-list style) is the write-through fallback for providers that may + purge unknown keywords, scoped per account so a restore only ever touches the + selected account. Keywords are written lowercase and + compared with `EqualFold` everywhere (servers may normalize the case); the store + is account-scoped (`All(key)` etc.), the key derived via `activeAccountKey()` + (From → User → Name fallback) and captured per-op (`tagOp.registryKey`) so a done + message landing after an account switch still writes the right registry; chips + follow the active account (`setTagAccount` in New and on ctrl+a). + **`[tags] enabled = false` is the global kill-switch** (the feature is opt-in): + disabled, `newTagsModel` returns before wiring the chip pipeline (a nil + `tagKeywordSource` is the no-chips state — same as an unreadable registry) and + `openTags` refuses the picker; per-tag `[tags.] enabled = false` is the + fine-grained hide for single keywords while the feature is ON and can never + override the global off. + Tests: `TestMem_AddRemoveKeyword_RoundTrip`, `TestMem_KeywordCaseInsensitive`, + `TestMem_MovePreservesKeyword`, `TestTagsStore_RoundTripPerAccountFiles`, + `TestTagsStore_AccountsAreIsolated`, `TestTagsStore_EmailAddressKeyVerbatim`, + `TestTagChips_DisabledConfigIsGlobalKillSwitch`. +- **Reserved keywords are blocked and never displayed** — input starting with `$` or + `\` is rejected in the picker with "not allowed to use this keyword, it is reserved + for other systems" (`tags.NormalizeKeyword`), and chips render only for + registry-known keywords (`tagChipKeywords`), so foreign flags like `$HasAttachment` + or Thunderbird's `$label1-5` never chip. Tests: `TestTagsPicker_ReservedKeywordRejected`, + `TestTagChips_OnlyRegistryKeywordsRender`. +- **The picker previews display tags** — a tag with a `display` value renders + a live chip preview right of its keyword in the `k` dialog, through + the identical pill/fallback path and config colours as the inbox row. + Test: `TestTagsPicker_ShowsDisplayPreviewRightOfKeyword`. +- **A tag toggle is optimistic, never a reload** — `k` (configurable via + `[tags] key`) flips `Email.Keywords` at once in `m.emails`, the folder cache snapshot + and the picker's captured targets (`applyTag`/`setKeywordLocal`), then runs the + STOREs via `withConn` (mutating ⇒ never retried); `tagsModel.pending` overlays every fetch + result until `tagsDoneMsg` so a refresh cannot snap chips back, and an error reverts + exactly the ops that never reached the server with the error in the status line — + the exact `n`/`pendingSeen` contract. `imap_disabled` accounts skip the server write + and say "tag saved locally". Tests: `TestTagsPicker_ToggleOptimisticFlip`, + `TestTagsPicker_RefreshLandingMidFlightKeepsChip`, `TestTagsCmd_ErrorReverts`, + `TestTagsPicker_LocalOnlyAccount`. +- **Chips never overflow a row** — chips render BEFORE the subject as the + display text with one space of coloured padding on each side + (` work `), multiple chips separated by one UNcoloured space plus one more + before the subject so they read as separate labels (` work invoice ` — the middle space is the uncoloured + one; `styleTagChip`/`tagRuleSource`, theme + reverse video by default; `TestTagChips_SeparatedByUncoloredSpace`). + Per-tag `[tags.]` config sections override `display` (nerd-font + symbols welcome — display-only, the stored keyword never changes), `fg`/`bg` + (hex or ANSI number; anything else falls back to the `[tags]` defaults with a + startup-notice warning — lipgloss would silently drop the colour) and + `enabled = false` to hide that keyword's chips (display-only; the tag stays on + the server and in the picker). Chip width is subtracted from the subject + budget, degrading to first chip + `…` subject, then to no chips; non-ASCII + runes in a display name are budgeted as 2 cells (font-dependent icon widths + can only leave slack, never overflow); the 8-cell subject floor applies to + chip-less rows only. Chip text bypasses `displaySafe` deliberately. Collapsed + merge rows show no chips. `[tags] nerd_pill = true` switches chips to + nerd-font pills (half-disc end caps U+E0B6/U+E0B4 in the chip's background + colour; `tagPillMode`, default off — needs a nerd font). Padding rule: the + pill adds NONE — spaces inside a `display` are honored verbatim (they ARE + the padding, typed on purpose; invisible stowaways like U+FE0F are always + stripped), while the fallback always normalizes to exactly one standard pad + per side (`TestTagChips_PillPaddingRule`). + Tests: `TestTagChips_WidthAccounting`, + `TestTagChips_OnlyRegistryKeywordsRender`, `TestTagRules_DisplayTextAndHidden`, + `TestTagChips_NerdPillMode`, `TestResolveTagColor`, `TestRowFitsTerminalWidth`. +- **`k` capability probe leaves no trace** — `:keyword-test` / `:kt` + (`KeywordProbe` in `internal/imap/keywords.go`, dialog in `internal/ui/keywordtest.go`) + writes exactly one `NeomdTest` keyword to one message, reports PERMANENTFLAGS/`\*`, + STORE, SEARCH and cleanup per step, and removes the keyword again (keep mode via + `:kt keep` opts out for persistence checks). Tests: `TestKeywordProbe_*`. + ## Reading & Security - **Large mail fetches text first; `Attachment.Data` may be nil** — for a diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6d4fa..aed9aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ - **`make status` on the headless server now says whether screening actually works** — the old target only checked that the process existed, which is how a DNS outage on the server (Tailscale rewrote `resolv.conf`, no global nameserver configured) went unnoticed for 25 days while every one-minute cycle failed with `lookup imap.mail.hostpoint.ch ... server misbehaving` and no mail was screened. `status` (`scripts/headless-server/Makefile`) now prints the last three log lines and a verdict from the most recent cycle result: `OK: last screening cycle succeeded`, or `ERROR: last screening cycle FAILED:` with the error text and `failing since: `, exiting 1. It also exits 1 when the daemon is not running or the log file is missing (renamed/deleted under a running daemon). `make sync-headless` runs it after deploy instead of its own `ps` + `tail -20`. Verified on the FreeBSD server with BSD make against the live log, a synthetic failing log and an empty log; no Go code changed. +# 2026-10-03 + +- **IMAP keyword tagging (`k`)** — emails can be tagged with IMAP keywords (RFC 9051 user flags), the standard server-side tagging mechanism: tags live on the message, survive reinstalls and are visible to other clients (probe evidence: Infomaniak has full support incl. `PERMANENTFLAGS \*`, SEARCH and persistence across sessions; keywords are invisible but preserved in its webmail). `k` opens a picker on the marked set or cursor email (`✓` all targets tagged, `~` some): `enter`/`space` toggle, `a` adds a new tag (lowercased, atom-charset `[a-z0-9][a-z0-9-_.]{0,31}`), `/` filters; tags with a `display` value show a live chip preview to the right of the keyword (same pill/fallback rendering and config colours as the inbox row). Tags render as colour chips BEFORE the subject — the display text with one space of coloured padding on each side (` work `), multiple chips separated by one uncoloured space (plus one more before the subject) so they read as separate labels, with an opt-in nerd-font pill style (`[tags] nerd_pill = true` — half-sphere end caps, no renderer-added padding: spaces in a `display` are honored verbatim and copy-paste stowaways like variation selectors are stripped; the padded-text fallback remains the default and always normalizes to one pad per side), reverse video of the active theme by default and overridable via the `[tags] fg`/`bg` defaults (hex or ANSI number, or `bg = "transparent"` to emit no background so a transparent terminal shows through — pill caps then draw in the fg colour; anything else falls back with a startup-notice warning); per-tag `[tags.]` sections customize `display` (nerd-font symbols welcome — display-only, the stored keyword never changes), `fg`, `bg`, and `enabled = false` hides that tag's chips without touching the server-side tag. Chip width is subtracted from the subject budget (non-ASCII display runes budgeted as 2 cells) so rows never overflow. Reserved keywords (`$`/`\` prefix — `$label1-5`, `$HasAttachment`, …) are rejected at input ("reserved for other systems") and never chip; only registry-known custom tags render. Toggling is optimistic exactly like the `n` seen-toggle: chips flip at once, the STORE runs behind the list via `withConn` (never retried), an error reverts with the status line, and a fetch landing mid-flight cannot snap chips back (`tagsModel.pending` overlay). Every successful toggle writes through to `/tags/
/` — one folder per account (keyed by the account's bare lowercased From address, a valid folder name on every supported platform), one line-based Message-ID list per keyword inside (screener-list style; the per-account split keeps a later restore a per-account operation) — as the purge fallback. `imap_disabled` accounts keep the tag locally ("tag saved locally — account has no IMAP"). Opt-in via `[tags] enabled = true` (off by default) — the global kill-switch: disabled, neither the picker nor any chip renders, and a per-tag `enabled = true` cannot override the global off (per-tag `enabled = false` only hides single tags' chips while the feature is on); `key` (default "k"), `fg`, `bg` configure it further. The `:keyword-test` / `:kt` probe dialog ships with the feature as the per-account capability check. Where: `internal/imap/tags.go` (`AddKeyword`/`RemoveKeyword`), `internal/imap/keywords.go` (probe), `internal/tags/` (registry), `internal/ui/tags.go` (picker, chips), `internal/imap/client.go` (`Email.Keywords` parsed from the existing FLAGS fetch — zero extra round trips), `internal/ui/inbox.go` (chips). Tests: `TestMem_AddRemoveKeyword_RoundTrip`, `TestMem_KeywordCaseInsensitive`, `TestMem_FetchHeaders_ParsesKeywords`, `TestMem_MovePreservesKeyword`, `TestTagsStore_RoundTripPerAccountFiles`, `TestTagsPicker_ToggleOptimisticFlip`, `TestTagsPicker_NewTagLowercased`, `TestTagsPicker_ReservedKeywordRejected`, `TestTagsCmd_ErrorReverts`, `TestTagChips_WidthAccounting`, `TestTagChips_OnlyRegistryKeywordsRender`, `TestTagChips_DisabledConfigIsGlobalKillSwitch`, `TestKeywordProbe_FullRoundTripOnMemserver`. + # 2026-10-02 - **0.10 audit fixes** — a pre-release audit of the instant-IMAP work found these; each is fixed with a pinning test. diff --git a/CLAUDE.md b/CLAUDE.md index 65fafac..6b1f98f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ Folder operations prefer RFC 6851 MOVE; `U` undo uses UIDPLUS destination UIDs c **Screener** (`internal/screener/`) reads line-based lists of email addresses from paths defined in config. Default paths are under `~/.config/neomd/lists/`. Classification (`I`/`O`/`F`/`P`) appends to the corresponding list file and moves the message to the matching folder. Auto-screening runs on Inbox load and on a 5-minute background timer (`ui.bg_sync_interval`). +**Keyword tags** — IMAP keywords are the primary storage (`internal/imap/tags.go` STOREs, parsed into `Email.Keywords` from the FLAGS every FetchHeaders already returns). `/tags//.txt` (`internal/tags/`) is the per-account write-through backup (line-based Message-ID lists). The picker/chips live in `internal/ui/tags.go`: toggles are optimistic exactly like the `n` seen-toggle (`tagsModel.pending` overlay, `withConn` STOREs never retried, error reverts); chips render before the subject with width accounting; `[tags]` + `[tags.]` config drive display, colours (`bg = "transparent"` emits no background) and `nerd_pill`. + **Config** (`internal/config/`) — TOML at `~/.config/neomd/config.toml`, auto-created with placeholders. Supports multiple `[[accounts]]` and SMTP-only `[[senders]]` aliases (cycled with `ctrl+f` in compose/pre-send). OAuth2 authentication supported via `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, `oauth2_scopes` fields. `-config PATH` flag overrides location. **Keyring credentials** (`internal/keyring/`) — accounts may set `password = "keyring"` to fetch the password from the OS keyring (zalando/go-keyring; macOS Keychain, Secret Service on Linux, Credential Manager on Windows). Resolution happens in `config.Load()` so all consumers (IMAP at boot, SMTP at send, `[[senders]]` aliases) see the resolved value. If the keyring entry is missing or the service is unavailable, the `"keyring"` sentinel is preserved and a warning is logged. Service name: `neomd`; key format: `account//password`. diff --git a/README.md b/README.md index ce08d5d..5886d52 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ These features are the one that makes neomd different to other email clients out - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [→](https://neomd.ssp.sh/docs/reading/) - **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped in the terminal view; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) injects a CSP that blocks scripts and frames — remote images are intentionally allowed there since you're explicitly choosing to see the full email [→](https://neomd.ssp.sh/docs/reading/#spy-pixel-blocking) - **GitHub/Obsidian-style callouts** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons [→](https://neomd.ssp.sh/docs/sending/#callouts-admonition) +- **Server-side email tags (IMAP keywords)** — opt-in `[tags] enabled = true`, then `k` on the cursor email or a marked set toggles tags stored as IMAP keywords on the message: they survive reinstalls and other keyword-aware clients see them; tags show as coloured chips before the subject (nerd-font pill style optional), with per-tag display names, colours, and visibility in config; a local per-account registry (`~/.config/neomd/tags/`) backs everything up; `:kt` probes your provider's keyword support [→](https://neomd.ssp.sh/docs/configuration/#email-tags-imap-keywords) - **Listmonk newsletter integration** — compose an email to a virtual address (e.g. `listmonk@ssp.sh`) and neomd creates a scheduled campaign in [Listmonk](https://listmonk.app) via API instead of sending via SMTP; configure multiple trigger addresses to target different subscriber lists; pre-send screen shows campaign details [→](https://neomd.ssp.sh/docs/integrations/listmonk/) ### Inbox & Screener diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index 12cdf90..5496577 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -143,6 +143,7 @@ These features are the one that makes neomd different to other email clients out - **Glamour reading** — incoming emails rendered as styled Markdown in the terminal [→](https://neomd.ssp.sh/docs/reading/) - **Spy pixel blocking** — tracking pixels from newsletter services (Mailchimp, SendGrid, HubSpot, etc.) are automatically detected, counted, and stripped in the terminal view; `°` indicator in the inbox and tracker domains in the reader header; browser view (`O`) injects a CSP that blocks scripts and frames — remote images are intentionally allowed there since you're explicitly choosing to see the full email [→](https://neomd.ssp.sh/docs/reading/#spy-pixel-blocking) - **GitHub/Obsidian-style callouts** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons [→](https://neomd.ssp.sh/docs/sending/#callouts-admonition) +- **Server-side email tags (IMAP keywords)** — opt-in `[tags] enabled = true`, then `k` on the cursor email or a marked set toggles tags stored as IMAP keywords on the message: they survive reinstalls and other keyword-aware clients see them; tags show as coloured chips before the subject (nerd-font pill style optional), with per-tag display names, colours, and visibility in config; a local per-account registry (`~/.config/neomd/tags/`) backs everything up; `:kt` probes your provider's keyword support [→](https://neomd.ssp.sh/docs/configuration/#email-tags-imap-keywords) - **Listmonk newsletter integration** — compose an email to a virtual address (e.g. `listmonk@ssp.sh`) and neomd creates a scheduled campaign in [Listmonk](https://listmonk.app) via API instead of sending via SMTP; configure multiple trigger addresses to target different subscriber lists; pre-send screen shows campaign details [→](https://neomd.ssp.sh/docs/integrations/listmonk/) ### Inbox & Screener diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index 8241d2d..3c63c92 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -103,6 +103,20 @@ Your Title, Your Company Connect: [LinkedIn](https://example.com/) *sent from [neomd](https://neomd.ssp.sh)*""" + +[tags] +enabled = false # opt-in master switch: false = feature completely off (no picker, no chips) +key = "k" # leader chord key that opens the picker +nerd_pill = false # render chips as nerd-font pills (half-sphere caps); needs a nerd font +fg = "" # default chip text colour ("#RRGGBB" or ANSI number; empty = theme reverse video) +bg = "" # default chip background (or "transparent"/"none" for no background at all) + +# Optional per-tag overrides — display only, the stored IMAP keyword never changes: +# [tags.important] +# enabled = true # false hides this tag's chips while the feature is on; never overrides the global switch +# display = " Important" # chip text; nerd-font symbols welcome (pill mode honors typed spaces) +# fg = "#FFFF00" # hex or ANSI number +# bg = "#8B0000" # or "transparent" ``` @@ -535,6 +549,20 @@ The scopes required depends on the provider and is better confirmed by your emai - For GMAIL, follow the documentation [here](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol) +## Email Tags (IMAP Keywords) + +Opt-in (`[tags] enabled = true`) server-side tagging: `k` on the cursor email or a marked set opens a picker that toggles IMAP keywords — the RFC 9051 tagging mechanism, so tags live on the message, survive reinstalls, and are seen by other clients that support keywords. Tags render as coloured chips **before** the subject (` work ` by default, or nerd-font pills with `nerd_pill = true`); per-tag `[tags.]` sections override the display text (nerd-font symbols welcome), colours, and visibility. + +`enabled = false` (the default) is the global kill-switch: neither the picker nor any chip renders. The per-tag `enabled = false` is the fine-grained form — it hides that one tag's chips while the feature is on — and can never override the global switch. + +Every toggle writes through to a local per-account registry at `~/.config/neomd/tags//.txt` (one Message-ID per line) — the backup if a provider ever purges unknown keywords. Keywords are lowercased and compared case-insensitively; keywords starting with `$` or `\` belong to other systems and are never offered or rendered. + +Provider support varies — run `:kt` (`:keyword-test`) on an email to probe your server: it stores a test keyword, verifies it via FETCH and SEARCH, and removes it again, with a verdict per step. + +{{< callout type="info" >}} +Verified with full support on Infomaniak (`PERMANENTFLAGS \*`, STORE + SEARCH, persistence across sessions; keywords are invisible but preserved in their webmail). Third-party keywords like `$HasAttachment` may already live on your messages — neomd never touches or displays them. +{{< /callout >}} + ## Dedicated Platforms diff --git a/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index adb4587..a898650 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -103,6 +103,7 @@ To update both the help overlay and this document at once, edit that file and ru | `/` | IMAP search ALL emails on server (From + Subject) | | `c` | contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy "Name ", enter compose to contact | | `S` | scan current folder for spy pixels (skips already scanned) | +| `k` | tag picker — toggle IMAP keyword tags on marked/cursor email(s) (opt-in: [tags] enabled = true); a new tag, / filter, chips show before the subject | | `h (reader)` | show key mail headers (From, Reply-To, List-Id, List-Unsubscribe, Return-Path, spam verdicts, …) — press again for all headers, again to return to the email | | `u (reader)` | unsubscribe — List-Unsubscribe https link opens in $BROWSER, mailto: prefills a compose, else first body link mentioning "unsubscribe" | | `d (reader)` | download raw email source (.eml) to ~/Downloads | @@ -177,6 +178,7 @@ To update both the help overlay and this document at once, edit that file and ru | `:mark-read / :mr` | mark all emails in current folder as read | | `:reload / :r` | reload current folder | | `:check / :ch` | show screener classification for selected email | +| `:keyword-test / :kt` | IMAP keyword (tag) support probe on the selected email — save/retrieve/cleanup round trip; ":keyword-test keep" leaves the keyword on the message | | `:thread / :t` | show full conversation for the selected email (across folders) | | `:scan-spy-pixels / :ssp` | scan current folder for tracking pixels (background, skips already scanned) | | `:recover / :rec` | reopen the most recent compose backup from ~/.cache/neomd/drafts/ | diff --git a/internal/config/config.go b/internal/config/config.go index 5ad8da0..fb0b905 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -310,6 +310,119 @@ func (u UIConfig) AutoScreen() bool { return *u.AutoScreenOnLoad } +// TagsConfig holds the IMAP keyword tagging feature ([tags] in config.toml). +// Tags are stored as IMAP keywords on the message (server-side, visible to +// other clients); /tags/ is the local write-through registry. +type TagsConfig struct { + // Enabled is the master switch — opt-in: the feature is OFF until the + // config says `enabled = true`. false (or an absent [tags] section) + // leaves the binding inert AND renders no chips at all (a global + // kill-switch that per-tag [tags.] enabled values cannot + // override — those only hide single keywords while the feature is on). + Enabled bool `toml:"enabled"` + // Key is the leader-chord key that opens the tag picker (default "k", + // i.e. k). Matched in handleChord against the chord key. + Key string `toml:"key"` + // NerdPill renders chips as nerd-font "pills" — half-sphere end caps + // (U+E0B6/U+E0B4) coloured like the chip — instead of the padded-text + // fallback. Needs a nerd font in the terminal; default false. + NerdPill bool `toml:"nerd_pill"` + // FG/BG are the default chip colours — the fallback for every tag + // without its own [tags.] section. Values: hex ("#1F1F28"), an + // ANSI number ("21"), or "transparent"/"none" for bg (emits no + // background, so a transparent terminal shows through; pill caps then + // draw in the fg colour). Empty = reverse video of the active theme + // (Background=theme Text, Foreground=theme Bg); anything else falls back + // with a startup warning. The built-in palettes are never touched. + FG string `toml:"fg"` + BG string `toml:"bg"` + // Rules holds the per-tag [tags.] sections (display name, + // colours, visibility). They are sub-tables of [tags], invisible to the + // struct decode, so Load() fills this map with a second raw pass — not a + // TOML field of its own. + Rules map[string]TagRule `toml:"-"` +} + +// TagRule is one per-tag override, written as [tags.] in +// config.toml. It only affects how that tag's chip DISPLAYS — the keyword +// stored on the server is never renamed or removed by it. +type TagRule struct { + // Enabled controls the chip's visibility: false hides every chip for + // this keyword (the tag itself stays on the server and in the picker, + // it just stops rendering); true (or omitted) shows it. + Enabled *bool `toml:"enabled"` + // Display is the chip text. May contain nerd-font symbols and spaces — + // it is display-only, the keyword saved on the server is unchanged. + // Empty = the keyword itself. + Display string `toml:"display"` + // FG/BG override the colours for this tag; empty fields fall back to the + // [tags] fg/bg defaults, then to the theme's reverse video. Same value + // formats as [tags] fg/bg (hex, ANSI number, bg "transparent"). + FG string `toml:"fg"` + BG string `toml:"bg"` +} + +// Shown reports whether the tag's chips render (default true — writing a +// per-tag section customizes, only enabled = false hides). +func (r TagRule) Shown() bool { return r.Enabled == nil || *r.Enabled } + +// Rule returns the per-tag section for keyword (case-insensitive, IMAP +// keywords compare case-insensitively). +func (t TagsConfig) Rule(keyword string) (TagRule, bool) { + if t.Rules == nil || keyword == "" { + return TagRule{}, false + } + for name, rule := range t.Rules { + if strings.EqualFold(name, keyword) { + return rule, true + } + } + return TagRule{}, false +} + +// TagsKey returns the picker's leader-chord key (default "k"). +func (t TagsConfig) TagsKey() string { + if t.Key == "" { + return "k" + } + return t.Key +} + +// parseTagRules extracts the [tags.] sub-tables from config.toml. +// The regular struct decode cannot see them (they would need one struct +// field per tag), so this walks the raw TOML tree: every key under [tags] +// whose value is a table becomes a TagRule, keyed lowercased. Scalar keys +// (enabled, key, fg, bg of [tags] itself) are skipped by the is-table check. +func parseTagRules(path string) map[string]TagRule { + raw := map[string]any{} + if _, err := toml.DecodeFile(path, &raw); err != nil { + return nil + } + tags, ok := raw["tags"].(map[string]any) + if !ok { + return nil + } + var rules map[string]TagRule + for name, val := range tags { + tbl, ok := val.(map[string]any) + if !ok { + continue // [tags] own scalar settings + } + if rules == nil { + rules = make(map[string]TagRule) + } + r := TagRule{} + if b, ok := tbl["enabled"].(bool); ok { + r.Enabled = &b + } + r.Display, _ = tbl["display"].(string) + r.FG, _ = tbl["fg"].(string) + r.BG, _ = tbl["bg"].(string) + rules[strings.ToLower(name)] = r + } + return rules +} + // Resolved returns a copy with sensible fallbacks filled in for any field // the user enabled-but-left-blank. Safe to call when Enabled is false. func (n NotificationsConfig) Resolved() NotificationsConfig { @@ -382,6 +495,9 @@ type Config struct { Listmonk ListmonkConfig `toml:"listmonk"` + // Tags configures IMAP keyword tagging ([tags] in config.toml). + Tags TagsConfig `toml:"tags"` + // OOO configures out-of-office auto-replies, processed by the headless // daemon (--headless) only. Replies go solely to screened-in senders, // once per sender per OOO period. @@ -396,6 +512,12 @@ type Config struct { // MergesFile is /merges.toml — user-defined merged threads // (see internal/merge). Set during Load(), not a TOML field. MergesFile string `toml:"-"` + // TagsDir is /tags/ — the local registry of IMAP keyword + // tags (see internal/tags): one folder per account, one line-based file + // per keyword (the write-through backup of server-side tags; the split + // keeps a later restore a per-account operation). Set during Load(), + // not a TOML field. + TagsDir string `toml:"-"` } // OOOConfig holds out-of-office auto-reply settings ([ooo] in config.toml). @@ -660,6 +782,8 @@ func Load(path string) (*Config, error) { // exists (single syncable file; the daemon also re-reads it every pass). cfg.OOOFile = filepath.Join(filepath.Dir(path), "ooo.toml") cfg.MergesFile = filepath.Join(filepath.Dir(path), "merges.toml") + cfg.TagsDir = filepath.Join(filepath.Dir(path), "tags") + cfg.Tags.Rules = parseTagRules(path) if override, err := LoadOOOOverride(cfg.OOOFile); err != nil { return nil, err } else if override != nil { @@ -817,6 +941,13 @@ func defaults() *Config { MarkAsReadAfterSecs: 7, Signature: "*sent from [neomd](https://neomd.ssp.sh)*", }, + Tags: TagsConfig{ + // Opt-in feature: off until the user sets `enabled = true`. + // Written into the generated default config so the option is + // discoverable. + Enabled: false, + Key: "k", // k opens the tag picker once enabled + }, AI: AIConfig{ // Default: hand off to Claude Code in **interactive** mode (not // `-p` print mode, which would bill against Anthropic API credits diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9c21451..199b975 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -686,3 +686,137 @@ func TestUIConfig_InstantSwitchDefaultsTrue(t *testing.T) { t.Error("explicit false must disable") } } + +func TestLoad_SetsTagsDirNextToConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "neomd", "config.toml") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + minimal := "[[accounts]]\nname = \"t\"\nimap_host = \"imap.example.com:993\"\nsmtp_host = \"smtp.example.com:465\"\nuser = \"u@example.com\"\npassword = \"pw\"\nfrom = \"u@example.com\"\n" + if err := os.WriteFile(path, []byte(minimal), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := filepath.Join(dir, "neomd", "tags") + if cfg.TagsDir != want { + t.Errorf("TagsDir = %q, want %q", cfg.TagsDir, want) + } + // A config without [tags] keeps tagging disabled (opt-in) with the default key. + if cfg.Tags.Enabled { + t.Error("absent [tags] section must leave tagging disabled (opt-in)") + } + if cfg.Tags.TagsKey() != "k" { + t.Errorf("TagsKey = %q, want k", cfg.Tags.TagsKey()) + } +} + +func TestTagsConfig_EnabledOptIn(t *testing.T) { + if (TagsConfig{}).Enabled { + t.Error("zero-value TagsConfig must be disabled — tagging is opt-in") + } + tc := TagsConfig{Enabled: true, Key: "t"} + if !tc.Enabled { + t.Error("explicit enabled=true must enable") + } + if tc.TagsKey() != "t" { + t.Errorf("TagsKey = %q, want t", tc.TagsKey()) + } + if (TagsConfig{}).TagsKey() != "k" { + t.Error("zero-value TagsConfig must default to key k") + } +} + +func TestLoad_ParsesPerTagRules(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + conf := `[[accounts]] +name = "t" +imap_host = "imap.example.com:993" +smtp_host = "smtp.example.com:465" +user = "u@example.com" +password = "pw" +from = "u@example.com" + +[tags] +enabled = true +key = "t" + +[tags.important] +enabled = true +display = " Important" +fg = "#FFFF00" +bg = "#8B0000" + +[tags.secret] +enabled = false + +[tags.plain] +display = "Just Text" +` + if err := os.WriteFile(path, []byte(conf), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Tags.Enabled || cfg.Tags.TagsKey() != "t" { + t.Errorf("[tags] scalars misparsed: %+v", cfg.Tags) + } + r, ok := cfg.Tags.Rule("important") + if !ok { + t.Fatal("[tags.important] not extracted") + } + if !r.Shown() || r.Display != " Important" || r.FG != "#FFFF00" || r.BG != "#8B0000" { + t.Errorf("important rule = %+v", r) + } + // Case-insensitive lookup (IMAP keywords compare case-insensitively). + if r, ok := cfg.Tags.Rule("IMPORTANT"); !ok || !r.Shown() { + t.Errorf("Rule(IMPORTANT) = %+v,%v", r, ok) + } + r, ok = cfg.Tags.Rule("secret") + if !ok || r.Shown() { + t.Errorf("secret rule = %+v,%v — enabled=false must hide", r, ok) + } + // A rule without the enabled key defaults to shown. + if r, ok = cfg.Tags.Rule("plain"); !ok || !r.Shown() || r.Display != "Just Text" { + t.Errorf("plain rule = %+v,%v", r, ok) + } +} + +func TestTagsConfig_NerdPill(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + conf := `[[accounts]] +name = "t" +imap_host = "imap.example.com:993" +smtp_host = "smtp.example.com:465" +user = "u@example.com" +password = "pw" +from = "u@example.com" + +[tags] +enabled = true +nerd_pill = true +` + if err := os.WriteFile(path, []byte(conf), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if !cfg.Tags.NerdPill { + t.Error("nerd_pill = true must parse") + } + if (TagsConfig{}).NerdPill { + t.Error("nerd_pill must default to false (padded-text fallback)") + } +} diff --git a/internal/imap/client.go b/internal/imap/client.go index f179434..d472351 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -62,6 +62,12 @@ type Email struct { InReplyTo string // first In-Reply-To message ID (for threading) References string // References header (space-separated Message-IDs for threading) SendAt time.Time // parsed X-Neomd-Send-At — non-zero only for send-later queued messages + // Keywords are the message's IMAP keywords (flags without the backslash + // prefix — system flags \Seen etc. live in the bools above). Server-side + // tags ride here at zero extra protocol cost: FLAGS arrive in every + // FetchHeaders call already. Comparisons are case-insensitive (some + // servers normalize the case); neomd writes lowercase. + Keywords []string // BodyStructure is the BODYSTRUCTURE already fetched with the headers; it // lets FetchBodyOf skip attachments on large mail without another round trip. BodyStructure imap.BodyStructure `json:"-"` @@ -509,7 +515,11 @@ func (c *Client) fetchHeadersWindow(ctx context.Context, folder string, n int, e if f == imap.FlagFlagged { e.Flagged = true } + if !strings.HasPrefix(string(f), "\\") { + e.Keywords = append(e.Keywords, string(f)) + } } + sort.Strings(e.Keywords) // server flag order is map-random; be deterministic if m.Envelope != nil { e.Subject = m.Envelope.Subject e.Date = m.Envelope.Date @@ -1032,7 +1042,11 @@ func (c *Client) FetchHeadersByUID(ctx context.Context, folder string, uids []ui if f == imap.FlagFlagged { e.Flagged = true } + if !strings.HasPrefix(string(f), "\\") { + e.Keywords = append(e.Keywords, string(f)) + } } + sort.Strings(e.Keywords) // server flag order is map-random; be deterministic if m.Envelope != nil { e.Subject = m.Envelope.Subject e.Date = m.Envelope.Date diff --git a/internal/imap/keywords.go b/internal/imap/keywords.go new file mode 100644 index 0000000..9c57374 --- /dev/null +++ b/internal/imap/keywords.go @@ -0,0 +1,226 @@ +package imap + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// KeywordProbeStep is the outcome of one IMAP round trip inside KeywordProbe. +type KeywordProbeStep struct { + Name string + Detail string // result detail (current flags, UIDs found, …) + Err string // raw error, empty when the step succeeded + OK bool +} + +// KeywordProbeResult reports everything KeywordProbe learned about a server's +// support for arbitrary message keywords (RFC 9051 §7.1.2, PERMANENTFLAGS \*). +type KeywordProbeResult struct { + Folder string + UID uint32 + Keyword string + + PermanentFlags []string + WildcardAllowed bool // \* in PERMANENTFLAGS — server advertises new keywords + MailboxKeywords []string // non-system flags in use in the mailbox + BaselineFlags []string // message flags before the probe + + Steps []KeywordProbeStep + + StoreSupported bool // STORE +FLAGS succeeded and a following FETCH saw the keyword + SearchSupported bool // UID SEARCH KEYWORD returned the probed message + CleanupOK bool // keyword absent after the cleanup STORE + Kept bool // keep mode: cleanup skipped, keyword left on the message + Err string +} + +// KeywordProbe runs a save/retrieve/cleanup round trip for one arbitrary +// keyword on one message to determine whether the server supports +// user-defined keywords. It SELECTs the mailbox (capturing PERMANENTFLAGS), +// FETCHes the message's baseline flags, STOREs the keyword, verifies it via +// FETCH and UID SEARCH, then removes it again — the message ends with its +// original flags. With keep=true the final cleanup is skipped and the keyword +// stays on the message (to verify persistence across sessions; a later normal +// run removes it). Every step is reported separately and a failing step does +// not abort the later ones (cleanup always runs in non-keep mode). Only a +// failed SELECT or connection error aborts; that error lands in Result.Err. +func (c *Client) KeywordProbe(ctx context.Context, folder string, uid uint32, keyword string, keep bool) *KeywordProbeResult { + if ctx == nil { + ctx = context.Background() + } + defer trace(time.Now(), "KeywordProbe %s uid=%d keyword=%s", folder, uid, keyword) + res := &KeywordProbeResult{Folder: folder, UID: uid, Keyword: keyword} + kw := imap.Flag(keyword) + err := c.withConn(ctx, func(conn *imapclient.Client) error { + // Always a fresh SELECT: the response carries the PERMANENTFLAGS this + // probe exists to inspect, even when the mailbox is already selected. + data, err := conn.Select(folder, nil).Wait() + if err != nil { + c.selectedMailbox = "" // a failed SELECT leaves no mailbox selected + return fmt.Errorf("SELECT %q: %w", folder, err) + } + c.selectedMailbox = folder + res.PermanentFlags = flagsToStrings(data.PermanentFlags) + for _, f := range data.PermanentFlags { + if f == imap.FlagWildcard { + res.WildcardAllowed = true + } + } + res.MailboxKeywords = keywordFlags(data.Flags) + res.Steps = append(res.Steps, KeywordProbeStep{ + Name: "SELECT — PERMANENTFLAGS", + OK: true, + Detail: "permanent: [" + strings.Join(res.PermanentFlags, " ") + "] keywords in use: [" + strings.Join(res.MailboxKeywords, " ") + "]", + }) + + step := func(name string, err error, ok bool, detail string) { + s := KeywordProbeStep{Name: name, OK: ok, Detail: detail} + if err != nil { + s.Err = err.Error() + } + res.Steps = append(res.Steps, s) + } + + baseline, found, err := fetchMessageFlags(conn, uid) + switch { + case err != nil: + step("FETCH — baseline flags", err, false, "") + case !found: + step("FETCH — baseline flags", nil, false, "message not returned by FETCH (UID moved or vanished?)") + default: + res.BaselineFlags = flagsToStrings(baseline) + step("FETCH — baseline flags", nil, true, "current: ["+strings.Join(res.BaselineFlags, " ")+"]") + } + + var uidSet imap.UIDSet + uidSet.AddNum(imap.UID(uid)) + storeErr := conn.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsAdd, + Flags: []imap.Flag{kw}, + }, nil).Close() + step("STORE +FLAGS "+keyword, storeErr, storeErr == nil, "") + + afterAdd, found, err := fetchMessageFlags(conn, uid) + stored := keywordCase(afterAdd, kw) + hasKw := err == nil && found && stored != "" + res.StoreSupported = storeErr == nil && hasKw + switch { + case err != nil: + step("FETCH — keyword set?", err, false, "") + case !found: + step("FETCH — keyword set?", nil, false, "message not returned by FETCH") + case hasKw && stored != keyword: + step("FETCH — keyword set?", nil, true, "keyword present, stored as \""+stored+"\" (server normalized the case)") + case hasKw: + step("FETCH — keyword set?", nil, true, "keyword present in flags") + default: + step("FETCH — keyword set?", nil, false, "STORE reported success but the keyword is not in the flags") + } + + detail := "" + searchData, err := conn.UIDSearch(&imap.SearchCriteria{Flag: []imap.Flag{kw}}, nil).Wait() + if err == nil { + if us, ok := searchData.All.(imap.UIDSet); ok { + if nums, _ := us.Nums(); len(nums) > 0 { + found = true + strs := make([]string, len(nums)) + for i, n := range nums { + strs[i] = fmt.Sprintf("%d", n) + } + detail = "found UID " + strings.Join(strs, ",") + } + } + if !found { + detail = "no messages carry the keyword" + } + } + res.SearchSupported = found + step("UID SEARCH KEYWORD "+keyword, err, err == nil && found, detail) + + if keep { + res.Kept = true + step("STORE -FLAGS "+keyword+" (cleanup skipped)", nil, true, "keep mode — keyword left on the message; re-run the test (r) to remove it") + return nil + } + + cleanErr := conn.Store(uidSet, &imap.StoreFlags{ + Op: imap.StoreFlagsDel, + Flags: []imap.Flag{kw}, + }, nil).Close() + step("STORE -FLAGS "+keyword+" (cleanup)", cleanErr, cleanErr == nil, "") + + afterDel, found, err := fetchMessageFlags(conn, uid) + gone := err == nil && found && keywordCase(afterDel, kw) == "" + res.CleanupOK = gone + switch { + case err != nil: + step("FETCH — keyword gone?", err, false, "") + case !found: + step("FETCH — keyword gone?", nil, false, "message not returned by FETCH") + case gone: + step("FETCH — keyword gone?", nil, true, "message back to its original flags") + default: + step("FETCH — keyword gone?", nil, false, "keyword still present after cleanup") + } + return nil + }) + if err != nil && res.Err == "" { + res.Err = err.Error() + } + return res +} + +// fetchMessageFlags UID-FETCHes the flags of one message. found is false when +// the server did not return that UID (moved or vanished); flags is then nil. +// A returned message with no flags yields found with an empty list. +func fetchMessageFlags(conn *imapclient.Client, uid uint32) (flags []imap.Flag, found bool, err error) { + var set imap.UIDSet + set.AddNum(imap.UID(uid)) + msgs, err := conn.Fetch(set, &imap.FetchOptions{UID: true, Flags: true}).Collect() + if err != nil { + return nil, false, err + } + for _, m := range msgs { + if uint32(m.UID) == uid { + return m.Flags, true, nil + } + } + return nil, false, nil +} + +// keywordCase returns the spelling under which the server stored the keyword, +// or "" when it is absent. IMAP flags compare case-insensitively (RFC 9051 +// §2.6) and some servers normalize the case (the in-memory server lowercases). +func keywordCase(flags []imap.Flag, kw imap.Flag) string { + for _, x := range flags { + if strings.EqualFold(string(x), string(kw)) { + return string(x) + } + } + return "" +} + +func flagsToStrings(flags []imap.Flag) []string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = string(f) + } + return out +} + +// keywordFlags extracts the keywords (non-system flags — everything without +// the backslash prefix, e.g. "ProjectX" or "$Label1") from a flag list. +func keywordFlags(flags []imap.Flag) []string { + var out []string + for _, f := range flags { + if !strings.HasPrefix(string(f), "\\") { + out = append(out, string(f)) + } + } + return out +} diff --git a/internal/imap/keywords_test.go b/internal/imap/keywords_test.go new file mode 100644 index 0000000..4d5c763 --- /dev/null +++ b/internal/imap/keywords_test.go @@ -0,0 +1,137 @@ +package imap + +import ( + "context" + "strings" + "testing" + + goimap "github.com/emersion/go-imap/v2" +) + +func TestKeywordProbe_FullRoundTripOnMemserver(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "kw-probe", true) + + res := cli.KeywordProbe(context.Background(), "INBOX", 1, "NeomdTest", false) + + // The in-memory server advertises \* and stores arbitrary flags, so the + // full save → search → cleanup round trip must pass. + if res.Err != "" { + t.Fatalf("probe error: %s", res.Err) + } + if !res.WildcardAllowed { + t.Errorf("WildcardAllowed = false; memserver advertises \\* (permanent: %v)", res.PermanentFlags) + } + if !res.StoreSupported { + t.Errorf("StoreSupported = false; steps: %+v", res.Steps) + } + if !res.SearchSupported { + t.Errorf("SearchSupported = false; steps: %+v", res.Steps) + } + if !res.CleanupOK { + t.Errorf("CleanupOK = false; steps: %+v", res.Steps) + } + if len(res.Steps) != 7 { + t.Errorf("len(Steps) = %d, want 7", len(res.Steps)) + } + for i, s := range res.Steps { + if !s.OK { + t.Errorf("step %d (%s) failed: %s %s", i, s.Name, s.Err, s.Detail) + } + } + // The seeded message was \Seen; the probe must leave exactly that behind. + if len(res.BaselineFlags) != 1 || res.BaselineFlags[0] != string(goimap.FlagSeen) { + t.Errorf("BaselineFlags = %v, want [\\Seen]", res.BaselineFlags) + } +} + +func TestKeywordProbe_LeavesOriginalFlagsUntouched(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "kw-keep", false) + + res := cli.KeywordProbe(context.Background(), "INBOX", 1, "NeomdTest", false) + if !res.CleanupOK { + t.Fatalf("cleanup failed: %+v", res.Steps) + } + flags, err := fetchMessageFlagsOnClient(cli, "INBOX", 1) + if err != nil { + t.Fatal(err) + } + if len(flags) != 0 { + t.Errorf("flags after probe = %v, want none (message was seeded without flags)", flags) + } +} + +func TestKeywordProbe_MissingMessageReportsGracefully(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "only-one", true) + + // UID 99 does not exist; every step must still run and report, not panic. + res := cli.KeywordProbe(context.Background(), "INBOX", 99, "NeomdTest", false) + if res.Err != "" { + t.Fatalf("unexpected fatal error: %s", res.Err) + } + if len(res.Steps) != 7 { + t.Fatalf("len(Steps) = %d, want 7 (steps must continue past failures)", len(res.Steps)) + } + if res.StoreSupported || res.SearchSupported || res.CleanupOK { + t.Errorf("unsupported message reported as supported: %+v", res) + } + if res.Steps[1].OK || res.Steps[1].Detail == "" { + t.Errorf("baseline step should report the missing message: %+v", res.Steps[1]) + } +} + +func TestKeywordProbe_KeepLeavesKeywordForNextRun(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "kw-keepmode", true) + + kept := cli.KeywordProbe(context.Background(), "INBOX", 1, "NeomdTest", true) + if !kept.Kept || kept.CleanupOK { + t.Fatalf("keep mode: Kept=%v CleanupOK=%v; steps: %+v", kept.Kept, kept.CleanupOK, kept.Steps) + } + if len(kept.Steps) != 6 { + t.Fatalf("keep mode len(Steps) = %d, want 6 (no cleanup steps)", len(kept.Steps)) + } + + // A later normal run must see the kept keyword in the server-side + // baseline flags and remove it again. + again := cli.KeywordProbe(context.Background(), "INBOX", 1, "NeomdTest", false) + foundKept := false + for _, f := range again.BaselineFlags { + if strings.EqualFold(f, "NeomdTest") { + foundKept = true + } + } + if !foundKept { + t.Errorf("normal run did not see the kept keyword in baseline flags: %v", again.BaselineFlags) + } + if !again.CleanupOK { + t.Errorf("normal run did not clean up the kept keyword: %+v", again.Steps) + } +} + +// fetchMessageFlagsOnClient re-selects and FETCHes via the public path so the +// test observes the true post-probe server state, not a probe-internal view. +func fetchMessageFlagsOnClient(cli *Client, folder string, uid uint32) ([]string, error) { + emails, err := cli.FetchHeaders(context.Background(), folder, 0) + if err != nil { + return nil, err + } + for _, e := range emails { + if e.UID == uid { + var out []string + if e.Seen { + out = append(out, "\\Seen") + } + if e.Answered { + out = append(out, "\\Answered") + } + if e.Flagged { + out = append(out, "\\Flagged") + } + return out, nil + } + } + return nil, nil +} diff --git a/internal/imap/tags.go b/internal/imap/tags.go new file mode 100644 index 0000000..738b80d --- /dev/null +++ b/internal/imap/tags.go @@ -0,0 +1,46 @@ +package imap + +import ( + "context" + "strings" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +// AddKeyword STOREs keyword onto the message (RFC 9051 §2.3 — flags without a +// backslash prefix are user keywords; servers that advertise PERMANENTFLAGS +// \* accept arbitrary ones). The keyword is normalized to lowercase: IMAP +// flags compare case-insensitively and some servers normalize the case on +// write, so one canonical spelling keeps the local mirror honest. +// +// Mutating op ⇒ withConn, never retried (a retry could re-add a keyword the +// user just removed). +func (c *Client) AddKeyword(ctx context.Context, folder string, uid uint32, keyword string) error { + return c.storeKeyword(ctx, folder, uid, keyword, imap.StoreFlagsAdd) +} + +// RemoveKeyword removes a user keyword from the message. Mutating op ⇒ withConn. +func (c *Client) RemoveKeyword(ctx context.Context, folder string, uid uint32, keyword string) error { + return c.storeKeyword(ctx, folder, uid, keyword, imap.StoreFlagsDel) +} + +func (c *Client) storeKeyword(ctx context.Context, folder string, uid uint32, keyword string, op imap.StoreFlagsOp) error { + if ctx == nil { + ctx = context.Background() + } + kw := strings.ToLower(strings.TrimSpace(keyword)) + defer trace(time.Now(), "StoreKeyword %s uid=%d keyword=%s add=%v", folder, uid, kw, op == imap.StoreFlagsAdd) + return c.withConn(ctx, func(conn *imapclient.Client) error { + if err := c.selectMailbox(folder); err != nil { + return err + } + var uidSet imap.UIDSet + uidSet.AddNum(imap.UID(uid)) + return conn.Store(uidSet, &imap.StoreFlags{ + Op: op, + Flags: []imap.Flag{imap.Flag(kw)}, + }, nil).Close() + }) +} diff --git a/internal/imap/tags_test.go b/internal/imap/tags_test.go new file mode 100644 index 0000000..79be3f8 --- /dev/null +++ b/internal/imap/tags_test.go @@ -0,0 +1,165 @@ +package imap + +// Keyword-tagging feature tests against the in-memory IMAP server: STORE +// add/remove round trip, case-insensitivity, FETCH parsing and MOVE keeping +// the keyword. No network (see memserver_test.go for the TLS setup). + +import ( + "context" + "fmt" + "testing" +) + +func keywordsOfUID(t *testing.T, cli *Client, folder string, uid uint32) []string { + t.Helper() + emails, err := cli.FetchHeaders(context.Background(), folder, 0) + if err != nil { + t.Fatal(err) + } + for _, e := range emails { + if e.UID == uid { + return e.Keywords + } + } + return nil +} + +func TestMem_AddRemoveKeyword_RoundTrip(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "tags-rt", false) + + if err := cli.AddKeyword(context.Background(), "INBOX", 1, "work"); err != nil { + t.Fatalf("AddKeyword: %v", err) + } + got := keywordsOfUID(t, cli, "INBOX", 1) + if len(got) != 1 || got[0] != "work" { + t.Fatalf("keywords after add = %v, want [work]", got) + } + // System flags must not leak into Keywords. + seedMessage(t, user, "INBOX", "tags-seen", true) + if err := cli.MarkSeen(context.Background(), "INBOX", 2); err != nil { + t.Fatal(err) + } + if got := keywordsOfUID(t, cli, "INBOX", 2); len(got) != 0 { + t.Errorf("seen-only message reports keywords %v, want none", got) + } + + if err := cli.RemoveKeyword(context.Background(), "INBOX", 1, "work"); err != nil { + t.Fatalf("RemoveKeyword: %v", err) + } + if got := keywordsOfUID(t, cli, "INBOX", 1); len(got) != 0 { + t.Errorf("keywords after remove = %v, want none", got) + } +} + +func TestMem_KeywordCaseInsensitive(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "tags-case", false) + + // neomd normalizes to lowercase on write, even when handed mixed case. + if err := cli.AddKeyword(context.Background(), "INBOX", 1, "ProJect-X"); err != nil { + t.Fatalf("AddKeyword: %v", err) + } + got := keywordsOfUID(t, cli, "INBOX", 1) + if len(got) != 1 || got[0] != "project-x" { + t.Fatalf("keywords = %v, want lowercase [project-x]", got) + } + // Removal is case-insensitive too (a server may have normalized the case). + if err := cli.RemoveKeyword(context.Background(), "INBOX", 1, "PROJECT-X"); err != nil { + t.Fatalf("RemoveKeyword: %v", err) + } + if got := keywordsOfUID(t, cli, "INBOX", 1); len(got) != 0 { + t.Errorf("keywords after case-mismatched remove = %v, want none", got) + } +} + +func TestMem_FetchHeaders_ParsesKeywords(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "tags-parse", true) + if err := cli.AddKeyword(context.Background(), "INBOX", 1, "work"); err != nil { + t.Fatal(err) + } + if err := cli.AddKeyword(context.Background(), "INBOX", 1, "invoice"); err != nil { + t.Fatal(err) + } + + emails, err := cli.FetchHeaders(context.Background(), "INBOX", 0) + if err != nil { + t.Fatal(err) + } + if len(emails) != 1 { + t.Fatalf("got %d emails, want 1", len(emails)) + } + e := emails[0] + if !e.Seen { + t.Error("\\Seen lost while keywords present") + } + // Keywords are parsed sorted — server flag order is map-random, so the + // parser normalizes for deterministic downstream state. + if fmt.Sprint(e.Keywords) != "[invoice work]" { + t.Errorf("Keywords = %v, want sorted [invoice work]", e.Keywords) + } +} + +func TestMem_FetchHeadersByUID_ParsesKeywords(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "ToScreen", "tags-byuid", false) + if err := cli.AddKeyword(context.Background(), "ToScreen", 1, "work"); err != nil { + t.Fatal(err) + } + emails, err := cli.FetchHeadersByUID(context.Background(), "ToScreen", []uint32{1}) + if err != nil { + t.Fatal(err) + } + if len(emails) != 1 || fmt.Sprint(emails[0].Keywords) != "[work]" { + t.Errorf("FetchHeadersByUID Keywords = %+v, want [work]", emails) + } +} + +func TestMem_MovePreservesKeyword(t *testing.T) { + cli, user := startMemIMAP(t) + seedMessage(t, user, "INBOX", "tags-move", true) + if err := cli.AddKeyword(context.Background(), "INBOX", 1, "work"); err != nil { + t.Fatal(err) + } + + destUID, err := cli.MoveMessage(context.Background(), "INBOX", 1, "Archive") + if err != nil { + t.Fatalf("MoveMessage: %v", err) + } + if destUID == 0 { + t.Fatal("no destination UID returned") + } + got := keywordsOfUID(t, cli, "Archive", destUID) + if len(got) != 1 || got[0] != "work" { + t.Errorf("keywords after move = %v, want [work] — tags must survive folder moves", got) + } + if !cliKeywordsSeen(t, cli, "Archive", destUID) { + t.Error("\\Seen lost across the move") + } +} + +func cliKeywordsSeen(t *testing.T, cli *Client, folder string, uid uint32) bool { + t.Helper() + emails, err := cli.FetchHeaders(context.Background(), folder, 0) + if err != nil { + t.Fatal(err) + } + for _, e := range emails { + if e.UID == uid { + return e.Seen + } + } + return false +} + +func TestMem_StoreKeyword_MissingMailboxErrors(t *testing.T) { + cli, _ := startMemIMAP(t) + if err := cli.AddKeyword(context.Background(), "NoSuchBox", 1, "work"); err == nil { + t.Error("expected SELECT error for missing mailbox") + } + // The failed SELECT must leave the connection usable (selection cleared). + if cli.selectedMailbox != "" { + t.Errorf("selectedMailbox = %q after failed SELECT, want empty", cli.selectedMailbox) + } +} diff --git a/internal/tags/store.go b/internal/tags/store.go new file mode 100644 index 0000000..c7971ca --- /dev/null +++ b/internal/tags/store.go @@ -0,0 +1,369 @@ +// Package tags persists neomd's local registry of IMAP keyword tags: every +// keyword neomd has ever set, keyed by Message-ID. The IMAP keyword on the +// message is the primary storage (it survives reinstalls and works in other +// clients); this registry is the write-through backup — a provider could +// purge unknown keywords, and the registry is what :tags-restore (Phase B) +// would re-apply from. It also doubles as the list of "known" tags for the +// picker and the chip renderer: foreign keywords ($HasAttachment, +// Thunderbird's $label1-5, …) never chip because they are not in here. +// +// Layout — one directory per account, one line-based file per keyword +// (screener-list style, no human editing expected): +// +// /tags//.txt # one Message-ID per line +// +// The per-account split is what makes a later restore selective: re-applying +// tags usually targets one account, and the restore must not touch the +// others. +package tags + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// safeAccount maps an account key to a stable folder name: characters +// outside [A-Za-z0-9._@%+-] collapse to "_". The allowed set covers email +// addresses verbatim (they are valid folder names on Linux AND Windows — +// Windows forbids <>:"/\|?* and control chars, none of which an address +// contains, and its reserved device names cannot collide with anything +// carrying an @), so the usual per-account key — the bare From address — +// passes through untouched. Sanitization only catches pathological input. +// Applied identically on write and lookup, so keys round-trip. +func safeAccount(account string) string { + var b strings.Builder + for _, r := range account { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '.', r == '_', r == '-', r == '@', r == '%', r == '+': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// tagFile is one keyword's list of Message-IDs. +type tagFile struct { + ids []string + dirty bool +} + +// Store is a concurrency-safe in-memory copy of the tags/ directory tree. +// All methods are safe on a nil receiver. +type Store struct { + mu sync.Mutex + dir string + entries map[string]map[string]*tagFile // account → keyword → file +} + +// Load reads dir (missing directory = empty store; malformed lines are +// skipped, not fatal — the registry is a backup, never a blocker). +func Load(dir string) (*Store, error) { + s := &Store{dir: dir, entries: map[string]map[string]*tagFile{}} + accounts, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + for _, acc := range accounts { + if !acc.IsDir() { + continue + } + files, err := os.ReadDir(filepath.Join(dir, acc.Name())) + if err != nil { + continue // unreadable account folder: treat as empty + } + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".txt") { + continue + } + kw := strings.TrimSuffix(f.Name(), ".txt") + ids, err := readIDList(filepath.Join(dir, acc.Name(), f.Name())) + if err != nil { + continue + } + if s.entries[acc.Name()] == nil { + s.entries[acc.Name()] = map[string]*tagFile{} + } + s.entries[acc.Name()][kw] = &tagFile{ids: ids} + } + } + return s, nil +} + +// readIDList parses one keyword file: one Message-ID per line, "#" starts a +// comment (full-line or trailing) — the screener-list convention. +func readIDList(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var ids []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + id := strings.Fields(line)[0] + if !strings.HasPrefix(id, "#") { + ids = append(ids, id) + } + } + return ids, nil +} + +// Save writes every changed keyword file atomically (temp + rename, 0600). +// Unchanged files are not touched. +func (s *Store) Save() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + for account, keywords := range s.entries { + for kw, f := range keywords { + if !f.dirty { + continue + } + if err := s.writeFile(account, kw, f); err != nil { + return err + } + f.dirty = false + } + } + return nil +} + +func (s *Store) writeFile(account, keyword string, f *tagFile) error { + dir := filepath.Join(s.dir, safeAccount(account)) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + var b strings.Builder + b.WriteString("# neomd tag registry — Message-IDs carrying this keyword\n") + for _, id := range f.ids { + b.WriteString(id) + b.WriteByte('\n') + } + tmp := filepath.Join(dir, keyword+".txt.tmp") + if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(dir, keyword+".txt")) +} + +// file returns the keyword file of account, creating it in memory when +// missing. Caller holds mu. +func (s *Store) file(account, keyword string) *tagFile { + account = safeAccount(account) + if s.entries[account] == nil { + s.entries[account] = map[string]*tagFile{} + } + f, ok := s.entries[account][keyword] + if !ok { + f = &tagFile{} + s.entries[account][keyword] = f + } + return f +} + +// Add puts ids under account/keyword (created if missing) and returns how +// many were new. Keywords are normalized to lowercase; empty ids and +// duplicates are skipped. A keyword stays "known" (All lists it) even when +// its last id is removed — the empty file remains. +func (s *Store) Add(account, keyword string, ids ...string) int { + if s == nil { + return 0 + } + kw := strings.ToLower(strings.TrimSpace(keyword)) + if kw == "" { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + f := s.file(account, kw) + have := make(map[string]bool, len(f.ids)) + for _, id := range f.ids { + have[id] = true + } + added := 0 + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" || have[id] { + continue + } + have[id] = true + f.ids = append(f.ids, id) + added++ + } + if added > 0 { + f.dirty = true + } + return added +} + +// Remove drops ids from account/keyword. It reports whether anything was +// removed. The keyword itself stays registered (its file remains, possibly +// empty) so it stays listed in the picker. +func (s *Store) Remove(account, keyword string, ids ...string) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.entries[safeAccount(account)][keyword] + if !ok { + return false + } + drop := make(map[string]bool, len(ids)) + for _, id := range ids { + if id = strings.TrimSpace(id); id != "" { + drop[id] = true + } + } + removed := false + kept := f.ids[:0:0] + for _, id := range f.ids { + if drop[id] { + removed = true + continue + } + kept = append(kept, id) + } + if removed { + f.ids = kept + f.dirty = true + } + return removed +} + +// KeywordsFor returns the registered keywords for one Message-ID within +// account, sorted. The registry is the backup, not the source of truth — +// callers merge this with the server-side keywords on the email itself. +func (s *Store) KeywordsFor(account, id string) []string { + if s == nil || id == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + var out []string + for kw, f := range s.entries[safeAccount(account)] { + for _, have := range f.ids { + if have == id { + out = append(out, kw) + break + } + } + } + sort.Strings(out) + return out +} + +// All returns every registered keyword of account, sorted (picker list + +// chip filter). Other accounts' keywords are invisible — restoring or +// browsing tags is always a per-account concern. +func (s *Store) All(account string) []string { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + acc := s.entries[safeAccount(account)] + out := make([]string, 0, len(acc)) + for kw := range acc { + out = append(out, kw) + } + sort.Strings(out) + return out +} + +// Count returns how many Message-IDs carry account/keyword (0 for unknown). +func (s *Store) Count(account, keyword string) int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + if f, ok := s.entries[safeAccount(account)][keyword]; ok { + return len(f.ids) + } + return 0 +} + +// maxKeywordLen is the atom-charset length cap: [a-z0-9][a-z0-9-_.]{0,31}. +const maxKeywordLen = 32 + +// ReservedKeywords documents keywords other systems own. The enforceable rule +// is the prefix: EVERY keyword starting with $ or \ is reserved (system flags +// use \, other MUAs' conventions use $) — this list is documentation, not the +// gate. +var ReservedKeywords = []string{ + "$Forwarded", "$MDNSent", "$Junk", "$NotJunk", "$Phishing", "$Important", // go-imap / common MUA conventions + "$HasAttachment", "$HasNoAttachment", // observed live on Infomaniak + "$label1", "$label2", "$label3", "$label4", "$label5", // Thunderbird +} + +// NormalizeKeyword validates and normalizes user input for a tag: lowercased, +// IMAP atom-safe ([a-z0-9][a-z0-9-_.]{0,31} after lowering), and never +// reserved ($ or \ prefix — those belong to system flags and other MUAs). +func NormalizeKeyword(input string) (string, error) { + kw := strings.ToLower(strings.TrimSpace(input)) + if kw == "" { + return "", fmt.Errorf("keyword must not be empty") + } + if strings.HasPrefix(kw, "$") || strings.HasPrefix(kw, "\\") { + return "", fmt.Errorf("not allowed to use this keyword, it is reserved for other systems") + } + if len(kw) > maxKeywordLen { + return "", fmt.Errorf("keyword too long (max %d characters)", maxKeywordLen) + } + for i, r := range kw { + ok := r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || + (i > 0 && (r == '-' || r == '_' || r == '.')) + if !ok { + return "", fmt.Errorf("keyword may only contain letters, digits, - _ . (got %q)", string(r)) + } + } + return kw, nil +} + +// Has reports whether keywords contains kw (IMAP flags compare +// case-insensitively — servers may normalize the case on write). +func Has(keywords []string, kw string) bool { + for _, k := range keywords { + if strings.EqualFold(k, kw) { + return true + } + } + return false +} + +// WithKeyword returns keywords with kw appended (lowercased, deduplicated) — +// the optimistic local flip for a tag being added. Returns the input slice +// unchanged when it is already there. +func WithKeyword(keywords []string, kw string) []string { + if Has(keywords, kw) { + return keywords + } + return append(keywords, strings.ToLower(kw)) +} + +// WithoutKeyword returns keywords minus kw (case-insensitive) — the optimistic +// local flip for a tag being removed, and the error-revert helper. +func WithoutKeyword(keywords []string, kw string) []string { + out := keywords[:0:0] + for _, k := range keywords { + if !strings.EqualFold(k, kw) { + out = append(out, k) + } + } + return out +} diff --git a/internal/tags/store_test.go b/internal/tags/store_test.go new file mode 100644 index 0000000..cb0a02c --- /dev/null +++ b/internal/tags/store_test.go @@ -0,0 +1,255 @@ +package tags + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func tmpDir(t *testing.T) string { + t.Helper() + return t.TempDir() +} + +func TestTagsStore_MissingDirEmpty(t *testing.T) { + s, err := Load(filepath.Join(tmpDir(t), "tags")) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := s.All("P"); len(got) != 0 { + t.Errorf("All = %v, want empty", got) + } +} + +func TestTagsStore_RoundTripPerAccountFiles(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + s, _ := Load(dir) + if n := s.Add("Personal", "Work", "", ""); n != 2 { + t.Fatalf("Add returned %d, want 2", n) + } + s.Add("Personal", "invoice", "") + if err := s.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + // Layout: one folder per account, one line-based file per keyword. + path := filepath.Join(dir, "Personal", "work.txt") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("work.txt: %v", err) + } + text := string(data) + if !strings.Contains(text, "\n") { + t.Errorf("work.txt = %q, want one Message-ID per line", text) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0o600 { + t.Errorf("perm = %o, want 0600", info.Mode().Perm()) + } + + s2, err := Load(dir) + if err != nil { + t.Fatalf("reload: %v", err) + } + // Keywords are normalized to lowercase on write. + if got := s2.All("Personal"); len(got) != 2 || got[0] != "invoice" || got[1] != "work" { + t.Errorf("All(Personal) = %v, want [invoice work]", got) + } + if got := s2.KeywordsFor("Personal", ""); len(got) != 2 || got[0] != "invoice" || got[1] != "work" { + t.Errorf("KeywordsFor = %v, want [invoice work]", got) + } + if s2.Count("Personal", "work") != 2 || s2.Count("Personal", "invoice") != 1 || s2.Count("Personal", "nope") != 0 { + t.Errorf("Count mismatch: %d %d %d", s2.Count("Personal", "work"), s2.Count("Personal", "invoice"), s2.Count("Personal", "nope")) + } + + // Atomic write: no temp files left behind. + filepath.Walk(dir, func(p string, info os.FileInfo, _ error) error { + if info != nil && strings.HasSuffix(p, ".tmp") { + t.Errorf("temp file left behind: %s", p) + } + return nil + }) +} + +func TestTagsStore_AccountsAreIsolated(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + s, _ := Load(dir) + s.Add("Personal", "work", "") + s.Add("Work", "work", "") + s.Add("Work", "client", "") + if err := s.Save(); err != nil { + t.Fatal(err) + } + s2, _ := Load(dir) + if got := s2.All("Personal"); len(got) != 1 || got[0] != "work" { + t.Errorf("All(Personal) = %v, want [work] only", got) + } + if got := s2.All("Work"); len(got) != 2 { + t.Errorf("All(Work) = %v, want [client work]", got) + } + if got := s2.KeywordsFor("Personal", ""); len(got) != 0 { + t.Errorf("Work's id resolved in Personal: %v — restore must stay per-account", got) + } + if s2.Count("Personal", "work") != 1 || s2.Count("Work", "work") != 1 { + t.Errorf("per-account counts wrong: %d %d", s2.Count("Personal", "work"), s2.Count("Work", "work")) + } +} + +func TestTagsStore_UnsafeAccountNameSanitized(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + s, _ := Load(dir) + s.Add("Work Info/2026", "work", "") + if err := s.Save(); err != nil { + t.Fatal(err) + } + s2, _ := Load(dir) + if got := s2.KeywordsFor("Work Info/2026", ""); len(got) != 1 { + t.Errorf("sanitized account did not round-trip: %v", got) + } + if _, err := os.Stat(filepath.Join(dir, "Work_Info_2026")); err != nil { + t.Errorf("expected sanitized folder: %v", err) + } +} + +func TestTagsStore_FileFormatComments(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + if err := os.MkdirAll(filepath.Join(dir, "P"), 0o700); err != nil { + t.Fatal(err) + } + body := "# full-line comment\n # trailing comment\n\n\n" + if err := os.WriteFile(filepath.Join(dir, "P", "hand.txt"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + s, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if got := s.KeywordsFor("P", ""); len(got) != 1 || got[0] != "hand" { + t.Errorf("comments must be skipped: %v", got) + } + if s.Count("P", "hand") != 2 { + t.Errorf("Count = %d, want 2 (blank line skipped)", s.Count("P", "hand")) + } +} + +func TestTagsStore_AddDedup(t *testing.T) { + s, _ := Load(filepath.Join(tmpDir(t), "tags")) + s.Add("P", " Work ", "") + if n := s.Add("P", "WORK", "", "", ""); n != 1 { + t.Errorf("second Add returned %d, want 1 (dup + empty skipped)", n) + } + if s.Count("P", "work") != 2 { + t.Errorf("Count = %d, want 2", s.Count("P", "work")) + } +} + +func TestTagsStore_RemoveKeepsKeywordKnown(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + s, _ := Load(dir) + s.Add("P", "work", "", "") + if !s.Remove("P", "work", "") { + t.Fatal("Remove = false, want true") + } + if s.Remove("P", "work", "") { + t.Error("Remove of unknown id = true, want false") + } + if err := s.Save(); err != nil { + t.Fatal(err) + } + if s.Count("P", "work") != 1 { + t.Errorf("Count = %d, want 1", s.Count("P", "work")) + } + if got := s.All("P"); len(got) != 1 || got[0] != "work" { + t.Errorf("All = %v, want [work] — an emptied keyword stays known (empty file)", got) + } + s2, _ := Load(dir) + if got := s2.All("P"); len(got) != 1 { + t.Errorf("empty keyword file lost across Save/Load: %v", got) + } +} + +func TestNormalizeKeyword(t *testing.T) { + ok := map[string]string{ + "work": "work", + " Work ": "work", + "Project-X": "project-x", + "in_1.2": "in_1.2", + "a": "a", + } + for in, want := range ok { + got, err := NormalizeKeyword(in) + if err != nil || got != want { + t.Errorf("NormalizeKeyword(%q) = %q,%v; want %q,nil", in, got, err, want) + } + } + bad := []string{ + "", " ", "$important", "\\Seen", "$label1", + "has space", "umlautä", "slash/x", "-leading", ".leading", "_leading", + "waytoolongkeywordwaytoolongkeywordway", // 37 chars + } + for _, in := range bad { + if got, err := NormalizeKeyword(in); err == nil { + t.Errorf("NormalizeKeyword(%q) = %q,nil; want error", in, got) + } + } + // The exact reserved-message wording the picker shows. + _, err := NormalizeKeyword("$HasAttachment") + if err == nil || err.Error() != "not allowed to use this keyword, it is reserved for other systems" { + t.Errorf("reserved error = %v, want the reserved-for-other-systems message", err) + } +} + +func TestKeywordListHelpers(t *testing.T) { + kws := []string{"Work", "invoice"} + if !Has(kws, "work") || !Has(kws, "INVOICE") || Has(kws, "nope") { + t.Error("Has must compare case-insensitively") + } + got := WithKeyword(kws, "WORK") + if len(got) != 2 { + t.Errorf("WithKeyword must not duplicate: %v", got) + } + got = WithKeyword(kws, "new") + if len(got) != 3 || got[2] != "new" { + t.Errorf("WithKeyword = %v, want appended lowercase new", got) + } + got = WithoutKeyword([]string{"work", "INVOICE", "x"}, "invoice") + if len(got) != 2 || got[0] != "work" || got[1] != "x" { + t.Errorf("WithoutKeyword = %v, want [work x]", got) + } +} + +func TestNilStoreSafe(t *testing.T) { + var s *Store + if n := s.Add("P", "work", ""); n != 0 { + t.Error("nil Add must be a no-op") + } + if s.Remove("P", "work", "") { + t.Error("nil Remove must be false") + } + if s.All("P") != nil || s.KeywordsFor("P", "") != nil || s.Count("P", "work") != 0 { + t.Error("nil store reads must be empty") + } + if err := s.Save(); err != nil { + t.Errorf("nil Save = %v, want nil", err) + } +} + +func TestTagsStore_EmailAddressKeyVerbatim(t *testing.T) { + dir := filepath.Join(tmpDir(t), "tags") + s, _ := Load(dir) + // The usual per-account key is the bare From address — it must pass into + // the folder name untouched (valid on Linux and Windows alike). + key := "markus@ik.me" + s.Add(key, "work", "") + if err := s.Save(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, key, "work.txt")); err != nil { + t.Fatalf("address must be the folder name verbatim: %v", err) + } + s2, _ := Load(dir) + if got := s2.KeywordsFor(key, ""); len(got) != 1 || got[0] != "work" { + t.Errorf("address-keyed round trip = %v", got) + } +} diff --git a/internal/ui/cmdline.go b/internal/ui/cmdline.go index dd3ef55..ef8e1ad 100644 --- a/internal/ui/cmdline.go +++ b/internal/ui/cmdline.go @@ -117,6 +117,19 @@ func init() { return m, nil }, }, + { + name: "keyword-test", + aliases: []string{"kt"}, + desc: "probe IMAP keyword (tag) support of the current server on the selected email; \":keyword-test keep\" leaves the keyword on the message to verify persistence", + runArgs: func(m *Model, args string) (tea.Model, tea.Cmd) { + if args != "" && args != "keep" { + m.status = "Usage: :keyword-test [keep]" + m.isError = true + return m, nil + } + return m.openKeywordTest(args == "keep") + }, + }, { name: "reset-toscreen", aliases: []string{"rts"}, diff --git a/internal/ui/inbox.go b/internal/ui/inbox.go index c5c8a28..bd8f293 100644 --- a/internal/ui/inbox.go +++ b/internal/ui/inbox.go @@ -112,10 +112,6 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSpyWidth + colSizeWidth + 2 // 2 spaces padding fromMax := 20 - subjectMax := width - fixed - fromMax - 2 - if subjectMax < 8 { - subjectMax = 8 - } sender := e.email.From if d.sentFolder != "" && e.email.Folder == d.sentFolder { @@ -132,14 +128,20 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It subjectText = fmt.Sprintf("%s (%d)", e.merge.title, len(e.merge.members)) } subjectText = sendLaterPrefix(e.email) + subjectText + + // subjectMax := width - fixed - fromMax - 2 is the original calculation; + // with tags it goes through calculateSubjectMaxWithTags, which also picks + // the chip set and subject variant that fit (see its comment). + subjectMax, subjectText, chipKws := calculateSubjectMaxWithTags(width, fixed, fromMax, subjectText, e.email.Keywords, e.merge != nil) subject := truncate(displaySafe(subjectText), subjectMax) if isSelected { - row := num + flag + replyStr + threadStr + dateStr + attachStr + spyStr + - padRight(from, fromMax) + " " + - padRight(subject, subjectMax) + " " + - sizeStr - fmt.Fprint(w, styleSelected.Render(row)) + // Chips keep their reverse-video style on the selected row (they must + // stay readable), so the row is composed in three segments. + head := num + flag + replyStr + threadStr + dateStr + attachStr + spyStr + + padRight(from, fromMax) + " " + tail := padRight(subject, subjectMax) + " " + sizeStr + fmt.Fprint(w, styleSelected.Render(head)+tagChipsStyled(chipKws)+styleSelected.Render(tail)) return } @@ -176,7 +178,7 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It subS := subStyle.Render(padRight(subject, subjectMax)) sizeS := lipgloss.NewStyle().Foreground(colorSizeCol).Render(sizeStr) - fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+spyS+fromS+" "+subS+" "+sizeS) + fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+spyS+fromS+" "+tagChipsStyled(chipKws)+subS+" "+sizeS) } // cleanFrom strips the part when a display name is present. @@ -447,3 +449,37 @@ func selectedItem(l list.Model) (emailItem, bool) { item, ok := l.SelectedItem().(emailItem) return item, ok } + +// calculateSubjectMaxWithTags returns the subject budget for one inbox row +// plus the chip set and subject text variant that fit it. Without tags +// (feature off, no chips on this row, or a collapsed merge row) it is a +// drop-in for the original calculation: width - fixed - fromMax - 2 with the +// 8-cell floor. With chips, their width is subtracted from that budget, and +// when the remainder gets tight the variants degrade in order: all chips → +// first chip only with a "…" subject → no chips (original behaviour). The +// 8-cell floor applies to chip-less results only — with chips the budget is +// exact, and re-flooding it would overflow the terminal width. +func calculateSubjectMaxWithTags(width, fixed, fromMax int, subject string, keywords []string, isMerge bool) (int, string, []tagChip) { + avail := width - fixed - fromMax - 2 + original := func() (int, string, []tagChip) { + max := avail + if max < 8 { + max = 8 + } + return max, subject, nil + } + + chips := tagChipKeywords(keywords) // nil while tagging is off / no known tags + if isMerge || len(chips) == 0 { + return original() + } + chipW := tagChipsWidth(chips) + if avail-chipW >= 8 { + return avail - chipW, subject, chips + } + first := chips[:1] + if fw := tagChipsWidth(first); avail-fw >= 1 { + return avail - fw, "…", first + } + return original() +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 970fbf0..5cb5678 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -78,6 +78,7 @@ var HelpSections = []HelpSection{ {"/", "IMAP search ALL emails on server (From + Subject)"}, {"c", "contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy \"Name \", enter compose to contact"}, {"S", "scan current folder for spy pixels (skips already scanned)"}, + {"k", "tag picker — toggle IMAP keyword tags on marked/cursor email(s) (opt-in: [tags] enabled = true); a new tag, / filter, chips show before the subject"}, {"h (reader)", "show key mail headers (From, Reply-To, List-Id, List-Unsubscribe, Return-Path, spam verdicts, …) — press again for all headers, again to return to the email"}, {"u (reader)", "unsubscribe — List-Unsubscribe https link opens in $BROWSER, mailto: prefills a compose, else first body link mentioning \"unsubscribe\""}, {"d (reader)", "download raw email source (.eml) to ~/Downloads"}, @@ -140,6 +141,7 @@ var HelpSections = []HelpSection{ {":mark-read / :mr", "mark all emails in current folder as read"}, {":reload / :r", "reload current folder"}, {":check / :ch", "show screener classification for selected email"}, + {":keyword-test / :kt", "IMAP keyword (tag) support probe on the selected email — save/retrieve/cleanup round trip; \":keyword-test keep\" leaves the keyword on the message"}, {":thread / :t", "show full conversation for the selected email (across folders)"}, {":scan-spy-pixels / :ssp", "scan current folder for tracking pixels (background, skips already scanned)"}, {":recover / :rec", "reopen the most recent compose backup from ~/.cache/neomd/drafts/"}, diff --git a/internal/ui/keywordtest.go b/internal/ui/keywordtest.go new file mode 100644 index 0000000..13bb780 --- /dev/null +++ b/internal/ui/keywordtest.go @@ -0,0 +1,236 @@ +package ui + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/sspaeti/neomd/internal/imap" +) + +// keywordTestKeyword is the flag the probe writes and removes again. Fixed so +// a crashed run's leftover is removed by the next run. +const keywordTestKeyword = "NeomdTest" + +// keywordTestTarget captures everything the probe needs about the test +// message by value — the tea.Cmd closure must not hold a pointer into +// m.emails (the UI re-sorts it in place). +type keywordTestTarget struct { + account string + host string + folder string + uid uint32 + subject string +} + +// keywordTestDoneMsg reports the IMAP keyword support probe result (:kt). +type keywordTestDoneMsg struct { + result *imap.KeywordProbeResult + target keywordTestTarget +} + +// keywordTestModel is the probe dialog's state on Model (the composeModel +// pattern: the state lives here, next to its methods; Model keeps one +// field, keywordTest). +type keywordTestModel struct { + running bool + result *imap.KeywordProbeResult + target keywordTestTarget +} + +// openKeywordTest opens the IMAP keyword support probe dialog (space k or +// :keyword-test) and starts the round trip on the selected email (first email +// as fallback). keep=true leaves the keyword on the message afterwards. +func (m Model) openKeywordTest(keep bool) (tea.Model, tea.Cmd) { + cli := m.imapCli() + if cli == nil { + m.status = "Keyword test: no IMAP connection for this account." + m.isError = true + return m, nil + } + e := selectedEmail(m.inbox) + if e == nil { + if items := m.inbox.Items(); len(items) > 0 { + if item, ok := items[0].(emailItem); ok { + e = &item.email + } + } + } + if e == nil { + m.status = "Keyword test: no email to attach the probe to." + m.isError = true + return m, nil + } + m.prevState = m.state + m.state = stateKeywordTest + m.keywordTest.running = true + m.keywordTest.result = nil + m.keywordTest.target = keywordTestTarget{ + account: m.activeAccountName(), + host: cli.Addr(), + folder: e.Folder, + uid: e.UID, + subject: e.Subject, + } + return m, tea.Batch(m.spinner.Tick, keywordTestCmd(cli, m.keywordTest.target, keep)) +} + +// keywordTestCmd runs the IMAP round trip off the UI thread and reports back +// via keywordTestDoneMsg. keep=true leaves the keyword on the message so +// persistence can be verified across sessions. +func keywordTestCmd(cli *imap.Client, target keywordTestTarget, keep bool) tea.Cmd { + return func() tea.Msg { + return keywordTestDoneMsg{ + result: cli.KeywordProbe(context.Background(), target.folder, target.uid, keywordTestKeyword, keep), + target: target, + } + } +} + +func (m Model) updateKeywordTest(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q", "h": + m.state = m.prevState + if m.state == stateKeywordTest { + m.state = stateInbox + } + m.keywordTest.running = false + m.keywordTest.result = nil + return m, nil + case "ctrl+c": + return m, tea.Quit + case "r", "K": + if m.keywordTest.running { + return m, nil + } + cli := m.imapCli() + if cli == nil { + m.status = "Keyword test: no IMAP connection for this account." + m.isError = true + return m, nil + } + // K re-runs in keep mode: the keyword stays on the message so its + // persistence can be checked later (r re-run removes it again). + keep := msg.String() == "K" + m.keywordTest.running = true + m.keywordTest.result = nil + return m, tea.Batch(m.spinner.Tick, keywordTestCmd(cli, m.keywordTest.target, keep)) + } + return m, nil +} + +// handleKeywordTestDone stores the probe result for the dialog; dropped +// when the dialog was closed before the probe finished. +func (m Model) handleKeywordTestDone(msg keywordTestDoneMsg) (tea.Model, tea.Cmd) { + if m.state != stateKeywordTest { + return m, nil // dialog closed before the probe finished + } + m.keywordTest.running = false + m.keywordTest.result = msg.result + return m, nil +} + +// keywordTestVerdict summarizes a probe result into one line for the dialog. +func keywordTestVerdict(r *imap.KeywordProbeResult) (string, bool) { + switch { + case r == nil: + return "", false + case r.Err != "" && len(r.Steps) == 0: + return "Probe failed before any step ran: " + r.Err, false + case r.Kept && r.StoreSupported && r.SearchSupported: + return "Keyword support confirmed — NeomdTest left on the message (keep mode). Re-run with r to remove it.", true + case r.StoreSupported && r.SearchSupported && r.CleanupOK && r.WildcardAllowed: + return "Full keyword support — STORE, SEARCH and \\* advertised. Server-side tagging will work.", true + case r.StoreSupported && r.SearchSupported && r.CleanupOK: + return "Keywords work (STORE + SEARCH OK), but \\* is not advertised — this server still accepts new keywords.", true + case r.StoreSupported && !r.CleanupOK: + return "STORE works but cleanup failed — the test keyword may remain on the message.", false + case r.StoreSupported && !r.SearchSupported: + return "Keywords are stored but UID SEARCH KEYWORD does not find them — search-based tag views would not work.", false + case !r.StoreSupported: + return "Arbitrary keywords are rejected — this server cannot be used for IMAP-keyword tagging.", false + default: + return "Partial support — see the step details above.", false + } +} + +var ( + keywordTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7E9CD8")) + keywordDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#727169")) + keywordOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#98BB6C")) + keywordErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E46876")) + keywordBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#54546D")). + Padding(1, 2) +) + +// viewKeywordTest renders the centered probe dialog. +func (m Model) viewKeywordTest() string { + t := m.keywordTest.target + w := 74 + + var lines []string + lines = append(lines, keywordTitleStyle.Render("IMAP Keyword (Tag) Support Test"), "") + lines = append(lines, fmt.Sprintf("%s %s · UID %d", keywordDimStyle.Render("Account "), fmt.Sprintf("%s (%s)", t.account, t.host), t.uid)) + lines = append(lines, fmt.Sprintf("%s %s", keywordDimStyle.Render("Mailbox "), t.folder)) + lines = append(lines, fmt.Sprintf("%s %s", keywordDimStyle.Render("Message "), truncate(t.subject, 58))) + lines = append(lines, fmt.Sprintf("%s %s", keywordDimStyle.Render("Keyword "), keywordTestKeyword)) + + if m.keywordTest.running || m.keywordTest.result == nil { + lines = append(lines, "", + fmt.Sprintf(" %s Probing %s …", m.spinner.View(), t.host)) + } else { + r := m.keywordTest.result + lines = append(lines, "", keywordDimStyle.Render("── Server capability ──")) + lines = append(lines, fmt.Sprintf(" PERMANENTFLAGS %s", truncate(strings.Join(r.PermanentFlags, " "), 54))) + if r.WildcardAllowed { + lines = append(lines, " "+keywordOKStyle.Render("✓ new keywords allowed (\\* wildcard advertised)")) + } else { + lines = append(lines, " "+keywordErrStyle.Render("✗ \\* wildcard NOT advertised — server may reject unknown keywords")) + } + if len(r.MailboxKeywords) > 0 { + lines = append(lines, fmt.Sprintf(" Keywords in use: %s", truncate(strings.Join(r.MailboxKeywords, ", "), 52))) + } + + lines = append(lines, "", keywordDimStyle.Render("── Probe steps ──")) + for _, s := range r.Steps { + mark := keywordErrStyle.Render("✗") + name := s.Name + if s.OK { + mark = keywordOKStyle.Render("✓") + } else { + name = keywordErrStyle.Render(name) + } + line := fmt.Sprintf(" %s %s", mark, name) + detail := s.Detail + if s.Err != "" { + detail = s.Err + } + if detail != "" { + line += keywordDimStyle.Render(" — " + truncate(detail, 52)) + } + lines = append(lines, line) + } + + verdict, good := keywordTestVerdict(r) + lines = append(lines, "") + vs := keywordErrStyle.Render + if good { + vs = keywordOKStyle.Render + } + lines = append(lines, " "+vs("Verdict: "+truncate(verdict, 64))) + + if r.Err != "" { + lines = append(lines, " "+keywordErrStyle.Render(truncate("Fatal: "+r.Err, 66))) + } + } + + lines = append(lines, "", keywordDimStyle.Render("esc/q close · r re-run (removes a kept keyword) · K re-run keeping the tag")) + + box := keywordBorderStyle.Width(w).Render(strings.Join(lines, "\n")) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} diff --git a/internal/ui/keywordtest_test.go b/internal/ui/keywordtest_test.go new file mode 100644 index 0000000..21ca627 --- /dev/null +++ b/internal/ui/keywordtest_test.go @@ -0,0 +1,109 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/sspaeti/neomd/internal/imap" +) + +func TestKeywordTestVerdict(t *testing.T) { + tests := []struct { + name string + res *imap.KeywordProbeResult + want string // substring of the verdict + good bool + }{ + { + name: "keep mode leaves the keyword", + res: &imap.KeywordProbeResult{ + Kept: true, StoreSupported: true, SearchSupported: true, + }, + want: "keep mode", + good: true, + }, + { + name: "full support", + res: &imap.KeywordProbeResult{ + WildcardAllowed: true, StoreSupported: true, SearchSupported: true, CleanupOK: true, + }, + want: "Full keyword support", + good: true, + }, + { + name: "stores but no wildcard advertised", + res: &imap.KeywordProbeResult{ + WildcardAllowed: false, StoreSupported: true, SearchSupported: true, CleanupOK: true, + }, + want: "not advertised", + good: true, + }, + { + name: "store without search", + res: &imap.KeywordProbeResult{ + StoreSupported: true, SearchSupported: false, CleanupOK: true, + }, + want: "UID SEARCH KEYWORD does not find", + good: false, + }, + { + name: "store rejected", + res: &imap.KeywordProbeResult{}, + want: "rejected", + good: false, + }, + { + name: "fatal before steps", + res: &imap.KeywordProbeResult{Err: "SELECT failed"}, + want: "SELECT failed", + good: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, good := keywordTestVerdict(tt.res) + if good != tt.good { + t.Errorf("good = %v, want %v (verdict %q)", good, tt.good, got) + } + if !strings.Contains(got, tt.want) { + t.Errorf("verdict %q does not contain %q", got, tt.want) + } + }) + } +} + +func TestKeywordTestVerdictNil(t *testing.T) { + if got, good := keywordTestVerdict(nil); got != "" || good { + t.Errorf("nil result: got %q, %v; want empty, false", got, good) + } +} + +func TestViewKeywordTestRenders(t *testing.T) { + m := Model{width: 120, height: 40} + m.state = stateKeywordTest + m.keywordTest.target = keywordTestTarget{account: "acc", host: "imap.example.com:993", folder: "INBOX", uid: 42, subject: "s"} + m.keywordTest.result = &imap.KeywordProbeResult{ + Folder: "INBOX", UID: 42, Keyword: "NeomdTest", + PermanentFlags: []string{"\\Seen", "\\*"}, + WildcardAllowed: true, + BaselineFlags: []string{"\\Seen"}, + StoreSupported: true, SearchSupported: true, CleanupOK: true, + Steps: []imap.KeywordProbeStep{{Name: "SELECT — PERMANENTFLAGS", OK: true, Detail: "…"}}, + } + out := m.viewKeywordTest() + for _, want := range []string{"IMAP Keyword (Tag) Support Test", "Full keyword support", "✓", "esc/q close"} { + if !strings.Contains(out, want) { + t.Errorf("dialog render missing %q", want) + } + } + + // Running state renders the spinner line without touching the result. + m2 := Model{width: 120, height: 40} + m2.state = stateKeywordTest + m2.keywordTest.running = true + m2.keywordTest.target = m.keywordTest.target + out2 := m2.viewKeywordTest() + if !strings.Contains(out2, "Probing imap.example.com") { + t.Errorf("running render missing probe line: %q", out2) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 61bc6c1..8495b92 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -40,14 +40,16 @@ import ( type viewState int const ( - stateInbox viewState = iota - stateReading // reading a single email - stateCompose // composing a new email - statePresend // pre-send review: add attachments, then send or edit again - stateHelp // help overlay - stateWelcome // first-run welcome popup - stateReaction // emoji reaction picker - stateContacts // contacts picker (space c) + stateInbox viewState = iota + stateReading // reading a single email + stateCompose // composing a new email + statePresend // pre-send review: add attachments, then send or edit again + stateHelp // help overlay + stateWelcome // first-run welcome popup + stateReaction // emoji reaction picker + stateContacts // contacts picker (space c) + stateTags // IMAP keyword tag picker (space k) + stateKeywordTest // IMAP keyword support probe dialog (:kt) ) // async message types @@ -724,6 +726,9 @@ type Model struct { reactionSelected int // selected emoji index (0-7) pendingReaction bool // true if we need to fetch body before entering reaction mode + // IMAP keyword support probe (:keyword-test / :kt) — state in keywordtest.go + keywordTest keywordTestModel + // Status / error status string isError bool @@ -782,6 +787,10 @@ type Model struct { contactsFilterActive bool contactsCursor int + // IMAP keyword tags (space k): picker state, optimistic-toggle overlay + // and registry — tagsModel lives in tags.go next to its methods. + tags tagsModel + // cmdMode / cmdText / cmdTabI implement vim-style ":" command line. cmdMode bool cmdText string @@ -875,7 +884,14 @@ func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mail if err != nil { notice = "merges.toml: " + err.Error() } - return Model{ + // Tags (space k): registry load and chip styling live in tags.go — + // newTagsModel also wires the package-level chip pipeline and returns + // a startup notice when the registry or a colour value needs attention. + tm, tagNotice := newTagsModel(cfg, colorBg, colorText) + if tagNotice != "" { + notice = tagNotice + } + m := Model{ cfg: cfg, accounts: cfg.ActiveAccounts(), clients: clients, @@ -895,11 +911,14 @@ func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mail spyScannedKeys: scannedKeys, contacts: cs, merges: ms, + tags: tm, startupNotice: notice, sortField: "date", sortReverse: true, // newest first mailto: mp, } + setTagAccount(m.activeAccountKey()) // chips/picker registry follows the active account + return m } // tokenSourceFor returns the OAuth2 token source for the account with the @@ -2808,7 +2827,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.folder == m.cfg.Folders.Inbox && msg.folder == m.activeFolder() { m.skipAutoScreenOnce = false } - msg.emails = m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails)) + msg.emails = m.withPendingTags(msg.account, m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails))) if m.folderCache == nil { m.folderCache = make(map[string]folderSnapshot) } @@ -3292,6 +3311,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.applyFilter() + case tagsDoneMsg: + // Chips flipped at toggle time; release the pending overlay and + // write the registry through — or revert what never reached the + // server. Runs even when the picker was closed before completion: + // the toggle itself must always be finished. + return m.handleTagsDone(msg) + + case keywordTestDoneMsg: + return m.handleKeywordTestDone(msg) + case markAsReadTimerMsg: // Timer fired - mark email as read if user is still viewing it if m.state == stateReading && m.markAsReadUID == msg.uid && m.markAsReadFolder == msg.folder { @@ -3580,7 +3609,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.folderCache == nil { m.folderCache = make(map[string]folderSnapshot) } - msg.emails = m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails)) + msg.emails = m.withPendingTags(msg.account, m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails))) // Cache a copy: a cache-hit Tab sorts the snapshot in place, and the // screener below classifies msg.emails. Rows the screener is about // to MOVE are left out, so a Tab to Inbox never shows them. @@ -3641,7 +3670,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Same overlays as a user-driven load: a prefetch that started // before an in-flight MOVE or n toggle must not resurrect the row // or the old flag. - emails := m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails)) + emails := m.withPendingTags(msg.account, m.withPendingSeen(msg.account, m.withoutPending(msg.account, msg.emails))) m.folderCache[cacheKey(msg.account, msg.folder)] = folderSnapshot{emails: emails, fetchedAt: time.Now()} } } @@ -3874,6 +3903,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateReaction(msg) case stateContacts: return m.updateContacts(msg) + case stateTags: + return m.updateTags(msg) + case stateKeywordTest: + return m.updateKeywordTest(msg) } } @@ -4101,7 +4134,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case " ": // leader key — wait for digit or shortcut m.pendingKey = " " - m.status = "leader: 1-9 folder tab / IMAP search c contacts S scan spy pixels w welcome (esc to cancel)" + m.status = "leader: 1-9 folder tab / IMAP search c contacts S scan spy pixels k tags w welcome (esc to cancel)" return m, nil case "M": @@ -4437,7 +4470,8 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.activeFolderI = 0 m.leaveSyntheticView() - m.prefetched = false // warm the new account's tabs too + m.prefetched = false // warm the new account's tabs too + setTagAccount(m.activeAccountKey()) // chips follow the account return m, m.loadActiveFolder() } @@ -4795,6 +4829,11 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { m.status = "Scanning for spy pixels…" return m, m.spyScanCmd() } + // Tag picker: [tags] key (default "k"); opt-in feature — inert until + // [tags] enabled = true (openTags shows the hint). + if m.cfg != nil && key == m.cfg.Tags.TagsKey() { + return m.openTags() + } if len(key) == 1 && key >= "1" && key <= "9" { idx := int(key[0] - '1') // 0-based if idx < len(m.folders) { @@ -7117,6 +7156,10 @@ func (m Model) View() string { return m.viewReaction() case stateContacts: return m.viewContacts() + case stateTags: + return m.viewTags() + case stateKeywordTest: + return m.viewKeywordTest() } return "" } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 1fbd1ca..f708a66 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -111,6 +111,11 @@ var ( styleOffTab lipgloss.Style styleSuggestion lipgloss.Style styleSuggestionSelected lipgloss.Style + // styleTagChip renders ▐kw▐ tag chips: reverse video of the active theme + // (Background = theme Text, Foreground = theme Bg). [tags] fg/bg in + // config.toml override it after ApplyTheme in New — the built-in + // palettes are never touched. + styleTagChip lipgloss.Style ) func init() { @@ -295,6 +300,10 @@ func rebuildStyles() { styleSuggestionSelected = lipgloss.NewStyle(). Foreground(colorPrimary). Bold(true) + + styleTagChip = lipgloss.NewStyle(). + Foreground(colorBg). + Background(colorText) } // tabZone records the X range for a clickable folder tab. diff --git a/internal/ui/tags.go b/internal/ui/tags.go new file mode 100644 index 0000000..00c451b --- /dev/null +++ b/internal/ui/tags.go @@ -0,0 +1,918 @@ +package ui + +// ── IMAP keyword tags (space k) ────────────────────────────────────────── +// +// Tags are stored as IMAP keywords on the message itself (server-side, works +// in other clients, survives reinstalls); /tags/ is the local +// write-through registry (internal/tags — one folder per account, one +// line-based file per keyword) used as the purge fallback and the +// "known tags" list. The picker toggles optimistically — the chip flips at +// once and the STORE runs behind the list, exactly like the n \Seen toggle: +// no spinner, no reload, error reverts with the error in the status line. + +import ( + "fmt" + "log" + "sort" + "strconv" + "strings" + "unicode" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/tags" +) + +// tagKeywordSource feeds the chip renderer with the registry's known +// keywords. It is a package-level indirection (set once in New) because the +// list delegate renders rows without access to the Model — the same pattern +// as the mutable theme colour vars. nil (tests, disabled feature) = no chips. +// The closure reads tagActiveAccount so chips follow the active account. +var tagKeywordSource func() []string + +// tagActiveAccount mirrors the active account name for the package-level +// chip pipeline (setTagAccount keeps it in sync; single-goroutine UI). +var tagActiveAccount string + +// tagOp is one planned keyword change, captured by value so the STORE +// goroutine never reads through a pointer into the list. cli is resolved on +// the main goroutine at command construction; nil = imap_disabled account +// (the tag is registry-only for that op). registryKey is the account's +// tags// folder (its email address) captured at toggle time — a done +// message landing after an account switch must still write the right +// account's registry. +type tagOp struct { + account string + registryKey string + folder string + uid uint32 + messageID string + cli *imap.Client +} + +// tagsDoneMsg reports the keyword STOREs of one tag toggle. ops[:done] +// reached the server; ops[done:] did not (err set) and must be reverted. +type tagsDoneMsg struct { + keyword string + add bool + ops []tagOp + done int + err error +} + +// tagPendingKey names one in-flight optimistic keyword flip: +// account + source folder + UID + keyword. +type tagPendingKey struct { + account string + folder string + uid uint32 + keyword string +} + +// tagsModel is the whole tag-feature state on Model (the composeModel +// pattern: the state lives here, next to its methods; Model keeps one +// field, tags). targets is captured by value when the picker opens and +// flipped in place on every toggle (main goroutine only). +type tagsModel struct { + store *tags.Store + targets []imap.Email + cursor int + filter string + filterActive bool + inputActive bool // "a" — typing a new tag name + input string + err string // validation error shown inside the picker + + // pending holds the wanted keyword state of in-flight tag toggles + // (space k); fetch results landing meanwhile take this state so a + // refresh cannot snap the chips back. Cleared by tagsDoneMsg. + pending map[tagPendingKey]bool +} + +// newTagsModel loads the local write-through registry (one folder per +// account under /tags/) and resolves the chip styling from +// config; called once from New. The store is nil when the directory cannot +// be read — tagging still works server-side, only chips and the picker's +// known-tag list degrade. Returns a startup notice (registry failure or +// unrecognized colour values), "" when all is well. The built-in theme +// palettes are never touched; the package-level chip pipeline is wired here. +func newTagsModel(cfg *config.Config, themeFG, themeBG lipgloss.Color) (tagsModel, string) { + // [tags] enabled = false is the GLOBAL kill-switch (the feature is + // opt-in): the chip pipeline stays unwired — a nil tagKeywordSource is + // the no-chips state, same as an unreadable registry — so no keyword + // ever renders, no matter what a [tags.] section says. Per-tag + // enabled = false is the fine-grained form: hide single keywords while + // the feature is on. The picker has its own gate in openTags. + if !cfg.Tags.Enabled { + return tagsModel{}, "" + } + var notice string + store, err := tags.Load(cfg.TagsDir) + if err != nil { + notice = "tags registry: " + err.Error() + store = nil + } + tagKeywordSource = func() []string { return store.All(tagActiveAccount) } + chipStyle, tagRules, colorWarn := buildTagStyles(&cfg.Tags, themeFG, themeBG) + styleTagChip = chipStyle + tagRuleSource = tagRules + tagPillMode = cfg.Tags.NerdPill + if colorWarn != "" && notice == "" { + notice = colorWarn + } + return tagsModel{store: store}, notice +} + +// activeAccountKey returns the stable registry key of the active account: +// the bare, lowercased From address (falling back to the IMAP user, then the +// account name). Email addresses are valid folder names on every platform +// neomd supports, and every account has its own address — so the key doubles +// as the tags// directory name without any mangling. +func (m Model) activeAccountKey() string { + if m.accountI < len(m.accounts) { + acc := m.accounts[m.accountI] + for _, cand := range []string{acc.From, acc.User, acc.Name} { + if a := strings.ToLower(extractEmailAddr(strings.TrimSpace(cand))); a != "" { + return a + } + } + } + return "default" +} + +// openTags opens the tag picker on the marked set (or the cursor email — +// same target rule as every bulk action) and captures the targets by value. +func (m Model) openTags() (tea.Model, tea.Cmd) { + if m.cfg != nil && !m.cfg.Tags.Enabled { + m.status = "Tags are disabled — opt in with [tags] enabled = true." + m.isError = false + return m, nil + } + targets := m.targetEmails() + if len(targets) == 0 { + m.status = "Tags: no email selected." + m.isError = true + return m, nil + } + m.prevState = m.state + m.state = stateTags + m.tags.targets = append([]imap.Email(nil), targets...) + m.tags.cursor = 0 + m.tags.filter = "" + m.tags.filterActive = false + m.tags.inputActive = false + m.tags.input = "" + m.tags.err = "" + return m, nil +} + +// pickerTags returns the keywords the picker lists: every registry keyword +// plus custom (non-reserved) keywords the targets already carry — those must +// be removable even when the local registry never saw them. Sorted, deduped +// case-insensitively. +func (m Model) pickerTags() []string { + var kws []string + if m.tags.store != nil { + kws = append(kws, m.tags.store.All(m.activeAccountKey())...) + } + for _, e := range m.tags.targets { + for _, kw := range e.Keywords { + if !strings.HasPrefix(kw, "$") && !strings.HasPrefix(kw, "\\") { + kws = append(kws, strings.ToLower(kw)) + } + } + } + sort.Strings(kws) + out := kws[:0] + for i, kw := range kws { + if i == 0 || !strings.EqualFold(kw, kws[i-1]) { + out = append(out, kw) + } + } + return out +} + +// filteredPickerTags applies the picker's / filter (case-insensitive substring). +func (m Model) filteredPickerTags() []string { + all := m.pickerTags() + q := strings.ToLower(strings.TrimSpace(m.tags.filter)) + if q == "" { + return all + } + var out []string + for _, kw := range all { + if strings.Contains(kw, q) { + out = append(out, kw) + } + } + return out +} + +// tagVerdict returns '✓' when every target carries kw, '~' when some do, +// ' ' when none does. Case-insensitive (servers may normalize the case). +func tagVerdict(targets []imap.Email, kw string) rune { + some, all := false, true + for _, e := range targets { + if tags.Has(e.Keywords, kw) { + some = true + } else { + all = false + } + } + switch { + case all && len(targets) > 0: + return '✓' + case some: + return '~' + default: + return ' ' + } +} + +func (m Model) updateTags(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // New-tag input mode: printable runes extend the keyword, enter submits + // (validated + applied to all targets), esc returns to the list. + if m.tags.inputActive { + switch key { + case "esc": + m.tags.inputActive = false + m.tags.input = "" + m.tags.err = "" + return m, nil + case "enter": + kw, err := tags.NormalizeKeyword(m.tags.input) + if err != nil { + m.tags.err = err.Error() + return m, nil + } + m.tags.inputActive = false + m.tags.input = "" + m.tags.err = "" + return m.applyTag(kw) + case "backspace", "ctrl+h": + if r := []rune(m.tags.input); len(r) > 0 { + m.tags.input = string(r[:len(r)-1]) + } + return m, nil + case "ctrl+c": + return m, tea.Quit + default: + if len([]rune(key)) == 1 { + m.tags.input += key + m.tags.err = "" + } + return m, nil + } + } + + // Filter typing mode (same contract as the contacts picker). + entries := m.filteredPickerTags() + clamp := func() { + if m.tags.cursor >= len(entries) { + m.tags.cursor = len(entries) - 1 + } + if m.tags.cursor < 0 { + m.tags.cursor = 0 + } + } + clamp() + if m.tags.filterActive { + switch key { + case "esc": + if m.tags.filter != "" { + m.tags.filter = "" + m.tags.cursor = 0 + } else { + m.tags.filterActive = false + } + return m, nil + case "enter": + m.tags.filterActive = false + return m, nil + case "backspace", "ctrl+h": + if r := []rune(m.tags.filter); len(r) > 0 { + m.tags.filter = string(r[:len(r)-1]) + m.tags.cursor = 0 + } + return m, nil + case "ctrl+c": + return m, tea.Quit + default: + if len([]rune(key)) == 1 { + m.tags.filter += key + m.tags.cursor = 0 + } + return m, nil + } + } + + switch key { + case "esc", "q": + m.state = m.prevState + if m.state == stateTags { + m.state = stateInbox + } + return m, nil + case "ctrl+c": + return m, tea.Quit + case "/": + m.tags.filterActive = true + return m, nil + case "a": + m.tags.inputActive = true + m.tags.input = "" + m.tags.err = "" + return m, nil + case "j", "down": + if m.tags.cursor < len(entries)-1 { + m.tags.cursor++ + } + case "k", "up": + if m.tags.cursor > 0 { + m.tags.cursor-- + } + case "G": + m.tags.cursor = len(entries) - 1 + clamp() + case "g": + m.tags.cursor = 0 + case " ", "enter": + if len(entries) == 0 { + return m, nil + } + return m.applyTag(entries[m.tags.cursor]) + } + return m, nil +} + +// tagsClientFor resolves the IMAP client of one account STRICTLY: nil for +// imap_disabled (and unknown) accounts — never the primary fallback +// imapCliForAccount does — because a keyword STORE addressed with another +// account's connection would tag the wrong server's folder+UID. +func (m Model) tagsClientFor(account string) *imap.Client { + for i, a := range m.accounts { + if strings.EqualFold(a.Name, account) && i < len(m.clients) { + return m.clients[i] // nil = imap_disabled → registry-only op + } + } + return nil +} + +// applyTag toggles kw on every target: when all targets carry it the tag is +// removed, otherwise it is added. The local flip happens at once (list + +// cache + the picker's captured targets); the STOREs run behind the list. +func (m Model) applyTag(kw string) (tea.Model, tea.Cmd) { + account := m.activeAccountName() + add := tagVerdict(m.tags.targets, kw) != '✓' + if m.tags.pending == nil { + m.tags.pending = make(map[tagPendingKey]bool) + } + cli := m.tagsClientFor(account) + ops := make([]tagOp, 0, len(m.tags.targets)) + for i := range m.tags.targets { + e := &m.tags.targets[i] + m.setKeywordLocal(account, e.Folder, e.UID, kw, add) + e.Keywords = flipEmailKeywords(e.Keywords, kw, add) + m.tags.pending[tagPendingKey{account: account, folder: e.Folder, uid: e.UID, keyword: kw}] = add + ops = append(ops, tagOp{ + account: account, + registryKey: m.activeAccountKey(), + folder: e.Folder, + uid: e.UID, + messageID: e.MessageID, + cli: cli, + }) + } + // Marks keep their meaning (the rows stay); only the chips change. + return m, tea.Batch(m.applyFilter(), m.tagsCmd(kw, add, ops)) +} + +// flipKeywords returns keywords with kw added/removed — the pure helper +// behind the optimistic flip (tags.WithKeyword / WithoutKeyword). +// setKeywordLocal sets keyword presence on folder/uid in the visible list and +// in account's cached snapshot of that folder (mirrors setSeenLocal — set, +// not toggle, so it is safe when the two alias). The account is passed +// explicitly: a done/revert message can land after the user switched +// accounts, and the cache key must never point into the wrong account. +func (m *Model) setKeywordLocal(account, folder string, uid uint32, keyword string, add bool) { + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].Folder == folder { + m.emails[i].Keywords = flipEmailKeywords(m.emails[i].Keywords, keyword, add) + } + } + if snap, ok := m.folderCache[cacheKey(account, folder)]; ok { + for i := range snap.emails { + if snap.emails[i].UID == uid && snap.emails[i].Folder == folder { + snap.emails[i].Keywords = flipEmailKeywords(snap.emails[i].Keywords, keyword, add) + } + } + } +} + +func flipEmailKeywords(keywords []string, keyword string, add bool) []string { + if add { + return tags.WithKeyword(keywords, keyword) + } + return tags.WithoutKeyword(keywords, keyword) +} + +// tagsCmd runs the STOREs for ops in order on the primary connection(s), +// fail-fast like toggleSeenCmd. Local-only ops (nil client) count as done — +// the registry is their only storage. +func (m Model) tagsCmd(keyword string, add bool, ops []tagOp) tea.Cmd { + return func() tea.Msg { + for i, op := range ops { + if op.cli == nil { + continue + } + var err error + if add { + err = op.cli.AddKeyword(nil, op.folder, op.uid, keyword) + } else { + err = op.cli.RemoveKeyword(nil, op.folder, op.uid, keyword) + } + if err != nil { + return tagsDoneMsg{keyword: keyword, add: add, ops: ops, done: i, err: err} + } + } + return tagsDoneMsg{keyword: keyword, add: add, ops: ops, done: len(ops)} + } +} + +// handleTagsDone finishes a toggle: success updates the write-through +// registry (and saves it in the background); an error reverts every op that +// never reached the server, with the error in the status line. +func (m Model) handleTagsDone(msg tagsDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + for _, op := range msg.ops[msg.done:] { + // Only touch rows still owned by the active account — the user + // may have switched accounts while the STORE was in flight. + if op.account == m.activeAccountName() { + m.setKeywordLocal(op.account, op.folder, op.uid, msg.keyword, !msg.add) + } + delete(m.tags.pending, tagPendingKey{account: op.account, folder: op.folder, uid: op.uid, keyword: msg.keyword}) + } + m.status = fmt.Sprintf("tag %q: %v (reverted)", msg.keyword, msg.err) + m.isError = true + return m, m.applyFilter() + } + // Success: release the pending overlay … + for _, op := range msg.ops { + delete(m.tags.pending, tagPendingKey{account: op.account, folder: op.folder, uid: op.uid, keyword: msg.keyword}) + } + // … and write the registry through (background save, merges-style). + var ids []string + for _, op := range msg.ops { + if op.messageID != "" { + ids = append(ids, op.messageID) + } + } + if len(ids) > 0 { + key := "" + if len(msg.ops) > 0 { + key = msg.ops[0].registryKey + } + if msg.add { + m.tags.store.Add(key, msg.keyword, ids...) + } else { + m.tags.store.Remove(key, msg.keyword, ids...) + } + store := m.tags.store + safeGo(func() { + if err := store.Save(); err != nil { + log.Printf("tags: saving registry failed: %v", err) + } + }) + } + localOnly := true + for _, op := range msg.ops { + if op.cli != nil { + localOnly = false + break + } + } + if localOnly { + m.status = "tag saved locally — account has no IMAP" + m.isError = false + } + return m, nil +} + +// withPendingTags overlays the wanted keyword state of in-flight tag toggles +// on a fetch result for account ("" = active), in place — a fetch that +// started before the STORE still carries the old keywords and must not snap +// the chips back (the withPendingSeen contract, applied to tags). +func (m Model) withPendingTags(account string, emails []imap.Email) []imap.Email { + if len(m.tags.pending) == 0 { + return emails + } + if account == "" { + account = m.activeAccountName() + } + for i := range emails { + e := &emails[i] + for k, add := range m.tags.pending { + if k.account == account && k.folder == e.Folder && k.uid == e.UID { + e.Keywords = flipEmailKeywords(e.Keywords, k.keyword, add) + } + } + } + return emails +} + +// ── chips ──────────────────────────────────────────────────────────────── + +// tagRuleView is a keyword's resolved display configuration (from +// [tags.] in config.toml, with the defaults filled in). The colours +// are carried explicitly (not as a prebuilt style) so the pill caps can take +// the background directly — no getter round-trips. +type tagRuleView struct { + show bool + display string + fg, bg lipgloss.Color +} + +// bodyStyle is the chip body: display text on the chip colours. An empty bg +// (bg = "transparent") emits no background at all — the terminal's own +// background (and its transparency) shows through. +func (v tagRuleView) bodyStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(v.fg).Background(v.bg) +} + +// edgeStyle is a pill cap: the half-disc glyph is FILLED with the foreground +// colour, so painting it in the chip's background merges it into the pill. +// With a transparent background the caps fall back to the text colour — +// there is no pill fill for them to blend into. +func (v tagRuleView) edgeStyle() lipgloss.Style { + capColor := v.bg + if capColor == "" { + capColor = v.fg + } + return lipgloss.NewStyle().Foreground(capColor) +} + +// tagRuleSource resolves one keyword to its display rule; nil (feature not +// wired in tests) = show under the keyword with the default chip style. +var tagRuleSource func(kw string) tagRuleView + +func tagRule(kw string) tagRuleView { + if tagRuleSource == nil { + // No resolver wired (tests): the theme's reverse video, so fallback + // chips keep their coloured padding. + return tagRuleView{show: true, fg: colorBg, bg: colorText} + } + return tagRuleSource(kw) +} + +// tagChipKeywords filters keywords down to the registry-known ones (in +// registry order) — only neomd's own custom tags chip; foreign keywords +// ($HasAttachment, $label1-5, …) never render. Returns keyword/display pairs +// with each tag's per-tag rule applied (hidden tags drop out). +func tagChipKeywords(keywords []string) []tagChip { + if tagKeywordSource == nil { + return nil + } + known := tagKeywordSource() + var out []tagChip + for _, k := range known { + for _, kw := range keywords { + if !strings.EqualFold(kw, k) { + continue + } + if r := tagRule(k); r.show { + out = append(out, tagChip{keyword: k, view: r}) + } + break + } + } + return out +} + +// tagChip is one rendered chip: the keyword it represents plus its resolved +// display rule. +type tagChip struct { + keyword string + view tagRuleView +} + +// tagPillMode renders chips as nerd-font pills — half-sphere end caps +// coloured like the chip body ([tags] nerd_pill = true). Default off: the +// padded-text chip needs no special font. +var tagPillMode bool + +// Pill end caps (Powerline extras): left and right half-discs. Rendered in +// the chip's background colour they merge into a rounded pill around the +// text; no padding spaces needed in this mode. +const ( + tagPillLeft = "\ue0b6" + tagPillRight = "\ue0b4" +) + +// displayVerbatim is the pill body: the rule display after invisible-rune +// stripping (variation selectors U+FE0F, ZWJ, BOM — copied nerd-font icons +// often carry them and terminals then render the symbol with wide "emoji +// presentation" padding, a phantom space inside the pill), with the user's +// own leading/trailing spaces preserved — in pill mode they ARE the padding, +// typed on purpose. Falls back to the keyword when no display is set. +func (c tagChip) displayVerbatim() string { + if d := stripInvisible(c.view.display); d != "" { + return d + } + return c.keyword +} + +// displayTrimmed is the whitespace-trimmed form for the fallback chip, which +// supplies exactly one standard pad space per side itself — display-defined +// spaces never double the fallback's padding. +func (c tagChip) displayTrimmed() string { + if d := strings.TrimSpace(stripInvisible(c.view.display)); d != "" { + return d + } + return c.keyword +} + +// stripInvisible removes zero-width / combining runes (the same rune classes +// displaySafe collapses for row safety) — EXCEPT the text-presentation +// selector U+FE0E: appending it to an emoji-range symbol (⚡) forces the +// monochrome text font instead of the terminal's color-emoji fallback, which +// paints an opaque box behind the glyph that no transparent background can +// survive. VS16 (U+FE0F, emoji presentation) is still stripped. +func stripInvisible(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '\ufe0e' { // keep: explicit text presentation + b.WriteRune(r) + continue + } + if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// text is the chip's plain fallback form: the display text with exactly one +// standard space on each side (the padding is part of the coloured chip, so +// chips read as labels). No block glyphs — the colour IS the chip. With a +// transparent background the pads are skipped: they would be invisible dead +// cells, so the chip renders as tight fg-coloured text. +func (c tagChip) text() string { + d := c.displayTrimmed() + if c.view.bg == "" { + return d + } + return " " + d + " " +} + +// measure is the plain form whose safe-width is the chip's width budget — +// identical to text() in fallback mode, the pill glyph form otherwise (the +// end caps are non-ASCII and therefore budgeted as 2 cells each; a pill adds +// NO padding of its own — spaces in the display string are honored verbatim). +func (c tagChip) measure() string { + if tagPillMode { + return tagPillLeft + c.displayVerbatim() + tagPillRight + } + return c.text() +} + +func (c tagChip) styled() string { + if tagPillMode { + return c.view.edgeStyle().Render(tagPillLeft) + + c.view.bodyStyle().Render(c.displayVerbatim()) + + c.view.edgeStyle().Render(tagPillRight) + } + return c.view.bodyStyle().Render(c.text()) +} + +// tagChipsPlain joins the chips' plain text for width accounting — matches +// tagChipsStyled. Fallback: one uncoloured separator space between chips and +// one more before the subject. Pill mode: pills touch directly and sit flush +// against the subject (the caps ARE the delimiters). +func tagChipsPlain(chips []tagChip) string { + if len(chips) == 0 { + return "" + } + texts := make([]string, len(chips)) + for i, c := range chips { + texts[i] = c.measure() + } + if tagPillMode { + return strings.Join(texts, "") + } + return strings.Join(texts, " ") + " " +} + +// tagChipsStyled is the rendered form of tagChipsPlain. +func tagChipsStyled(chips []tagChip) string { + if len(chips) == 0 { + return "" + } + styled := make([]string, len(chips)) + for i, c := range chips { + styled[i] = c.styled() + } + if tagPillMode { + return strings.Join(styled, "") + } + return strings.Join(styled, " ") + " " +} + +// tagChipsWidth is the display width budget the chips need. Non-ASCII runes +// (nerd-font symbols in a display name, umlauts, CJK) are budgeted as 2 +// cells: terminal fonts disagree on their real width, and over-reserving can +// only leave slack — under-reserving would overflow the row. +func tagChipsWidth(chips []tagChip) int { + return tagSafeWidth(tagChipsPlain(chips)) +} + +// tagSafeWidth measures s with every non-ASCII rune budgeted as 2 cells. +func tagSafeWidth(s string) int { + w := 0 + for _, r := range s { + if r < 0x80 { + w += runewidth.RuneWidth(r) + } else { + w += 2 + } + } + return w +} + +// resolveTagColor turns a configured colour value into a lipgloss.Color: +// empty → fallback, "transparent"/"none" → no colour at all (terminals +// cannot render semi-transparent fills — an unset background is the only +// transparency, the terminal's own shows through), hex ("#1F1F28") or an +// ANSI number ("21") passes through, anything else (typo, colour name) +// falls back — lipgloss would silently drop it to no colour at all. +func resolveTagColor(val string, fallback lipgloss.Color) lipgloss.Color { + val = strings.TrimSpace(val) + switch { + case val == "": + return fallback + case strings.EqualFold(val, "transparent"), strings.EqualFold(val, "none"): + return lipgloss.Color("") // explicit no-colour, not the fallback + case strings.HasPrefix(val, "#"), strconvItoa(val): + return lipgloss.Color(val) + default: + return fallback + } +} + +// strconvItoa reports whether s is a plain decimal number. +func strconvItoa(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return len(s) > 0 +} + +// validTagColor reports whether val is a usable colour value (empty, +// transparent, hex, or ANSI number) — anything else falls back and is worth +// a startup warning. +func validTagColor(val string) bool { + val = strings.TrimSpace(val) + if strings.EqualFold(val, "transparent") || strings.EqualFold(val, "none") { + return true + } + return val == "" || strings.HasPrefix(val, "#") || strconvItoa(val) +} + +// buildTagStyles resolves the chip styling from config: the default chip +// style (theme reverse, [tags] fg/bg override) and the per-tag rule resolver +// ([tags.] display/fg/bg/enabled, falling back per field to the +// [tags] defaults). Returns a warning about unrecognized colour values, if any. +func buildTagStyles(cfg *config.TagsConfig, themeFG, themeBG lipgloss.Color) (lipgloss.Style, func(string) tagRuleView, string) { + defaultFG := resolveTagColor(cfg.FG, themeFG) + defaultBG := resolveTagColor(cfg.BG, themeBG) + def := lipgloss.NewStyle().Foreground(defaultFG).Background(defaultBG) + + var warn []string + if !validTagColor(cfg.FG) { + warn = append(warn, "[tags] fg = "+strconv.Quote(cfg.FG)) + } + if !validTagColor(cfg.BG) { + warn = append(warn, "[tags] bg = "+strconv.Quote(cfg.BG)) + } + for name, rule := range cfg.Rules { + if !validTagColor(rule.FG) { + warn = append(warn, "[tags."+name+"] fg = "+strconv.Quote(rule.FG)) + } + if !validTagColor(rule.BG) { + warn = append(warn, "[tags."+name+"] bg = "+strconv.Quote(rule.BG)) + } + } + + resolve := func(kw string) tagRuleView { + rule, ok := cfg.Rule(kw) + if !ok { + return tagRuleView{show: true, fg: defaultFG, bg: defaultBG} + } + return tagRuleView{ + show: rule.Shown(), + display: rule.Display, + fg: resolveTagColor(rule.FG, defaultFG), + bg: resolveTagColor(rule.BG, defaultBG), + } + } + warning := "" + if len(warn) > 0 { + warning = "unknown tag colour (falling back): " + strings.Join(warn, ", ") + } + return def, resolve, warning +} + +// ── picker view ────────────────────────────────────────────────────────── + +func (m Model) viewTags() string { + entries := m.filteredPickerTags() + w := 60 + var lines []string + lines = append(lines, styleHeader.Render(fmt.Sprintf("Tags — %d email%s", len(m.tags.targets), plural(len(m.tags.targets)))), "") + + rows := 12 + start := 0 + if m.tags.cursor >= rows { + start = m.tags.cursor - rows + 1 + } + end := start + rows + if end > len(entries) { + end = len(entries) + } + for i := start; i < end; i++ { + kw := entries[i] + mark := " " + if v := tagVerdict(m.tags.targets, kw); v != ' ' { + mark = string(v) + " " + } + count := "" + if m.tags.store != nil { + if n := m.tags.store.Count(m.activeAccountKey(), kw); n > 0 { + count = fmt.Sprintf("(%d)", n) + } + } + // Tags with a display value get a live chip preview, right-aligned + // in a FIXED column at the row's right edge — rendered through the + // same pill/fallback path and config colours as the inbox row. The + // keyword and count columns are fixed too, so removing a tag (count + // disappears) never shifts the preview. + var previewField string + if rule := tagRule(kw); strings.TrimSpace(stripInvisible(rule.display)) != "" { + if tagSafeWidth(tagChip{keyword: kw, view: rule}.measure()) > 18 { + rule.display = runewidth.Truncate(stripInvisible(rule.display), 16, "…") // keep the dialog tidy + } + previewField = lipgloss.NewStyle().Width(26). + Render(tagChip{keyword: kw, view: rule}.styled()) + } + countCol := "" + if count != "" { + countCol = count + } + kwCol := runewidth.Truncate(kw, 20, "…") + line := fmt.Sprintf(" %s%-20s%-6s%s", mark, kwCol, countCol, previewField) + if i == m.tags.cursor { + line = styleSelected.Render("▸" + line[1:]) + } + lines = append(lines, line) + } + if len(entries) == 0 { + lines = append(lines, styleHelp.Render(" no tags yet — press a to add one"), "") + } + + if m.tags.err != "" { + lines = append(lines, " "+styleError.Render(truncate(m.tags.err, w-6))) + } + if m.tags.inputActive { + lines = append(lines, "", styleInputLabel.Render("new tag:")+" "+m.tags.input+"█") + } else if m.tags.filterActive { + lines = append(lines, "", styleInputLabel.Render("filter:")+" "+m.tags.filter+"█") + } + lines = append(lines, "", styleHelp.Render(" enter/space toggle · a new tag · / filter · esc close")) + + // Border follows the active theme (built at render time, like the row styles). + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2) + box := boxStyle.Width(w).Render(strings.Join(lines, "\n")) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +// setTagAccount points the package-level chip pipeline at the active account +// (the registry is per-account; the delegate cannot see the Model). +func setTagAccount(name string) { tagActiveAccount = name } diff --git a/internal/ui/tags_test.go b/internal/ui/tags_test.go new file mode 100644 index 0000000..2815741 --- /dev/null +++ b/internal/ui/tags_test.go @@ -0,0 +1,818 @@ +package ui + +// IMAP keyword tagging (space k): the picker toggles optimistically (chips +// flip at once, STORE behind the list, no spinner/reload), a refresh landing +// mid-flight cannot snap chips back, an error reverts them, reserved +// keywords are rejected at input, foreign keywords never chip, and rows with +// chips never overflow the terminal width. + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/tags" +) + +// tagsTestDir is the registry directory of the most recent tagsTestModel +// call (tests run sequentially). +var tagsTestDir string + +// waitTagFile waits for the background write-through (safeGo Save in +// handleTagsDone) to land on disk, so t.TempDir cleanup never races the +// save goroutine. +func waitTagFile(t *testing.T, rel string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(filepath.Join(tagsTestDir, rel)); err == nil { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("tag file %s never landed on disk", rel) +} + +// tagsTestModel wraps instantModel with the tag machinery enabled: a temp +// registry wired to the chip renderer and a tags/ directory. +func tagsTestModel(t *testing.T, n int) Model { + t.Helper() + m := instantModel(t, n) + m.cfg.Tags.Enabled = true // the feature is opt-in + tagsTestDir = t.TempDir() + "/tags" + store, err := tags.Load(tagsTestDir) + if err != nil { + t.Fatal(err) + } + m.tags.store = store + prev := tagKeywordSource + tagKeywordSource = func() []string { return store.All("me@x") } + t.Cleanup(func() { tagKeywordSource = prev; tagRuleSource = nil }) + return m +} + +func keywordsOf(m Model, uid uint32) []string { + for _, e := range m.emails { + if e.UID == uid { + return e.Keywords + } + } + panic("uid not in list") +} + +func openPicker(t *testing.T, m Model) Model { + t.Helper() + res, _ := m.handleChord(" ", m.cfg.Tags.TagsKey()) + mm, ok := res.(Model) + if !ok { + t.Fatal("handleChord did not return a Model") + } + if mm.state != stateTags { + t.Fatalf("state = %v, want stateTags", mm.state) + } + return mm +} + +func TestTagsPicker_ToggleOptimisticFlip(t *testing.T) { + m := tagsTestModel(t, 3) // list 3,2,1, cursor on uid 3 + m.tags.store.Add("me@x", "work", "") // a known tag to pick + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + if mm.state != stateTags { + t.Fatalf("state = %v, want stateTags", mm.state) + } + if len(mm.tags.targets) != 1 || mm.tags.targets[0].UID != 3 { + t.Fatalf("targets = %v, want uid 3", mm.tags.targets) + } + + res, cmd := mm.updateTags(key(" ")) + mm = res.(Model) + if cmd == nil { + t.Fatal("toggle must fire the STORE command") + } + if mm.loading { + t.Error("toggle must not trigger a spinner/reload (loading must stay false)") + } + if got := keywordsOf(mm, 3); len(got) != 1 || got[0] != "work" { + t.Errorf("keywords after toggle = %v, want [work] immediately", got) + } + if got := keywordsOf(mm, 2); len(got) != 0 { + t.Errorf("other rows must stay untagged, got %v", got) + } + // The pending overlay is armed so a fetch cannot snap the chip back. + if !mm.tags.pending[tagPendingKey{account: "P", folder: "INBOX", uid: 3, keyword: "work"}] { + t.Error("pendingTags must record the wanted keyword state") + } + // The chip is visible in the rendered row (after ANSI strip). + if row := stripANSI(renderRow(listItemAt(mm, 3), 120)); !strings.Contains(row, " work ") { + t.Errorf("row with tag must show a chip: %q", row) + } + + // Completing the STORE releases the overlay and writes the registry through. + msg := cmd() + res, _ = mm.Update(msg) + mm = res.(Model) + if len(mm.tags.pending) != 0 { + t.Errorf("pendingTags must be cleared, got %v", mm.tags.pending) + } + if got := mm.tags.store.KeywordsFor("me@x", ""); len(got) != 1 || got[0] != "work" { + t.Errorf("registry after success = %v, want [work]", got) + } + waitTagFile(t, filepath.Join("me@x", "work.txt")) + // Toggling again removes (all targets carry it now). + res, _ = mm.updateTags(key(" ")) + mm = res.(Model) + if got := keywordsOf(mm, 3); len(got) != 0 { + t.Errorf("second toggle must remove the tag, got %v", got) + } +} + +func listItemAt(m Model, uid uint32) emailItem { + for _, it := range m.inbox.Items() { + if ei, ok := it.(emailItem); ok && ei.email.UID == uid { + return ei + } + } + panic("uid not in inbox list") +} + +func TestTagsPicker_RefreshLandingMidFlightKeepsChip(t *testing.T) { + m := tagsTestModel(t, 2) + m.tags.store.Add("me@x", "work", "") + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + res, _ = mm.updateTags(key(" ")) + mm = res.(Model) + + stale := append([]imap.Email(nil), mm.emails...) // server still untagged + for i := range stale { + stale[i].Keywords = nil + } + mm.refreshing = true + res, _ = mm.Update(emailsLoadedMsg{emails: stale, folder: "INBOX", account: "P"}) + mm = res.(Model) + if got := keywordsOf(mm, 2); len(got) != 1 || got[0] != "work" { + t.Errorf("a refresh landing while the STORE is in flight must not snap the chip back, got %v", got) + } +} + +func TestTagsCmd_ErrorReverts(t *testing.T) { + m := tagsTestModel(t, 2) + m.tags.store.Add("me@x", "work", "") + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + res, _ = mm.updateTags(key(" ")) + mm = res.(Model) + if got := keywordsOf(mm, 2); len(got) != 1 { + t.Fatalf("optimistic flip missing before error, got %v", got) + } + + // done=0: the STORE never reached the server — the flip must revert. + res, _ = mm.Update(tagsDoneMsg{ + keyword: "work", add: true, + ops: []tagOp{{account: "P", folder: "INBOX", uid: 2, messageID: ""}}, + done: 0, err: errors.New("STORE failed"), + }) + mm = res.(Model) + if got := keywordsOf(mm, 2); len(got) != 0 { + t.Errorf("keywords after error = %v, want reverted to none", got) + } + if !mm.isError || !strings.Contains(mm.status, "reverted") { + t.Errorf("status = %q isError=%v, want revert message in status line", mm.status, mm.isError) + } + if len(mm.tags.pending) != 0 { + t.Errorf("pendingTags after error = %v, want released", mm.tags.pending) + } + if got := mm.tags.store.KeywordsFor("me@x", ""); len(got) != 0 { + t.Errorf("registry must not record a failed STORE, got %v", got) + } +} + +func TestTagsPicker_NewTagLowercased(t *testing.T) { + m := tagsTestModel(t, 1) + mm := openPicker(t, m) + res, _ := mm.updateTags(key("a")) + mm = res.(Model) + if !mm.tags.inputActive { + t.Fatal("a must open the new-tag input") + } + for _, r := range "ProJect-X" { + res, _ = mm.updateTags(key(string(r))) + mm = res.(Model) + } + res, cmd := mm.updateTags(key("enter")) + mm = res.(Model) + if cmd == nil { + t.Fatal("submit must fire the STORE command") + } + if got := keywordsOf(mm, 1); len(got) != 1 || got[0] != "project-x" { + t.Errorf("new tag must be applied lowercased, got %v", got) + } + // Deliver the done message: the registry is written through there. + res, _ = mm.Update(cmd()) + mm = res.(Model) + if got := mm.tags.store.All("me@x"); len(got) != 1 || got[0] != "project-x" { + t.Errorf("registry = %v, want [project-x]", got) + } + waitTagFile(t, filepath.Join("me@x", "project-x.txt")) + if mm.tags.inputActive { + t.Error("input must close after a successful submit") + } +} + +func TestTagsPicker_InvalidKeywordRejected(t *testing.T) { + m := tagsTestModel(t, 1) + mm := openPicker(t, m) + res, _ := mm.updateTags(key("a")) + mm = res.(Model) + if !mm.tags.inputActive { + t.Fatal("a must open the new-tag input") + } + for _, bad := range []string{"has space", "ä", "-lead", strings.Repeat("x", 33)} { + mm.tags.input = bad + res, _ := mm.updateTags(key("enter")) + m2 := res.(Model) + if m2.tags.err == "" { + t.Errorf("input %q must be rejected with a message", bad) + } + if got := keywordsOf(m2, 1); len(got) != 0 { + t.Errorf("input %q must not tag anything, got %v", bad, got) + } + if !m2.tags.inputActive { + t.Errorf("input mode must stay open after rejecting %q", bad) + } + } +} + +func TestTagsPicker_ReservedKeywordRejected(t *testing.T) { + m := tagsTestModel(t, 1) + mm := openPicker(t, m) + res, _ := mm.updateTags(key("a")) + mm = res.(Model) + for _, reserved := range []string{"$important", "\\Seen", "$label1"} { + mm.tags.input = reserved + res, _ := mm.updateTags(key("enter")) + m2 := res.(Model) + if m2.tags.err != "not allowed to use this keyword, it is reserved for other systems" { + t.Errorf("reserved keyword %q: error = %q", reserved, m2.tags.err) + } + } +} + +func TestTagChips_OnlyRegistryKeywordsRender(t *testing.T) { + m := tagsTestModel(t, 1) + m.tags.store.Add("me@x", "work", "") + m.emails[0].Keywords = []string{"$HasNoAttachment", "$label1", "Work", "foreign-tag"} + m.applyFilter() + row := stripANSI(renderRow(listItemAt(m, 1), 120)) + if !strings.Contains(row, " work ") { + t.Errorf("registry-known keyword must chip: %q", row) + } + if strings.Contains(row, "foreign-tag") || strings.Contains(row, "$") || strings.Contains(row, "label1") { + t.Errorf("foreign/reserved keywords must never chip: %q", row) + } + if !strings.Contains(row, " work s") { + t.Errorf("chips must render BEFORE the subject with one uncoloured space before it: %q", row) + } +} + +func TestTagChips_WidthAccounting(t *testing.T) { + m := tagsTestModel(t, 1) + for _, kw := range []string{"work", "invoice", "project-x", "very-important"} { + m.tags.store.Add("me@x", kw, "") + } + m.emails[0].Keywords = []string{"work", "invoice", "project-x", "very-important"} + m.applyFilter() + item := listItemAt(m, 1) + for _, w := range []int{80, 120, 190, 70, 60} { + row := stripANSI(renderRow(item, w)) + if got := runewidth.StringWidth(row); got > w { + t.Errorf("width=%d: rendered row is %d cells (> %d)\n row: %q", w, got, w, row) + } + } + // Wide enough: all four chips show, before the subject. + row := stripANSI(renderRow(item, 120)) + for _, kw := range []string{"work", "invoice", "project-x", "very-important"} { + if !strings.Contains(row, " "+kw+" ") { + t.Errorf("width=120 must show chip %s: %q", kw, row) + } + } + // Narrow: degrade to fewer chips + "…" subject, never overflow. At 62 the + // first chip (▐invoice▐, 10 cells incl. separator) fits into the 11-cell + // budget; at 60 even that does not and the row falls back to chip-less. + for _, w := range []int{62, 60} { + row := stripANSI(renderRow(item, w)) + if got := runewidth.StringWidth(row); got > w { + t.Errorf("width=%d: row is %d cells: %q", w, got, row) + } + if w == 62 { + if !strings.Contains(row, " invoice ") { + t.Errorf("width=62 should still show the first chip: %q", row) + } + if !strings.Contains(row, "…") { + t.Errorf("width=62 should degrade the subject to …: %q", row) + } + } + } + // A chip row is never wider than the same row without chips: chips can + // only shrink the subject budget. + noChip := m.emails[0] + noChip.Keywords = nil + plain := emailItem{email: noChip, index: item.index} + for _, w := range []int{80, 120, 190, 60} { + got := runewidth.StringWidth(stripANSI(renderRow(item, w))) + base := runewidth.StringWidth(stripANSI(renderRow(plain, w))) + if got > base { + t.Errorf("width=%d: chip row (%d cells) wider than chip-less row (%d)", w, got, base) + } + } +} + +func TestTagChips_DisabledFeatureNoChips(t *testing.T) { + // tagKeywordSource nil (feature never wired, e.g. unreadable tags/): + // keywords on the email must not render anything. + m := tagsTestModel(t, 1) + prev := tagKeywordSource + tagKeywordSource = nil + defer func() { tagKeywordSource = prev }() + m.emails[0].Keywords = []string{"work"} + m.applyFilter() + row := stripANSI(renderRow(listItemAt(m, 1), 120)) + if strings.Contains(row, " work ") { + t.Errorf("chips must not render while the registry is unavailable: %q", row) + } +} + +func TestTagChips_DisabledConfigIsGlobalKillSwitch(t *testing.T) { + // [tags] enabled = false must turn the feature off COMPLETELY: no chip + // may render, even for registry-known keywords and even when a per-tag + // section says enabled = true (per-tag enabled is the fine-grained hide + // for while the feature is ON). Pins the newTagsModel wiring: disabled + // must leave the chip pipeline unwired. + dir := t.TempDir() + seed, err := tags.Load(dir) + if err != nil { + t.Fatal(err) + } + seed.Add("me@x", "work", "") + if err := seed.Save(); err != nil { + t.Fatal(err) + } + + prevKw, prevRule, prevAcc, prevPill, prevStyle := tagKeywordSource, tagRuleSource, tagActiveAccount, tagPillMode, styleTagChip + defer func() { + tagKeywordSource, tagRuleSource, tagActiveAccount, tagPillMode, styleTagChip = prevKw, prevRule, prevAcc, prevPill, prevStyle + }() + tagActiveAccount = "me@x" // the wired closure reads the active account + // Fresh-process state between phases: New() runs once per process with + // the pipeline vars at their zero values, so "disabled" means "never + // wired", not "unwired again after an enabled run". + resetPipeline := func() { tagKeywordSource, tagRuleSource, tagPillMode = nil, nil, false } + + yes := true + cfg := &config.Config{TagsDir: dir, Tags: config.TagsConfig{ + Enabled: true, // control first: wired and chipping + Rules: map[string]config.TagRule{"work": {Enabled: &yes}}, + }} + resetPipeline() + newTagsModel(cfg, colorBg, colorText) + if tagKeywordSource == nil { + t.Fatal("control: chip pipeline must be wired when [tags] enabled = true") + } + m := instantModel(t, 1) + m.emails[0].Keywords = []string{"work"} + m.applyFilter() + if row := stripANSI(renderRow(listItemAt(m, 1), 120)); !strings.Contains(row, " work ") { + t.Fatalf("control: chip must render when enabled = true: %q", row) + } + + cfg.Tags.Enabled = false + resetPipeline() + tm, notice := newTagsModel(cfg, colorBg, colorText) + if notice != "" { + t.Errorf("notice = %q, want none while the feature is off", notice) + } + if tm.store != nil { + t.Error("registry must not load while the feature is off") + } + if tagKeywordSource != nil { + t.Error("chip pipeline must stay unwired while [tags] enabled = false") + } + if row := stripANSI(renderRow(listItemAt(m, 1), 120)); strings.Contains(row, " work ") { + t.Errorf("[tags] enabled = false is the global kill-switch — no chips, per-tag rules notwithstanding: %q", row) + } +} + +func TestTagsPicker_DisabledConfigRefused(t *testing.T) { + m := tagsTestModel(t, 1) + m.cfg.Tags.Enabled = false + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + if mm.state == stateTags { + t.Error("picker must not open when [tags] enabled = false") + } + if !strings.Contains(mm.status, "disabled") { + t.Errorf("status = %q, want a disabled hint", mm.status) + } +} + +func TestTagsPicker_LocalOnlyAccount(t *testing.T) { + // imap_disabled account: cli is nil, the toggle is registry-only and + // the done message says so. + m := tagsTestModel(t, 1) + m.tags.store.Add("me@x", "work", "") + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + res, cmd := mm.updateTags(key(" ")) + mm = res.(Model) + msg := cmd().(tagsDoneMsg) + if msg.err != nil { + t.Fatalf("local-only toggle must not error: %v", msg.err) + } + res, _ = mm.Update(msg) + mm = res.(Model) + if !strings.Contains(mm.status, "locally") { + t.Errorf("status = %q, want the local-only notice", mm.status) + } + if got := mm.tags.store.KeywordsFor("me@x", ""); len(got) != 1 || got[0] != "work" { + t.Errorf("registry = %v, want [work] — the registry is the only storage here", got) + } + waitTagFile(t, filepath.Join("me@x", "work.txt")) +} + +func TestTagsPicker_NoSelectionGuard(t *testing.T) { + m := tagsTestModel(t, 1) + m.emails = nil + m.applyFilter() + res, _ := m.handleChord(" ", "k") + mm := res.(Model) + if mm.state == stateTags { + t.Error("picker must not open without a target") + } + if !mm.isError { + t.Error("missing target must set an error status") + } +} + +func TestTagsPicker_EscCloses(t *testing.T) { + m := tagsTestModel(t, 1) + mm := openPicker(t, m) + res, _ := mm.updateTags(key("esc")) + m2 := res.(Model) + if m2.state == stateTags { + t.Error("esc must close the picker") + } +} + +func TestTagVerdict(t *testing.T) { + targets := []imap.Email{ + {UID: 1, Keywords: []string{"work"}}, + {UID: 2, Keywords: nil}, + } + if got := tagVerdict(targets, "work"); got != '~' { + t.Errorf("verdict for some = %q, want ~", got) + } + targets[1].Keywords = []string{"WORK"} + if got := tagVerdict(targets, "work"); got != '✓' { + t.Errorf("verdict for all (case-insensitive) = %q, want ✓", got) + } + if got := tagVerdict(targets, "none"); got != ' ' { + t.Errorf("verdict for none = %q, want space", got) + } +} + +func TestTagRules_DisplayTextAndHidden(t *testing.T) { + m := tagsTestModel(t, 1) + for _, kw := range []string{"work", "important", "secret"} { + m.tags.store.Add("me@x", kw, "") + } + hide := false + cfg := config.TagsConfig{ + Rules: map[string]config.TagRule{ + "important": {Display: "\uf0e7 Important"}, // nerd-font bolt + text + "secret": {Enabled: &hide}, // enabled=false → chips hidden + }, + } + def, resolve, warn := buildTagStyles(&cfg, colorBg, colorText) + if warn != "" { + t.Fatalf("unexpected warning: %q", warn) + } + tagRuleSource = resolve + styleTagChip = def + defer func() { tagRuleSource = nil }() + m.emails[0].Keywords = []string{"work", "important", "secret"} + m.applyFilter() + + row := stripANSI(renderRow(listItemAt(m, 1), 120)) + if !strings.Contains(row, " \uf0e7 Important ") { + t.Errorf("per-tag display text must chip verbatim: %q", row) + } + if !strings.Contains(row, " work ") { + t.Errorf("tag without a rule must chip as the keyword: %q", row) + } + if strings.Contains(row, "secret") { + t.Errorf("enabled=false must hide that tag's chip: %q", row) + } + // The keyword on the email is untouched — hiding is display-only. + if got := keywordsOf(m, 1); len(got) != 3 { + t.Errorf("Keywords = %v, want all three (hidden is display-only)", got) + } +} + +func TestTagRuleDisplayWidthBudgetsNonASCIIAsTwo(t *testing.T) { + // A nerd-font icon in a display name must be budgeted as 2 cells so the + // row can never overflow, whatever the terminal's font actually does. + chips := []tagChip{{keyword: "important", view: tagRuleView{show: true, display: "\uf0e7 Important", fg: colorBg, bg: colorText}}} + // padding + icon(2) + text + padding + the trailing space before the subject + if w := tagChipsWidth(chips); w != len(" ")+2+len(" Important")+len(" ")+1 { + t.Errorf("tagChipsWidth = %d, want icon counted as 2 cells (plus separators)", w) + } +} + +func TestResolveTagColor(t *testing.T) { + fb := lipgloss.Color("#000000") + cases := map[string]string{ + "": "#000000", // empty → fallback + "#FF0000": "#FF0000", // hex passes through + "21": "21", // ANSI number passes through + "transparent": "", // explicit no-colour (terminal transparency) + "None": "", // …case-insensitive + "yellow": "#000000", // colour names are NOT supported (lipgloss would drop them) → fallback + "nope": "#000000", // unknown → fallback (typo never blanks a chip) + } + for in, want := range cases { + if got := string(resolveTagColor(in, fb)); got != want { + t.Errorf("resolveTagColor(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildTagStyles_WarnsOnUnknownColors(t *testing.T) { + cfg := config.TagsConfig{ + FG: "#FF0000", BG: "yelow", // typo + Rules: map[string]config.TagRule{ + "x": {FG: "reddish"}, + }, + } + _, _, warn := buildTagStyles(&cfg, colorBg, colorText) + if !strings.Contains(warn, `[tags] bg = "yelow"`) || !strings.Contains(warn, `[tags.x] fg = "reddish"`) { + t.Errorf("warning = %q, want both unknown colours named", warn) + } +} + +func TestTagChips_SeparatedByUncoloredSpace(t *testing.T) { + chips := []tagChip{ + {keyword: "work", view: tagRuleView{show: true, fg: colorBg, bg: colorText}}, + {keyword: "invoice", view: tagRuleView{show: true, fg: colorBg, bg: colorText}}, + } + // Between two words: the first chip's coloured pad, the UNcoloured + // separator, the second chip's coloured pad — three spaces, the middle + // one outside any coloured run, so chips read as separate entities. One + // more uncoloured space follows the block, before the subject. + if got := tagChipsPlain(chips); got != " work invoice " { + t.Errorf("plain = %q, want %q", got, " work invoice ") + } + // The separators are part of the width budget. + if w := tagChipsWidth(chips); w != tagSafeWidth(" work invoice ") { + t.Errorf("width = %d, want %d", w, tagSafeWidth(" work invoice ")) + } + if a, b := chips[0].styled(), chips[1].styled(); tagChipsStyled(chips) != a+" "+b+" " { + t.Error("styled chips must be joined by one uncoloured space plus one before the subject") + } +} + +func TestTagChips_NerdPillMode(t *testing.T) { + tagPillMode = true + defer func() { tagPillMode = false }() + + chip := tagChip{keyword: "work", view: tagRuleView{show: true}} + // Pill: half-disc caps + bare text, no padding spaces inside. + if got := chip.measure(); got != "\ue0b6work\ue0b4" { + t.Errorf("pill measure = %q, want caps without padding", got) + } + if a, b := "\ue0b6", "\ue0b4"; !strings.HasPrefix(chip.measure(), a) || !strings.HasSuffix(chip.measure(), b) { + t.Errorf("pill must start/end with the half-disc glyphs: %q", chip.measure()) + } + // Pills sit flush: no separator between pills, none before the subject — + // the caps ARE the delimiters. + chips := []tagChip{chip, {keyword: "invoice", view: tagRuleView{show: true}}} + if got := tagChipsPlain(chips); got != "\ue0b6work\ue0b4\ue0b6invoice\ue0b4" { + t.Errorf("pill block = %q", got) + } + // End caps are non-ASCII → budgeted as 2 cells each; no separator cells. + if w := tagChipsWidth(chips); w != 2+4+2+2+7+2 { + t.Errorf("pill width = %d, want flush pills with caps at 2 cells", w) + } + // Per-tag colours resolve explicitly on the view (caps take the bg). + cfg := config.TagsConfig{Rules: map[string]config.TagRule{ + "important": {FG: "#FFFF00", BG: "#8B0000"}, + }} + def, resolve, warn := buildTagStyles(&cfg, lipgloss.Color("#111111"), lipgloss.Color("#222222")) + if warn != "" { + t.Fatalf("unexpected warning: %q", warn) + } + v := resolve("important") + if v.fg != lipgloss.Color("#FFFF00") || v.bg != lipgloss.Color("#8B0000") { + t.Errorf("rule colours = %q/%q, want the configured hex", v.fg, v.bg) + } + if v.edgeStyle().GetForeground() != lipgloss.Color("#8B0000") { + t.Error("pill caps must take the rule's background colour") + } + if def.GetForeground() != lipgloss.Color("#111111") || def.GetBackground() != lipgloss.Color("#222222") { + t.Errorf("default style = %q/%q", def.GetForeground(), def.GetBackground()) + } +} + +func TestTagChips_PillPaddingRule(t *testing.T) { + tagPillMode = true + defer func() { tagPillMode = false }() + + // Pill mode adds NO padding of its own: a clean single-symbol display + // renders cap+content+cap with zero space runes anywhere… + clean := tagChip{keyword: "important", view: tagRuleView{show: true, display: "\uf0e7"}} + if got := clean.displayVerbatim(); got != "\uf0e7" { + t.Errorf("clean symbol = %q", got) + } + if strings.ContainsAny(clean.measure(), " ") { + t.Errorf("pill must not add spaces: %q", clean.measure()) + } + if got := tagChipsStyled([]tagChip{clean}); strings.Contains(stripANSI(got), " ") { + t.Errorf("pill styled output must contain no space: %q", got) + } + // …while display-typed spaces are honored as intentional pill padding — + // and copy-paste stowaways (variation selector U+FE0F) are stripped + // either way. + padded := tagChip{keyword: "important", view: tagRuleView{show: true, display: " \uf0e7\ufe0f "}} + if got := padded.displayVerbatim(); got != " \uf0e7 " { + t.Errorf("display spaces must be honored, VS16 stripped: %q", got) + } + // U+FE0E (text presentation) is the cure for symbols that render with an + // opaque emoji-fallback box — it must survive the stripping. + vs15 := tagChip{keyword: "important", view: tagRuleView{show: true, display: "\u26a1\ufe0e"}} + if got := vs15.displayVerbatim(); got != "\u26a1\ufe0e" { + t.Errorf("text-presentation selector must be preserved: %q", got) + } + if got, want := padded.measure(), tagPillLeft+" \uf0e7 "+tagPillRight; got != want { + t.Errorf("padded pill = %q, want %q", got, want) + } + + // Fallback always normalizes to exactly one standard pad per side, + // whatever spaces the display carries… + tagPillMode = false + opaque := func(v tagRuleView) tagRuleView { v.fg, v.bg = colorBg, colorText; return v } + if got := (tagChip{keyword: "important", view: opaque(padded.view)}).text(); got != " \uf0e7 " { + t.Errorf("fallback must trim display padding and add its own single pads: %q", got) + } + if got := (tagChip{keyword: "important", view: opaque(clean.view)}).text(); got != " \uf0e7 " { + t.Errorf("fallback text = %q", got) + } + // …but with a TRANSPARENT background the pads are skipped — they would + // be invisible dead cells — and the chip is tight fg-coloured text. + ghost := tagRuleView{show: true, display: " \uf0e7\ufe0f ", fg: colorBg} + if got := (tagChip{keyword: "important", view: ghost}).text(); got != "\uf0e7" { + t.Errorf("transparent fallback must render tight: %q", got) + } +} + +func TestTagsPicker_ShowsDisplayPreviewRightOfKeyword(t *testing.T) { + m := tagsTestModel(t, 1) + for _, kw := range []string{"plain", "fancy"} { + m.tags.store.Add("me@x", kw, "") + } + cfg := config.TagsConfig{Rules: map[string]config.TagRule{ + "fancy": {Display: "\uf0e7 Fancy"}, + }} + def, resolve, _ := buildTagStyles(&cfg, lipgloss.Color("#111111"), lipgloss.Color("#222222")) + tagRuleSource = resolve + styleTagChip = def + defer func() { tagRuleSource = nil }() + + mm := openPicker(t, m) + view := stripANSI(mm.viewTags()) + + // The fancy tag: keyword on the left, display preview to its right. + if !strings.Contains(view, "fancy") || !strings.Contains(view, "\uf0e7 Fancy") { + t.Errorf("picker must show keyword and display preview:\n%s", view) + } + iKw := strings.Index(view, "fancy") + iPrev := strings.Index(view, "\uf0e7 Fancy") + if iKw == -1 || iPrev == -1 || iPrev < iKw { + t.Errorf("preview must be to the RIGHT of the keyword (kw@%d prev@%d):\n%s", iKw, iPrev, view) + } + // Tags without a display show the keyword only. + if !strings.Contains(view, "plain") { + t.Errorf("plain tag missing from picker:\n%s", view) + } + + // Pill mode previews through the same path: the display keeps its + // verbatim spaces inside the caps. + tagPillMode = true + view2 := stripANSI(mm.viewTags()) + tagPillMode = false + if !strings.Contains(view2, "\ue0b6\uf0e7 Fancy\ue0b4") { + t.Errorf("pill preview missing caps + verbatim display:\n%s", view2) + } +} + +func TestTagsPicker_PreviewColumnStableWithoutCount(t *testing.T) { + m := tagsTestModel(t, 1) + m.tags.store.Add("me@x", "kept", "") // count 1 + m.tags.store.Add("me@x", "gone") // listed, count 0 (no ids) + cfg := config.TagsConfig{Rules: map[string]config.TagRule{ + "kept": {Display: "Kept Tag"}, + "gone": {Display: "Gone Tag"}, + }} + def, resolve, _ := buildTagStyles(&cfg, lipgloss.Color("#111111"), lipgloss.Color("#222222")) + tagRuleSource = resolve + styleTagChip = def + defer func() { tagRuleSource = nil }() + + mm := openPicker(t, m) + view := stripANSI(mm.viewTags()) + + // Both previews START at the same column of their own row (left-aligned + // in the fixed right-side column), whether or not a count is present. + startCol := func(match string) int { + for _, line := range strings.Split(view, "\n") { + if i := strings.Index(line, match); i >= 0 { + // cell column, not byte index (▸ is 3 bytes / 1 cell) + return runewidth.StringWidth(line[:i]) + } + } + t.Fatalf("preview %q missing:\n%s", match, view) + return -1 + } + if kept, gone := startCol("Kept Tag"), startCol("Gone Tag"); kept != gone { + t.Errorf("preview column must be fixed: kept starts at %d, gone at %d\n%s", kept, gone, view) + } + if !strings.Contains(view, "(1)") || strings.Contains(view, "(0)") { + t.Errorf("count column wrong:\n%s", view) + } +} + +func TestTagColors_TransparentBackground(t *testing.T) { + cfg := config.TagsConfig{Rules: map[string]config.TagRule{ + "ghost": {FG: "#FFFF00", BG: "transparent"}, + }} + def, resolve, warn := buildTagStyles(&cfg, lipgloss.Color("#111111"), lipgloss.Color("#222222")) + if warn != "" { + t.Fatalf("transparent must be a valid colour: %q", warn) + } + v := resolve("ghost") + if got := v.bodyStyle().GetBackground(); got != lipgloss.Color("") { + t.Errorf("transparent bg must emit no background, got %q", got) + } + if got := v.edgeStyle().GetForeground(); got != lipgloss.Color("#FFFF00") { + t.Errorf("pill caps must fall back to fg when bg is transparent, got %q", got) + } + // The default style is untouched by the transparent rule. + if def.GetBackground() != lipgloss.Color("#222222") { + t.Errorf("default bg changed: %q", def.GetBackground()) + } + if (config.TagsConfig{}).BG != "" { + t.Error("sanity") + } +} + +func TestCalculateSubjectMaxWithTags(t *testing.T) { + m := tagsTestModel(t, 1) + for _, kw := range []string{"work", "invoice", "project-x", "very-important"} { + m.tags.store.Add("me@x", kw, "") + } + chips := []string{"work", "invoice", "project-x", "very-important"} + + // Feature off / no chips / merge row → exactly the original calculation. + orig := 120 - 29 - 20 - 2 + if got, subj, c := calculateSubjectMaxWithTags(120, 29, 20, "s", nil, false); got != orig || subj != "s" || c != nil { + t.Errorf("no-keywords: %d %q %v, want original formula %d", got, subj, c, orig) + } + if got, _, _ := calculateSubjectMaxWithTags(58, 29, 20, "s", nil, false); got != 8 { + t.Errorf("floor lost: %d, want 8", got) + } + if got, _, c := calculateSubjectMaxWithTags(120, 29, 20, "s", chips, true); c != nil || got != orig { + t.Errorf("merge rows must not chip: %d %v", got, c) + } + + // Wide: all chips keep their budget. + all := tagChipKeywords(chips) + want := orig - tagChipsWidth(all) + if got, subj, c := calculateSubjectMaxWithTags(120, 29, 20, "subject", chips, false); got != want || subj != "subject" || len(c) != 4 { + t.Errorf("wide: %d %q %d chips, want %d with 4 chips", got, subj, len(c), want) + } + // Narrow (62): first chip only + ellipsis subject. + fw := tagChipsWidth(all[:1]) + if got, subj, c := calculateSubjectMaxWithTags(62, 29, 20, "subject", chips, false); got != (62-51)-fw || subj != "…" || len(c) != 1 { + t.Errorf("narrow: %d %q %d chips, want first-chip + ellipsis", got, subj, len(c)) + } + // Very narrow (58): not even one chip fits → original behaviour, no chips. + if got, subj, c := calculateSubjectMaxWithTags(58, 29, 20, "subject", chips, false); c != nil || subj != "subject" { + t.Errorf("tight: must drop chips and keep the subject, got %d %q %v", got, subj, c) + } +}