Skip to content

Commit 433c5c1

Browse files
committed
adding listmonk template as an optiional setting
1 parent 101e7e6 commit 433c5c1

7 files changed

Lines changed: 103 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
# 2026-05-12
4+
- **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
5+
6+
# 2026-05-08
7+
- **`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
8+
- **`/` 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 (`<space>/` / `:search`) already searches To and is unaffected
9+
- Review and stability
10+
- **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
11+
- **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
12+
- **Security: path traversal in `.ics` calendar attachment open (`<space> 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-<unix-ts>.ics` when the sender-supplied name is unusable
13+
- **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-<unix-ts>`
14+
- **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
15+
- **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 `<addr>` form when present, otherwise returns the trimmed input. Unused `"net"` import dropped
16+
- **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
17+
- **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
18+
- **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
19+
- **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
20+
- **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
21+
322
# 2026-05-06
423
- **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 `<leader>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-<name>-<random>` files written by `writeAttachmentsTemp` from the saved MIME parts; the user can replace them with the original local paths if those files moved
524
- **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

docs/content/docs/integrations/listmonk.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ list_ids = [2]
3333
[[listmonk.triggers]]
3434
address = "listmonk-book@ssp.sh"
3535
list_ids = [4]
36+
template_id = 5 # optional: override default template (e.g. "Book Update Template")
3637

3738
[[listmonk.triggers]]
3839
address = "listmonk@ssp.sh"
@@ -46,6 +47,22 @@ list_ids = [2, 4] # send to all lists
4647
| `api_token` | API token (supports `$ENV` expansion) |
4748
| `delay_minutes` | Minutes to delay before campaign sends (default 30) |
4849

