Skip to content

Email tags via IMAP keywords — <space>k picker, subject chips - #38

Merged
sspaeti merged 5 commits into
ssp-data:mainfrom
farangkao:feat/imap-keyword-tags
Oct 18, 2026
Merged

sspaeti merged 5 commits into
ssp-data:mainfrom
farangkao:feat/imap-keyword-tags

Conversation

@farangkao

@farangkao farangkao commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

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

neomd-tags1

The configuration (simply turn the feature on, by default it is off, will produce the result above)

[tags]
enabled = true        

Nerdfont Pill support

neomd-tags2

just enable the option:

[tags]
enabled = true
nerd_pill = true

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
neomd-tags3
example config, just add a section for each tag you want to customize:

[tags.important]
enabled = true
display = "Important"
fg = "#FF9900"
bg = "#660000"

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)

neomd-tags4

config example for "test" tag

[tags.test]
enabled = true
display = " Testing"
fg = "#00FF00"
bg = "#006600"

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.
neomd-tags5

full config example for this config:

[tags]
enabled = true 
nerd_pill = false
key = "k" 

[tags.important]
enabled = true
display = ""
fg = "#FF9944"
bg = "none"

[tags.test]
enabled = true
display = ""
fg = "#00FF00"
bg = "none"

[tags.help]
enabled = true
display = ""
fg = "#777777"
bg = "none"

Tag picker ,default k

Easily select, add new Tags and see what's selected on the currently selected emails (multiple possible)
neomd-tags6

What this is for (more Details)

