diff --git a/CHANGELOG.md b/CHANGELOG.md index dd88bed..025f2f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +# 2026-05-06 +- **Theming with 6 built-in palettes** — pick via `[ui].theme = "kanagawa"` (default, byte-for-byte identical to pre-theme state), `kanagawa-paper`, `kanagawa-light` (only light theme — Lotus palette on a paperwhite `#F2EFE9` background), `rose-pine`, `gruvbox`, or `osaka-jade`. Optional top-level `[theme]` block overrides individual colour slots (`primary`, `unread`, `error`, `bg`, …) on top of any built-in. `ApplyTheme` runs in `ui.New()` before any rendering, so theme switching needs only a config edit + restart. Regression test verifies the kanagawa default does not drift; theme tests cover override merge, unknown-name fallback, and round-trip uniqueness +- **AI handoff key in pre-send (`i`)** — new `[ai].command` config (default `claude`) wires any LLM CLI (`claude`, `codex`, `aichat`, `sgpt`, …) to the pre-send `i` key. neomd writes the current draft to `/tmp/neomd/neomd-ai-*.md` with the standard `# [neomd: ...]` headers, spawns ` [args...] `, and re-reads the file on exit so header and body edits round-trip back into the draft (same parser as the regular editor flow). Quit the AI tool to return to neomd's pre-send screen. Pre-send footer surfaces the active command (`i AI (claude, quit to return)`). `nvim` is intentionally not a useful choice here — the compose buffer is already in nvim before pre-send, so spawning nvim on `i` would just re-edit. Set `command = ""` to disable the binding +- **iCalendar RSVP + local calendar handoff** — emails with a `text/calendar` part or `.ics` attachment now show a `📅` summary card in the reader header (`event title · date · location`). Leader chord ` v {a|d|t|o}` sends an RFC 5546/6047 (iMIP) accept/decline/tentative reply, or opens the `.ics` in `[calendar].open_command` (default `xdg-open`, set to `morgen`/`khal`/`/usr/bin/gnome-calendar` to force a specific app). MIME envelope: `multipart/mixed > [multipart/alternative > (text/plain + text/calendar;method=REPLY)] + .ics attachment` with `Subject: Accepted: ` (matches Gmail's native button format) and bracketed RFC 5322 `In-Reply-To` headers. RSVPs save to the `Sent` folder and mark the original invite `\Answered`. **Reliability note:** Outlook 365 / Exchange / Apple iCloud / CalDAV servers auto-process iMIP REPLIES server-side; Gmail has deprioritized iMIP processing in 2026, so Gmail organizers may need to manually note your reply (or just use Gmail's native Yes/No button for Gmail-originated invites). New self-contained `internal/calendar/` package on top of `arran4/golang-ical`; new `internal/smtp/rsvp.go` for the iMIP MIME envelope. Only the first `VEVENT` is processed; recurring rules, counter-proposals, and cancellations are out of scope +- **OS keyring credential storage** ([#5](https://github.com/ssp-data/neomd/pull/5), thanks [@notthatjesus](https://github.com/notthatjesus)) — set `password = "keyring"` in any `[[accounts]]` block to fetch the IMAP/SMTP password from the OS keyring (macOS Keychain, Linux Secret Service via gnome-keyring/kwallet, Windows Credential Manager) at startup. OAuth2 tokens also persist in the keyring with explicit file fallback for headless/SSH systems where no keyring service is available. Sentinel resolution runs inside `config.Load()` so every consumer — IMAP at boot, SMTP at send, `[[senders]]` aliases that reference an account — sees the resolved password automatically without per-call lookups. New `internal/keyring/` package with mock-backed tests; storage keys are `neomd/account//{password|oauth2}` + # 2026-05-04 - **Fix: send from `imap_disabled = true` account no longer panics** — sending an email from an account configured as send-only (typically Gmail with `imap_disabled = true`) crashed the TUI with `nil pointer dereference` in `tokenSourceFor` because the helper called `.TokenSource()` on the intentionally-nil IMAP client. The same nil-deref existed in `imapCli`, `imapCliForAccount`, and `primaryIMAPClient` — any code path that resolved an IMAP client for a send-only account would crash. All four helpers now skip nil entries (and fall back to the first non-nil client where appropriate); `sendEmailCmd` also guards `cli.SaveSent` and `replyCli.MarkAnswered` so a fully send-only configuration silently skips the Sent-folder copy instead of panicking. Four regression tests added in `internal/ui/imap_client_helpers_test.go` - **Fix: notify state key keeps IMAP folder name (preserves baselines across upgrades)** — yesterday's label-normalisation fix accidentally changed the persisted state key from `Personal|INBOX` to `Personal|Inbox`, which would have re-baselined every existing user on upgrade and silently swallowed one round of notifications. `MaybeNotify` now takes both the IMAP name (used for the state key) and the UI label (used only for the allowlist comparison) so existing `notify_state.json` files keep working untouched. Regression test added diff --git a/CLAUDE.md b/CLAUDE.md index 91611b7..4aacdfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,7 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c - `internal/daemon/` — headless background mode (`--headless`): screener loop without TUI - `internal/mailtls/` — TLS/STARTTLS connection helpers - `internal/oauth2/` — OAuth2 flow for Gmail/Office365 +- `internal/calendar/` — iCalendar (.ics) parsing + iMIP RSVP reply construction (`arran4/golang-ical`); used by reader card and ` v {a|d|t}` chord - `internal/integration_test.go` — integration tests (live IMAP/SMTP); lives at package level, not in a sub-package **Spy pixel detection** (`internal/imap/tracker_list.go` + `client.go`): Two-layer approach — (1) curated denylist of 150+ tracking services in `KnownTrackers` with `IdentifyTracker()` for attribution ("Mailchimp", "HubSpot"); (2) generic 1×1 pixel heuristic via `detectSpyPixels()` on raw HTML. Results flow through `SpyPixelInfo` struct returned by `FetchBody()` and `ScanSpyPixels()`. Cached to `~/.cache/neomd/spy_pixels` (format: `+key` for spy, `-key` for scanned clean). diff --git a/README.md b/README.md index 3a5700a..cd14b50 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Keep your inbox clean without effort. ### Composing & Sending - **Pre-send review** — after closing the editor, review To/Subject/body before sending; attach files, save to Drafts, or re-open the editor — no accidental sends [→](https://neomd.ssp.sh/docs/sending/#pre-send-review) +- **AI handoff (`i` in pre-send)** — `[ai].command` (default `claude`) wires any LLM CLI (claude, codex, aichat, …) to the pre-send `i` key; neomd writes the draft to a temp markdown, spawns the command, and re-reads on exit when you quit the tool — header and body edits round-trip back. No in-app AI dependency [→](https://neomd.ssp.sh/docs/configuration/#ai-handoff-pre-send-i-key) - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within Neovim via `a`; the reader lists all attachments and `1`–`9` downloads and opens them [→](https://neomd.ssp.sh/docs/sending/#attachments) - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed [→](https://neomd.ssp.sh/docs/sending/#emoji-reactions) - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [→](https://neomd.ssp.sh/docs/keybindings/#multi-select--undo) @@ -166,6 +167,7 @@ Keep your inbox clean without effort. ### Reading - **Threaded inbox** — related emails grouped together with a vertical connector line (`│`/`╰`), Twitter-style; threads detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [→](https://neomd.ssp.sh/docs/reading/#threaded-inbox) +- **iCalendar RSVP** — meeting invites (`text/calendar` / `.ics`) show a `📅` card in the reader; leader chord ` v {a|d|t}` sends an RFC 5546/6047 (iMIP) accept/decline/tentative reply; ` v o` hands the `.ics` off to your local calendar app via `[calendar].open_command` (default `xdg-open`, set to `morgen`, `khal`, etc.) [→](https://neomd.ssp.sh/docs/configuration/#calendar-invites-icalendar--imip) - **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [→](https://neomd.ssp.sh/docs/reading/#conversation-view) - **Link opener** — links in emails are numbered `[1]`–`[0]` in the reader header; press `space+digit` to open in `$BROWSER` [→](https://neomd.ssp.sh/docs/reading/#links) - **Everything view** — `ge` or `:everything` shows the 50 most recent emails across all folders; find emails that were screened out, moved to spam, or otherwise hard to locate [→](https://neomd.ssp.sh/docs/keybindings/#folders) @@ -179,6 +181,7 @@ Keep your inbox clean without effort. - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients [→](https://neomd.ssp.sh/docs/sending/#cc-bcc-reply-all-and-forward) - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) [→](https://neomd.ssp.sh/docs/sending/#drafts) - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder [→](https://neomd.ssp.sh/docs/sending/#multiple-from-addresses) +- **OS keyring credentials** — set `password = "keyring"` to fetch the IMAP/SMTP password from your OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager); OAuth2 tokens also stored in keyring with file fallback for headless/SSH; resolution happens at config load so `[[senders]]` aliases inherit the resolved password automatically [→](https://neomd.ssp.sh/docs/configuration/#storing-passwords-in-the-os-keyring) - **HTML signatures** — configure separate text and HTML signatures; text signature appears in editor and plain text part, HTML signature in HTML part only; use `[html-signature]` placeholder to control inclusion per-email [→](https://neomd.ssp.sh/docs/configuration/#html-signatures) - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` @@ -186,7 +189,7 @@ Keep your inbox clean without effort. - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required; stays in sync if you use it on mobile or different device [→](https://neomd.ssp.sh/docs/configuration/) - **RFC 5322 compliant email delivery** — Message-IDs use sender's domain, proper MIME multipart/alternative structure (text/plain before text/html), quoted-printable encoding, and all required headers; ensures deliverability across all providers, spam filter compatibility, and correct email threading [→](https://neomd.ssp.sh/docs/configuration/email-standards/) -- **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette +- **Themes** — six built-in palettes (`kanagawa` default, `kanagawa-paper`, `kanagawa-light` for daylight terminals, `rose-pine`, `gruvbox`, `osaka-jade`); pick via `[ui].theme = "..."` and override individual colour slots in an optional `[theme]` block [→](https://neomd.ssp.sh/docs/configuration/#theming) > [!NOTE] > neomd's **speed** depends entirely on your IMAP provider. On Hostpoint (the provider I use), a folder switch takes **~33ms** which feels instant. On Gmail, the same operation takes **~570ms** which is noticeably slow. See [Benchmark](#benchmark) for full details and how to test your provider. diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index cf3a67a..e4bb937 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -161,6 +161,7 @@ Keep your inbox clean without effort. ### Composing & Sending - **Pre-send review** — after closing the editor, review To/Subject/body before sending; attach files, save to Drafts, or re-open the editor — no accidental sends [→](https://neomd.ssp.sh/docs/sending/#pre-send-review) +- **AI handoff (`i` in pre-send)** — `[ai].command` (default `claude`) wires any LLM CLI (claude, codex, aichat, …) to the pre-send `i` key; neomd writes the draft to a temp markdown, spawns the command, and re-reads on exit when you quit the tool — header and body edits round-trip back. No in-app AI dependency [→](https://neomd.ssp.sh/docs/configuration/#ai-handoff-pre-send-i-key) - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within Neovim via `a`; the reader lists all attachments and `1`–`9` downloads and opens them [→](https://neomd.ssp.sh/docs/sending/#attachments) - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed [→](https://neomd.ssp.sh/docs/sending/#emoji-reactions) - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [→](https://neomd.ssp.sh/docs/keybindings/#multi-select--undo) @@ -169,6 +170,7 @@ Keep your inbox clean without effort. ### Reading - **Threaded inbox** — related emails grouped together with a vertical connector line (`│`/`╰`), Twitter-style; threads detected via `In-Reply-To`/`Message-ID` headers with a subject+participant fallback; newest reply on top, root at bottom; `·` reply indicator shows which emails you've answered [→](https://neomd.ssp.sh/docs/reading/#threaded-inbox) +- **iCalendar RSVP** — meeting invites (`text/calendar` / `.ics`) show a `📅` card in the reader; leader chord ` v {a|d|t}` sends an RFC 5546/6047 (iMIP) accept/decline/tentative reply; ` v o` hands the `.ics` off to your local calendar app via `[calendar].open_command` (default `xdg-open`, set to `morgen`, `khal`, etc.) [→](https://neomd.ssp.sh/docs/configuration/#calendar-invites-icalendar--imip) - **Conversation view** — `T` or `:thread` shows the full conversation across folders (Inbox, Sent, Archive, etc.) in a temporary tab with `[Folder]` prefix; see your replies alongside received emails [→](https://neomd.ssp.sh/docs/reading/#conversation-view) - **Link opener** — links in emails are numbered `[1]`–`[0]` in the reader header; press `space+digit` to open in `$BROWSER` [→](https://neomd.ssp.sh/docs/reading/#links) - **Everything view** — `ge` or `:everything` shows the 50 most recent emails across all folders; find emails that were screened out, moved to spam, or otherwise hard to locate [→](https://neomd.ssp.sh/docs/keybindings/#folders) @@ -182,6 +184,7 @@ Keep your inbox clean without effort. - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients [→](https://neomd.ssp.sh/docs/sending/#cc-bcc-reply-all-and-forward) - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) [→](https://neomd.ssp.sh/docs/sending/#drafts) - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder [→](https://neomd.ssp.sh/docs/sending/#multiple-from-addresses) +- **OS keyring credentials** — set `password = "keyring"` to fetch the IMAP/SMTP password from your OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager); OAuth2 tokens also stored in keyring with file fallback for headless/SSH; resolution happens at config load so `[[senders]]` aliases inherit the resolved password automatically [→](https://neomd.ssp.sh/docs/configuration/#storing-passwords-in-the-os-keyring) - **HTML signatures** — configure separate text and HTML signatures; text signature appears in editor and plain text part, HTML signature in HTML part only; use `[html-signature]` placeholder to control inclusion per-email [→](https://neomd.ssp.sh/docs/configuration/#html-signatures) - **Address autocomplete** — To/Cc/Bcc fields autocomplete from screener lists; navigate with `ctrl+n`/`ctrl+p`, accept with `tab` @@ -189,7 +192,7 @@ Keep your inbox clean without effort. - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required; stays in sync if you use it on mobile or different device [→](https://neomd.ssp.sh/docs/configuration/) - **RFC 5322 compliant email delivery** — Message-IDs use sender's domain, proper MIME multipart/alternative structure (text/plain before text/html), quoted-printable encoding, and all required headers; ensures deliverability across all providers, spam filter compatibility, and correct email threading [→](https://neomd.ssp.sh/docs/configuration/email-standards/) -- **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette +- **Themes** — six built-in palettes (`kanagawa` default, `kanagawa-paper`, `kanagawa-light` for daylight terminals, `rose-pine`, `gruvbox`, `osaka-jade`); pick via `[ui].theme = "..."` and override individual colour slots in an optional `[theme]` block [→](https://neomd.ssp.sh/docs/configuration/#theming) {{< callout type="info" >}} neomd's **speed** depends entirely on your IMAP provider. On Hostpoint (the provider I use), a folder switch takes **~33ms** which feels instant. On Gmail, the same operation takes **~570ms** which is noticeably slow. See [Benchmark](#benchmark) for full details and how to test your provider. diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index 16a82e3..6f3f474 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -74,7 +74,7 @@ spam = "spam" #check capitalization of your pre-existing Spam folder, so # Gmail uses different folder names — see docs/content/gmail.md for the correct mapping. [ui] -theme = "dark" # dark | light | auto +theme = "kanagawa" # kanagawa | kanagawa-paper | kanagawa-light | rose-pine | gruvbox | osaka-jade inbox_count = 200 # how many newest emails neomd loads per folder/reload auto_screen_on_load = true # screen inbox automatically on every load (default true) bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5) @@ -322,6 +322,78 @@ BR Simon - The `text` field is backward compatible: if empty, neomd falls back to the legacy `signature` field - The `--` separator is added automatically before the text signature + +## Theming + +Pick from six built-in palettes via `[ui].theme`: + +| Name | Mode | Source | +|---|---|---| +| `kanagawa` (default) | dark | https://github.com/rebelot/kanagawa.nvim | +| `kanagawa-paper` | dark | https://github.com/thesimonho/kanagawa-paper.nvim | +| `kanagawa-light` | **light** | Lotus palette from kanagawa.nvim, paperwhite (#F2EFE9) background | +| `rose-pine` | dark | https://github.com/rose-pine/rose-pine-theme | +| `gruvbox` | dark | https://github.com/morhetz/gruvbox | +| `osaka-jade` | dark | https://github.com/Justikun/omarchy-osaka-jade-theme | + +Override individual colour slots (any subset) via the optional `[theme]` block: + +```toml +[ui] +theme = "rose-pine" + +[theme] +# All slots are hex strings ("#rrggbb"). Empty / missing slots fall back +# to the named built-in. +primary = "#ff79c6" # accent (active tab, header, autocomplete) +unread = "#bd93f9" # unread email emphasis +error = "#ff5555" +# bg, border, subtle, selected, text, muted, number, date, +# author_read, subject_read, size_col, author_unread, subject_unread, success +``` + +## Calendar invites + +```toml +[calendar] +open_command = "xdg-open" # what ` v o` runs to import .ics into your local calendar app + # Linux: defaults to xdg-mime registration; set to "morgen", "khal", + # "/usr/bin/gnome-calendar", etc. to force a specific app +``` + +Workflow + caveats (sending an iMIP REPLY ≠ importing into your calendar) are documented in [Reading → Calendar Invites](../reading/#calendar-invites-icalendar--rsvp). + +## AI handoff (pre-send `i` key) + +`[ai]` wires any external CLI to the pre-send `i` key. neomd: + +1. Shows a one-line prompt for your instruction (e.g. `fix grammar`, `make it more formal`, `tighten this`). +2. Writes the current draft to a temp markdown file (with the same `# [neomd: ...]` headers used during compose). +3. Spawns the command with the file path appended as the last arg. Any `{prompt}` token in `args` is replaced by what you typed. +4. Re-reads the file on exit so the AI's edits replace your draft body. + +Press Enter on an empty prompt to run interactively (no `{prompt}` substitution); type an instruction + Enter to run non-interactively; press Esc to cancel. Quit the AI tool (`ctrl+c`, `q`, `/quit`, `ZZ`, …) to return to neomd's pre-send screen. + +```toml +[ai] +command = "claude" # default: Claude Code CLI +args = ["edit {file}: {prompt}"] # default: tells claude what file + what to do +# command = "codex" +# command = "aichat" +``` + +Two placeholders are substituted at spawn time: `{prompt}` becomes your typed instruction, `{file}` becomes the draft's basename. neomd also sets the spawned process's working directory to the temp dir holding the draft, so claude's built-in Edit tool reaches the file natively (no `--add-dir` needed). + +If you type `fix grammar` at the prompt, the spawn is `claude "edit neomd-ai-XYZ.md: fix grammar"` running in `/tmp/neomd/`. Claude opens interactively, sees the file in cwd, edits in place, you `/quit` when satisfied, neomd picks up the changes. + +> [!IMPORTANT] +> Default args use the **interactive** form, not `claude -p`. The `-p` (print) flag in Claude Code is non-interactive and bills against your **API credits** rather than your Claude Pro/Max subscription — it leaks money even when you're paying for a plan. Interactive mode runs under your subscription auth. Only switch to `args = ["-p", "edit {file}: {prompt}"]` if you have an API key with credits and explicitly want the scripted, no-review flow. + +If you press Enter on an empty prompt, only the `{prompt}` placeholder is replaced (with `""`) — the resulting `"edit neomd-ai-XYZ.md: "` still tells claude which file to look at, so claude opens interactively in the temp dir with that file pre-mentioned and waits for your follow-up instruction. + +`nvim` is intentionally **not** the default: the compose buffer is already open in nvim before pre-send, so spawning nvim on `i` would just re-edit. You can already use [avante.nvim](https://github.com/yetone/avante.nvim) or others within neovim composer to do any AI you'd like. +But instead, pick a tool that does work. The handoff reuses the same parser as the regular editor flow, so headers (To, Cc, Bcc, Subject) the AI tool may rewrite are picked up automatically. If `command` is empty the `i` key is a no-op. + ## OAuth2 Authentication Neomd supports OpenAuth2 authenticated accounts, you just need to add `oauth2_client_id`, `oauth2_client_secret`, `oauth2_scopes` and `oauth2_issuer_url`. diff --git a/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index fa98123..f40d9dd 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -102,6 +102,10 @@ To update both the help overlay and this document at once, edit that file and ru | `d (reader)` | download raw email source (.eml) to ~/Downloads | | `n (reader)` | append open email's sender to notify.txt (desktop notifications opt-in) | | `N (reader)` | append @domain of open email's sender to notify.txt | +| `va (reader, calendar invite)` | RSVP accept — send iMIP REPLY to organizer | +| `vd (reader, calendar invite)` | RSVP decline | +| `vt (reader, calendar invite)` | RSVP tentative | +| `vo (reader, calendar invite)` | open .ics in [calendar].open_command (default xdg-open, e.g. morgen) | | `w` | show welcome screen | @@ -141,6 +145,7 @@ To update both the help overlay and this document at once, edit that file and ru | `s (pre-send)` | spell check — open in nvim with spell on, jump to first error | | `p (pre-send)` | preview email in $BROWSER (images rendered, same as recipient sees) | | `e (pre-send)` | re-open editor to edit body | +| `i (pre-send)` | AI handoff — write draft to temp file, spawn [ai].command (default `claude`; quit the tool to return to neomd, edits round-trip back) | | `enter (pre-send)` | confirm and send | | `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open | | `space+1-0 (reader)` | open link 1-10 in $BROWSER (0 = 10th link) | diff --git a/docs/content/docs/reading.md b/docs/content/docs/reading.md index 41627bf..7850662 100644 --- a/docs/content/docs/reading.md +++ b/docs/content/docs/reading.md @@ -83,6 +83,44 @@ Press `space` then `d` in the reader to download the full raw email source (`.em This is useful for archiving emails, debugging headers, or importing into other email clients. The status bar shows a green confirmation when the download completes. +## Calendar Invites (iCalendar / RSVP) + +When an email has a `text/calendar` part or `.ics` attachment, the reader shows a card and exposes a chord: + +``` +📅 Q2 Planning · Mon, 21 Apr 2026 14:00–15:00 · Conference Room A +``` + +| Key | Action | +|-----|--------| +| ` v a` / `d` / `t` | RSVP accept / decline / tentative — send iMIP REPLY to the organizer | +| ` v o` | hand `.ics` to your local calendar app via `[calendar].open_command` | + +**Sending an RSVP ≠ adding the event to your calendar.** neomd sends one iMIP email (RFC 5546/6047 REPLY) to the organizer; that's the entire effect. To put the meeting on your own calendar, press ` v o` to hand the `.ics` to your local calendar app. + +After sending: the REPLY lands in your `Sent` folder, the original invite gets `\Answered` (`·` indicator), and the status bar shows `RSVP sent: accepted`. Responder = active account's `user`; recipient = the event's `ORGANIZER`; `[[senders]]` aliases are not used. + +### Will the organizer's calendar auto-update? + +The organizer's mail server processes the iMIP REPLY before they see it — no manual click needed on their end. Reliability depends on whose server they're on: + +| Organizer's calendar | Auto-updates attendee list? | +|---|---| +| Outlook 365 / Exchange (on-prem or Online) | **Yes**, server-side, recipient often doesn't even see the email | +| Apple iCloud Calendar | **Yes**, server-side | +| Fastmail / CalDAV / Nextcloud / self-hosted | **Yes** | +| Gmail (`@gmail.com`) | **Unreliable in 2026** — Google has deprioritized server-side iMIP. Email arrives, calendar often doesn't update | +| Google Workspace | Tenant-dependent | + +**Practical workflow:** for a Gmail-organized invite, just click Yes/No in Gmail's web UI (no email is sent — Google updates internally). Use neomd's ` v {a|d|t}` for invites from Outlook, Apple, CalDAV, or anywhere iMIP is honored. The organizer always gets a clear `Accepted: ` email regardless, so it's never silent — just the calendar-side automation that varies. + +> [!INFO] +> Check the `Sent` folder, and verify with a colleague's invite where you can ask them whether their attendee list updated. + +**Local handoff (` v o`).** `xdg-mime query default text/calendar` shows what will run on Arch. Override with `[calendar].open_command = "morgen"` (or `khal import`, `thunderbird`, …). + +Only the first `VEVENT` is processed; recurring (`RRULE`), counter-proposals (`METHOD=COUNTER`), and cancellations (`METHOD=CANCEL`) are out of scope — those still arrive as new emails you can re-process or import via ` v o`. + ## Threaded Inbox Related emails are automatically grouped together in the inbox list. Threads are detected using a hybrid approach: diff --git a/go.mod b/go.mod index 498a0f8..05b5b26 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/arran4/golang-ical v0.3.5 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect diff --git a/go.sum b/go.sum index d031145..962722e 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A= +github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go new file mode 100644 index 0000000..7c0b00a --- /dev/null +++ b/internal/calendar/calendar.go @@ -0,0 +1,176 @@ +// Package calendar parses iCalendar (.ics) attachments and builds RFC +// 5546/6047 (iMIP) RSVP replies. Used to render meeting-invite cards in +// the reader and to send accept/decline/tentative responses with one +// keystroke. +// +// We deliberately handle only the common single-event REQUEST case: the +// first VEVENT is parsed, recurring rules (RRULE) are reported as-is, and +// METHOD=COUNTER / METHOD=CANCEL are out of scope. +package calendar + +import ( + "bytes" + "fmt" + "strings" + "time" + + ics "github.com/arran4/golang-ical" +) + +// Status is the responder's RSVP choice. +type Status string + +const ( + StatusAccepted Status = "ACCEPTED" + StatusDeclined Status = "DECLINED" + StatusTentative Status = "TENTATIVE" +) + +// Event is the subset of VEVENT data the UI needs to render a card. +type Event struct { + Summary string + Location string + Start time.Time + End time.Time + AllDay bool + Organizer string // bare email address, mailto: prefix stripped + Attendees []string // bare email addresses +} + +// Parse reads a single VCALENDAR/VEVENT body and returns the event metadata. +// If the body has multiple VEVENTs (e.g. recurring rule expanded by the +// sender), only the first is returned — matches matcha's behaviour and +// keeps the reader card simple. +func Parse(data []byte) (*Event, error) { + cal, err := ics.ParseCalendar(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("parse vcalendar: %w", err) + } + events := cal.Events() + if len(events) == 0 { + return nil, fmt.Errorf("vcalendar has no VEVENT") + } + v := events[0] + + e := &Event{} + if p := v.GetProperty(ics.ComponentPropertySummary); p != nil { + e.Summary = p.Value + } + if p := v.GetProperty(ics.ComponentPropertyLocation); p != nil { + e.Location = p.Value + } + if p := v.GetProperty(ics.ComponentPropertyOrganizer); p != nil { + e.Organizer = stripMailto(p.Value) + } + for _, a := range v.Attendees() { + e.Attendees = append(e.Attendees, a.Email()) + } + + // All-day events use VALUE=DATE on DTSTART/DTEND. Detect that explicitly + // because GetStartAt happily parses a DATE-only value as midnight UTC. + if dtStart := v.GetProperty(ics.ComponentPropertyDtStart); dtStart != nil { + if vals := dtStart.ICalParameters[string(ics.ParameterValue)]; len(vals) > 0 && strings.EqualFold(vals[0], "DATE") { + e.AllDay = true + if t, err := v.GetAllDayStartAt(); err == nil { + e.Start = t + } + } + } + if !e.AllDay { + if t, err := v.GetStartAt(); err == nil { + e.Start = t + } + } + if t, err := v.GetEndAt(); err == nil { + e.End = t + } + + return e, nil +} + +// BuildRSVP constructs a METHOD:REPLY iCalendar body suitable for the +// text/calendar part of an iMIP response. It: +// - parses the original .ics (preserves UID, DTSTART, DTEND, SEQUENCE) +// - sets METHOD:REPLY at calendar level +// - refreshes DTSTAMP to now (UTC) +// - removes every ATTENDEE on the first VEVENT and re-adds only the +// responder with PARTSTAT= and RSVP=TRUE +// +// responderEmail must match (case-insensitively) one of the original +// ATTENDEEs for Google Calendar / Outlook to credit the response. If it +// doesn't match the function still succeeds — some servers accept it, +// others ignore it. +func BuildRSVP(originalICS []byte, responderEmail string, status Status) ([]byte, error) { + cal, err := ics.ParseCalendar(bytes.NewReader(originalICS)) + if err != nil { + return nil, fmt.Errorf("parse original vcalendar: %w", err) + } + events := cal.Events() + if len(events) == 0 { + return nil, fmt.Errorf("vcalendar has no VEVENT") + } + + cal.SetMethod(ics.MethodReply) + + v := events[0] + v.SetDtStampTime(time.Now().UTC()) + + // Map our public Status to the library's PartStat parameter. + var partStat ics.ParticipationStatus + switch status { + case StatusAccepted: + partStat = ics.ParticipationStatusAccepted + case StatusDeclined: + partStat = ics.ParticipationStatusDeclined + case StatusTentative: + partStat = ics.ParticipationStatusTentative + default: + return nil, fmt.Errorf("unknown rsvp status: %q", status) + } + + // Remove ALL existing ATTENDEE lines, then re-add only the responder. + // Google Calendar / Outlook require this — leaving other attendees in + // the reply causes the response to be rejected or attributed wrong. + v.RemoveProperty(ics.ComponentPropertyAttendee) + v.AddProperty( + ics.ComponentPropertyAttendee, + "mailto:"+responderEmail, + partStat, + ics.WithRSVP(true), + ) + + var buf bytes.Buffer + if err := cal.SerializeTo(&buf); err != nil { + return nil, fmt.Errorf("serialize reply vcalendar: %w", err) + } + return buf.Bytes(), nil +} + +// FormatTime renders the event's start (and optionally end) for display in +// the reader card. Examples: +// +// "Mon, 21 Apr 2026 14:00–15:00" +// "Mon, 21 Apr 2026 14:00" +// "Mon, 21 Apr 2026 (all day)" +// +// Empty strings indicate the event has no start time set. +func (e *Event) FormatTime() string { + if e.Start.IsZero() { + return "" + } + loc := e.Start.Local() + day := loc.Format("Mon, 02 Jan 2006") + if e.AllDay { + return day + " (all day)" + } + startTime := loc.Format("15:04") + if !e.End.IsZero() { + endTime := e.End.Local().Format("15:04") + return fmt.Sprintf("%s %s–%s", day, startTime, endTime) + } + return fmt.Sprintf("%s %s", day, startTime) +} + +func stripMailto(s string) string { + return strings.TrimPrefix(strings.TrimSpace(s), "mailto:") +} diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go new file mode 100644 index 0000000..af12864 --- /dev/null +++ b/internal/calendar/calendar_test.go @@ -0,0 +1,168 @@ +package calendar + +import ( + "bytes" + "strings" + "testing" + "time" +) + +const fixtureRequest = `BEGIN:VCALENDAR +PRODID:-//Test//Test//EN +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +UID:test-event-12345@example.com +DTSTAMP:20260101T120000Z +DTSTART:20260421T140000Z +DTEND:20260421T150000Z +SUMMARY:Q2 Planning Meeting +LOCATION:Conference Room A +ORGANIZER:mailto:boss@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com +SEQUENCE:0 +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR +` + +const fixtureAllDay = `BEGIN:VCALENDAR +PRODID:-//Test//Test//EN +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +UID:allday@example.com +DTSTAMP:20260101T120000Z +DTSTART;VALUE=DATE:20260501 +DTEND;VALUE=DATE:20260502 +SUMMARY:Holiday +ORGANIZER:mailto:hr@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:me@example.com +END:VEVENT +END:VCALENDAR +` + +func TestParse_BasicRequest(t *testing.T) { + e, err := Parse([]byte(fixtureRequest)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if e.Summary != "Q2 Planning Meeting" { + t.Errorf("Summary = %q, want %q", e.Summary, "Q2 Planning Meeting") + } + if e.Location != "Conference Room A" { + t.Errorf("Location = %q, want %q", e.Location, "Conference Room A") + } + if e.Organizer != "boss@example.com" { + t.Errorf("Organizer = %q, want bare email", e.Organizer) + } + if len(e.Attendees) != 2 { + t.Errorf("Attendees count = %d, want 2", len(e.Attendees)) + } + wantStart := time.Date(2026, 4, 21, 14, 0, 0, 0, time.UTC) + if !e.Start.Equal(wantStart) { + t.Errorf("Start = %v, want %v", e.Start, wantStart) + } + if e.AllDay { + t.Error("expected AllDay = false for time-bound event") + } +} + +func TestParse_AllDay(t *testing.T) { + e, err := Parse([]byte(fixtureAllDay)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !e.AllDay { + t.Error("expected AllDay = true for VALUE=DATE event") + } + if e.Summary != "Holiday" { + t.Errorf("Summary = %q", e.Summary) + } +} + +func TestParse_ErrorOnEmpty(t *testing.T) { + _, err := Parse([]byte("not a calendar")) + if err == nil { + t.Error("expected error on garbage input") + } +} + +func TestBuildRSVP_AcceptedStripsOtherAttendees(t *testing.T) { + reply, err := BuildRSVP([]byte(fixtureRequest), "me@example.com", StatusAccepted) + if err != nil { + t.Fatalf("BuildRSVP: %v", err) + } + s := string(reply) + + if !strings.Contains(s, "METHOD:REPLY") { + t.Errorf("reply missing METHOD:REPLY:\n%s", s) + } + if !strings.Contains(s, "PARTSTAT=ACCEPTED") { + t.Errorf("reply missing PARTSTAT=ACCEPTED:\n%s", s) + } + if strings.Contains(s, "other@example.com") { + t.Error("reply must NOT contain non-responding attendees (Google rejects RSVPs that include others)") + } + if strings.Count(s, "ATTENDEE") != 1 { + t.Errorf("reply must have exactly one ATTENDEE line, got %d", strings.Count(s, "ATTENDEE")) + } + if !strings.Contains(s, "mailto:me@example.com") { + t.Errorf("reply must include responder email:\n%s", s) + } + // UID/DTSTART/DTEND must survive the round-trip so the server can + // match the reply to the original event. + for _, mustHave := range []string{"UID:test-event-12345@example.com", "DTSTART:20260421T140000Z", "DTEND:20260421T150000Z", "SUMMARY:Q2 Planning Meeting"} { + if !strings.Contains(s, mustHave) { + t.Errorf("reply missing %q:\n%s", mustHave, s) + } + } +} + +func TestBuildRSVP_DeclinedAndTentative(t *testing.T) { + for _, st := range []Status{StatusDeclined, StatusTentative} { + reply, err := BuildRSVP([]byte(fixtureRequest), "me@example.com", st) + if err != nil { + t.Fatalf("BuildRSVP(%s): %v", st, err) + } + want := "PARTSTAT=" + string(st) + if !bytes.Contains(reply, []byte(want)) { + t.Errorf("BuildRSVP(%s) missing %q", st, want) + } + } +} + +func TestBuildRSVP_DTSTAMPUpdated(t *testing.T) { + // Original DTSTAMP is 20260101; reply's DTSTAMP must be more recent. + reply, err := BuildRSVP([]byte(fixtureRequest), "me@example.com", StatusAccepted) + if err != nil { + t.Fatalf("BuildRSVP: %v", err) + } + s := string(reply) + if strings.Contains(s, "DTSTAMP:20260101T120000Z") { + t.Error("DTSTAMP should be refreshed to now, not preserved from original") + } + if !strings.Contains(s, "DTSTAMP:") { + t.Error("DTSTAMP line missing") + } +} + +func TestEvent_FormatTime(t *testing.T) { + e, err := Parse([]byte(fixtureRequest)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + out := e.FormatTime() + if out == "" { + t.Error("FormatTime returned empty for an event with start/end") + } + if !strings.Contains(out, "2026") { + t.Errorf("FormatTime missing year: %q", out) + } + + allDay, _ := Parse([]byte(fixtureAllDay)) + if !strings.Contains(allDay.FormatTime(), "all day") { + t.Errorf("FormatTime should mark all-day events: %q", allDay.FormatTime()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 66c513a..772ba74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,63 @@ type ScreenerConfig struct { Notify string `toml:"notify"` // optional: addresses or @domain entries that fire desktop notifications } +// Theme overrides individual colour slots used by the UI. Any field left +// empty falls back to the active built-in theme value (selected via +// `[ui].theme`). All fields are hex strings, e.g. "#7E9CD8". The actual +// built-in palettes (kanagawa, kanagawa-paper, rose-pine, gruvbox, +// osaka-jade) live in internal/ui/styles.go; this struct is only the +// TOML-facing override surface. +type Theme struct { + Bg string `toml:"bg"` + Border string `toml:"border"` + Subtle string `toml:"subtle"` + Selected string `toml:"selected"` + Text string `toml:"text"` + Muted string `toml:"muted"` + Primary string `toml:"primary"` + Unread string `toml:"unread"` + Number string `toml:"number"` + Date string `toml:"date"` + AuthorRead string `toml:"author_read"` + SubjectRead string `toml:"subject_read"` + SizeCol string `toml:"size_col"` + AuthorUnread string `toml:"author_unread"` + SubjectUnread string `toml:"subject_unread"` + Error string `toml:"error"` + Success string `toml:"success"` +} + +// CalendarConfig configures local handoff for iCalendar invites. The reader +// shows a card whenever an email contains a `text/calendar` part or `.ics` +// attachment. The leader chord ` v {a|d|t}` sends an iMIP RSVP reply; +// ` v o` writes the .ics to a cache file and runs OpenCommand against +// it, letting the user import the event into their local calendar app +// (default `xdg-open` follows the system's MIME handler; set to `morgen`, +// `khal`, etc. to force a specific app). +type CalendarConfig struct { + OpenCommand string `toml:"open_command"` // default "xdg-open" +} + +// AIConfig configures the pre-send "AI handoff" key (`i`). When pressed, +// neomd shows a one-line prompt for an instruction (e.g. "fix grammar"), +// writes the current draft to a temp markdown file with the standard +// `# [neomd: ...]` headers, spawns ` [args...]` with cwd set to +// the file's directory, and re-reads the file on exit so any changes +// round-trip back into the draft. Quit the AI tool to return. +// +// Two placeholders are substituted in args at spawn time: +// - `{prompt}` → the typed instruction (or empty for interactive mode) +// - `{file}` → the draft's basename (cwd is set to its directory) +// +// Default args = ["edit {file}: {prompt}"]. With prompt "fix grammar" the +// spawn is `claude "edit neomd-ai-XYZ.md: fix grammar"` (cwd /tmp/neomd) — +// claude finds the file via cwd and edits in place. Set `command = ""` to +// disable the binding. +type AIConfig struct { + Command string `toml:"command"` + Args []string `toml:"args"` // {prompt} and {file} placeholders are substituted +} + // NotificationsConfig controls desktop notifications for emails landing in // folders the user cares about, scoped to senders listed in screener.notify. // TUI-only: the headless daemon never fires notifications. @@ -297,6 +354,9 @@ type Config struct { Folders FoldersConfig `toml:"folders"` UI UIConfig `toml:"ui"` Notifications NotificationsConfig `toml:"notifications"` + AI AIConfig `toml:"ai"` + Theme Theme `toml:"theme"` + Calendar CalendarConfig `toml:"calendar"` // AutoBCC, if set, is added to every outgoing email's Bcc field so the // user keeps a copy in an external mailbox (e.g. their hey.com archive). @@ -602,12 +662,28 @@ func defaults() *Config { Spam: "Spam", }, UI: UIConfig{ - Theme: "dark", + Theme: "kanagawa", // built-in: kanagawa | kanagawa-paper | kanagawa-light | rose-pine | gruvbox | osaka-jade InboxCount: 200, BgSyncInterval: 5, MarkAsReadAfterSecs: 7, Signature: "*sent from [neomd](https://neomd.ssp.sh)*", }, + AI: AIConfig{ + // Default: hand off to Claude Code in **interactive** mode (not + // `-p` print mode, which would bill against Anthropic API credits + // instead of a Pro/Max subscription). + // + // `{prompt}` is the user's typed instruction. `{file}` is the + // basename of the temp draft. neomd sets the spawned command's + // cwd to the file's directory, so claude can reach the file + // natively via its Edit tool without --add-dir tricks. + // + // With this default, typing "fix grammar" at the AI prompt + // produces `claude "edit neomd-ai-XYZ.md: fix grammar"` (cwd + // set), and claude edits the file in place. + Command: "claude", + Args: []string{"edit {file}: {prompt}"}, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8ec5b42..94f7670 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -372,6 +372,92 @@ func TestValidate_NegativeUIValues(t *testing.T) { } } +func TestLoad_AIConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "Personal" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "me@example.com" +password = "x" +from = "Me " + +[ai] +command = "claude" +args = ["--print", "--cwd"] +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AI.Command != "claude" { + t.Errorf("AI.Command = %q, want %q", cfg.AI.Command, "claude") + } + if len(cfg.AI.Args) != 2 || cfg.AI.Args[0] != "--print" || cfg.AI.Args[1] != "--cwd" { + t.Errorf("AI.Args = %v, want [--print --cwd]", cfg.AI.Args) + } +} + +func TestLoad_AIConfigOmittedFallsBackToDefault(t *testing.T) { + // When [ai] is absent, defaults() supplies "claude" so the i key works + // out of the box. Users who want the binding disabled set command = "". + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "Personal" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "me@example.com" +password = "x" +from = "Me " +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AI.Command != "claude" { + t.Errorf("AI.Command = %q, want %q (default from defaults())", cfg.AI.Command, "claude") + } +} + +func TestLoad_AIConfigEmptyStringDisables(t *testing.T) { + // Setting command = "" must remain empty (override default) so the + // binding can be disabled deliberately. + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "Personal" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "me@example.com" +password = "x" +from = "Me " + +[ai] +command = "" +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AI.Command != "" { + t.Errorf("AI.Command = %q, want empty (explicit disable)", cfg.AI.Command) + } +} + func TestUseKeyring(t *testing.T) { tests := []struct { password string @@ -478,3 +564,50 @@ account = "Personal" t.Error("UseKeyring() should be false after successful resolution") } } + +func TestLoad_AIConfigDefaultUsesInteractiveClaude(t *testing.T) { + // Regression for two prior bugs: + // 1. Defaults must NOT include `-p` (claude's print mode bills against + // API credits even with a Pro/Max subscription — triggered "Credit + // balance is too low" in real use). + // 2. The arg string must mention {file} so claude knows which file to + // edit. Without it the spawn was `claude "fix grammar" /path/file` + // — claude treated the path positional as ignored text and started + // running `git status` in its cwd instead of editing the draft. + // + // Default `args = ["edit {file}: {prompt}"]` paired with cmd.Dir set to + // the temp dir lets claude reach the file via its built-in Edit tool. + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "Personal" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "me@example.com" +password = "x" +from = "Me " +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.AI.Args) != 1 { + t.Fatalf("AI.Args = %v, want exactly one arg", cfg.AI.Args) + } + got := cfg.AI.Args[0] + if !strings.Contains(got, "{file}") { + t.Errorf("AI.Args = %q — must contain {file} placeholder so claude can locate the draft", got) + } + if !strings.Contains(got, "{prompt}") { + t.Errorf("AI.Args = %q — must contain {prompt} placeholder so the typed instruction is forwarded", got) + } + for _, a := range cfg.AI.Args { + if a == "-p" || a == "--print" { + t.Errorf("default args contain %q — that forces API billing instead of subscription auth", a) + } + } +} diff --git a/internal/imap/client.go b/internal/imap/client.go index 0a533bb..f06ed74 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -28,10 +28,11 @@ import ( // Email is a fully parsed email message. // Attachment holds a decoded email attachment (file or inline binary part). type Attachment struct { - Filename string // from Content-Disposition filename or Content-Type name param - ContentType string // e.g. "application/pdf" - ContentID string // Content-ID without angle brackets (for inline cid: references) - Data []byte + Filename string // from Content-Disposition filename or Content-Type name param + ContentType string // e.g. "application/pdf" + ContentID string // Content-ID without angle brackets (for inline cid: references) + Data []byte + IsCalendarInvite bool // true for text/calendar parts or filenames ending in .ics } type Email struct { @@ -1128,6 +1129,15 @@ func (c *Client) SaveDraft(ctx context.Context, folder string, raw []byte) error // - rawHTML: original HTML part verbatim (empty for plain-text emails) // - webURL: "view online" URL extracted from List-Post header or plain-text // preamble (e.g. Substack's "View this post on the web at https://…") +// isCalendarPart returns true if a part is an iCalendar invite by either its +// MIME type (text/calendar with optional method=…) or filename suffix. +func isCalendarPart(contentType, filename string) bool { + if strings.HasPrefix(strings.ToLower(contentType), "text/calendar") { + return true + } + return strings.HasSuffix(strings.ToLower(filename), ".ics") +} + func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string, spyPixels SpyPixelInfo) { e, err := message.Read(bytes.NewReader(raw)) if err != nil && !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) { @@ -1195,10 +1205,11 @@ func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Atta } data, _ := io.ReadAll(p.Body) attachments = append(attachments, Attachment{ - Filename: filename, - ContentType: ct, - ContentID: cid, - Data: data, + Filename: filename, + ContentType: ct, + ContentID: cid, + Data: data, + IsCalendarInvite: isCalendarPart(ct, filename), }) continue } @@ -1208,9 +1219,10 @@ func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Atta data, _ := io.ReadAll(p.Body) if filename != "" { attachments = append(attachments, Attachment{ - Filename: filename, - ContentType: ct, - Data: data, + Filename: filename, + ContentType: ct, + Data: data, + IsCalendarInvite: isCalendarPart(ct, filename), }) } continue diff --git a/internal/smtp/rsvp.go b/internal/smtp/rsvp.go new file mode 100644 index 0000000..53bc086 --- /dev/null +++ b/internal/smtp/rsvp.go @@ -0,0 +1,155 @@ +package smtp + +import ( + "bytes" + "encoding/base64" + "fmt" + "mime" + "net/mail" + "strings" + "time" +) + +// BuildRSVPMessage builds the iMIP MIME body for an RSVP reply. +// Structure (matches matcha + Google Calendar / Outlook expectations): +// +// multipart/mixed +// ├── multipart/alternative +// │ ├── text/plain; charset=utf-8 +// │ └── text/calendar; method=REPLY; charset=utf-8; base64 +// └── application/ics; name="invite.ics"; base64 (also includes the calendar body) +// +// Some receivers prefer the inline text/calendar; others key off the +// .ics attachment — including both maximises compatibility. +// +// Args: +// - from / to: sender / recipient (organizer email). +// - subject: typically "Re: ". +// - plainBody: short human-readable confirmation (e.g. "ACCEPTED: …"). +// - calendarReply: the bytes returned by calendar.BuildRSVP — METHOD:REPLY .ics. +// - inReplyTo / references: original Message-ID for threading. +func BuildRSVPMessage(from, to, subject, plainBody string, calendarReply []byte, inReplyTo, references string) ([]byte, error) { + if len(calendarReply) == 0 { + return nil, fmt.Errorf("rsvp: calendarReply is empty") + } + + domain := "neomd.local" + if d, ok := extractDomain(from); ok { + domain = d + } + + mixedBoundary, err := randomBoundary() + if err != nil { + return nil, err + } + altBoundary, err := randomBoundary() + if err != nil { + return nil, err + } + msgID, err := randomMsgID() + if err != nil { + return nil, err + } + + var b bytes.Buffer + hdr := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } + + hdr("From", from) + hdr("To", to) + hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) + hdr("Date", time.Now().Format(time.RFC1123Z)) + hdr("Message-ID", "<"+msgID+"@"+domain+">") + // RFC 5322 §3.6.4 requires angle brackets around msg-ids in In-Reply-To + // and References. The IMAP envelope returns bare IDs, so wrap on the way + // out — Gmail's iMIP processor refuses to match a REPLY to an event when + // In-Reply-To isn't bracketed, silently treating it as ordinary mail. + if inReplyTo != "" { + hdr("In-Reply-To", wrapMsgIDs(inReplyTo)) + } + if references != "" { + hdr("References", wrapMsgIDs(references)) + } + hdr("MIME-Version", "1.0") + hdr("Content-Type", `multipart/mixed; boundary="`+mixedBoundary+`"`) + hdr("X-Mailer", "neomd") + b.WriteString("\r\n") + + // multipart/alternative wrapper + fmt.Fprintf(&b, "--%s\r\n", mixedBoundary) + fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", altBoundary) + + // text/plain part + fmt.Fprintf(&b, "--%s\r\n", altBoundary) + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + b.WriteString(plainBody) + if !strings.HasSuffix(plainBody, "\n") { + b.WriteString("\r\n") + } + b.WriteString("\r\n") + + // text/calendar (inline reply) + fmt.Fprintf(&b, "--%s\r\n", altBoundary) + b.WriteString("Content-Type: text/calendar; charset=utf-8; method=REPLY\r\n") + b.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + writeBase64(&b, calendarReply) + b.WriteString("\r\n") + + // close alt + fmt.Fprintf(&b, "--%s--\r\n", altBoundary) + + // .ics attachment (mirrors the inline payload byte-for-byte) + fmt.Fprintf(&b, "--%s\r\n", mixedBoundary) + b.WriteString("Content-Type: application/ics; name=\"invite.ics\"\r\n") + b.WriteString("Content-Disposition: attachment; filename=\"invite.ics\"\r\n") + b.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + writeBase64(&b, calendarReply) + b.WriteString("\r\n") + + // close mixed + fmt.Fprintf(&b, "--%s--\r\n", mixedBoundary) + + return b.Bytes(), nil +} + +// writeBase64 emits the data as base64 in 76-char lines (RFC 2045). +func writeBase64(b *bytes.Buffer, data []byte) { + enc := base64.StdEncoding.EncodeToString(data) + const lineLen = 76 + for i := 0; i < len(enc); i += lineLen { + end := i + lineLen + if end > len(enc) { + end = len(enc) + } + b.WriteString(enc[i:end]) + b.WriteString("\r\n") + } +} + +// senderEmail strips the display-name part of a From header so we can use +// the bare email as the responder address in the RSVP ATTENDEE line. +// Returns the original string on parse failure. +func senderEmail(from string) string { + if addr, err := mail.ParseAddress(from); err == nil { + return addr.Address + } + return from +} + +// wrapMsgIDs ensures every whitespace-separated message-id token in the +// input is wrapped in angle brackets. Tokens that are already bracketed are +// left alone. Used for In-Reply-To (single token) and References (chain). +func wrapMsgIDs(s string) string { + parts := strings.Fields(s) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if !strings.HasPrefix(p, "<") { + p = "<" + p + } + if !strings.HasSuffix(p, ">") { + p = p + ">" + } + out = append(out, p) + } + return strings.Join(out, " ") +} diff --git a/internal/smtp/rsvp_test.go b/internal/smtp/rsvp_test.go new file mode 100644 index 0000000..272bc65 --- /dev/null +++ b/internal/smtp/rsvp_test.go @@ -0,0 +1,158 @@ +package smtp + +import ( + "bytes" + "strings" + "testing" + + "github.com/emersion/go-message/mail" +) + +func TestBuildRSVPMessage_Structure(t *testing.T) { + calendarReply := []byte("BEGIN:VCALENDAR\nVERSION:2.0\nMETHOD:REPLY\nBEGIN:VEVENT\nUID:test@example.com\nDTSTAMP:20260506T120000Z\nATTENDEE;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:me@example.com\nEND:VEVENT\nEND:VCALENDAR\n") + + raw, err := BuildRSVPMessage( + "Me ", + "organizer@example.com", + "Re: Q2 Planning Meeting", + "ACCEPTED: Q2 Planning Meeting\nMon, 21 Apr 2026 14:00–15:00 at Conference Room A", + calendarReply, + "", + "", + ) + if err != nil { + t.Fatalf("BuildRSVPMessage: %v", err) + } + + // Outer envelope: parse top-level headers. + r, err := mail.CreateReader(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("mail.CreateReader: %v", err) + } + defer r.Close() + + // Verify the threading headers survived. + from := r.Header.Get("From") + if !strings.Contains(from, "me@example.com") { + t.Errorf("From header lost the address: %q", from) + } + if got := r.Header.Get("In-Reply-To"); got != "" { + t.Errorf("In-Reply-To = %q", got) + } + if got := r.Header.Get("Subject"); !strings.Contains(got, "Q2 Planning Meeting") { + t.Errorf("Subject lost: %q", got) + } + + // Verify there's a text/calendar; method=REPLY part inline AND an + // application/ics attachment — both required for max compatibility. + var hasInlineCalendar, hasIcsAttachment bool + for { + p, err := r.NextPart() + if err != nil { + break + } + ct := p.Header.Get("Content-Type") + switch h := p.Header.(type) { + case *mail.InlineHeader: + ct, _, _ = h.ContentType() + if strings.HasPrefix(ct, "text/calendar") { + if !strings.Contains(p.Header.Get("Content-Type"), "method=REPLY") { + t.Errorf("inline text/calendar missing method=REPLY: %q", p.Header.Get("Content-Type")) + } + hasInlineCalendar = true + } + case *mail.AttachmentHeader: + fname, _ := h.Filename() + if fname == "invite.ics" { + hasIcsAttachment = true + } + } + } + if !hasInlineCalendar { + t.Errorf("missing inline text/calendar; method=REPLY part. Raw:\n%s", string(raw)) + } + if !hasIcsAttachment { + t.Errorf("missing application/ics attachment named invite.ics") + } +} + +func TestBuildRSVPMessage_ThreadingHeadersBracketed(t *testing.T) { + // Regression: Gmail's iMIP processor rejects RSVPs whose In-Reply-To + // is missing the angle brackets RFC 5322 requires. + calendarReply := []byte("BEGIN:VCALENDAR\nVERSION:2.0\nMETHOD:REPLY\nEND:VCALENDAR\n") + cases := []struct { + name string + inReplyToInput string + referencesInput string + wantInReplyTo string + wantReferences string + }{ + { + name: "bare ids get wrapped", + inReplyToInput: "calendar-abc@google.com", + referencesInput: "calendar-abc@google.com", + wantInReplyTo: "", + wantReferences: "", + }, + { + name: "already-bracketed ids stay unchanged", + inReplyToInput: "", + referencesInput: "", + wantInReplyTo: "", + wantReferences: "", + }, + { + name: "references chain wraps each token", + inReplyToInput: "newest@x.com", + referencesInput: "oldest@x.com middle@x.com newest@x.com", + wantInReplyTo: "", + wantReferences: " ", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw, err := BuildRSVPMessage("me@example.com", "to@example.com", "Accepted: x", "ok\n", calendarReply, tc.inReplyToInput, tc.referencesInput) + if err != nil { + t.Fatalf("BuildRSVPMessage: %v", err) + } + s := string(raw) + if !strings.Contains(s, "In-Reply-To: "+tc.wantInReplyTo+"\r\n") { + t.Errorf("In-Reply-To wrong; want %q in:\n%s", tc.wantInReplyTo, s) + } + if !strings.Contains(s, "References: "+tc.wantReferences+"\r\n") { + t.Errorf("References wrong; want %q in:\n%s", tc.wantReferences, s) + } + }) + } +} + +func TestWrapMsgIDs(t *testing.T) { + cases := map[string]string{ + "": "", + "foo@bar": "", + "": "", + "a@x b@y c@z": " ", + " ": " ", + " b@y ": " ", + } + for in, want := range cases { + if got := wrapMsgIDs(in); got != want { + t.Errorf("wrapMsgIDs(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSenderEmail(t *testing.T) { + tests := []struct { + in, want string + }{ + {"Me ", "me@example.com"}, + {"me@example.com", "me@example.com"}, + {`"Some Name" `, "other@example.com"}, + } + for _, tt := range tests { + if got := senderEmail(tt.in); got != tt.want { + t.Errorf("senderEmail(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 8b2c766..4f47ac8 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -77,6 +77,10 @@ var HelpSections = []HelpSection{ {"d (reader)", "download raw email source (.eml) to ~/Downloads"}, {"n (reader)", "append open email's sender to notify.txt (desktop notifications opt-in)"}, {"N (reader)", "append @domain of open email's sender to notify.txt"}, + {"va (reader, calendar invite)", "RSVP accept — send iMIP REPLY to organizer"}, + {"vd (reader, calendar invite)", "RSVP decline"}, + {"vt (reader, calendar invite)", "RSVP tentative"}, + {"vo (reader, calendar invite)", "open .ics in [calendar].open_command (default xdg-open, e.g. morgen)"}, {"w", "show welcome screen"}, }}, {"Sort (, prefix)", [][2]string{ @@ -108,6 +112,7 @@ var HelpSections = []HelpSection{ {"s (pre-send)", "spell check — open in nvim with spell on, jump to first error"}, {"p (pre-send)", "preview email in $BROWSER (images rendered, same as recipient sees)"}, {"e (pre-send)", "re-open editor to edit body"}, + {"i (pre-send)", "AI handoff — write draft to temp file, spawn [ai].command (default `claude`; quit the tool to return to neomd, edits round-trip back)"}, {"enter (pre-send)", "confirm and send"}, {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"}, {"space+1-0 (reader)", "open link 1-10 in $BROWSER (0 = 10th link)"}, diff --git a/internal/ui/model.go b/internal/ui/model.go index 29444c0..996a9d3 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -18,9 +18,11 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sspaeti/neomd/internal/calendar" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/editor" "github.com/sspaeti/neomd/internal/imap" @@ -541,6 +543,12 @@ type Model struct { pendingSend *pendingSendData presendFromI int // index into presendFroms() for the From field cycle + // AI handoff (pre-send `i`) — when active, shows a one-line input for the + // instruction that gets substituted into [ai].args via {prompt}. Empty + // input falls through to interactive mode (no substitution). + aiPromptActive bool + aiPromptInput textinput.Model + // Reaction reactionEmail *imap.Email // email being reacted to reactionSelected int // selected emoji index (0-7) @@ -644,6 +652,16 @@ type Model struct { // New creates and initialises the TUI model. func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mailto ...*MailtoParams) Model { + // Theme is applied here so any in-memory rendering inside this constructor + // already uses the user's chosen palette. Default name is "kanagawa" for + // byte-for-byte parity with the pre-theme state when [ui].theme is unset. + themeName := cfg.UI.Theme + if themeName == "" || themeName == "dark" || themeName == "light" || themeName == "auto" { + // Legacy [ui].theme values (pre-named-theme era) → kanagawa default. + themeName = "kanagawa" + } + ApplyTheme(themeName, cfg.Theme) + sp := spinner.New() sp.Spinner = spinner.Dot @@ -2085,7 +2103,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.openLinks = extractLinks(msg.body) - _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openSpyPixels, m.openLinks, m.cfg.UI.Theme, m.width) + _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openSpyPixels, m.openLinks, glamourStyleFor(m.cfg.UI.Theme), m.width) m.state = stateReading // Refresh inbox list if immediate mode, or start timer if m.cfg.UI.MarkAsReadAfterSecs <= 0 { @@ -2149,6 +2167,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case rsvpDoneMsg: + if msg.err != nil { + m.status = "RSVP send failed: " + msg.err.Error() + m.isError = true + } else { + m.status = "RSVP sent: " + strings.ToLower(string(msg.status)) + m.isError = false + } + return m, nil + + case icsOpenedMsg: + if msg.err != nil { + m.status = "Open .ics failed: " + msg.err.Error() + m.isError = true + } else { + m.status = "Opened " + msg.path + m.isError = false + } + return m, nil + case saveDraftDoneMsg: if msg.err != nil { m.status = "Draft error: " + msg.err.Error() @@ -3498,6 +3536,16 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.isError = false return m, m.downloadEMLCmd() } + // space + v = leader for calendar RSVP chord (v + a/d/t/o) + if key == "v" { + if m.calendarInvite() == nil { + m.status = "No calendar invite in this email." + return m, nil + } + m.readerPending = "v" + m.status = "rsvp: a=accept · d=decline · t=tentative · o=open in calendar app" + return m, nil + } // space + n / N = add open email's sender (or @domain) to notify.txt if key == "n" || key == "N" { if m.openEmail == nil { @@ -3534,6 +3582,21 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.reader.GotoTop() return m, nil } + case "v": // v {a|d|t|o} — calendar RSVP chord + switch key { + case "a": + return m, m.sendRSVPCmd(calendar.StatusAccepted) + case "d": + return m, m.sendRSVPCmd(calendar.StatusDeclined) + case "t": + return m, m.sendRSVPCmd(calendar.StatusTentative) + case "o": + return m, m.openICSCmd() + default: + m.status = fmt.Sprintf("rsvp: unknown chord v%s — use a/d/t/o or esc", key) + m.isError = true + return m, nil + } case "D": // Di / Do = domain-level screen IN / OUT for the open email if key != "i" && key != "o" { m.status = fmt.Sprintf("unknown: D%s (use Di or Do)", key) @@ -3981,6 +4044,184 @@ func (m Model) downloadEMLCmd() tea.Cmd { } } +// rsvpDoneMsg is fired when an RSVP send completes. +type rsvpDoneMsg struct { + status calendar.Status + err error +} + +// icsOpenedMsg is fired after writing the .ics to ~/.cache and invoking +// the configured calendar open_command. +type icsOpenedMsg struct { + path string + err error +} + +// calendarInvite returns the first calendar invite attachment on the +// currently open email, or nil if none. +func (m Model) calendarInvite() *imap.Attachment { + for i := range m.openAttachments { + if m.openAttachments[i].IsCalendarInvite { + return &m.openAttachments[i] + } + } + return nil +} + +// sendRSVPCmd builds an iMIP REPLY message and sends it to the event +// organizer. The responder address is the active account's user email. +func (m Model) sendRSVPCmd(status calendar.Status) tea.Cmd { + att := m.calendarInvite() + if att == nil { + m.status = "No calendar invite to RSVP to." + return nil + } + ev, err := calendar.Parse(att.Data) + if err != nil { + m.status = "RSVP parse: " + err.Error() + m.isError = true + return nil + } + if ev.Organizer == "" { + m.status = "RSVP: invite has no ORGANIZER — cannot determine recipient." + m.isError = true + return nil + } + + acct := m.activeAccount() + responder := acct.User + from := acct.From + if from == "" { + from = responder + } + + reply, err := calendar.BuildRSVP(att.Data, responder, status) + if err != nil { + m.status = "RSVP build: " + err.Error() + m.isError = true + return nil + } + + // Subject prefix matches what Gmail's own native "Yes / Maybe / No" + // buttons emit: "Accepted: ", "Declined: ", + // "Tentative: ". Gmail's iMIP processor uses this prefix as one + // of its signals to match the REPLY to a calendar event; "Re: " + // gets treated as an ordinary reply and the calendar isn't updated. + low := strings.ToLower(string(status)) + verb := strings.ToUpper(low[:1]) + low[1:] + subject := verb + ": " + ev.Summary + plain := fmt.Sprintf("%s: %s\n", verb, ev.Summary) + if when := ev.FormatTime(); when != "" { + plain += when + if ev.Location != "" { + plain += " at " + ev.Location + } + plain += "\n" + } + + var inReplyTo, references string + if m.openEmail != nil { + inReplyTo = m.openEmail.MessageID + references = m.openEmail.References + } + + raw, err := smtp.BuildRSVPMessage(from, ev.Organizer, subject, plain, reply, inReplyTo, references) + if err != nil { + m.status = "RSVP MIME: " + err.Error() + m.isError = true + return nil + } + + h, p := splitAddr(acct.SMTP) + cfg := smtp.Config{ + Host: h, Port: p, + User: acct.User, + Password: acct.Password, + From: from, + STARTTLS: acct.STARTTLS, + TLSCertFile: acct.TLSCertFile, + TokenSource: m.tokenSourceFor(acct.Name), + } + + to := ev.Organizer + sentCli := m.sentDraftsIMAPClient() + sentFolder := m.cfg.Folders.Sent + openEmail := m.openEmail + replyCli := m.imapCliForAccount(acct.Name) + return func() tea.Msg { + if err := smtp.SendRaw(cfg, []string{to}, raw); err != nil { + return rsvpDoneMsg{status: status, err: err} + } + // Save a copy to Sent so the user has an audit trail of which + // RSVPs they sent — regular replies save, RSVPs should too. + // Non-fatal: if SaveSent fails the RSVP is already on the wire. + if sentCli != nil && sentFolder != "" { + _ = sentCli.SaveSent(nil, sentFolder, raw) + } + // Mark the original invite as \Answered so the inbox shows the + // reply indicator (·), matching reply / forward behaviour. + if openEmail != nil && replyCli != nil { + _ = replyCli.MarkAnswered(nil, openEmail.Folder, openEmail.UID) + } + return rsvpDoneMsg{status: status} + } +} + +// openICSCmd writes the calendar invite to ~/.cache/neomd/ical/ and runs +// the configured open_command (default xdg-open). Lets the user import +// the event into their local calendar app (e.g. morgen, khal, GNOME). +func (m Model) openICSCmd() tea.Cmd { + att := m.calendarInvite() + if att == nil { + m.status = "No calendar invite to open." + return nil + } + // Split open_command on whitespace so configs like + // `open_command = "khal import"` exec the binary `khal` with arg + // `import`, not a literal binary named "khal import". Empty falls back + // to xdg-open. Quoted multi-word paths are not supported here — users + // who need that should symlink or wrap in a shell script. + cmdParts := strings.Fields(strings.TrimSpace(m.cfg.Calendar.OpenCommand)) + if len(cmdParts) == 0 { + cmdParts = []string{"xdg-open"} + } + + home, err := os.UserHomeDir() + if err != nil { + m.status = "open .ics: " + err.Error() + m.isError = true + return nil + } + dir := filepath.Join(home, ".cache", "neomd", "ical") + if err := os.MkdirAll(dir, 0700); err != nil { + m.status = "open .ics: " + err.Error() + m.isError = true + return nil + } + // Path traversal hardening: filepath.Base() strips any directory + // components the sender may have stuck in Content-Disposition: filename + // (e.g. "../../../.bashrc" → ".bashrc"). The result is then joined under + // the cache dir so we can never write outside ~/.cache/neomd/ical/. + name := filepath.Base(att.Filename) + if name == "" || name == "." || name == "/" { + name = fmt.Sprintf("invite-%d.ics", time.Now().Unix()) + } + dst := filepath.Join(dir, name) + if err := os.WriteFile(dst, att.Data, 0600); err != nil { + m.status = "open .ics: " + err.Error() + m.isError = true + return nil + } + + return func() tea.Msg { + args := append(cmdParts[1:], dst) + if err := exec.Command(cmdParts[0], args...).Start(); err != nil { + return icsOpenedMsg{path: dst, err: err} + } + return icsOpenedMsg{path: dst} + } +} + // extractWebVersionURL looks for the "view in browser" / "read online" link // that newsletter platforms insert near the top of every HTML email. // Searches only the first 3000 bytes (the link is always in the preheader). @@ -4219,6 +4460,23 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } + // AI prompt input mode: typed when user pressed `i`. Enter submits + // (empty → interactive, non-empty → templated into [ai].args). Esc + // cancels back to the normal pre-send screen. + if m.aiPromptActive { + switch msg.String() { + case "esc": + m.aiPromptActive = false + return m, nil + case "enter": + prompt := strings.TrimSpace(m.aiPromptInput.Value()) + m.aiPromptActive = false + return m.launchAIHandoffCmd(ps, prompt) + } + var cmd tea.Cmd + m.aiPromptInput, cmd = m.aiPromptInput.Update(msg) + return m, cmd + } switch msg.String() { case "enter": m.loading = true @@ -4268,6 +4526,23 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": // Open in nvim with spell checking, cursor on first error. return m.launchSpellCheckCmd(ps) + case "i": + // AI handoff: prompt for a one-line instruction, then spawn + // [ai].command. Empty input → interactive mode (no {prompt} + // substitution); a typed instruction (e.g. "fix grammar") gets + // templated into [ai].args via {prompt} so e.g. claude can run + // non-interactively as `claude -p "fix grammar" `. + if strings.TrimSpace(m.cfg.AI.Command) == "" { + m.status = "AI handoff: set [ai].command in config.toml (e.g. command = \"claude\")" + return m, nil + } + ti := textinput.New() + ti.Placeholder = "fix grammar (Enter to run · empty = interactive · esc to cancel)" + ti.CharLimit = 200 + ti.Focus() + m.aiPromptInput = ti + m.aiPromptActive = true + return m, nil case "d": // Save to Drafts without sending. return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) @@ -4354,6 +4629,94 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { }) } +// launchAIHandoffCmd writes the current draft to a temp markdown file and +// spawns [ai].command against it. Same temp-file round-trip as the editor and +// spell-check flows: on exit, the file is re-parsed and the draft body is +// replaced. The configured command receives the file path as its final +// argument so e.g. `claude` or `codex` work the same as `nvim`. +// +// `prompt` is the optional one-line instruction the user typed after `i`. +// Any `{prompt}` placeholder in [ai].args is replaced by it so non-interactive +// CLIs can run directly, e.g. with [ai].args = ["-p", "{prompt}"] and +// prompt = "fix grammar" the spawn becomes `claude -p "fix grammar" `. +// Empty prompt → no substitution → interactive mode (current behaviour). +func (m Model) launchAIHandoffCmd(ps *pendingSendData, prompt string) (tea.Model, tea.Cmd) { + prelude := editor.Prelude(ps.to, ps.cc, ps.bcc, m.presendFrom(), ps.subject, "") + content := prelude + ps.body + + f, err := os.CreateTemp(neomdTempDir(), "neomd-ai-*.md") + if err != nil { + m.status = "AI handoff: " + err.Error() + m.isError = true + return m, nil + } + tmpPath := f.Name() + f.WriteString(content) //nolint + f.Close() + + // Expand {prompt} and {file} placeholders. {file} is the basename only + // (claude/codex/aichat take files via prompt mention, not positional); + // the directory containing the file becomes the spawned command's cwd + // just below so the AI tool's built-in file-edit tool can reach it + // without --add-dir or absolute paths. + filename := filepath.Base(tmpPath) + args := make([]string, 0, len(m.cfg.AI.Args)) + for _, a := range m.cfg.AI.Args { + hadPromptPlaceholder := strings.Contains(a, "{prompt}") + s := strings.ReplaceAll(a, "{prompt}", prompt) + s = strings.ReplaceAll(s, "{file}", filename) + // Empty input + arg that was just `{prompt}` → drop the arg so the + // spawn becomes `claude` rather than `claude ""`. (An arg that + // contained both `{prompt}` and `{file}`, like the default, is + // non-empty after {file} expansion and survives.) + if hadPromptPlaceholder && s == "" { + continue + } + args = append(args, s) + } + cmd := exec.Command(m.cfg.AI.Command, args...) + cmd.Dir = filepath.Dir(tmpPath) + m.state = stateCompose + draftBackups := m.cfg.UI.DraftBackups() + return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { + backupDraft(tmpPath, draftBackups) + defer os.Remove(tmpPath) + // Read the temp file regardless of exit code. AI CLIs typically exit + // non-zero on Ctrl+C (130 = SIGINT), and that's the documented "quit + // to return" path — treating it as an error would clear attachments + // and bounce the user back to the inbox via editorDoneMsg{err}. The + // file we wrote at launch is still there even if the tool exited + // cleanly without touching it; parsing then yields the original draft. + raw, readErr := os.ReadFile(tmpPath) + if readErr != nil { + // Surface the exec error too if both happened — useful for + // diagnosing missing-binary cases ([ai].command typo). + err := readErr + if execErr != nil { + err = fmt.Errorf("%w (ai exec: %v)", readErr, execErr) + } + return editorDoneMsg{err: err} + } + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) + if pto == "" { + pto = ps.to + } + if pcc == "" { + pcc = ps.cc + } + if pbcc == "" { + pbcc = ps.bcc + } + if pfrom == "" { + pfrom = m.presendFrom() + } + if psubject == "" { + psubject = ps.subject + } + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} + }) +} + // previewInBrowser renders the composed email as HTML (same pipeline as sending) // and opens it in $BROWSER so the user can verify images and formatting. func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { @@ -5040,13 +5403,21 @@ func (m Model) viewPresend() string { } b.WriteString("\n") } - if m.status != "" { + if m.aiPromptActive { + // One-line instruction prompt active — replaces the help footer + // until the user submits (Enter) or cancels (Esc). + b.WriteString(styleInputLabel.Render("AI prompt:") + " " + m.aiPromptInput.View()) + } else if m.status != "" { b.WriteString(statusBar(m.status, m.isError)) } else { if isListmonk { b.WriteString(styleHelp.Render(" enter schedule campaign · e edit · p preview · ctrl+f from · d draft · esc cancel · x discard")) } else { - b.WriteString(styleHelp.Render(" enter send · e edit · p preview · a attach · D remove attach · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard")) + aiHint := "" + if strings.TrimSpace(m.cfg.AI.Command) != "" { + aiHint = " · i AI (quit to return)" + } + b.WriteString(styleHelp.Render(" enter send · e edit · s spell · p preview · a attach · D remove attach" + aiHint + " · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard")) } } return b.String() diff --git a/internal/ui/reader.go b/internal/ui/reader.go index 613f345..1fa401c 100644 --- a/internal/ui/reader.go +++ b/internal/ui/reader.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" + "github.com/sspaeti/neomd/internal/calendar" "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/render" ) @@ -137,12 +138,48 @@ func renderEmailHeader(e *imap.Email, attachments []imap.Attachment, spyPixels i lines = append(lines, spyStyle.Render("° "+label)) } + if card := calendarInviteCard(attachments); card != "" { + lines = append(lines, card) + } + content := strings.Join(lines, "\n") _ = width return styleEmailMeta.Render(content) + "\n" } +// calendarInviteCard renders a one-line summary of an iCalendar invite if one +// is attached. Empty string when no invite is found or parsing fails. The +// reader keeps it succinct because the full event detail is already in the +// email body — this just surfaces the action: "Press v {a|d|t|o}". +func calendarInviteCard(attachments []imap.Attachment) string { + for i := range attachments { + if !attachments[i].IsCalendarInvite { + continue + } + ev, err := calendar.Parse(attachments[i].Data) + if err != nil { + continue + } + when := ev.FormatTime() + summary := ev.Summary + if summary == "" { + summary = "(untitled event)" + } + card := fmt.Sprintf("📅 %s", summary) + if when != "" { + card += " · " + when + } + if ev.Location != "" { + card += " · " + ev.Location + } + card += " · press v {a|d|t|o}" + cardStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + return cardStyle.Render(card) + } + return "" +} + // readerHelp returns the one-line help string for the reader view. // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. func readerHelp(isDraft bool, hasLinks bool) string { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index df7b014..1fbd1ca 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -4,114 +4,298 @@ import ( "fmt" "github.com/charmbracelet/lipgloss" + "github.com/sspaeti/neomd/internal/config" ) -// Kanagawa palette — https://github.com/rebelot/kanagawa.nvim +// Built-in palettes. Default = kanagawa (byte-for-byte identical to pre-theme +// state). Switch with `[ui].theme = ""`; per-slot overrides via the +// optional [theme] block in config.toml. +var themes = map[string]config.Theme{ + "kanagawa": { + // https://github.com/rebelot/kanagawa.nvim + Bg: "#1F1F28", Border: "#54546D", Subtle: "#363646", Selected: "#223249", + Text: "#DCD7BA", Muted: "#727169", + Primary: "#7E9CD8", Unread: "#957FB8", + Number: "#7E9CD8", Date: "#E6C384", + AuthorRead: "#E46876", SubjectRead: "#7AA89F", SizeCol: "#727169", + AuthorUnread: "#DCA561", SubjectUnread: "#7FB4CA", + Error: "#C34043", Success: "#98BB6C", + }, + "kanagawa-paper": { + // https://github.com/thesimonho/kanagawa-paper.nvim — muted variant + Bg: "#1F1F28", Border: "#4A4A5E", Subtle: "#363646", Selected: "#2A3A52", + Text: "#C8C2A8", Muted: "#6E6D67", + Primary: "#7090C2", Unread: "#876FA8", + Number: "#7090C2", Date: "#D8B470", + AuthorRead: "#D45F6E", SubjectRead: "#6FA08F", SizeCol: "#6E6D67", + AuthorUnread: "#C19459", SubjectUnread: "#6FA0BC", + Error: "#B83C3D", Success: "#88AB60", + }, + "kanagawa-light": { + // Lotus palette from rebelot/kanagawa.nvim's day variant, with the + // paperwhite background popularised by Spacheck's Obsidian port. The + // only built-in light theme — pick this for daylight terminals. + Bg: "#F2EFE9", Border: "#A09CAC", Subtle: "#E5DDB0", Selected: "#B5CBD2", + Text: "#545464", Muted: "#8A8980", + Primary: "#4D699B", Unread: "#624C83", + Number: "#4D699B", Date: "#CC6D00", + AuthorRead: "#C84053", SubjectRead: "#597B75", SizeCol: "#8A8980", + AuthorUnread: "#CC6D00", SubjectUnread: "#4E8CA2", + Error: "#C84053", Success: "#6F894E", + }, + "rose-pine": { + // https://github.com/rose-pine/rose-pine-theme — main variant + Bg: "#191724", Border: "#26233A", Subtle: "#1F1D2E", Selected: "#403D52", + Text: "#E0DEF4", Muted: "#6E6A86", + Primary: "#C4A7E7", Unread: "#EBBCBA", + Number: "#31748F", Date: "#F6C177", + AuthorRead: "#EB6F92", SubjectRead: "#9CCFD8", SizeCol: "#6E6A86", + AuthorUnread: "#F6C177", SubjectUnread: "#9CCFD8", + Error: "#EB6F92", Success: "#31748F", + }, + "gruvbox": { + // https://github.com/morhetz/gruvbox — dark medium + Bg: "#282828", Border: "#504945", Subtle: "#3C3836", Selected: "#45403D", + Text: "#EBDBB2", Muted: "#928374", + Primary: "#83A598", Unread: "#D3869B", + Number: "#83A598", Date: "#FABD2F", + AuthorRead: "#FB4934", SubjectRead: "#8EC07C", SizeCol: "#928374", + AuthorUnread: "#FE8019", SubjectUnread: "#B8BB26", + Error: "#FB4934", Success: "#B8BB26", + }, + "osaka-jade": { + // https://github.com/Justikun/omarchy-osaka-jade-theme + Bg: "#111C18", Border: "#53685B", Subtle: "#23372B", Selected: "#2D4537", + Text: "#C1C497", Muted: "#53685B", + Primary: "#2DD5B7", Unread: "#D2689C", + Number: "#2DD5B7", Date: "#E5C736", + AuthorRead: "#FF5345", SubjectRead: "#549E6A", SizeCol: "#53685B", + AuthorUnread: "#E5C736", SubjectUnread: "#ACD4CF", + Error: "#FF5345", Success: "#549E6A", + }, +} + +// Mutable colour vars — populated by ApplyTheme. All UI files reference these +// directly, so reassigning them at startup updates every callsite that builds +// styles at render time. var ( - // ── Base chrome ───────────────────────────────────────────────────────── - colorBg = lipgloss.Color("#1F1F28") // sumiInk1 — default background - colorBorder = lipgloss.Color("#54546D") // sumiInk4 — borders, float edges - colorSubtle = lipgloss.Color("#363646") // sumiInk3 — cursorline - colorSelected = lipgloss.Color("#223249") // waveBlue1 — visual selection - colorText = lipgloss.Color("#DCD7BA") // fujiWhite — default foreground - colorMuted = lipgloss.Color("#727169") // fujiGray — comments, dim text - - // ── Primary accent (header, active tab) ───────────────────────────────── - colorPrimary = lipgloss.Color("#7E9CD8") // crystalBlue — functions & titles - - // ── Unread indicator ──────────────────────────────────────────────────── - colorUnread = lipgloss.Color("#957FB8") // oniViolet — statements & keywords - - // ── Index column colours ──────────────────────────────────────────────── - colorNumber = lipgloss.Color("#7E9CD8") // crystalBlue — row number - colorDateCol = lipgloss.Color("#E6C384") // carpYellow — date - colorAuthorRead = lipgloss.Color("#E46876") // waveRed — sender (read) - colorSubjectRead = lipgloss.Color("#7AA89F") // waveAqua2 — subject (read) - colorSizeCol = lipgloss.Color("#727169") // fujiGray — size - colorAuthorUnread = lipgloss.Color("#DCA561") // autumnYellow — sender (unread, warm standout) - colorSubjectUnread = lipgloss.Color("#7FB4CA") // springBlue — subject (unread) - - // ── Status colours ────────────────────────────────────────────────────── - colorError = lipgloss.Color("#C34043") // autumnRed - colorSuccess = lipgloss.Color("#98BB6C") // springGreen + colorBg, colorBorder, colorSubtle, colorSelected lipgloss.Color + colorText, colorMuted lipgloss.Color + colorPrimary, colorUnread lipgloss.Color + colorNumber, colorDateCol lipgloss.Color + colorAuthorRead, colorSubjectRead, colorSizeCol lipgloss.Color + colorAuthorUnread, colorSubjectUnread lipgloss.Color + colorError, colorSuccess lipgloss.Color ) +// Mutable style vars rebuilt by rebuildStyles after ApplyTheme. The ones built +// at render time in other files don't need rebuilding; only the package-level +// ones do. var ( + styleHeader lipgloss.Style + styleFolder lipgloss.Style + styleStatus lipgloss.Style + styleError lipgloss.Style + styleEmailMeta lipgloss.Style + styleFrom lipgloss.Style + styleSubject lipgloss.Style + styleDate lipgloss.Style + styleUnread lipgloss.Style + styleRead lipgloss.Style + styleSelected lipgloss.Style + styleHelp lipgloss.Style + styleSeparator lipgloss.Style + styleInputLabel lipgloss.Style + styleInputField lipgloss.Style + styleSuccess lipgloss.Style + styleOffTab lipgloss.Style + styleSuggestion lipgloss.Style + styleSuggestionSelected lipgloss.Style +) + +func init() { + // Default to kanagawa so package-level style vars are populated before + // any UI rendering. ApplyTheme can override later from config. + ApplyTheme("kanagawa", config.Theme{}) +} + +// glamourStyleFor maps a neomd theme name to a glamour built-in style for +// rendering email markdown in the reader. Glamour ships with a fixed set of +// styles (`dark`, `light`, `auto`, `notty`, …); passing an unknown name +// silently falls back to `notty` which strips colours and wrapping, so we +// must translate. Light palettes → `light`; everything else (including the +// pre-theme legacy values "dark"/"light"/"auto" and unknown names) → +// `dark`. The legacy "auto" was rarely useful in practice and would now +// be ambiguous, so we collapse it to `dark` for predictability. +func glamourStyleFor(themeName string) string { + if themeName == "kanagawa-light" || themeName == "light" { + return "light" + } + return "dark" +} + +// ApplyTheme switches the active palette and rebuilds the style vars. Pass an +// override theme to mutate individual slots; empty fields fall through to the +// named built-in. Unknown names fall back to kanagawa. +func ApplyTheme(name string, override config.Theme) { + t, ok := themes[name] + if !ok { + t = themes["kanagawa"] + } + t = mergeTheme(t, override) + + colorBg = lipgloss.Color(t.Bg) + colorBorder = lipgloss.Color(t.Border) + colorSubtle = lipgloss.Color(t.Subtle) + colorSelected = lipgloss.Color(t.Selected) + colorText = lipgloss.Color(t.Text) + colorMuted = lipgloss.Color(t.Muted) + colorPrimary = lipgloss.Color(t.Primary) + colorUnread = lipgloss.Color(t.Unread) + colorNumber = lipgloss.Color(t.Number) + colorDateCol = lipgloss.Color(t.Date) + colorAuthorRead = lipgloss.Color(t.AuthorRead) + colorSubjectRead = lipgloss.Color(t.SubjectRead) + colorSizeCol = lipgloss.Color(t.SizeCol) + colorAuthorUnread = lipgloss.Color(t.AuthorUnread) + colorSubjectUnread = lipgloss.Color(t.SubjectUnread) + colorError = lipgloss.Color(t.Error) + colorSuccess = lipgloss.Color(t.Success) + + rebuildStyles() +} + +func mergeTheme(base, over config.Theme) config.Theme { + if over.Bg != "" { + base.Bg = over.Bg + } + if over.Border != "" { + base.Border = over.Border + } + if over.Subtle != "" { + base.Subtle = over.Subtle + } + if over.Selected != "" { + base.Selected = over.Selected + } + if over.Text != "" { + base.Text = over.Text + } + if over.Muted != "" { + base.Muted = over.Muted + } + if over.Primary != "" { + base.Primary = over.Primary + } + if over.Unread != "" { + base.Unread = over.Unread + } + if over.Number != "" { + base.Number = over.Number + } + if over.Date != "" { + base.Date = over.Date + } + if over.AuthorRead != "" { + base.AuthorRead = over.AuthorRead + } + if over.SubjectRead != "" { + base.SubjectRead = over.SubjectRead + } + if over.SizeCol != "" { + base.SizeCol = over.SizeCol + } + if over.AuthorUnread != "" { + base.AuthorUnread = over.AuthorUnread + } + if over.SubjectUnread != "" { + base.SubjectUnread = over.SubjectUnread + } + if over.Error != "" { + base.Error = over.Error + } + if over.Success != "" { + base.Success = over.Success + } + return base +} + +func rebuildStyles() { styleHeader = lipgloss.NewStyle(). - Foreground(colorPrimary). - Bold(true). - Padding(0, 1) + Foreground(colorPrimary). + Bold(true). + Padding(0, 1) styleFolder = lipgloss.NewStyle(). - Foreground(colorMuted). - Padding(0, 1) + Foreground(colorMuted). + Padding(0, 1) styleStatus = lipgloss.NewStyle(). - Foreground(colorMuted). - Padding(0, 1) + Foreground(colorMuted). + Padding(0, 1) styleError = lipgloss.NewStyle(). - Foreground(colorError). - Padding(0, 1) + Foreground(colorError). + Padding(0, 1) styleEmailMeta = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(colorBorder). - Padding(0, 1). - MarginBottom(1) + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1). + MarginBottom(1) styleFrom = lipgloss.NewStyle(). - Foreground(colorPrimary). - Bold(true) + Foreground(colorPrimary). + Bold(true) styleSubject = lipgloss.NewStyle(). - Foreground(colorText). - Bold(true) + Foreground(colorText). + Bold(true) styleDate = lipgloss.NewStyle(). - Foreground(colorMuted) + Foreground(colorMuted) styleUnread = lipgloss.NewStyle(). - Foreground(colorUnread). - Bold(true) + Foreground(colorUnread). + Bold(true) styleRead = lipgloss.NewStyle(). - Foreground(colorMuted) + Foreground(colorMuted) styleSelected = lipgloss.NewStyle(). - Background(colorSelected). - Foreground(colorText) + Background(colorSelected). + Foreground(colorText) styleHelp = lipgloss.NewStyle(). - Foreground(colorMuted). - Padding(0, 1) + Foreground(colorMuted). + Padding(0, 1) styleSeparator = lipgloss.NewStyle(). - Foreground(colorBorder) + Foreground(colorBorder) styleInputLabel = lipgloss.NewStyle(). - Foreground(colorPrimary). - Bold(true). - Width(10) + Foreground(colorPrimary). + Bold(true). + Width(10) styleInputField = lipgloss.NewStyle(). - Foreground(colorText) + Foreground(colorText) styleSuccess = lipgloss.NewStyle(). - Foreground(colorSuccess) + Foreground(colorSuccess) styleOffTab = lipgloss.NewStyle(). - Foreground(colorMuted). - Italic(true). - Padding(0, 1) + Foreground(colorMuted). + Italic(true). + Padding(0, 1) styleSuggestion = lipgloss.NewStyle(). - Foreground(colorMuted) + Foreground(colorMuted) styleSuggestionSelected = lipgloss.NewStyle(). - Foreground(colorPrimary). - Bold(true) -) + Foreground(colorPrimary). + Bold(true) +} // tabZone records the X range for a clickable folder tab. type tabZone struct { diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go new file mode 100644 index 0000000..177b14a --- /dev/null +++ b/internal/ui/styles_test.go @@ -0,0 +1,130 @@ +package ui + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/sspaeti/neomd/internal/config" +) + +// TestKanagawaDefault — regression: default applied at init must match the +// historical hardcoded values byte-for-byte so unset [ui].theme = current main. +func TestKanagawaDefault(t *testing.T) { + ApplyTheme("kanagawa", config.Theme{}) + expected := map[string]lipgloss.Color{ + "colorBg": "#1F1F28", + "colorBorder": "#54546D", + "colorSubtle": "#363646", + "colorSelected": "#223249", + "colorText": "#DCD7BA", + "colorMuted": "#727169", + "colorPrimary": "#7E9CD8", + "colorUnread": "#957FB8", + "colorNumber": "#7E9CD8", + "colorDateCol": "#E6C384", + "colorAuthorRead": "#E46876", + "colorSubjectRead": "#7AA89F", + "colorSizeCol": "#727169", + "colorAuthorUnread": "#DCA561", + "colorSubjectUnread": "#7FB4CA", + "colorError": "#C34043", + "colorSuccess": "#98BB6C", + } + got := map[string]lipgloss.Color{ + "colorBg": colorBg, + "colorBorder": colorBorder, + "colorSubtle": colorSubtle, + "colorSelected": colorSelected, + "colorText": colorText, + "colorMuted": colorMuted, + "colorPrimary": colorPrimary, + "colorUnread": colorUnread, + "colorNumber": colorNumber, + "colorDateCol": colorDateCol, + "colorAuthorRead": colorAuthorRead, + "colorSubjectRead": colorSubjectRead, + "colorSizeCol": colorSizeCol, + "colorAuthorUnread": colorAuthorUnread, + "colorSubjectUnread": colorSubjectUnread, + "colorError": colorError, + "colorSuccess": colorSuccess, + } + for k, want := range expected { + if got[k] != want { + t.Errorf("%s = %q, want %q (kanagawa default must not regress)", k, got[k], want) + } + } +} + +func TestApplyTheme_KnownNames(t *testing.T) { + for _, name := range []string{"kanagawa", "kanagawa-paper", "kanagawa-light", "rose-pine", "gruvbox", "osaka-jade"} { + ApplyTheme(name, config.Theme{}) + if colorBg == "" || colorPrimary == "" { + t.Errorf("ApplyTheme(%q): expected colors populated, got empty", name) + } + } + // Restore default for downstream tests. + ApplyTheme("kanagawa", config.Theme{}) +} + +func TestApplyTheme_UnknownFallsBackToKanagawa(t *testing.T) { + ApplyTheme("nonexistent-theme", config.Theme{}) + if colorPrimary != lipgloss.Color("#7E9CD8") { + t.Errorf("unknown theme should fall back to kanagawa primary #7E9CD8, got %q", colorPrimary) + } + ApplyTheme("kanagawa", config.Theme{}) +} + +func TestApplyTheme_OverrideMerges(t *testing.T) { + ApplyTheme("kanagawa", config.Theme{Primary: "#FF0000", Error: "#00FF00"}) + if colorPrimary != lipgloss.Color("#FF0000") { + t.Errorf("override Primary not applied: got %q want #FF0000", colorPrimary) + } + if colorError != lipgloss.Color("#00FF00") { + t.Errorf("override Error not applied: got %q want #00FF00", colorError) + } + // Non-overridden slot still reflects kanagawa value. + if colorBg != lipgloss.Color("#1F1F28") { + t.Errorf("non-overridden Bg should stay kanagawa #1F1F28, got %q", colorBg) + } + ApplyTheme("kanagawa", config.Theme{}) +} + +func TestApplyTheme_DifferentThemesProduceDifferentStyles(t *testing.T) { + // Lipgloss strips ANSI when stdout is not a TTY (test env), so compare the + // underlying style colour values directly rather than rendered output. + ApplyTheme("kanagawa", config.Theme{}) + kanagawaPrimary := styleHeader.GetForeground() + + ApplyTheme("rose-pine", config.Theme{}) + rosePinePrimary := styleHeader.GetForeground() + + if kanagawaPrimary == rosePinePrimary { + t.Errorf("kanagawa and rose-pine should yield different styleHeader foreground; both = %v", kanagawaPrimary) + } + ApplyTheme("kanagawa", config.Theme{}) +} + +func TestGlamourStyleFor(t *testing.T) { + // Regression for review #201 finding 2: passing the new named-theme + // strings (kanagawa, rose-pine, …) directly into glamour silently + // falls back to "notty" which strips colours/wrapping. The mapper must + // always return one of glamour's built-in style names. + cases := map[string]string{ + "kanagawa": "dark", + "kanagawa-paper": "dark", + "kanagawa-light": "light", + "rose-pine": "dark", + "gruvbox": "dark", + "osaka-jade": "dark", + "": "dark", // empty config falls through to dark + "unknown-theme": "dark", // unknown names also fall through, never to notty + "light": "light", // legacy literal still respected + "auto": "dark", // legacy "auto" collapses to dark for predictability + } + for in, want := range cases { + if got := glamourStyleFor(in); got != want { + t.Errorf("glamourStyleFor(%q) = %q, want %q", in, got, want) + } + } +}