Repository navigation
Email tags via IMAP keywords — <space>k picker, subject chips - #38
Conversation
RFC 9051 user keywords are the standard server-side tagging mechanism, and the FLAGS list already arrives with every FetchHeaders — so non-system flags are parsed into a new Email.Keywords field at zero extra round trips (sorted: server flag order is map-random). - AddKeyword / RemoveKeyword: STORE via withConn — mutating ops are never retried (repo invariant). Lowercase on write; every comparison is EqualFold (some servers normalize the case). - KeywordProbe: save → FETCH-verify → UID SEARCH → cleanup round trip on one message, reporting PERMANENTFLAGS (incl. the \* wildcard), STORE, SEARCH and cleanup as separate steps with a verdict — the per-account capability check for tagging. Probe evidence: Infomaniak has full support (STORE + SEARCH + \*), keywords persist across sessions and stay invisible-but-preserved in their webmail. Tests (memserver, no network): TestMem_AddRemoveKeyword_RoundTrip, TestMem_KeywordCaseInsensitive, TestMem_FetchHeaders_ParsesKeywords, TestMem_FetchHeadersByUID_ParsesKeywords, TestMem_MovePreservesKeyword, TestMem_StoreKeyword_MissingMailboxErrors, TestKeywordProbe_*.
Local write-through backup of the server-side IMAP keyword tags, and
the "known tags" list for the picker and chip filter. A provider could
someday purge unknown keywords (not an officially supported feature
server-side) — the registry is what a restore would re-apply from.
Layout: <config dir>/tags/<account-address>/<keyword>.txt — one folder
per account (keyed by the bare lowercased From address, which is a
valid folder name on every supported platform; the per-account split
keeps a later restore a single-account operation), one line-based
Message-ID list per keyword, screener-list style (# comments, one ID
per line). No human editing expected, so no TOML. Message-ID keys
survive folder moves (merges.toml precedent). Atomic per-file saves;
a keyword stays known even when its last id is removed.
NormalizeKeyword enforces the atom charset [a-z0-9][a-z0-9-_.]{0,31}
and rejects reserved keywords (every $ or \ prefix — system flags and
other MUAs' conventions) with the picker's exact error message.
Tests: TestTagsStore_RoundTripPerAccountFiles,
TestTagsStore_AccountsAreIsolated, TestTagsStore_EmailAddressKeyVerbatim,
TestTagsStore_FileFormatComments, TestTagsStore_AddDedup,
TestTagsStore_RemoveKeepsKeywordKnown, TestTagsStore_UnsafeAccountNameSanitized,
TestNormalizeKeyword, TestKeywordListHelpers, TestNilStoreSafe.
Opt-in: tagging stays off until enabled = true (the <space>k binding
is inert otherwise). key (default "k") names the leader chord.
Colours ([tags] fg/bg — the defaults every tag falls back to):
- hex ("#1F1F28"), ANSI number ("21"), or for bg "transparent"/"none"
to emit no background at all — the only real transparency terminals
offer, the terminal's own shows through
- unknown values fall back to the theme's reverse video and are named
in the startup notice (lipgloss would silently drop them)
Per-tag sub-tables [tags.<keyword>] override, display-only:
- display: chip text — nerd-font symbols welcome, the stored keyword
never changes
- fg/bg: same value formats; empty fields inherit the [tags] defaults
- enabled = false: hides that keyword's chips (the tag stays on the
server and in the picker)
Sub-tables are invisible to the struct TOML decode (they would need
one field per tag), so Load() extracts them with a raw second pass
(parseTagRules). TagsDir points at <config dir>/tags/.
Tests: TestLoad_ParsesPerTagRules, TestLoad_SetsTagsDirNextToConfig,
TestLoad_ParsesPerTagRules, TestTagsConfig_NerdPill,
TestTagsConfig_EnabledOptIn.
<space>k opens a tag picker on the marked set or cursor email (same target rule as every bulk action). Keys: j/k move, enter/space toggle, a adds a new tag (lowercased, atom-charset validated, reserved $/\ keywords rejected), / filters, esc/q close. Rows show all/some verdicts (✓/~), the registry count, and — for tags with a display value — a live chip preview in a fixed column, rendered through the identical path as the inbox row. Toggling is optimistic exactly like the n \Seen toggle: chips flip at once in the list, the folder cache and the picker's captured targets; the STOREs run behind the list (never a spinner, never a reload); the pending overlay keeps a fetch landing mid-flight from snapping chips back; an error reverts exactly the ops that never reached the server, with the error in the status line. imap_disabled accounts skip the server write and say "tag saved locally". Every successful toggle writes through to the per-account registry; the account key is captured per-op, so a done message landing after an account switch still writes the right account. Chips render BEFORE the subject, as colour chips: - fallback (default): the display text with one space of coloured padding per side; uncoloured separator spaces between chips and one before the subject; with bg = transparent the pads are skipped (invisible dead cells) and the chip is tight fg-coloured text - [tags] nerd_pill = true: half-disc end caps (U+E0B6/U+E0B4) in the chip's background colour, no renderer-added padding — typed spaces in a display are honored verbatim; VS16 stowaways (emoji presentation) are stripped, VS15 (text presentation) preserved so emoji-range symbols can opt out of the opaque emoji-fallback box - width accounting: chip width is subtracted from the subject budget, non-ASCII runes budgeted as 2 cells (font-dependent icon widths can only leave slack), degrading to first chip + ellipsis, then to no chips — rows never overflow (TestRowFitsTerminalWidth invariant) - reserved keywords never chip; only registry-known tags do, so foreign flags like $HasAttachment or $label1-5 never render The probe dialog ships as :keyword-test / :kt (same protocol layer as the imap commit): per-step report, verdict, keep mode for persistence checks; side-effect-free afterwards. All tag state lives in tagsModel / keywordTestModel sub-models in tags.go / keywordtest.go (the composeModel pattern), so model.go only carries the one-line wiring: two struct fields, newTagsModel in New, and the Update/View dispatch cases. Also: keybindings rows in keys.go (docs regenerate via make docs), :kt command in cmdline.go, chip styles in styles.go (theme reverse video default; [tags] fg/bg override without touching the built-in palettes). Tests: TestTagsPicker_ToggleOptimisticFlip, TestTagsPicker_RefreshLandingMidFlightKeepsChip, TestTagsCmd_ErrorReverts, TestTagsPicker_NewTagLowercased, TestTagsPicker_InvalidKeywordRejected, TestTagsPicker_ReservedKeywordRejected, TestTagsPicker_LocalOnlyAccount, TestTagsPicker_DisabledConfigRefused, TestTagsPicker_EscCloses, TestTagsPicker_ShowsDisplayPreviewRightOfKeyword, TestTagsPicker_PreviewColumnStableWithoutCount, TestTagChips_WidthAccounting, TestTagChips_OnlyRegistryKeywordsRender, TestTagChips_SeparatedByUncoloredSpace, TestTagChips_NerdPillMode, TestTagChips_PillPaddingRule, TestTagRules_DisplayTextAndHidden, TestTagColors_TransparentBackground, TestResolveTagColor, TestBuildTagStyles_WarnsOnUnknownColors, TestTagVerdict, TestCalculateSubjectMaxWithTags, TestKeywordTestVerdict.
- docs/content/docs/configuration: annotated [tags] block (incl. the commented per-tag example) in the full config example, plus an "Email Tags (IMAP Keywords)" section — opt-in, chips/pills, registry layout, :kt probe, Infomaniak evidence callout - README: feature bullet under "What Makes It Different" (synced to the docs site _index via make docs) - keybindings.md: regenerated from keys.go (<space>k picker, :kt) - CHANGELOG: dated feature entry with the full behaviour + test list - AGENTS.md: new "IMAP Keyword Tags" invariant section (registry is the backup, reserved keywords blocked, optimistic-toggle contract, chip width rules, probe leaves no trace) - CLAUDE.md: architecture bullet for the tags packages
|
Holy moly, this is beautiful!! I want this, I didn't even know that something like this exists in standard email (RFC). Thanks so much for your work and the great explanation, I'm going to use that for the docs. Give me some time to merge and verify, but that's definitely going to be added. Amazing. |
|
make sure merge #35 (comment) ,before this |
|
The red ❌ on the What failed: Pre-existing: on plain Fix: same class as the flake in #35 ( |
|
Apparently Thunderbird too, as someone suggested: https://support.mozilla.org/en-US/kb/message-tags
when i get some time, i will investigate to how this can we used best, but I think you laid out all the needed steps or at least looks amazing already - probably less color as I don't want too much noise and focus on the emails: |
|
thats why the default setting is just inverted , simple. but users can use colors, if they want 😁 |
That's amazing. I will try just nerd icons, none colored. But just the text as first example, without color or just as is, might be perfect too, need to test it. |
|
check my plugin for easy nerdfonts insertion: |
Amazing, will do! You built great stuff, similar style to mine, do you have more? 😉 I just integrated your picker into my emoji picker (sspaeti/dotfiles@abe563a): https://x.com/sspaeti/status/2108128332332318851. Amazing. |
|
my best project is still private on github, i am soon opening it up |
|
Amazing, OK I added it 1:1 plus added a mono option, set is by default disabled as not everyone had nerdfonts or support of tags from their provider. I changed the shortcut to I also added a filter function, so per list you can actually see only filtered tags. The documentation is here: https://neomd.ssp.sh/docs/configuration/tags/ It's in in main and I'm testing it a bit more - new release Thanks again for this great feature 🙏. |


Motivation
Next to the existing folders, tags are the one thing i've missed in neomd.
So i did some research and found it is possible with IMAP message keywords (RFC 9051 user flags)
Both of my tested providers (mail.ch and infomaniak.com) support them, they are on their mail client invisible but stored in the imap data.
They are not part of the message body so .eml files do not carry them.
How it looks
Standard View
The configuration (simply turn the feature on, by default it is off, will produce the result above)
Nerdfont Pill support
just enable the option:
Each Tag can be colored, enabled/disabled, and show it's custom Display text
Note: the effective tags are limited to the IMAP specifications, and always lowercase to avoid problems

example config, just add a section for each tag you want to customize:
Using Nerdfonts
Note: Omarchy has by default Nerdfonts support, so nothing special to do, except to insert the glyph into your config.
(this author has a special enhanced emoji picker version in the omarchy plugins site to easily select nerdfont symbols)
config example for "test" tag
Compact Nerdfonts Glyph-only
This is my favorite view, using glyphs only with a "none" background, if your terminal is transparent it will support that.

full config example for this config:
Tag picker ,default k
Easily select, add new Tags and see what's selected on the currently selected emails (multiple possible)

What this is for (more Details)
Email tagging, the way you'd expect it in a modern client: press
<space>kon an email (or a marked set), toggle tags on it, and see them as small
coloured chips before the subject in the list.
The difference to a local only solution: tags are stored as IMAP keywords on the message itself
(RFC 9051 user flags — the standard tagging mechanism IMAP has always had),
so they live on the server, survive reinstalls, follow the mail into other
keyword-aware clients, and cost zero extra round trips in normal operation.
The feature is opt-in:
[tags] enabled = truein config.toml. Untilthen the
<space>kbinding is inert and nothing else changes.Note: in the config folder a new subfolder 'tags' is created when the feature is on,
to have a local backup, for possible restore feature (not yet part of this commit)
In the same style as the lists, where each file is a list of message-ids for that tag, one per line.
Technical implementation overview
Four small layers, each additive:
internal/imap/): the FLAGS list already arrives withevery
FetchHeaders, so non-system flags are parsed into a newEmail.Keywordsfield — no additional protocol round trips anywhere onthe read path. Toggling a tag is one
STORE +FLAGS/-FLAGSper message,issued through
withConnlike every other mutating op (never retried).internal/ui/tags.go): a picker dialog (verdicts✓/~forall/some targets, inline new-tag input with charset validation,
/filter) and the chip renderer. Toggling is optimistic exactly like the
existing
nseen-toggle: the chip flips at once in list and foldercache, the STORE runs behind the list, an error reverts with the status
line, and an overlay keeps an in-flight refresh from snapping the chips
back. No spinner, no folder reload.
internal/tags/): every successful toggle writesthrough to
<config dir>/tags/<account-address>/<keyword>.txt— oneline-based Message-ID list per keyword (screener-list style). This is
the backup if a provider ever purges unknown keywords (they are not an
officially supported feature server-side) and the "known tags" list
that keeps foreign keywords from ever rendering as chips. One folder
per account, so a later restore can target a single account.
internal/config/):[tags](enabled, key, chip colours)plus per-tag sub-tables
[tags.<keyword>]for display text (nerd-fontsymbols welcome — display-only, the stored keyword never changes),
colours, and visibility (
enabled = falsehides that tag's chipswithout touching the server).
Chip rendering has strict width accounting (chip width is subtracted from
the subject budget, degrading gracefully on narrow terminals) — rows with
chips never overflow, extending the existing
TestRowFitsTerminalWidthinvariant. A
[tags] nerd_pill = truemode renders chips as nerd-fontpills with half-sphere end caps;
bg = "transparent"emits no backgroundat all, for transparent terminals best used with nerdfont glyphs only display.
:keyword-test(:kt) ships with the feature as the per-accountcapability probe: it stores one test keyword on the selected email,
verifies it via FETCH and
UID SEARCH, removes it again, and reportsevery step (incl.
PERMANENTFLAGS \*) with a verdict.Why IMAP keywords
sidecar state, no heuristics.
:ktdialog, run against real accounts):Infomaniak has full support —
PERMANENTFLAGS … \*advertised, STORE,UID SEARCH KEYWORDand cleanup all pass; keywords persist acrosssessions and are invisible-but-preserved in their webmail. Third-party
keywords (
$HasNoAttachment) already live on that server, so themechanism is in active use by other clients.
merge-sync" idea in
docs/superpowers/specs/2026-09-18-merge-threads-design.md:merge state (free-text titles, sender rules) cannot be a keyword, but
per-message tags are exactly what keywords are for.
fails with a clear status-line error (and reverts locally);
:ktdocuments what the account's server can do.
Notes for review
Per CONTRIBUTING, the load-bearing areas this touches:
<space>k(configurable via[tags] key) and the:keyword-test/:ktcommand;keys.gorows andthe generated keybindings docs.
FetchHeaders/FetchHeadersByUID(append non-system flags toEmail.Keywords) andthe new
AddKeyword/RemoveKeywordSTOREs. No changes to threading,screener, compose/send, moves/undo, daemon, or theme palettes.
Further notes:
enabled = falseby default) so nothing changes for existingusers until they ask for it — happy to flip the default if you prefer.
ui → docs) and each builds and passes its tests standalone.
and ANSI numbers only, unknown values fall back with a startup-notice
warning. Named colours could be a separate follow-up.
Test plan
Ran (all green):
go test ./...— unit suite, incl. new memserver tests:TestMem_AddRemoveKeyword_RoundTrip,TestMem_KeywordCaseInsensitive,TestMem_FetchHeaders_ParsesKeywords,TestMem_MovePreservesKeyword,registry tests (
TestTagsStore_*), and the UI suite(
TestTagsPicker_*,TestTagChips_*,TestTagRules_*,TestTagsCmd_ErrorReverts, …).go test ./... -run Hardening— the offline round-trip hardening suite.go test -race ./internal/ui ./internal/tags.go vet ./...,gofmton all touched files (the two pre-existingfiles that drift under newer local Go versions are untouched).
TestIntegration_*, all 27) against a realInfomaniak account — a second provider besides the Hostpoint demo.
folders, restart persistence (server + registry), reserved-keyword
rejection, nerd-pill mode, transparent backgrounds, multi-account
switching (
ctrl+a), per-tag display/colour overrides, picker previewcolumn.
Suggested review order:
internal/imap/tags.go→internal/tags/store.go→ the
[tags]block ininternal/config/config.go→internal/ui/tags.go→ the chip block in
internal/ui/inbox.go.Configuration example