Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; `<config dir>/tags/<address>/` (`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.<keyword>] 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 `<space>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** — `<space>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.<keyword>]` 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`.
- **`<space>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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <timestamp of the first error after the last success>`, 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 (`<space>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). `<space>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.<keyword>]` 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 `<config dir>/tags/<address>/` — 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.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). `<config dir>/tags/<account-address>/<keyword>.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.<keyword>]` 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/<name>/password`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<space>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
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<space>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
Expand Down
Loading
Loading