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: [![neomd demo](https://img.youtube.com/vi/lpmHqIrCC-w/maxresdefault.jpg)](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: +![listmonk](/images/listmonk-scheduled.png) + + +And in Listmonk itself: +![listmonk](/images/listmonk-scheduled-2.png) +![listmonk](/images/listmonk-scheduled-3.png) + +## 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. + +![listmonk](/images/listmonk-scheduled-4.png) + +## 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() }