50+
Per-trigger fields:
51+
52+
| Field | Description |
53+
|-------|-------------|
54+
| `address` | Virtual email address that fires this trigger |
55+
| `list_ids` | One or more Listmonk list IDs to send to |
56+
| `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. |
57+
58+
### Per-list templates
59+
60+
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:
61+
62+
```bash
63+
curl -u "admin:token" https://list.ssp.sh/api/templates | jq '.data[] | {id, name, is_default}'
64+
```
65+
4966
### Trigger addresses
5067

5168
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,
6986
When the To address matches a trigger, the pre-send review changes:
7087

7188
- Header shows **"Newsletter via Listmonk"** instead of "Ready to send"
72-
- Displays the target **list IDs** and **schedule delay**
89+
- Displays the target **list IDs**, **template ID** (if overridden), and **schedule delay**
7390
- Help bar shows `enter schedule campaign` instead of `enter send`
7491

7592

@@ -93,7 +110,7 @@ The email body (Markdown) is sent as-is with `content_type: "markdown"` — List
93110

94111
neomd uses two Listmonk API calls:
95112

96-
1. `POST /api/campaigns` — creates campaign in DRAFT status with `send_at` set to now + `delay_minutes`
113+
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)
97114
2. `PUT /api/campaigns/{id}/status` — sets status to `scheduled`
98115

99116
Authentication is HTTP Basic Auth. The campaign name is auto-generated as `"{subject} - {timestamp}"`.

internal/config/config.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,12 @@ type Config struct {
368368
}
369369

370370
// ListmonkTrigger maps a virtual email address to Listmonk list IDs.
371+
// TemplateID, when non-zero, overrides Listmonk's default template for the
372+
// generated campaign (e.g. a per-list "Book Update" template).
371373
type ListmonkTrigger struct {
372-
Address string `toml:"address"`
373-
ListIDs []int `toml:"list_ids"`
374+
Address string `toml:"address"`
375+
ListIDs []int `toml:"list_ids"`
376+
TemplateID int `toml:"template_id"`
374377
}
375378

376379
// ListmonkConfig holds settings for the Listmonk newsletter integration.

internal/listmonk/client.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type campaignRequest struct {
4141
ContentType string `json:"content_type"`
4242
Type string `json:"type"`
4343
SendAt string `json:"send_at,omitempty"`
44+
TemplateID int `json:"template_id,omitempty"`
4445
}
4546

4647
// statusRequest is the JSON payload for PUT /api/campaigns/{id}/status.
@@ -56,14 +57,14 @@ type campaignResponse struct {
5657
}
5758

5859
// CreateAndSchedule creates a campaign in DRAFT status, then schedules it.
59-
// Returns the campaign ID.
60-
func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int, delay time.Duration) (int, error) {
60+
// Returns the campaign ID. templateID is optional (0 = use Listmonk default).
61+
func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int, templateID int, delay time.Duration) (int, error) {
6162
if delay == 0 {
6263
delay = 30 * time.Minute
6364
}
6465
sendAt := time.Now().UTC().Add(delay)
6566

66-
id, err := c.createCampaign(subject, markdownBody, listIDs, sendAt)
67+
id, err := c.createCampaign(subject, markdownBody, listIDs, templateID, sendAt)
6768
if err != nil {
6869
return 0, err
6970
}
@@ -73,7 +74,7 @@ func (c *Client) CreateAndSchedule(subject, markdownBody string, listIDs []int,
7374
return id, nil
7475
}
7576

76-
func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time.Time) (int, error) {
77+
func (c *Client) createCampaign(subject, body string, listIDs []int, templateID int, sendAt time.Time) (int, error) {
7778
name := fmt.Sprintf("%s - %s", subject, sendAt.Format("2006-01-02 15:04"))
7879
payload := campaignRequest{
7980
Name: name,
@@ -83,6 +84,7 @@ func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time
8384
ContentType: "markdown",
8485
Type: "regular",
8586
SendAt: sendAt.Format(time.RFC3339),
87+
TemplateID: templateID,
8688
}
8789

8890
data, err := json.Marshal(payload)

internal/listmonk/client_test.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func TestCreateAndSchedule(t *testing.T) {
4848
APIToken: "testtoken",
4949
})
5050

51-
id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 30*time.Minute)
51+
id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 5, 30*time.Minute)
5252
if err != nil {
5353
t.Fatalf("unexpected error: %v", err)
5454
}
@@ -76,6 +76,9 @@ func TestCreateAndSchedule(t *testing.T) {
7676
if gotStatus.Status != "scheduled" {
7777
t.Errorf("status = %q, want %q", gotStatus.Status, "scheduled")
7878
}
79+
if gotCreate.TemplateID != 5 {
80+
t.Errorf("template_id = %d, want 5", gotCreate.TemplateID)
81+
}
7982
}
8083

8184
func TestCreateAndSchedule_APIError(t *testing.T) {
@@ -85,7 +88,7 @@ func TestCreateAndSchedule_APIError(t *testing.T) {
8588
defer srv.Close()
8689

8790
c := NewClient(Config{URL: srv.URL, APIUser: "u", APIToken: "t"})
88-
_, err := c.CreateAndSchedule("Test", "body", []int{1}, 10*time.Minute)
91+
_, err := c.CreateAndSchedule("Test", "body", []int{1}, 0, 10*time.Minute)
8992
if err == nil {
9093
t.Fatal("expected error for bad request")
9194
}
@@ -127,6 +130,22 @@ func TestResolveListIDs(t *testing.T) {
127130
}
128131
}
129132

133+
func TestResolveTemplateID(t *testing.T) {
134+
triggers := []Trigger{
135+
{Address: "listmonk@ssp.sh", ListIDs: []int{2}}, // no template override
136+
{Address: "listmonk-book@ssp.sh", ListIDs: []int{4}, TemplateID: 5}, // override
137+
}
138+
if got := ResolveTemplateID(triggers, "listmonk-book@ssp.sh"); got != 5 {
139+
t.Errorf("book override: got %d, want 5", got)
140+
}
141+
if got := ResolveTemplateID(triggers, "listmonk@ssp.sh"); got != 0 {
142+
t.Errorf("default: got %d, want 0", got)
143+
}
144+
if got := ResolveTemplateID(triggers, "nobody@example.com"); got != 0 {
145+
t.Errorf("no match: got %d, want 0", got)
146+
}
147+
}
148+
130149
func TestIsTriggerAddress(t *testing.T) {
131150
triggers := []Trigger{
132151
{Address: "listmonk@ssp.sh", ListIDs: []int{1}},

internal/listmonk/hook.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import (
55
)
66

77
// Trigger maps a virtual email address to Listmonk list IDs.
8+
// TemplateID, when non-zero, overrides Listmonk's default template for
9+
// campaigns created from this trigger.
810
type Trigger struct {
9-
Address string
10-
ListIDs []int
11+
Address string
12+
ListIDs []int
13+
TemplateID int
1114
}
1215

1316
// ResolveListIDs returns the combined list IDs for all trigger addresses
@@ -30,6 +33,19 @@ func ResolveListIDs(triggers []Trigger, toField string) []int {
3033
return ids
3134
}
3235

36+
// ResolveTemplateID returns the first non-zero template ID from triggers
37+
// matching any recipient in the To field. Returns 0 if no override.
38+
func ResolveTemplateID(triggers []Trigger, toField string) int {
39+
for _, addr := range splitAddrs(toField) {
40+
for _, t := range triggers {
41+
if strings.EqualFold(addr, t.Address) && t.TemplateID != 0 {
42+
return t.TemplateID
43+
}
44+
}
45+
}
46+
return 0
47+
}
48+
3349
// IsTriggerAddress returns true if any address in toField matches a trigger.
3450
func IsTriggerAddress(triggers []Trigger, toField string) bool {
3551
return len(ResolveListIDs(triggers, toField)) > 0

0 commit comments

Comments
 (0)