···11# Changelog
2233# 2026-04-23
44+- **Download raw email source (`space+d` in reader)** — saves the full raw MIME source as `.eml` to `~/Downloads/` with filename `neomd-YYYYMMDD-<subject>.eml`; useful for archiving, debugging email headers, or importing into other clients; status bar shows download progress and completion; filenames deduplicated automatically
45- **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`
5667# 2026-04-21
+1
docs/content/docs/keybindings.md
···9696|-----|--------|
9797| `<space>1 … <space>9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) |
9898| `<space>/` | IMAP search ALL emails on server (From + Subject) |
9999+| `<space>d (reader)` | download raw email source (.eml) to ~/Downloads |
99100| `<space>w` | show welcome screen |
100101101102
+6
docs/content/docs/reading.md
···52525353Press `1`–`9` to download attachment N to `~/Downloads/` and open it with `xdg-open`. Filenames are deduplicated automatically if a file already exists.
54545555+## Download Raw Email Source
5656+5757+Press `space` then `d` in the reader to download the full raw email source (`.eml` file) to `~/Downloads/`. The file is named `neomd-YYYYMMDD-<subject>.eml` using the email's date and sanitized subject line.
5858+5959+This is useful for archiving emails, debugging headers, or importing into other email clients. The status bar shows a green confirmation when the download completes.
6060+5561## Threaded Inbox
56625763Related emails are automatically grouped together in the inbox list. Threads are detected using a hybrid approach:
+33
internal/imap/client.go
···758758 return markdown, rawHTML, webURL, attachments, references, err
759759}
760760761761+// FetchRaw fetches the full raw MIME source (EML) for a single message.
762762+func (c *Client) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, error) {
763763+ if ctx == nil {
764764+ ctx = context.Background()
765765+ }
766766+ var raw []byte
767767+ err := c.withConn(ctx, func(conn *imapclient.Client) error {
768768+ if err := c.selectMailbox(folder); err != nil {
769769+ return err
770770+ }
771771+772772+ var fetchSet imap.UIDSet
773773+ fetchSet.AddNum(imap.UID(uid))
774774+775775+ msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{
776776+ UID: true,
777777+ BodySection: []*imap.FetchItemBodySection{{Peek: true}},
778778+ }).Collect()
779779+ if err != nil {
780780+ return fmt.Errorf("FETCH raw uid=%d: %w", uid, err)
781781+ }
782782+ if len(msgs) == 0 {
783783+ return fmt.Errorf("message uid=%d not found in %s", uid, folder)
784784+ }
785785+786786+ if len(msgs[0].BodySection) > 0 {
787787+ raw = msgs[0].BodySection[0].Bytes
788788+ }
789789+ return nil
790790+ })
791791+ return raw, err
792792+}
793793+761794// MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851).
762795// Returns the UID assigned at the destination (may differ from src UID on some
763796// servers). Falls back to the original uid if the server does not report UIDPLUS
+1
internal/ui/keys.go
···7171 {"Leader Key Mappings (space prefix)", [][2]string{
7272 {"<space>1 … <space>9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"},
7373 {"<space>/", "IMAP search ALL emails on server (From + Subject)"},
7474+ {"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"},
7475 {"<space>w", "show welcome screen"},
7576 }},
7677 {"Sort (, prefix)", [][2]string{
+80-1
internal/ui/model.go
···131131 path string
132132 err error
133133 }
134134+ emlDownloadedMsg struct {
135135+ path string
136136+ err error
137137+ }
134138 editorDoneMsg struct {
135139 to, cc, bcc, from, subject, body string
136140 err error
···17701774 }
17711775 return m, nil
1772177617771777+ case emlDownloadedMsg:
17781778+ if msg.err != nil {
17791779+ m.status = "Download error: " + msg.err.Error()
17801780+ m.isError = true
17811781+ } else {
17821782+ m.status = "Saved EML to " + msg.path
17831783+ m.isError = false
17841784+ }
17851785+ return m, nil
17861786+17731787 case saveDraftDoneMsg:
17741788 if msg.err != nil {
17751789 m.status = "Draft error: " + msg.err.Error()
···29482962 m.status = "link number (11-99): l__"
29492963 return m, nil
29502964 }
29652965+ // space + d = download raw EML source
29662966+ if key == "d" {
29672967+ m.status = "Downloading EML…"
29682968+ m.isError = false
29692969+ return m, m.downloadEMLCmd()
29702970+ }
29512971 // Not a digit or 'l' — fall through
29522972 case "l": // l + first digit (waiting for second digit)
29532973 if len(key) == 1 && key >= "0" && key <= "9" {
···32443264 }
32453265}
3246326632673267+// downloadEMLCmd fetches the raw MIME source and saves it as .eml to ~/Downloads.
32683268+func (m Model) downloadEMLCmd() tea.Cmd {
32693269+ e := m.openEmail
32703270+ if e == nil {
32713271+ return nil
32723272+ }
32733273+ cli := m.imapCli()
32743274+ folder := e.Folder
32753275+ uid := e.UID
32763276+ subject := e.Subject
32773277+ emailDate := e.Date
32783278+ return func() tea.Msg {
32793279+ raw, err := cli.FetchRaw(nil, folder, uid)
32803280+ if err != nil {
32813281+ return emlDownloadedMsg{err: err}
32823282+ }
32833283+ home, err := os.UserHomeDir()
32843284+ if err != nil {
32853285+ return emlDownloadedMsg{err: err}
32863286+ }
32873287+ dir := filepath.Join(home, "Downloads")
32883288+ if err := os.MkdirAll(dir, 0755); err != nil {
32893289+ return emlDownloadedMsg{err: fmt.Errorf("create Downloads: %w", err)}
32903290+ }
32913291+ // Sanitize subject for filename
32923292+ safe := strings.Map(func(r rune) rune {
32933293+ if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
32943294+ return '_'
32953295+ }
32963296+ return r
32973297+ }, subject)
32983298+ if len(safe) > 80 {
32993299+ safe = safe[:80]
33003300+ }
33013301+ if safe == "" {
33023302+ safe = "email"
33033303+ }
33043304+ datePart := emailDate.Format("20060102")
33053305+ base := fmt.Sprintf("neomd-%s-%s.eml", datePart, safe)
33063306+ dst := filepath.Join(dir, base)
33073307+ if _, err := os.Stat(dst); err == nil {
33083308+ for i := 1; ; i++ {
33093309+ dst = filepath.Join(dir, fmt.Sprintf("neomd-%s-%s_%d.eml", datePart, safe, i))
33103310+ if _, err := os.Stat(dst); os.IsNotExist(err) {
33113311+ break
33123312+ }
33133313+ }
33143314+ }
33153315+ if err := os.WriteFile(dst, raw, 0644); err != nil {
33163316+ return emlDownloadedMsg{err: fmt.Errorf("save EML: %w", err)}
33173317+ }
33183318+ return emlDownloadedMsg{path: dst}
33193319+ }
33203320+}
33213321+32473322// extractWebVersionURL looks for the "view in browser" / "read online" link
32483323// that newsletter platforms insert near the top of every HTML email.
32493324// Searches only the first 3000 bytes (the link is always in the preheader).
···43574432 b.WriteString(m.reader.View())
43584433 }
43594434 isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts
43604360- b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0))
44354435+ if m.status != "" {
44364436+ b.WriteString("\n" + statusBar(m.status, m.isError))
44374437+ } else {
44384438+ b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0))
44394439+ }
43614440 return b.String()
43624441}
43634442
+1-1
internal/ui/reader.go
···139139 if isDraft {
140140 keys = append(keys, "E draft")
141141 }
142142- keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach")
142142+ keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach", "space+d eml")
143143 if hasLinks {
144144 keys = append(keys, "space+1-0 links", "space+l11-99 links 11+")
145145 }