A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #12 from ssp-data/listmonk-integration

Add Listmonk integration: Sending to newsletter via email

authored by

Simon Späti and committed by
GitHub
7105d8d4 dd1955eb

+548 -7
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-23 4 + - **Listmonk newsletter integration** — send newsletters to subscribers by composing an email to a virtual trigger address (e.g. `listmonk@ssp.sh`); neomd intercepts the send and creates a scheduled campaign in [Listmonk](https://listmonk.app) via its REST API instead of delivering via SMTP; configure multiple trigger addresses in `[[listmonk.triggers]]` to target different subscriber lists (newsletter, book, all); pre-send screen shows "Newsletter via Listmonk" with target list IDs and schedule delay; campaigns are created as draft then set to `scheduled` status with configurable delay (default 30 minutes); authentication via HTTP Basic Auth with environment variable expansion for API token; new self-contained `internal/listmonk/` package with full test coverage (httptest mocks); documented in `docs/integrations/listmonk.md` 5 + 3 6 # 2026-04-21 4 7 - **RFC 5322 compliant Message-ID** — Message-IDs now use the sender's domain instead of hardcoded `@neomd`; ensures proper email threading, spam filter compatibility, and domain reputation consistency; uses `net/mail.ParseAddress()` for robust RFC 5322 address parsing; validates From address before sending and rejects invalid addresses that would result in `@localhost` Message-IDs; added comprehensive test coverage for BuildMessage, BuildDraftMessage, and BuildReactionMessage paths; documented email standards compliance in `docs/email-standards.md` 5 8 - **Fix: From validation allows local-only addresses** — `extractDomain()` now returns `(domain, ok bool)` to distinguish between parsing failures (invalid address) and valid `user@localhost` addresses; validation only rejects unparseable addresses, not legitimate local mail system configurations; prevents regression that would have blocked valid RFC 5322 addresses
+4
CLAUDE.md
··· 67 67 - `internal/config/` — TOML config parsing 68 68 - `internal/editor/` — spawns $EDITOR with neomd-*.md temp files 69 69 - `internal/render/` — glamour-based Markdown rendering for terminal 70 + - `internal/daemon/` — headless background mode (`--headless`): screener loop without TUI 70 71 - `internal/mailtls/` — TLS/STARTTLS connection helpers 71 72 - `internal/oauth2/` — OAuth2 flow for Gmail/Office365 73 + - `internal/integration_test.go` — integration tests (live IMAP/SMTP); lives at package level, not in a sub-package 74 + 75 + **CI:** GitHub Actions runs `go test ./...` + `go vet ./...` on every PR. 72 76 73 77 ## Project-Specific Conventions 74 78
+1
README.md
··· 156 156 - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [more](https://ssp-data.github.io/neomd/docs/configurations/headless/) 157 157 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 158 158 - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [more](https://ssp-data.github.io/neomd/docs/configuration/) 159 + - **Listmonk newsletter integration** — compose an email to a virtual address (e.g. `listmonk@ssp.sh`) and neomd creates a scheduled campaign in [Listmonk](https://listmonk.app) via API instead of sending via SMTP; configure multiple trigger addresses to target different subscriber lists; pre-send screen shows campaign details; inspired by [HEY World](https://www.hey.com/world/) [more](https://ssp-data.github.io/neomd/docs/integrations/listmonk/) 159 160 - **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 [more](https://ssp-data.github.io/neomd/docs/configurations/email-standards/) 160 161 161 162 > [!NOTE]
+7 -4
docs/content/_index.md
··· 9 9 A minimal terminal email client&nbsp;<br class="sm:hx-block hx-hidden" />for people who read & write in Markdown 10 10 {{< /hextra/hero-headline >}} 11 11 </div> 12 - 12 + <br> 13 13 <div class="hx-mb-12"> 14 14 {{< hextra/hero-subtitle >}} 15 15 Compose in Neovim, navigate with Vim motions, screen emails like HEY,&nbsp;<br class="sm:hx-block hx-hidden" />process your inbox with GTD — all from the terminal 16 16 {{< /hextra/hero-subtitle >}} 17 17 </div> 18 - 18 + <br> 19 19 <div class="hx-mb-6"> 20 20 {{< hextra/hero-button text="Overview and Philosophy" link="docs" >}} 21 21 </div> 22 - 22 + <br> 23 23 <div class="hx-mt-6"></div> 24 + <br> 24 25 25 26 <div class="hx-mt-12 hx-mb-8"> 26 27 <h2 class="hx-text-4xl hx-font-bold hx-tracking-tight hx-text-gray-900 dark:hx-text-gray-50">What Makes neomd Different?</h2> 27 28 </div> 28 - 29 + <br> 29 30 {{< hextra/feature-grid >}} 30 31 {{< hextra/feature-card 31 32 title="HEY-Style Screener" ··· 75 76 [![neomd demo](https://img.youtube.com/vi/lpmHqIrCC-w/maxresdefault.jpg)](https://youtu.be/8aKkldYLWV8) 76 77 77 78 79 + <br> 78 80 <div class="hx-mt-12 hx-mb-8"> 79 81 <h2 class="hx-text-4xl hx-font-bold hx-tracking-tight hx-text-gray-900 dark:hx-text-gray-50">Documentation</h2> 80 82 </div> ··· 86 88 {{< card link="docs/screener" title="Screener Workflow" subtitle="How to classify emails, bulk operations, and screener lists" >}} 87 89 {{< card link="docs/reading" title="Reading Emails" subtitle="Navigation, images, links, attachments, threading" >}} 88 90 {{< card link="docs/sending" title="Sending Emails" subtitle="Compose, attachments, CC/BCC, drafts, HTML signatures" >}} 91 + {{< card link="docs/integrations/" title="Integrations" subtitle="Integrations with Newsletter such as Listmonk" >}} 89 92 {{< card link="docs/faq" title="FAQ" subtitle="Frequently asked questions" >}} 90 93 {{< /cards >}} 91 94
+1
docs/content/docs/_index.md
··· 159 159 - **Headless daemon mode** — run `neomd --headless` on a server to continuously screen emails in the background without the TUI; watches screener list files for changes via Syncthing; emails are auto-screened every `bg_sync_interval` minutes so mobile apps see correctly filtered IMAP folders; perfect for running on a NAS while using the TUI on laptop/Android [more](https://ssp-data.github.io/neomd/docs/configurations/headless/) 160 160 - **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette 161 161 - **IMAP + SMTP** — direct connection via RFC 6851 MOVE, no local sync daemon required and keeps it in sync if you use it on mobile or different device [more](https://ssp-data.github.io/neomd/docs/configuration/) 162 + - **Listmonk newsletter integration** — compose an email to a virtual address (e.g. `listmonk@ssp.sh`) and neomd creates a scheduled campaign in [Listmonk](https://listmonk.app) via API instead of sending via SMTP; configure multiple trigger addresses to target different subscriber lists; pre-send screen shows campaign details; inspired by [HEY World](https://www.hey.com/world/) [more](https://ssp-data.github.io/neomd/docs/integrations/listmonk/) 162 163 - **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 [more](https://ssp-data.github.io/neomd/docs/configurations/email-standards/) 163 164 164 165 {{< callout type="info" >}}
+8
docs/content/docs/integrations/_index.md
··· 1 + --- 2 + title: Neomd Integrations 3 + weight: 30 4 + sidebar: 5 + open: false 6 + --- 7 + 8 + Specific integration for neomd, e.g. Listmonk as first one.
+99
docs/content/docs/integrations/listmonk.md
··· 1 + --- 2 + title: Listmonk Newsletter Integration 3 + weight: 1 4 + --- 5 + 6 + Send newsletters to your subscribers by composing an email in neomd. Address it to a virtual trigger address (e.g. `listmonk@ssp.sh`), and neomd creates a [Listmonk](https://listmonk.app) campaign via API instead of sending via SMTP. Inspired by [HEY World](https://www.hey.com/world/). 7 + 8 + ## How it works 9 + 10 + 1. Compose an email as usual (`c`) 11 + 2. Set the **To** field to a configured trigger address (e.g. `listmonk-newsletter@ssp.sh`) 12 + 3. Write your newsletter in Markdown — it becomes the campaign body 13 + 4. The pre-send screen shows **"Newsletter via Listmonk"** with the target list IDs and schedule delay 14 + 5. Press `enter` — neomd creates a campaign in Listmonk and schedules it 15 + 16 + The campaign is created as a draft, then immediately set to `scheduled` status. Listmonk handles the actual delivery to your subscribers (via Amazon SES or whatever messenger you configured). 17 + 18 + ## Configuration 19 + 20 + Add a `[listmonk]` section to your `config.toml`: 21 + 22 + ```toml 23 + [listmonk] 24 + url = "https://list.ssp.sh" 25 + api_user = "sspaeti-api" 26 + api_token = "${LISTMONK_API_TOKEN}" 27 + delay_minutes = 30 28 + 29 + [[listmonk.triggers]] 30 + address = "listmonk-newsletter@ssp.sh" 31 + list_ids = [2] 32 + 33 + [[listmonk.triggers]] 34 + address = "listmonk-book@ssp.sh" 35 + list_ids = [4] 36 + 37 + [[listmonk.triggers]] 38 + address = "listmonk@ssp.sh" 39 + list_ids = [2, 4] # send to all lists 40 + ``` 41 + 42 + | Field | Description | 43 + |-------|-------------| 44 + | `url` | Base URL of your Listmonk instance | 45 + | `api_user` | API username for HTTP Basic Auth | 46 + | `api_token` | API token (supports `$ENV` expansion) | 47 + | `delay_minutes` | Minutes to delay before campaign sends (default 30) | 48 + 49 + ### Trigger addresses 50 + 51 + 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: 52 + 53 + - `listmonk-newsletter@ssp.sh` → sends to your newsletter list only 54 + - `listmonk-book@ssp.sh` → sends to your book subscribers only 55 + - `listmonk@ssp.sh` → sends to both lists at once 56 + 57 + The trigger address doesn't need to be a real mailbox — neomd intercepts it before any SMTP delivery. 58 + 59 + ### Getting your list IDs 60 + 61 + List IDs are visible in the Listmonk admin UI, or via the API: 62 + 63 + ```bash 64 + curl -u "admin:token" https://list.ssp.sh/api/lists | jq '.data.results[] | {id, name}' 65 + ``` 66 + 67 + ## Pre-send screen 68 + 69 + When the To address matches a trigger, the pre-send review changes: 70 + 71 + - Header shows **"Newsletter via Listmonk"** instead of "Ready to send" 72 + - Displays the target **list IDs** and **schedule delay** 73 + - Help bar shows `enter schedule campaign` instead of `enter send` 74 + 75 + 76 + ### How it looks 77 + 78 + When sent in neomd: 79 + ![listmonk](/images/listmonk-scheduled.png) 80 + 81 + 82 + And in Listmonk itself: 83 + ![listmonk](/images/listmonk-scheduled-2.png) 84 + ![listmonk](/images/listmonk-scheduled-3.png) 85 + 86 + ## Content 87 + 88 + The email body (Markdown) is sent as-is with `content_type: "markdown"` — Listmonk converts it to HTML using its own template engine. The compose subject becomes the campaign subject. 89 + 90 + ![listmonk](/images/listmonk-scheduled-4.png) 91 + 92 + ## API details 93 + 94 + neomd uses two Listmonk API calls: 95 + 96 + 1. `POST /api/campaigns` — creates campaign in DRAFT status with `send_at` set to now + `delay_minutes` 97 + 2. `PUT /api/campaigns/{id}/status` — sets status to `scheduled` 98 + 99 + Authentication is HTTP Basic Auth. The campaign name is auto-generated as `"{subject} - {timestamp}"`.
docs/static/images/listmonk-scheduled-2.png

This is a binary file and will not be displayed.

docs/static/images/listmonk-scheduled-3.png

This is a binary file and will not be displayed.

docs/static/images/listmonk-scheduled-4.png

This is a binary file and will not be displayed.

docs/static/images/listmonk-scheduled.png

This is a binary file and will not be displayed.

images/listmonk-scheduled.png

This is a binary file and will not be displayed.

+24
internal/config/config.go
··· 201 201 // Format: "addr@example.com" or "Name <addr@example.com>". Shown in the 202 202 // composer and pre-send review so it's never a silent BCC. 203 203 AutoBCC string `toml:"auto_bcc"` 204 + 205 + Listmonk ListmonkConfig `toml:"listmonk"` 206 + } 207 + 208 + // ListmonkTrigger maps a virtual email address to Listmonk list IDs. 209 + type ListmonkTrigger struct { 210 + Address string `toml:"address"` 211 + ListIDs []int `toml:"list_ids"` 212 + } 213 + 214 + // ListmonkConfig holds settings for the Listmonk newsletter integration. 215 + type ListmonkConfig struct { 216 + URL string `toml:"url"` 217 + APIUser string `toml:"api_user"` 218 + APIToken string `toml:"api_token"` 219 + DelayMinutes int `toml:"delay_minutes"` 220 + Triggers []ListmonkTrigger `toml:"triggers"` 221 + } 222 + 223 + // ListmonkEnabled returns true if Listmonk integration is configured. 224 + func (c *Config) ListmonkEnabled() bool { 225 + return c.Listmonk.URL != "" && len(c.Listmonk.Triggers) > 0 204 226 } 205 227 206 228 // ActiveAccounts returns the list of configured accounts. ··· 323 345 cfg.Account.Password = expandEnv(cfg.Account.Password) 324 346 cfg.Account.User = expandEnv(cfg.Account.User) 325 347 cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile)) 348 + 349 + cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken) 326 350 327 351 return cfg, nil 328 352 }
+142
internal/listmonk/client.go
··· 1 + // Package listmonk provides an API client for creating and scheduling 2 + // campaigns on a Listmonk newsletter server. 3 + package listmonk 4 + 5 + import ( 6 + "bytes" 7 + "encoding/json" 8 + "fmt" 9 + "net/http" 10 + "time" 11 + ) 12 + 13 + // Config holds Listmonk connection settings. 14 + type Config struct { 15 + URL string 16 + APIUser string 17 + APIToken string 18 + DelayMinutes int // default 30 19 + } 20 + 21 + // Client wraps Listmonk API calls. 22 + type Client struct { 23 + cfg Config 24 + http *http.Client 25 + } 26 + 27 + // NewClient creates a Listmonk API client. 28 + func NewClient(cfg Config) *Client { 29 + return &Client{ 30 + cfg: cfg, 31 + http: &http.Client{Timeout: 30 * time.Second}, 32 + } 33 + } 34 + 35 + // campaignRequest is the JSON payload for POST /api/campaigns. 36 + type campaignRequest struct { 37 + Name string `json:"name"` 38 + Subject string `json:"subject"` 39 + Lists []int `json:"lists"` 40 + Body string `json:"body"` 41 + ContentType string `json:"content_type"` 42 + Type string `json:"type"` 43 + SendAt string `json:"send_at,omitempty"` 44 + } 45 + 46 + // statusRequest is the JSON payload for PUT /api/campaigns/{id}/status. 47 + type statusRequest struct { 48 + Status string `json:"status"` 49 + } 50 + 51 + // campaignResponse wraps the Listmonk API response for campaign creation. 52 + type campaignResponse struct { 53 + Data struct { 54 + ID int `json:"id"` 55 + } `json:"data"` 56 + } 57 + 58 + // 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) { 61 + if delay == 0 { 62 + delay = 30 * time.Minute 63 + } 64 + sendAt := time.Now().UTC().Add(delay) 65 + 66 + id, err := c.createCampaign(subject, markdownBody, listIDs, sendAt) 67 + if err != nil { 68 + return 0, err 69 + } 70 + if err := c.setStatus(id, "scheduled"); err != nil { 71 + return id, fmt.Errorf("created campaign #%d but failed to schedule: %w", id, err) 72 + } 73 + return id, nil 74 + } 75 + 76 + func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time.Time) (int, error) { 77 + name := fmt.Sprintf("%s - %s", subject, sendAt.Format("2006-01-02 15:04")) 78 + payload := campaignRequest{ 79 + Name: name, 80 + Subject: subject, 81 + Lists: listIDs, 82 + Body: body, 83 + ContentType: "markdown", 84 + Type: "regular", 85 + SendAt: sendAt.Format(time.RFC3339), 86 + } 87 + 88 + data, err := json.Marshal(payload) 89 + if err != nil { 90 + return 0, fmt.Errorf("marshal campaign: %w", err) 91 + } 92 + 93 + req, err := http.NewRequest("POST", c.cfg.URL+"/api/campaigns", bytes.NewReader(data)) 94 + if err != nil { 95 + return 0, err 96 + } 97 + req.Header.Set("Content-Type", "application/json") 98 + req.SetBasicAuth(c.cfg.APIUser, c.cfg.APIToken) 99 + 100 + resp, err := c.http.Do(req) 101 + if err != nil { 102 + return 0, fmt.Errorf("create campaign: %w", err) 103 + } 104 + defer resp.Body.Close() 105 + 106 + if resp.StatusCode != http.StatusOK { 107 + var buf bytes.Buffer 108 + buf.ReadFrom(resp.Body) 109 + return 0, fmt.Errorf("create campaign: HTTP %d: %s", resp.StatusCode, buf.String()) 110 + } 111 + 112 + var result campaignResponse 113 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 114 + return 0, fmt.Errorf("parse campaign response: %w", err) 115 + } 116 + return result.Data.ID, nil 117 + } 118 + 119 + func (c *Client) setStatus(campaignID int, status string) error { 120 + data, _ := json.Marshal(statusRequest{Status: status}) 121 + 122 + url := fmt.Sprintf("%s/api/campaigns/%d/status", c.cfg.URL, campaignID) 123 + req, err := http.NewRequest("PUT", url, bytes.NewReader(data)) 124 + if err != nil { 125 + return err 126 + } 127 + req.Header.Set("Content-Type", "application/json") 128 + req.SetBasicAuth(c.cfg.APIUser, c.cfg.APIToken) 129 + 130 + resp, err := c.http.Do(req) 131 + if err != nil { 132 + return fmt.Errorf("set campaign status: %w", err) 133 + } 134 + defer resp.Body.Close() 135 + 136 + if resp.StatusCode != http.StatusOK { 137 + var buf bytes.Buffer 138 + buf.ReadFrom(resp.Body) 139 + return fmt.Errorf("set campaign status: HTTP %d: %s", resp.StatusCode, buf.String()) 140 + } 141 + return nil 142 + }
+140
internal/listmonk/client_test.go
··· 1 + package listmonk 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + "time" 10 + ) 11 + 12 + func TestCreateAndSchedule(t *testing.T) { 13 + var gotCreate campaignRequest 14 + var gotStatus statusRequest 15 + var createCalled, statusCalled bool 16 + 17 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 + // Verify basic auth 19 + user, pass, ok := r.BasicAuth() 20 + if !ok || user != "testuser" || pass != "testtoken" { 21 + http.Error(w, "unauthorized", http.StatusUnauthorized) 22 + return 23 + } 24 + 25 + switch { 26 + case r.Method == "POST" && r.URL.Path == "/api/campaigns": 27 + createCalled = true 28 + json.NewDecoder(r.Body).Decode(&gotCreate) 29 + json.NewEncoder(w).Encode(map[string]any{ 30 + "data": map[string]any{"id": 42}, 31 + }) 32 + 33 + case r.Method == "PUT" && strings.HasSuffix(r.URL.Path, "/status"): 34 + statusCalled = true 35 + json.NewDecoder(r.Body).Decode(&gotStatus) 36 + w.WriteHeader(http.StatusOK) 37 + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}}) 38 + 39 + default: 40 + http.Error(w, "not found", http.StatusNotFound) 41 + } 42 + })) 43 + defer srv.Close() 44 + 45 + c := NewClient(Config{ 46 + URL: srv.URL, 47 + APIUser: "testuser", 48 + APIToken: "testtoken", 49 + }) 50 + 51 + id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 30*time.Minute) 52 + if err != nil { 53 + t.Fatalf("unexpected error: %v", err) 54 + } 55 + if id != 42 { 56 + t.Errorf("got campaign ID %d, want 42", id) 57 + } 58 + if !createCalled { 59 + t.Error("create endpoint not called") 60 + } 61 + if !statusCalled { 62 + t.Error("status endpoint not called") 63 + } 64 + if gotCreate.Subject != "My Newsletter" { 65 + t.Errorf("subject = %q, want %q", gotCreate.Subject, "My Newsletter") 66 + } 67 + if gotCreate.ContentType != "markdown" { 68 + t.Errorf("content_type = %q, want %q", gotCreate.ContentType, "markdown") 69 + } 70 + if len(gotCreate.Lists) != 2 || gotCreate.Lists[0] != 1 || gotCreate.Lists[1] != 2 { 71 + t.Errorf("lists = %v, want [1 2]", gotCreate.Lists) 72 + } 73 + if gotCreate.SendAt == "" { 74 + t.Error("send_at should be set") 75 + } 76 + if gotStatus.Status != "scheduled" { 77 + t.Errorf("status = %q, want %q", gotStatus.Status, "scheduled") 78 + } 79 + } 80 + 81 + func TestCreateAndSchedule_APIError(t *testing.T) { 82 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 + http.Error(w, `{"message":"bad request"}`, http.StatusBadRequest) 84 + })) 85 + defer srv.Close() 86 + 87 + c := NewClient(Config{URL: srv.URL, APIUser: "u", APIToken: "t"}) 88 + _, err := c.CreateAndSchedule("Test", "body", []int{1}, 10*time.Minute) 89 + if err == nil { 90 + t.Fatal("expected error for bad request") 91 + } 92 + if !strings.Contains(err.Error(), "HTTP 400") { 93 + t.Errorf("error should mention HTTP 400, got: %v", err) 94 + } 95 + } 96 + 97 + func TestResolveListIDs(t *testing.T) { 98 + triggers := []Trigger{ 99 + {Address: "listmonk-newsletter@ssp.sh", ListIDs: []int{1}}, 100 + {Address: "listmonk-book@ssp.sh", ListIDs: []int{2}}, 101 + {Address: "listmonk@ssp.sh", ListIDs: []int{1, 2}}, 102 + } 103 + 104 + tests := []struct { 105 + name string 106 + to string 107 + wantLen int 108 + wantIDs []int 109 + }{ 110 + {"newsletter only", "listmonk-newsletter@ssp.sh", 1, []int{1}}, 111 + {"book only", "listmonk-book@ssp.sh", 1, []int{2}}, 112 + {"both lists via single addr", "listmonk@ssp.sh", 2, []int{1, 2}}, 113 + {"no match", "someone@example.com", 0, nil}, 114 + {"case insensitive", "Listmonk-Newsletter@SSP.SH", 1, []int{1}}, 115 + {"with display name", "Newsletter <listmonk-newsletter@ssp.sh>", 1, []int{1}}, 116 + {"multiple to addrs", "listmonk-newsletter@ssp.sh, listmonk-book@ssp.sh", 2, []int{1, 2}}, 117 + {"dedup list IDs", "listmonk@ssp.sh, listmonk-newsletter@ssp.sh", 2, []int{1, 2}}, 118 + } 119 + 120 + for _, tt := range tests { 121 + t.Run(tt.name, func(t *testing.T) { 122 + ids := ResolveListIDs(triggers, tt.to) 123 + if len(ids) != tt.wantLen { 124 + t.Errorf("got %d IDs %v, want %d", len(ids), ids, tt.wantLen) 125 + } 126 + }) 127 + } 128 + } 129 + 130 + func TestIsTriggerAddress(t *testing.T) { 131 + triggers := []Trigger{ 132 + {Address: "listmonk@ssp.sh", ListIDs: []int{1}}, 133 + } 134 + if !IsTriggerAddress(triggers, "listmonk@ssp.sh") { 135 + t.Error("should match trigger address") 136 + } 137 + if IsTriggerAddress(triggers, "random@example.com") { 138 + t.Error("should not match non-trigger address") 139 + } 140 + }
+55
internal/listmonk/hook.go
··· 1 + package listmonk 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // Trigger maps a virtual email address to Listmonk list IDs. 8 + type Trigger struct { 9 + Address string 10 + ListIDs []int 11 + } 12 + 13 + // ResolveListIDs returns the combined list IDs for all trigger addresses 14 + // that match any recipient in the To field. Returns nil if no match. 15 + func ResolveListIDs(triggers []Trigger, toField string) []int { 16 + seen := make(map[int]bool) 17 + var ids []int 18 + for _, addr := range splitAddrs(toField) { 19 + for _, t := range triggers { 20 + if strings.EqualFold(addr, t.Address) { 21 + for _, id := range t.ListIDs { 22 + if !seen[id] { 23 + seen[id] = true 24 + ids = append(ids, id) 25 + } 26 + } 27 + } 28 + } 29 + } 30 + return ids 31 + } 32 + 33 + // IsTriggerAddress returns true if any address in toField matches a trigger. 34 + func IsTriggerAddress(triggers []Trigger, toField string) bool { 35 + return len(ResolveListIDs(triggers, toField)) > 0 36 + } 37 + 38 + // splitAddrs splits a comma-separated To field and extracts bare email addresses. 39 + func splitAddrs(field string) []string { 40 + var addrs []string 41 + for _, part := range strings.Split(field, ",") { 42 + part = strings.TrimSpace(part) 43 + if part == "" { 44 + continue 45 + } 46 + // Handle "Name <addr>" format 47 + if idx := strings.LastIndex(part, "<"); idx >= 0 { 48 + if end := strings.Index(part[idx:], ">"); end >= 0 { 49 + part = part[idx+1 : idx+end] 50 + } 51 + } 52 + addrs = append(addrs, strings.TrimSpace(part)) 53 + } 54 + return addrs 55 + }
+64 -3
internal/ui/model.go
··· 21 21 "github.com/sspaeti/neomd/internal/config" 22 22 "github.com/sspaeti/neomd/internal/editor" 23 23 "github.com/sspaeti/neomd/internal/imap" 24 + "github.com/sspaeti/neomd/internal/listmonk" 24 25 "github.com/sspaeti/neomd/internal/render" 25 26 "github.com/sspaeti/neomd/internal/screener" 26 27 "github.com/sspaeti/neomd/internal/smtp" ··· 56 57 sendDoneMsg struct { 57 58 err error 58 59 warning string 60 + info string // non-error informational message (e.g. Listmonk success) 59 61 replyToUID uint32 // set \Answered on this email after send 60 62 replyToFolder string 61 63 } ··· 795 797 } 796 798 } 797 799 800 + // listmonkTriggers converts config triggers to listmonk.Trigger slice. 801 + func (m Model) listmonkTriggers() []listmonk.Trigger { 802 + triggers := make([]listmonk.Trigger, len(m.cfg.Listmonk.Triggers)) 803 + for i, t := range m.cfg.Listmonk.Triggers { 804 + triggers[i] = listmonk.Trigger{Address: t.Address, ListIDs: t.ListIDs} 805 + } 806 + return triggers 807 + } 808 + 809 + func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int) tea.Cmd { 810 + cfg := m.cfg.Listmonk 811 + delay := time.Duration(cfg.DelayMinutes) * time.Minute 812 + if delay == 0 { 813 + delay = 30 * time.Minute 814 + } 815 + return func() tea.Msg { 816 + client := listmonk.NewClient(listmonk.Config{ 817 + URL: cfg.URL, 818 + APIUser: cfg.APIUser, 819 + APIToken: cfg.APIToken, 820 + }) 821 + campaignID, err := client.CreateAndSchedule(subject, markdownBody, listIDs, delay) 822 + if err != nil { 823 + return sendDoneMsg{err: fmt.Errorf("listmonk: %w", err)} 824 + } 825 + mins := cfg.DelayMinutes 826 + if mins == 0 { 827 + mins = 30 828 + } 829 + return sendDoneMsg{info: fmt.Sprintf("Campaign #%d scheduled via Listmonk (sends in %d min)", campaignID, mins)} 830 + } 831 + } 832 + 798 833 func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) { 799 834 if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) { 800 835 return m, nil ··· 1701 1736 } else if msg.warning != "" { 1702 1737 m.status = msg.warning 1703 1738 m.isError = true // show in red so user notices 1739 + m.state = stateInbox 1740 + } else if msg.info != "" { 1741 + m.status = msg.info 1742 + m.isError = false 1704 1743 m.state = stateInbox 1705 1744 } else { 1706 1745 m.status = "Sent!" ··· 3455 3494 includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) 3456 3495 m.attachments = nil 3457 3496 m.pendingSend = nil 3497 + // Route to Listmonk if the To address matches a configured trigger. 3498 + if m.cfg.ListmonkEnabled() { 3499 + if listIDs := listmonk.ResolveListIDs(m.listmonkTriggers(), ps.to); len(listIDs) > 0 { 3500 + return m, tea.Batch(m.spinner.Tick, m.sendListmonkCmd(ps.subject, cleanBody, listIDs)) 3501 + } 3502 + } 3458 3503 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)) 3459 3504 case "ctrl+f": 3460 3505 froms := m.presendFroms() ··· 4179 4224 if ps == nil { 4180 4225 return "" 4181 4226 } 4227 + isListmonk := m.cfg.ListmonkEnabled() && listmonk.IsTriggerAddress(m.listmonkTriggers(), ps.to) 4182 4228 var b strings.Builder 4183 - b.WriteString(styleHeader.Render(" Ready to send") + "\n") 4184 - b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n") 4229 + if isListmonk { 4230 + b.WriteString(styleHeader.Render(" Newsletter via Listmonk") + "\n") 4231 + b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") 4232 + listIDs := listmonk.ResolveListIDs(m.listmonkTriggers(), ps.to) 4233 + delay := m.cfg.Listmonk.DelayMinutes 4234 + if delay == 0 { 4235 + delay = 30 4236 + } 4237 + b.WriteString(styleHelp.Render(fmt.Sprintf(" Lists: %v · Schedule: in %d min", listIDs, delay)) + "\n\n") 4238 + } else { 4239 + b.WriteString(styleHeader.Render(" Ready to send") + "\n") 4240 + b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n") 4241 + } 4185 4242 4186 4243 lbl := styleInputLabel.Render 4187 4244 fromLine := m.presendFrom() ··· 4222 4279 if m.status != "" { 4223 4280 b.WriteString(statusBar(m.status, m.isError)) 4224 4281 } else { 4225 - 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")) 4282 + if isListmonk { 4283 + b.WriteString(styleHelp.Render(" enter schedule campaign · e edit · p preview · ctrl+f from · d draft · esc cancel · x discard")) 4284 + } else { 4285 + 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")) 4286 + } 4226 4287 } 4227 4288 return b.String() 4228 4289 }