diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fb23bd..5f36607 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+# 2026-04-23
+- **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`
+
# 2026-04-21
- **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`
- **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
diff --git a/CLAUDE.md b/CLAUDE.md
index 6aef6b5..5f563e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,8 +67,12 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c
- `internal/config/` — TOML config parsing
- `internal/editor/` — spawns $EDITOR with neomd-*.md temp files
- `internal/render/` — glamour-based Markdown rendering for terminal
+- `internal/daemon/` — headless background mode (`--headless`): screener loop without TUI
- `internal/mailtls/` — TLS/STARTTLS connection helpers
- `internal/oauth2/` — OAuth2 flow for Gmail/Office365
+- `internal/integration_test.go` — integration tests (live IMAP/SMTP); lives at package level, not in a sub-package
+
+**CI:** GitHub Actions runs `go test ./...` + `go vet ./...` on every PR.
## Project-Specific Conventions
diff --git a/README.md b/README.md
index 6e9dbf0..c907518 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,7 @@ Or in Gmail:
- **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/)
- **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette
- **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/)
+- **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/)
- **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/)
> [!NOTE]
diff --git a/docs/content/_index.md b/docs/content/_index.md
index fc480a1..cc4e869 100644
--- a/docs/content/_index.md
+++ b/docs/content/_index.md
@@ -9,23 +9,24 @@ toc: false
A minimal terminal email client
for people who read & write in Markdown
{{< /hextra/hero-headline >}}
-
+
{{< hextra/hero-subtitle >}}
Compose in Neovim, navigate with Vim motions, screen emails like HEY,
process your inbox with GTD — all from the terminal
{{< /hextra/hero-subtitle >}}
-
+
{{< hextra/hero-button text="Overview and Philosophy" link="docs" >}}
-
+
+
What Makes neomd Different?
-
+
{{< hextra/feature-grid >}}
{{< hextra/feature-card
title="HEY-Style Screener"
@@ -75,6 +76,7 @@ YouTube rundown of most features:
[](https://youtu.be/8aKkldYLWV8)
+
Documentation
@@ -86,6 +88,7 @@ YouTube rundown of most features:
{{< card link="docs/screener" title="Screener Workflow" subtitle="How to classify emails, bulk operations, and screener lists" >}}
{{< card link="docs/reading" title="Reading Emails" subtitle="Navigation, images, links, attachments, threading" >}}
{{< card link="docs/sending" title="Sending Emails" subtitle="Compose, attachments, CC/BCC, drafts, HTML signatures" >}}
+ {{< card link="docs/integrations/" title="Integrations" subtitle="Integrations with Newsletter such as Listmonk" >}}
{{< card link="docs/faq" title="FAQ" subtitle="Frequently asked questions" >}}
{{< /cards >}}
diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md
index 5f380c5..549f86b 100644
--- a/docs/content/docs/_index.md
+++ b/docs/content/docs/_index.md
@@ -159,6 +159,7 @@ Or in Gmail:
- **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/)
- **Kanagawa theme** — colors from the [kanagawa.nvim](https://github.com/rebelot/kanagawa.nvim) palette
- **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/)
+- **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/)
- **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/)
{{< callout type="info" >}}
diff --git a/docs/content/docs/integrations/_index.md b/docs/content/docs/integrations/_index.md
new file mode 100644
index 0000000..b49a24f
--- /dev/null
+++ b/docs/content/docs/integrations/_index.md
@@ -0,0 +1,8 @@
+---
+title: Neomd Integrations
+weight: 30
+sidebar:
+ open: false
+---
+
+Specific integration for neomd, e.g. Listmonk as first one.
diff --git a/docs/content/docs/integrations/listmonk.md b/docs/content/docs/integrations/listmonk.md
new file mode 100644
index 0000000..e4db6ad
--- /dev/null
+++ b/docs/content/docs/integrations/listmonk.md
@@ -0,0 +1,99 @@
+---
+title: Listmonk Newsletter Integration
+weight: 1
+---
+
+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/).
+
+## How it works
+
+1. Compose an email as usual (`c`)
+2. Set the **To** field to a configured trigger address (e.g. `listmonk-newsletter@ssp.sh`)
+3. Write your newsletter in Markdown — it becomes the campaign body
+4. The pre-send screen shows **"Newsletter via Listmonk"** with the target list IDs and schedule delay
+5. Press `enter` — neomd creates a campaign in Listmonk and schedules it
+
+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).
+
+## Configuration
+
+Add a `[listmonk]` section to your `config.toml`:
+
+```toml
+[listmonk]
+url = "https://list.ssp.sh"
+api_user = "sspaeti-api"
+api_token = "${LISTMONK_API_TOKEN}"
+delay_minutes = 30
+
+[[listmonk.triggers]]
+address = "listmonk-newsletter@ssp.sh"
+list_ids = [2]
+
+[[listmonk.triggers]]
+address = "listmonk-book@ssp.sh"
+list_ids = [4]
+
+[[listmonk.triggers]]
+address = "listmonk@ssp.sh"
+list_ids = [2, 4] # send to all lists
+```
+
+| Field | Description |
+|-------|-------------|
+| `url` | Base URL of your Listmonk instance |
+| `api_user` | API username for HTTP Basic Auth |
+| `api_token` | API token (supports `$ENV` expansion) |
+| `delay_minutes` | Minutes to delay before campaign sends (default 30) |
+
+### 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:
+
+- `listmonk-newsletter@ssp.sh` → sends to your newsletter list only
+- `listmonk-book@ssp.sh` → sends to your book subscribers only
+- `listmonk@ssp.sh` → sends to both lists at once
+
+The trigger address doesn't need to be a real mailbox — neomd intercepts it before any SMTP delivery.
+
+### Getting your list IDs
+
+List IDs are visible in the Listmonk admin UI, or via the API:
+
+```bash
+curl -u "admin:token" https://list.ssp.sh/api/lists | jq '.data.results[] | {id, name}'
+```
+
+## Pre-send screen
+
+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**
+- Help bar shows `enter schedule campaign` instead of `enter send`
+
+
+### How it looks
+
+When sent in neomd:
+
+
+
+And in Listmonk itself:
+
+
+
+## Content
+
+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.
+
+
+
+## API details
+
+neomd uses two Listmonk API calls:
+
+1. `POST /api/campaigns` — creates campaign in DRAFT status with `send_at` set to now + `delay_minutes`
+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/docs/static/images/listmonk-scheduled-2.png b/docs/static/images/listmonk-scheduled-2.png
new file mode 100644
index 0000000..a95b677
Binary files /dev/null and b/docs/static/images/listmonk-scheduled-2.png differ
diff --git a/docs/static/images/listmonk-scheduled-3.png b/docs/static/images/listmonk-scheduled-3.png
new file mode 100644
index 0000000..47fdc48
Binary files /dev/null and b/docs/static/images/listmonk-scheduled-3.png differ
diff --git a/docs/static/images/listmonk-scheduled-4.png b/docs/static/images/listmonk-scheduled-4.png
new file mode 100644
index 0000000..bc40068
Binary files /dev/null and b/docs/static/images/listmonk-scheduled-4.png differ
diff --git a/docs/static/images/listmonk-scheduled.png b/docs/static/images/listmonk-scheduled.png
new file mode 100644
index 0000000..83ece1f
Binary files /dev/null and b/docs/static/images/listmonk-scheduled.png differ
diff --git a/images/listmonk-scheduled.png b/images/listmonk-scheduled.png
new file mode 100644
index 0000000..83ece1f
Binary files /dev/null and b/images/listmonk-scheduled.png differ
diff --git a/internal/config/config.go b/internal/config/config.go
index 718791e..be739ed 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -201,6 +201,28 @@ type Config struct {
// Format: "addr@example.com" or "Name ". Shown in the
// composer and pre-send review so it's never a silent BCC.
AutoBCC string `toml:"auto_bcc"`
+
+ Listmonk ListmonkConfig `toml:"listmonk"`
+}
+
+// ListmonkTrigger maps a virtual email address to Listmonk list IDs.
+type ListmonkTrigger struct {
+ Address string `toml:"address"`
+ ListIDs []int `toml:"list_ids"`
+}
+
+// ListmonkConfig holds settings for the Listmonk newsletter integration.
+type ListmonkConfig struct {
+ URL string `toml:"url"`
+ APIUser string `toml:"api_user"`
+ APIToken string `toml:"api_token"`
+ DelayMinutes int `toml:"delay_minutes"`
+ Triggers []ListmonkTrigger `toml:"triggers"`
+}
+
+// ListmonkEnabled returns true if Listmonk integration is configured.
+func (c *Config) ListmonkEnabled() bool {
+ return c.Listmonk.URL != "" && len(c.Listmonk.Triggers) > 0
}
// ActiveAccounts returns the list of configured accounts.
@@ -324,6 +346,8 @@ func Load(path string) (*Config, error) {
cfg.Account.User = expandEnv(cfg.Account.User)
cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile))
+ cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken)
+
return cfg, nil
}
diff --git a/internal/listmonk/client.go b/internal/listmonk/client.go
new file mode 100644
index 0000000..1d31b94
--- /dev/null
+++ b/internal/listmonk/client.go
@@ -0,0 +1,142 @@
+// Package listmonk provides an API client for creating and scheduling
+// campaigns on a Listmonk newsletter server.
+package listmonk
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Config holds Listmonk connection settings.
+type Config struct {
+ URL string
+ APIUser string
+ APIToken string
+ DelayMinutes int // default 30
+}
+
+// Client wraps Listmonk API calls.
+type Client struct {
+ cfg Config
+ http *http.Client
+}
+
+// NewClient creates a Listmonk API client.
+func NewClient(cfg Config) *Client {
+ return &Client{
+ cfg: cfg,
+ http: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+// campaignRequest is the JSON payload for POST /api/campaigns.
+type campaignRequest struct {
+ Name string `json:"name"`
+ Subject string `json:"subject"`
+ Lists []int `json:"lists"`
+ Body string `json:"body"`
+ ContentType string `json:"content_type"`
+ Type string `json:"type"`
+ SendAt string `json:"send_at,omitempty"`
+}
+
+// statusRequest is the JSON payload for PUT /api/campaigns/{id}/status.
+type statusRequest struct {
+ Status string `json:"status"`
+}
+
+// campaignResponse wraps the Listmonk API response for campaign creation.
+type campaignResponse struct {
+ Data struct {
+ ID int `json:"id"`
+ } `json:"data"`
+}
+
+// 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) {
+ if delay == 0 {
+ delay = 30 * time.Minute
+ }
+ sendAt := time.Now().UTC().Add(delay)
+
+ id, err := c.createCampaign(subject, markdownBody, listIDs, sendAt)
+ if err != nil {
+ return 0, err
+ }
+ if err := c.setStatus(id, "scheduled"); err != nil {
+ return id, fmt.Errorf("created campaign #%d but failed to schedule: %w", id, err)
+ }
+ return id, nil
+}
+
+func (c *Client) createCampaign(subject, body string, listIDs []int, sendAt time.Time) (int, error) {
+ name := fmt.Sprintf("%s - %s", subject, sendAt.Format("2006-01-02 15:04"))
+ payload := campaignRequest{
+ Name: name,
+ Subject: subject,
+ Lists: listIDs,
+ Body: body,
+ ContentType: "markdown",
+ Type: "regular",
+ SendAt: sendAt.Format(time.RFC3339),
+ }
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return 0, fmt.Errorf("marshal campaign: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", c.cfg.URL+"/api/campaigns", bytes.NewReader(data))
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.SetBasicAuth(c.cfg.APIUser, c.cfg.APIToken)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, fmt.Errorf("create campaign: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var buf bytes.Buffer
+ buf.ReadFrom(resp.Body)
+ return 0, fmt.Errorf("create campaign: HTTP %d: %s", resp.StatusCode, buf.String())
+ }
+
+ var result campaignResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return 0, fmt.Errorf("parse campaign response: %w", err)
+ }
+ return result.Data.ID, nil
+}
+
+func (c *Client) setStatus(campaignID int, status string) error {
+ data, _ := json.Marshal(statusRequest{Status: status})
+
+ url := fmt.Sprintf("%s/api/campaigns/%d/status", c.cfg.URL, campaignID)
+ req, err := http.NewRequest("PUT", url, bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.SetBasicAuth(c.cfg.APIUser, c.cfg.APIToken)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("set campaign status: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var buf bytes.Buffer
+ buf.ReadFrom(resp.Body)
+ return fmt.Errorf("set campaign status: HTTP %d: %s", resp.StatusCode, buf.String())
+ }
+ return nil
+}
diff --git a/internal/listmonk/client_test.go b/internal/listmonk/client_test.go
new file mode 100644
index 0000000..c703894
--- /dev/null
+++ b/internal/listmonk/client_test.go
@@ -0,0 +1,140 @@
+package listmonk
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCreateAndSchedule(t *testing.T) {
+ var gotCreate campaignRequest
+ var gotStatus statusRequest
+ var createCalled, statusCalled bool
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify basic auth
+ user, pass, ok := r.BasicAuth()
+ if !ok || user != "testuser" || pass != "testtoken" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/api/campaigns":
+ createCalled = true
+ json.NewDecoder(r.Body).Decode(&gotCreate)
+ json.NewEncoder(w).Encode(map[string]any{
+ "data": map[string]any{"id": 42},
+ })
+
+ case r.Method == "PUT" && strings.HasSuffix(r.URL.Path, "/status"):
+ statusCalled = true
+ json.NewDecoder(r.Body).Decode(&gotStatus)
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}})
+
+ default:
+ http.Error(w, "not found", http.StatusNotFound)
+ }
+ }))
+ defer srv.Close()
+
+ c := NewClient(Config{
+ URL: srv.URL,
+ APIUser: "testuser",
+ APIToken: "testtoken",
+ })
+
+ id, err := c.CreateAndSchedule("My Newsletter", "# Hello\n\nWorld", []int{1, 2}, 30*time.Minute)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if id != 42 {
+ t.Errorf("got campaign ID %d, want 42", id)
+ }
+ if !createCalled {
+ t.Error("create endpoint not called")
+ }
+ if !statusCalled {
+ t.Error("status endpoint not called")
+ }
+ if gotCreate.Subject != "My Newsletter" {
+ t.Errorf("subject = %q, want %q", gotCreate.Subject, "My Newsletter")
+ }
+ if gotCreate.ContentType != "markdown" {
+ t.Errorf("content_type = %q, want %q", gotCreate.ContentType, "markdown")
+ }
+ if len(gotCreate.Lists) != 2 || gotCreate.Lists[0] != 1 || gotCreate.Lists[1] != 2 {
+ t.Errorf("lists = %v, want [1 2]", gotCreate.Lists)
+ }
+ if gotCreate.SendAt == "" {
+ t.Error("send_at should be set")
+ }
+ if gotStatus.Status != "scheduled" {
+ t.Errorf("status = %q, want %q", gotStatus.Status, "scheduled")
+ }
+}
+
+func TestCreateAndSchedule_APIError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, `{"message":"bad request"}`, http.StatusBadRequest)
+ }))
+ defer srv.Close()
+
+ c := NewClient(Config{URL: srv.URL, APIUser: "u", APIToken: "t"})
+ _, err := c.CreateAndSchedule("Test", "body", []int{1}, 10*time.Minute)
+ if err == nil {
+ t.Fatal("expected error for bad request")
+ }
+ if !strings.Contains(err.Error(), "HTTP 400") {
+ t.Errorf("error should mention HTTP 400, got: %v", err)
+ }
+}
+
+func TestResolveListIDs(t *testing.T) {
+ triggers := []Trigger{
+ {Address: "listmonk-newsletter@ssp.sh", ListIDs: []int{1}},
+ {Address: "listmonk-book@ssp.sh", ListIDs: []int{2}},
+ {Address: "listmonk@ssp.sh", ListIDs: []int{1, 2}},
+ }
+
+ tests := []struct {
+ name string
+ to string
+ wantLen int
+ wantIDs []int
+ }{
+ {"newsletter only", "listmonk-newsletter@ssp.sh", 1, []int{1}},
+ {"book only", "listmonk-book@ssp.sh", 1, []int{2}},
+ {"both lists via single addr", "listmonk@ssp.sh", 2, []int{1, 2}},
+ {"no match", "someone@example.com", 0, nil},
+ {"case insensitive", "Listmonk-Newsletter@SSP.SH", 1, []int{1}},
+ {"with display name", "Newsletter ", 1, []int{1}},
+ {"multiple to addrs", "listmonk-newsletter@ssp.sh, listmonk-book@ssp.sh", 2, []int{1, 2}},
+ {"dedup list IDs", "listmonk@ssp.sh, listmonk-newsletter@ssp.sh", 2, []int{1, 2}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ids := ResolveListIDs(triggers, tt.to)
+ if len(ids) != tt.wantLen {
+ t.Errorf("got %d IDs %v, want %d", len(ids), ids, tt.wantLen)
+ }
+ })
+ }
+}
+
+func TestIsTriggerAddress(t *testing.T) {
+ triggers := []Trigger{
+ {Address: "listmonk@ssp.sh", ListIDs: []int{1}},
+ }
+ if !IsTriggerAddress(triggers, "listmonk@ssp.sh") {
+ t.Error("should match trigger address")
+ }
+ if IsTriggerAddress(triggers, "random@example.com") {
+ t.Error("should not match non-trigger address")
+ }
+}
diff --git a/internal/listmonk/hook.go b/internal/listmonk/hook.go
new file mode 100644
index 0000000..88691a8
--- /dev/null
+++ b/internal/listmonk/hook.go
@@ -0,0 +1,55 @@
+package listmonk
+
+import (
+ "strings"
+)
+
+// Trigger maps a virtual email address to Listmonk list IDs.
+type Trigger struct {
+ Address string
+ ListIDs []int
+}
+
+// ResolveListIDs returns the combined list IDs for all trigger addresses
+// that match any recipient in the To field. Returns nil if no match.
+func ResolveListIDs(triggers []Trigger, toField string) []int {
+ seen := make(map[int]bool)
+ var ids []int
+ for _, addr := range splitAddrs(toField) {
+ for _, t := range triggers {
+ if strings.EqualFold(addr, t.Address) {
+ for _, id := range t.ListIDs {
+ if !seen[id] {
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ }
+ }
+ }
+ }
+ return ids
+}
+
+// IsTriggerAddress returns true if any address in toField matches a trigger.
+func IsTriggerAddress(triggers []Trigger, toField string) bool {
+ return len(ResolveListIDs(triggers, toField)) > 0
+}
+
+// splitAddrs splits a comma-separated To field and extracts bare email addresses.
+func splitAddrs(field string) []string {
+ var addrs []string
+ for _, part := range strings.Split(field, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ // Handle "Name " format
+ if idx := strings.LastIndex(part, "<"); idx >= 0 {
+ if end := strings.Index(part[idx:], ">"); end >= 0 {
+ part = part[idx+1 : idx+end]
+ }
+ }
+ addrs = append(addrs, strings.TrimSpace(part))
+ }
+ return addrs
+}
diff --git a/internal/ui/model.go b/internal/ui/model.go
index f735b8a..bf79fdc 100644
--- a/internal/ui/model.go
+++ b/internal/ui/model.go
@@ -21,6 +21,7 @@ import (
"github.com/sspaeti/neomd/internal/config"
"github.com/sspaeti/neomd/internal/editor"
"github.com/sspaeti/neomd/internal/imap"
+ "github.com/sspaeti/neomd/internal/listmonk"
"github.com/sspaeti/neomd/internal/render"
"github.com/sspaeti/neomd/internal/screener"
"github.com/sspaeti/neomd/internal/smtp"
@@ -56,6 +57,7 @@ type (
sendDoneMsg struct {
err error
warning string
+ info string // non-error informational message (e.g. Listmonk success)
replyToUID uint32 // set \Answered on this email after send
replyToFolder string
}
@@ -795,6 +797,39 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su
}
}
+// listmonkTriggers converts config triggers to listmonk.Trigger slice.
+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}
+ }
+ return triggers
+}
+
+func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int) tea.Cmd {
+ cfg := m.cfg.Listmonk
+ delay := time.Duration(cfg.DelayMinutes) * time.Minute
+ if delay == 0 {
+ delay = 30 * time.Minute
+ }
+ return func() tea.Msg {
+ client := listmonk.NewClient(listmonk.Config{
+ URL: cfg.URL,
+ APIUser: cfg.APIUser,
+ APIToken: cfg.APIToken,
+ })
+ campaignID, err := client.CreateAndSchedule(subject, markdownBody, listIDs, delay)
+ if err != nil {
+ return sendDoneMsg{err: fmt.Errorf("listmonk: %w", err)}
+ }
+ mins := cfg.DelayMinutes
+ if mins == 0 {
+ mins = 30
+ }
+ return sendDoneMsg{info: fmt.Sprintf("Campaign #%d scheduled via Listmonk (sends in %d min)", campaignID, mins)}
+ }
+}
+
func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) {
if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) {
return m, nil
@@ -1702,6 +1737,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = msg.warning
m.isError = true // show in red so user notices
m.state = stateInbox
+ } else if msg.info != "" {
+ m.status = msg.info
+ m.isError = false
+ m.state = stateInbox
} else {
m.status = "Sent!"
m.isError = false
@@ -3455,6 +3494,12 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body)
m.attachments = nil
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))
+ }
+ }
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))
case "ctrl+f":
froms := m.presendFroms()
@@ -4179,9 +4224,21 @@ func (m Model) viewPresend() string {
if ps == nil {
return ""
}
+ isListmonk := m.cfg.ListmonkEnabled() && listmonk.IsTriggerAddress(m.listmonkTriggers(), ps.to)
var b strings.Builder
- b.WriteString(styleHeader.Render(" Ready to send") + "\n")
- b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n")
+ 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)
+ 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")
+ } else {
+ b.WriteString(styleHeader.Render(" Ready to send") + "\n")
+ b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n\n")
+ }
lbl := styleInputLabel.Render
fromLine := m.presendFrom()
@@ -4222,7 +4279,11 @@ func (m Model) viewPresend() string {
if m.status != "" {
b.WriteString(statusBar(m.status, m.isError))
} 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"))
+ 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"))
+ }
}
return b.String()
}