Skip to content

Commit 1660698

Browse files
committed
add feature to download emails as EML with leader+d
1 parent 8affa05 commit 1660698

8 files changed

Lines changed: 124 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
# 2026-04-23
4+
- **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`
56

67
# 2026-04-21

docs/content/docs/keybindings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ To update both the help overlay and this document at once, edit that file and ru
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) |
99+
| `<space>d (reader)` | download raw email source (.eml) to ~/Downloads |
99100
| `<space>w` | show welcome screen |
100101

101102

docs/content/docs/reading.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ Attach: [1] report.pdf [2] photo.png
5252

5353
Press `1``9` to download attachment N to `~/Downloads/` and open it with `xdg-open`. Filenames are deduplicated automatically if a file already exists.
5454

55+
## Download Raw Email Source
56+
57+
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.
58+
59+
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.
60+
5561
## Threaded Inbox
5662

5763
Related emails are automatically grouped together in the inbox list. Threads are detected using a hybrid approach:

internal/imap/client.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,39 @@ func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (stri
758758
return markdown, rawHTML, webURL, attachments, references, err
759759
}
760760

761+
// FetchRaw fetches the full raw MIME source (EML) for a single message.
762+
func (c *Client) FetchRaw(ctx context.Context, folder string, uid uint32) ([]byte, error) {
763+
if ctx == nil {
764+
ctx = context.Background()
765+
}
766+
var raw []byte
767+
err := c.withConn(ctx, func(conn *imapclient.Client) error {
768+
if err := c.selectMailbox(folder); err != nil {
769+
return err
770+
}
771+
772+
var fetchSet imap.UIDSet
773+
fetchSet.AddNum(imap.UID(uid))
774+
775+
msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{
776+
UID: true,
777+
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
778+
}).Collect()
779+
if err != nil {
780+
return fmt.Errorf("FETCH raw uid=%d: %w", uid, err)
781+
}
782+
if len(msgs) == 0 {
783+
return fmt.Errorf("message uid=%d not found in %s", uid, folder)
784+
}
785+
786+
if len(msgs[0].BodySection) > 0 {
787+
raw = msgs[0].BodySection[0].Bytes
788+
}
789+
return nil
790+
})
791+
return raw, err
792+
}
793+
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

internal/ui/keys.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ var HelpSections = []HelpSection{
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)"},
74+
{"<space>d (reader)", "download raw email source (.eml) to ~/Downloads"},
7475
{"<space>w", "show welcome screen"},
7576
}},
7677
{"Sort (, prefix)", [][2]string{

internal/ui/model.go

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ type (
131131
path string
132132
err error
133133
}
134+
emlDownloadedMsg struct {
135+
path string
136+
err error
137+
}
134138
editorDoneMsg struct {
135139
to, cc, bcc, from, subject, body string
136140
err error
@@ -1770,6 +1774,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
17701774
}
17711775
return m, nil
17721776

1777+
case emlDownloadedMsg:
1778+
if msg.err != nil {
1779+
m.status = "Download error: " + msg.err.Error()
1780+
m.isError = true
1781+
} else {
1782+
m.status = "Saved EML to " + msg.path
1783+
m.isError = false
1784+
}
1785+
return m, nil
1786+
17731787
case saveDraftDoneMsg:
17741788
if msg.err != nil {
17751789
m.status = "Draft error: " + msg.err.Error()
@@ -2948,6 +2962,12 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
29482962
m.status = "link number (11-99): l__"
29492963
return m, nil
29502964
}
2965+
// space + d = download raw EML source
2966+
if key == "d" {
2967+
m.status = "Downloading EML…"
2968+
m.isError = false
2969+
return m, m.downloadEMLCmd()
2970+
}
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" {
@@ -3244,6 +3264,61 @@ func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd {
32443264
}
32453265
}
32463266

3267+
// downloadEMLCmd fetches the raw MIME source and saves it as .eml to ~/Downloads.
3268+
func (m Model) downloadEMLCmd() tea.Cmd {
3269+
e := m.openEmail
3270+
if e == nil {
3271+
return nil
3272+
}
3273+
cli := m.imapCli()
3274+
folder := e.Folder
3275+
uid := e.UID
3276+
subject := e.Subject
3277+
emailDate := e.Date
3278+
return func() tea.Msg {
3279+
raw, err := cli.FetchRaw(nil, folder, uid)
3280+
if err != nil {
3281+
return emlDownloadedMsg{err: err}
3282+
}
3283+
home, err := os.UserHomeDir()
3284+
if err != nil {
3285+
return emlDownloadedMsg{err: err}
3286+
}
3287+
dir := filepath.Join(home, "Downloads")
3288+
if err := os.MkdirAll(dir, 0755); err != nil {
3289+
return emlDownloadedMsg{err: fmt.Errorf("create Downloads: %w", err)}
3290+
}
3291+
// Sanitize subject for filename
3292+
safe := strings.Map(func(r rune) rune {
3293+
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
3294+
return '_'
3295+
}
3296+
return r
3297+
}, subject)
3298+
if len(safe) > 80 {
3299+
safe = safe[:80]
3300+
}
3301+
if safe == "" {
3302+
safe = "email"
3303+
}
3304+
datePart := emailDate.Format("20060102")
3305+
base := fmt.Sprintf("neomd-%s-%s.eml", datePart, safe)
3306+
dst := filepath.Join(dir, base)
3307+
if _, err := os.Stat(dst); err == nil {
3308+
for i := 1; ; i++ {
3309+
dst = filepath.Join(dir, fmt.Sprintf("neomd-%s-%s_%d.eml", datePart, safe, i))
3310+
if _, err := os.Stat(dst); os.IsNotExist(err) {
3311+
break
3312+
}
3313+
}
3314+
}
3315+
if err := os.WriteFile(dst, raw, 0644); err != nil {
3316+
return emlDownloadedMsg{err: fmt.Errorf("save EML: %w", err)}
3317+
}
3318+
return emlDownloadedMsg{path: dst}
3319+
}
3320+
}
3321+
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).
@@ -4357,7 +4432,11 @@ func (m Model) viewReader() string {
43574432
b.WriteString(m.reader.View())
43584433
}
43594434
isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts
4360-
b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0))
4435+
if m.status != "" {
4436+
b.WriteString("\n" + statusBar(m.status, m.isError))
4437+
} else {
4438+
b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0))
4439+
}
43614440
return b.String()
43624441
}
43634442

internal/ui/reader.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func readerHelp(isDraft bool, hasLinks bool) string {
139139
if isDraft {
140140
keys = append(keys, "E draft")
141141
}
142-
keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach")
142+
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
}

scripts/sync-readme-to-docs.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ END {
6868
-e 's|docs/screener\.md|screener|g' \
6969
-e 's|docs/sending\.md|sending|g' \
7070
-e 's|docs/reading\.md|reading|g' \
71-
-e 's|images/|/images/|g' \
71+
-e 's|docs/static/images/|/images/|g' \
7272
>> "$DOCS_OVERVIEW"
7373

7474
echo "✅ Synced README.md → docs/content/docs/_index.md"

0 commit comments

Comments
 (0)