Email tagging, the way you'd expect it in a modern client: press <space>k
on 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 = true in config.toml. Until
then the <space>k binding 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:

  1. IMAP layer (internal/imap/): the FLAGS list already arrives with
    every FetchHeaders, so non-system flags are parsed into a new
    Email.Keywords field — no additional protocol round trips anywhere on
    the read path. Toggling a tag is one STORE +FLAGS/-FLAGS per message,
    issued through withConn like every other mutating op (never retried).
  2. UI (internal/ui/tags.go): a picker dialog (verdicts ✓/~ for
    all/some targets, inline new-tag input with charset validation, /
    filter) and the chip renderer. Toggling is optimistic exactly like the
    existing n seen-toggle: the chip flips at once in list and folder
    cache, 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.
  3. Local registry (internal/tags/): every successful toggle writes
    through to <config dir>/tags/<account-address>/<keyword>.txt — one
    line-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.
  4. Config (internal/config/): [tags] (enabled, key, chip colours)
    plus per-tag sub-tables [tags.<keyword>] for display text (nerd-font
    symbols welcome — display-only, the stored keyword never changes),
    colours, and visibility (enabled = false hides that tag's chips
    without 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 TestRowFitsTerminalWidth
invariant. A [tags] nerd_pill = true mode renders chips as nerd-font
pills with half-sphere end caps; bg = "transparent" emits no background
at all, for transparent terminals best used with nerdfont glyphs only display.

:keyword-test (:kt) ships with the feature as the per-account
capability probe: it stores one test keyword on the selected email,
verifies it via FETCH and UID SEARCH, removes it again, and reports
every step (incl. PERMANENTFLAGS \*) with a verdict.

Why IMAP keywords

  • They are the RFC-standard tagging mechanism — no custom headers, no
    sidecar state, no heuristics.
  • Probe evidence (the :kt dialog, run against real accounts):
    Infomaniak has full support — PERMANENTFLAGS … \* advertised, STORE,
    UID SEARCH KEYWORD and cleanup all pass; keywords persist across
    sessions and are invisible-but-preserved in their webmail. Third-party
    keywords ($HasNoAttachment) already live on that server, so the
    mechanism is in active use by other clients.
  • This is deliberately different from the rejected "keywords for
    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.
  • Graceful degradation: on a provider without keyword support the toggle
    fails with a clear status-line error (and reverts locally); :kt
    documents what the account's server can do.

Notes for review

Per CONTRIBUTING, the load-bearing areas this touches:

  • Keybindings: one new leader chord <space>k (configurable via
    [tags] key) and the :keyword-test/:kt command; keys.go rows and
    the generated keybindings docs.
  • IMAP paths: the two FLAGS parse loops in FetchHeaders /
    FetchHeadersByUID (append non-system flags to Email.Keywords) and
    the new AddKeyword/RemoveKeyword STOREs. No changes to threading,
    screener, compose/send, moves/undo, daemon, or theme palettes.

Further notes:

  • Opt-in (enabled = false by default) so nothing changes for existing
    users until they ask for it — happy to flip the default if you prefer.
  • The five feature commits are split by layer (imap → registry → config →
    ui → docs) and each builds and passes its tests standalone.
  • Colour names ("yellow" etc.) are intentionally not supported — hex
    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 ./..., gofmt on all touched files (the two pre-existing
    files that drift under newer local Go versions are untouched).
  • Live integration suite (TestIntegration_*, all 27) against a real
    Infomaniak account — a second provider besides the Hostpoint demo.
  • Manual testing in the TUI: single and bulk toggling, marked sets across
    folders, restart persistence (server + registry), reserved-keyword
    rejection, nerd-pill mode, transparent backgrounds, multi-account
    switching (ctrl+a), per-tag display/colour overrides, picker preview
    column.

Suggested review order: internal/imap/tags.go → internal/tags/store.go
→ the [tags] block in internal/config/config.go → internal/ui/tags.go
→ the chip block in internal/ui/inbox.go.

Configuration example

[tags]
enabled   = true        # opt-in master switch
key       = "k"         # <space>k opens the picker
nerd_pill = false       # half-sphere pill caps (needs a nerd font)
fg        = "#1F1F28"   # default chip colours (empty = theme reverse video)
bg        = "#DCD7BA"   # or "transparent" for no background at all

[tags.important]        # per-tag overrides — display only
enabled   = true        # false hides this tag's chips (tag stays on the server)
display   = " Important"  # nerd-font symbols welcome
fg        = "#FFFF00"
bg        = "#8B0000"

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
@sspaeti

sspaeti commented Oct 6, 2026

Copy link
Copy Markdown
Member

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.

@farangkao

Copy link
Copy Markdown
Contributor Author

make sure merge #35 (comment) ,before this

@farangkao

Copy link
Copy Markdown
Contributor Author

The red ❌ on the test check is a pre-existing flake on main, not a regression from this PR.

What failed: TestApplySenderRules_PersistsMatches with TempDir RemoveAll cleanup: unlinkat … directory not empty. applySenderRules persists merges.toml asynchronously (safeGo(store.Save)), and the test's t.TempDir() cleanup races that goroutine. Nothing in the tags code is involved — this branch doesn't touch that test or applySenderRules.

Pre-existing: on plain main (the commit this branch is based on) it reproduces with go test ./internal/ui -run TestApplySenderRules_PersistsMatches -count=300 — it survives ~50 runs by luck; GitHub runners' slower disks raise the odds.

Fix: same class as the flake in #35 (TestHarvestTypedRecipients). I've extended #35 to cover this test too, with one shared waitForFile helper for both — merging #35 first clears this CI failure. (So the merge order note was right, just for a different reason than it looked 🙂)

@farangkao

Copy link
Copy Markdown
Contributor Author

Today i've checked my GMail account (which is more a spam account 😄 )

it also supports the IMAP keywords:

image

@sspaeti

sspaeti commented Oct 8, 2026

Copy link
Copy Markdown
Member

Apparently Thunderbird too, as someone suggested: https://support.mozilla.org/en-US/kb/message-tags

image

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:

@farangkao

Copy link
Copy Markdown
Contributor Author

thats why the default setting is just inverted , simple. but users can use colors, if they want 😁

@sspaeti

sspaeti commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

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.

@farangkao

Copy link
Copy Markdown
Contributor Author

check my plugin for easy nerdfonts insertion:
https://github.com/farangkao/omarchy-emojis-nerd

@sspaeti

sspaeti commented Oct 8, 2026

Copy link
Copy Markdown
Member

check my plugin for easy nerdfonts insertion: farangkao/omarchy-emojis-nerd

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.

@farangkao

Copy link
Copy Markdown
Contributor Author

my best project is still private on github, i am soon opening it up

@sspaeti
sspaeti merged commit ab4266b into ssp-data:main Oct 18, 2026
1 check failed
@sspaeti

sspaeti commented Oct 18, 2026

Copy link
Copy Markdown
Member

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 space-t for tags, which is more natural to me, hope that's ok. k is more for common shortcuts IMO.

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 10.0.2. Let me know if you see any errors or things that don't work

Thanks again for this great feature 🙏.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants