diff --git a/CHANGELOG.md b/CHANGELOG.md index c953ac7..23e1708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +# 2026-05-12 +- **Per-trigger Listmonk template override** — `[[listmonk.triggers]]` entries now accept an optional `template_id = N` field that pins the Listmonk template for campaigns created from that trigger; triggers without the field fall through to Listmonk's own default template, so existing configs keep working unchanged. Useful when one virtual address should always use a specialised template (e.g. `listmonk-book@ssp.sh` → "Book Update Template", ID 5) while the rest stay on the generic newsletter template. The pre-send "Newsletter via Listmonk" review now surfaces the resolved template ID alongside the target list IDs and schedule delay, so it's visible before scheduling. Plumbed through `ListmonkTrigger` (config), `listmonk.Trigger` + new `ResolveTemplateID` (hook), `campaignRequest.TemplateID` (Listmonk REST payload, `omitempty` so default triggers send no field at all), and the UI send path. Regression test covers the override/default/no-match cases + +# 2026-05-08 +- **`Ms` move to Sent** — added `Ms` chord to move the cursor or marked email(s) to the configured Sent folder, mirroring the `gs` (go to Sent) shortcut. Previously every other major folder had a matching `M*` mover (`Mi`/`Ma`/`Mt`/…) but Sent was missing, so manually filing already-sent correspondence forwarded from another client required `:` commands or a multi-step copy. Lives in the same `M`-prefix dstMap as the rest, no behaviour change for other letters +- **`/` filter searches recipients in Sent folder** — the in-memory inbox filter (`/`) previously matched on `From + Subject` in every folder. In Sent that meant typing `/hussain` after sending Hussain an email returned nothing, because `From` is always you. The filter now matches on `To + CC + BCC + Subject` whenever the active folder is the configured Sent folder; all other folders are unchanged. The server-side IMAP search (`/` / `:search`) already searches To and is unaffected +- Review and stability + - **Concurrency: data race in spy pixel cache save eliminated** — `bodyLoadedMsg` and `spyScanProgressMsg` previously launched `safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) })`, evaluating `copyMap` *inside* the goroutine — the closure captured the model and read the live maps while the main goroutine continued mutating them on the next message. Both call sites now snapshot the maps on the main goroutine before launching the writer, so `copyMap` cannot race with subsequent map writes. The `// Takes copied maps to avoid concurrent access` comment on `saveSpyPixelCache` was true of the helper signature but the call sites violated the contract; both are now correct + - **Concurrency: `Screener` is now safe for concurrent use** — added `sync.RWMutex` guarding every map (`screenedIn`, `screenedOut`, `feed`, `paperTrail`, `spam`, `notify`). Auto-screen, background sync, search, notify, and TUI mutation paths all share one `*Screener`, so concurrent map writes would have panicked at runtime. `Classify`, `ClassifyDebug`, `IsEmpty`, `AllAddresses`, `ShouldNotify`, and `Snapshot` take a read lock; `Approve`, `Block`, `MarkSpam`, `MarkFeed`, `MarkPaperTrail`, `AddNotify`, `RemoveNotify`, and `Restore` take a write lock. Refactored `Snapshot`/`Restore` into public-locked + private `snapshotLocked`/`restoreLocked` variants so the existing transactional rollback inside `Approve`/`Block`/etc. doesn't double-lock. RWMutex keeps the hot read path parallel; `make test -race` clean + - **Security: path traversal in `.ics` calendar attachment open (` v o`)** — `filepath.Base("..")` returns `".."`, and `filepath.Join("~/.cache/neomd/ical", "..")` escapes the cache dir. The previous filter rejected `""`, `"."`, and `"/"` but not `".."`, so a hostile `Content-Disposition: filename` of `..` would let `os.WriteFile(att.Data)` overwrite `~/.cache/neomd/`. Now also rejects `".."` and any embedded path separator, falling back to `invite-.ics` when the sender-supplied name is unusable + - **Security: path traversal in attachment download (`xdg-open` flow)** — same root cause in `downloadOpenAttachmentCmd`: a malicious `.eml` part with `Content-Disposition: filename=".."` would write the attachment bytes to `~/Downloads/..` (i.e. `~`). This is reached by every reader-driven attachment open, so a hostile sender controlled the destination path. The filter now rejects `""`, `"."`, `".."`, `"/"`, and any embedded path separator, falling back to `attachment-` + - **Reliability: `?` no longer trapped in compose textinputs** — the global `?` → help binding fired from any state, including `stateCompose`. Compose feeds keys into a `bubbles/textinput` (To/CC/BCC/Subject), which never received `?` because the global handler intercepted first. Now gated to all states *except* `stateCompose`, so `?` types verbatim while composing and still toggles help everywhere else + - **Reliability: `extractAddr` no longer issues blocking DNS lookups on send** — the helper called `net.LookupHost` for every recipient and From address inside the SMTP send hot path, blocking the bubbletea Update loop on flaky DNS for seconds at a time. The lookup was also functionally pointless: an `||` short-circuit returned the address whenever it contained `@`, so DNS only mattered for malformed-but-bracket-shaped strings. Replaced with pure string parsing — extracts the `` form when present, otherwise returns the trimmed input. Unused `"net"` import dropped + - **Reliability: outbound emails preserve trailing whitespace on hard-break lines** — RFC 2045 §6.7 requires that any space or tab immediately before a CRLF be encoded as `=20` / `=09` in quoted-printable. The previous `writeQP` passed trailing whitespace through verbatim, which many SMTP relays silently strip — mangling Markdown's two-trailing-spaces hard-break syntax (precisely the syntax `normalizePlainText` adds when extracting plain text from HTML). The encoder now peeks ahead; trailing whitespace before `\n` or end-of-input is encoded + - **Reliability: bare `go` calls promoted to `safeGo`** — `saveCmdHistory` (`:` command-line history persistence) was launched as a bare `go func()`, bypassing the project's `safeGo` panic-recovery contract documented in `CLAUDE.md`. A panic inside the writer would have torn down the TUI without a stack trace. Now wrapped in `safeGo`, with a snapshot of the history slice taken on the main goroutine before launch so the writer cannot race with the next command-history mutation + - **Reliability: OAuth2 callback server panic recovery + non-blocking sends** — `runAuthFlow` launched the callback HTTP server as a bare `go func()`. A panic inside `net/http`'s request handling would have crashed the process during the OAuth2 dance. Goroutine now has a `defer recover()` that surfaces the panic as an `errCh` send. Additionally, `errCh` and `codeCh` are buffered size 1, but a re-fired callback (browser refresh, double redirect) would block on subsequent sends and leak the handler goroutine; all sends now use a `select`/`default` non-blocking pattern + - **Reliability: IMAP retry catches more transient failures** — `isNetErr` previously matched only four substrings (`use of closed network connection`, `connection reset by peer`, `broken pipe`, `EOF`), missing real-world transient failures: `tls: connection lost`, `i/o timeout`, `connection timed out`, `connection refused`, `no route to host`, `network is unreachable`. Those errors fell through the retry path, surfacing as user-visible failures instead of a clean reconnect. Now uses `errors.Is(err, net.ErrClosed | io.EOF | io.ErrUnexpectedEOF)` and `net.Error.Timeout()` typed checks plus a widened substring list covering TLS, syscall, and DNS-layer error formats + - **Reliability: `--headless` no longer panics when account 0 is `imap_disabled = true`** — `daemon.New(*cfg, imapClients[0], sc)` blindly passed the first IMAP client to the daemon. Accounts with `imap_disabled = true` produce a `nil` client by design; if account 0 happened to be send-only, `--headless` would crash on the first IMAP call. The launcher now scans `imapClients` for the first non-nil entry and fails fast with a clear error message ("`--headless requires at least one IMAP-enabled account`") if no IMAP-enabled account exists, instead of panicking deep in the screening loop + # 2026-05-06 - **Fix: attachments invisible after pre-send round-trip** — re-opening the editor from pre-send via `e` (re-edit), `s` (spell-check), or `i` (AI handoff), or continuing a saved draft, now re-injects tracked attachments as `# [attach] /path` lines right under the existing `# [neomd: ...]` headers instead of silently keeping them only in `m.attachments`. Previously the `[attach]` lines were extracted on the way to pre-send (correctly — they should never reach the recipient as text) but the editor reopened with the cleaned body, leaving no signal that PDFs/files were attached; users would re-attach and end up with duplicates. `extractInlineAttachments` now accepts both forms — `# [attach] /path` (header form, used on re-injection, visually grouped with the metadata headers) and `[attach] /path` (the form `a` continues to insert at cursor position for inline image placement). The editor body is now the source of truth: `editorDoneMsg` replaces `m.attachments` with whatever `[attach]` lines come back, instead of appending — so removing a line in the editor cleanly removes the attachment. For draft continuation the injected paths are the temp-extracted `/tmp/neomd/draft--` files written by `writeAttachmentsTemp` from the saved MIME parts; the user can replace them with the original local paths if those files moved - **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 diff --git a/docs/content/docs/integrations/listmonk.md b/docs/content/docs/integrations/listmonk.md index e4db6ad..9f5a38c 100644 --- a/docs/content/docs/integrations/listmonk.md +++ b/docs/content/docs/integrations/listmonk.md @@ -33,6 +33,7 @@ list_ids = [2] [[listmonk.triggers]] address = "listmonk-book@ssp.sh" list_ids = [4] +template_id = 5 # optional: override default template (e.g. "Book Update Template") [[listmonk.triggers]] address = "listmonk@ssp.sh" @@ -46,6 +47,22 @@ list_ids = [2, 4] # send to all lists | `api_token` | API token (supports `$ENV` expansion) | | `delay_minutes` | Minutes to delay before campaign sends (default 30) | +Per-trigger fields: + +| Field | Description | +|-------|-------------| +| `address` | Virtual email address that fires this trigger | +| `list_ids` | One or more Listmonk list IDs to send to | +| `template_id` | *(optional)* Listmonk template ID — when set, overrides the Listmonk default template for campaigns created from this trigger. Omit (or set to `0`) to use Listmonk's default. | + +### Per-list templates + +Each trigger can pin its own Listmonk template. For example, if you have a generic "Newsletter Template" set as the Listmonk default (ID 4) and a "Book Update Template" (ID 5) you only want to use for book announcements, set `template_id = 5` on the `listmonk-book` trigger and leave the others untouched — they fall back to the Listmonk default. Look up template IDs via the API: + +```bash +curl -u "admin:token" https://list.ssp.sh/api/templates | jq '.data[] | {id, name, is_default}' +``` + ### Trigger addresses Each `[[listmonk.triggers]]` entry maps a virtual email address to one or more Listmonk list IDs. You can configure multiple triggers to target different lists: @@ -69,7 +86,7 @@ curl -u "admin:token" https://list.ssp.sh/api/lists | jq '.data.results[] | {id, When the To address matches a trigger, the pre-send review changes: - Header shows **"Newsletter via Listmonk"** instead of "Ready to send" -- Displays the target **list IDs** and **schedule delay** +- Displays the target **list IDs**, **template ID** (if overridden), and **schedule delay** - Help bar shows `enter schedule campaign` instead of `enter send` @@ -93,7 +110,7 @@ The email body (Markdown) is sent as-is with `content_type: "markdown"` — List neomd uses two Listmonk API calls: -1. `POST /api/campaigns` — creates campaign in DRAFT status with `send_at` set to now + `delay_minutes` +1. `POST /api/campaigns` — creates campaign in DRAFT status with `send_at` set to now + `delay_minutes`; includes `template_id` only when the matching trigger sets a non-zero override (otherwise Listmonk applies its default template) 2. `PUT /api/campaigns/{id}/status` — sets status to `scheduled` Authentication is HTTP Basic Auth. The campaign name is auto-generated as `"{subject} - {timestamp}"`. diff --git a/internal/config/config.go b/internal/config/config.go index 772ba74..487de82 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -368,9 +368,12 @@ type Config struct { } // ListmonkTrigger maps a virtual email address to Listmonk list IDs. +// TemplateID, when non-zero, overrides Listmonk's default template for the +// generated campaign (e.g. a per-list "Book Update" template). type ListmonkTrigger struct { - Address string `toml:"address"` - ListIDs []int `toml:"list_ids"` + Address string `toml:"address"` + ListIDs []int `toml:"list_ids"` + TemplateID int `toml:"template_id"` } // ListmonkConfig holds settings for the Listmonk newsletter integration. diff --git a/internal/listmonk/client.go b/internal/listmonk/client.go index 1d31b94..c479d40 100644 --- a/internal/listmonk/client.go +++ b/internal/listmonk/client.go @@ -41,6 +41,7 @@ type campaignRequest struct { ContentType string `json:"content_type"` Type string `json:"type"` SendAt string `json:"send_at,omitempty"` + TemplateID int `json:"template_id,omitempty"` } // statusRequest is the JSON payload for PUT /api/campaigns/{id}/status. @@ -56,14 +57,14 @@ type campaignResponse struct { } // CreateAndSchedule creates a campaign in DRAFT status, then schedules it. -// Returns the campaign ID. -func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int, delay time.Duration) (int, error) { +// Returns the campaign ID. templateID is optional (0 = use Listmonk default). +func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int, templateID int, delay time.Duration) (int, error) { if delay == 0 { delay = 30 * time.Minute } sendAt := time.Now().UTC().Add(delay) - id, err := c.createCampaign(subject, markdownBody, listIDs, sendAt) + id, err := c.createCampaign(subject, markdownBody, listIDs, templateID, sendAt) if err != nil { return 0, err } @@ -73,7 +74,7 @@ func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int, return id, nil } -func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time.Time) (int, error) { +func (c *Client) createCampaign(subject, body string, listIDs []int, templateID int, sendAt time.Time) (int, error) { name := fmt.Sprintf("%s - %s", subject, sendAt.Format("2006-01-02 15:04")) payload := campaignRequest{ Name: name, @@ -83,6 +84,7 @@ func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time ContentType: "markdown", Type: "regular", SendAt: sendAt.Format(time.RFC3339), + TemplateID: templateID, } data, err := json.Marshal(payload) diff --git a/internal/listmonk/client_test.go b/internal/listmonk/client_test.go index c703894..0a7cd82 100644 --- a/internal/listmonk/client_test.go +++ b/internal/listmonk/client_test.go @@ -48,7 +48,7 @@ func TestCreateAndSchedule(t *testing.T) { APIToken: "testtoken", }) - id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 30*time.Minute) + id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 5, 30*time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -76,6 +76,9 @@ func TestCreateAndSchedule(t *testing.T) { if gotStatus.Status != "scheduled" { t.Errorf("status = %q, want %q", gotStatus.Status, "scheduled") } + if gotCreate.TemplateID != 5 { + t.Errorf("template_id = %d, want 5", gotCreate.TemplateID) + } } func TestCreateAndSchedule_APIError(t *testing.T) { @@ -85,7 +88,7 @@ func TestCreateAndSchedule_APIError(t *testing.T) { defer srv.Close() c := NewClient(Config{URL: srv.URL, APIUser: "u", APIToken: "t"}) - _, err := c.CreateAndSchedule("Test", "body", []int{1}, 10*time.Minute) + _, err := c.CreateAndSchedule("Test", "body", []int{1}, 0, 10*time.Minute) if err == nil { t.Fatal("expected error for bad request") } @@ -127,6 +130,22 @@ func TestResolveListIDs(t *testing.T) { } } +func TestResolveTemplateID(t *testing.T) { + triggers := []Trigger{ + {Address: "listmonk@ssp.sh", ListIDs: []int{2}}, // no template override + {Address: "listmonk-book@ssp.sh", ListIDs: []int{4}, TemplateID: 5}, // override + } + if got := ResolveTemplateID(triggers, "listmonk-book@ssp.sh"); got != 5 { + t.Errorf("book override: got %d, want 5", got) + } + if got := ResolveTemplateID(triggers, "listmonk@ssp.sh"); got != 0 { + t.Errorf("default: got %d, want 0", got) + } + if got := ResolveTemplateID(triggers, "nobody@example.com"); got != 0 { + t.Errorf("no match: got %d, want 0", got) + } +} + func TestIsTriggerAddress(t *testing.T) { triggers := []Trigger{ {Address: "listmonk@ssp.sh", ListIDs: []int{1}}, diff --git a/internal/listmonk/hook.go b/internal/listmonk/hook.go index 88691a8..83d5a68 100644 --- a/internal/listmonk/hook.go +++ b/internal/listmonk/hook.go @@ -5,9 +5,12 @@ import ( ) // Trigger maps a virtual email address to Listmonk list IDs. +// TemplateID, when non-zero, overrides Listmonk's default template for +// campaigns created from this trigger. type Trigger struct { - Address string - ListIDs []int + Address string + ListIDs []int + TemplateID int } // ResolveListIDs returns the combined list IDs for all trigger addresses @@ -30,6 +33,19 @@ func ResolveListIDs(triggers []Trigger, toField string) []int { return ids } +// ResolveTemplateID returns the first non-zero template ID from triggers +// matching any recipient in the To field. Returns 0 if no override. +func ResolveTemplateID(triggers []Trigger, toField string) int { + for _, addr := range splitAddrs(toField) { + for _, t := range triggers { + if strings.EqualFold(addr, t.Address) && t.TemplateID != 0 { + return t.TemplateID + } + } + } + return 0 +} + // IsTriggerAddress returns true if any address in toField matches a trigger. func IsTriggerAddress(triggers []Trigger, toField string) bool { return len(ResolveListIDs(triggers, toField)) > 0 diff --git a/internal/ui/model.go b/internal/ui/model.go index ad96d53..34e948b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -937,12 +937,12 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su func (m Model) listmonkTriggers() []listmonk.Trigger { triggers := make([]listmonk.Trigger, len(m.cfg.Listmonk.Triggers)) for i, t := range m.cfg.Listmonk.Triggers { - triggers[i] = listmonk.Trigger{Address: t.Address, ListIDs: t.ListIDs} + triggers[i] = listmonk.Trigger{Address: t.Address, ListIDs: t.ListIDs, TemplateID: t.TemplateID} } return triggers } -func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int) tea.Cmd { +func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int, templateID int) tea.Cmd { cfg := m.cfg.Listmonk delay := time.Duration(cfg.DelayMinutes) * time.Minute if delay == 0 { @@ -954,7 +954,7 @@ func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int) tea. APIUser: cfg.APIUser, APIToken: cfg.APIToken, }) - campaignID, err := client.CreateAndSchedule(subject, markdownBody, listIDs, delay) + campaignID, err := client.CreateAndSchedule(subject, markdownBody, listIDs, templateID, delay) if err != nil { return sendDoneMsg{err: fmt.Errorf("listmonk: %w", err)} } @@ -4498,8 +4498,10 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.pendingSend = nil // Route to Listmonk if the To address matches a configured trigger. if m.cfg.ListmonkEnabled() { - if listIDs := listmonk.ResolveListIDs(m.listmonkTriggers(), ps.to); len(listIDs) > 0 { - return m, tea.Batch(m.spinner.Tick, m.sendListmonkCmd(ps.subject, cleanBody, listIDs)) + triggers := m.listmonkTriggers() + if listIDs := listmonk.ResolveListIDs(triggers, ps.to); len(listIDs) > 0 { + templateID := listmonk.ResolveTemplateID(triggers, ps.to) + return m, tea.Batch(m.spinner.Tick, m.sendListmonkCmd(ps.subject, cleanBody, listIDs, templateID)) } } return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount, ps.inReplyTo, ps.references)) @@ -5418,12 +5420,18 @@ func (m Model) viewPresend() string { if isListmonk { b.WriteString(styleHeader.Render(" Newsletter via Listmonk") + "\n") b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") - listIDs := listmonk.ResolveListIDs(m.listmonkTriggers(), ps.to) + triggers := m.listmonkTriggers() + listIDs := listmonk.ResolveListIDs(triggers, ps.to) + templateID := listmonk.ResolveTemplateID(triggers, ps.to) delay := m.cfg.Listmonk.DelayMinutes if delay == 0 { delay = 30 } - b.WriteString(styleHelp.Render(fmt.Sprintf(" Lists: %v · Schedule: in %d min", listIDs, delay)) + "\n\n") + tmplPart := "" + if templateID != 0 { + tmplPart = fmt.Sprintf(" · Template: %d", templateID) + } + b.WriteString(styleHelp.Render(fmt.Sprintf(" Lists: %v%s · Schedule: in %d min", listIDs, tmplPart, delay)) + "\n\n") } else { b.WriteString(styleHeader.Render(" Ready to send") + "\n") b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n")