Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
11 changes: 7 additions & 4 deletions docs/content/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,24 @@ toc: false
A minimal terminal email client&nbsp;<br class="sm:hx-block hx-hidden" />for people who read & write in Markdown
{{< /hextra/hero-headline >}}
</div>

<br>
<div class="hx-mb-12">
{{< hextra/hero-subtitle >}}
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
{{< /hextra/hero-subtitle >}}
</div>

<br>
<div class="hx-mb-6">
{{< hextra/hero-button text="Overview and Philosophy" link="docs" >}}
</div>

<br>
<div class="hx-mt-6"></div>
<br>

<div class="hx-mt-12 hx-mb-8">
<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>
</div>

<br>
{{< hextra/feature-grid >}}
{{< hextra/feature-card
title="HEY-Style Screener"
Expand Down Expand Up @@ -75,6 +76,7 @@ YouTube rundown of most features:
[![neomd demo](https://img.youtube.com/vi/lpmHqIrCC-w/maxresdefault.jpg)](https://youtu.be/8aKkldYLWV8)


<br>
<div class="hx-mt-12 hx-mb-8">
<h2 class="hx-text-4xl hx-font-bold hx-tracking-tight hx-text-gray-900 dark:hx-text-gray-50">Documentation</h2>
</div>
Expand All @@ -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 >}}

Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" >}}
Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/integrations/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Neomd Integrations
weight: 30
sidebar:
open: false
---

Specific integration for neomd, e.g. Listmonk as first one.
99 changes: 99 additions & 0 deletions docs/content/docs/integrations/listmonk.md
Original file line number Diff line number Diff line change
@@ -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}"`.
Binary file added docs/static/images/listmonk-scheduled-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/static/images/listmonk-scheduled-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/static/images/listmonk-scheduled-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/static/images/listmonk-scheduled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/listmonk-scheduled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,28 @@ type Config struct {
// Format: "addr@example.com" or "Name <addr@example.com>". 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.
Expand Down Expand Up @@ -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
}

Expand Down
142 changes: 142 additions & 0 deletions internal/listmonk/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading