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
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(go test:*)"
"Bash(go test:*)",
"Bash(make build:*)",
"Bash(make test:*)"
]
}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ neomd-*.md

# OS
.DS_Store
.codex
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

# 2026-04-10
- **Issue #6 verification pass** — reviewed the user report against the current code and specifically verified that startup auto-screening does not route Inbox mail to Trash in the current implementation, while manual `ToScreen` screening remains message-by-message by design
- **Fix: Drafts/Spam reload off-tab folder mismatch** — reloading while viewing an off-tab folder now reloads that actual mailbox instead of the currently selected tab's folder; fixes the confusing case where Drafts could show Inbox content after pressing `R`
- **Fix: committed `/` filter now clears with `esc`** — pressing `esc` now reliably clears the in-memory inbox filter even after the filter was already applied
- **Help overlay improvements** — `?` help is now scrollable with `j/k`, arrow keys, and `d/u`; search begins only after pressing `/`, so opening help no longer immediately behaves like a search prompt
- **Attachment workflow guidance** — startup/welcome messaging now warns when the optional inline Neovim attachment integration is unavailable; README install docs now list `yazi` and the external `custom.lua` integration as optional requirements for `<leader>a`, while clarifying that pre-send `a` still works independently
- **UX hints** — inbox footer now exposes `, sort`; pre-send footer clarifies `s` as spell-check-and-edit versus plain `e` edit; compose/pre-send `ctrl+f` now shows a message when only one From identity is configured
- **Fix: sender-level screening from `ToScreen`** — approving/blocking/feed/papertrail/spam on a single unmarked message in `ToScreen` now expands to all currently queued mail from that sender, matching the intended HEY-style workflow
- **Safety guard: screener destinations may not point to Trash** — screening now refuses to run if `ToScreen`, `ScreenedOut`, `Feed`, `PaperTrail`, or `Spam` are configured to the same IMAP folder as Trash
- **Inbox paging clarity** — the inbox header now shows the current fetch limit (`loaded/limit`) and `d/u` page movement directly, so the “only 50 emails” behavior is visible without guessing
- **Discard confirmation for unsent mail** — `esc` in compose and `esc`/`x` in pre-send now ask for confirmation before dropping the message; recovery hints still point to `:recover`
- **Default Inbox load raised to 200** — new configs now use `inbox_count = 200`; README, config docs, and welcome text now clarify that normal loads/auto-screening only process that loaded Inbox slice, while `:screen-all` scans the full Inbox on the IMAP server
- **Compose/draft round-trip preservation** — editor/pre-send/draft/recover flows now preserve `Bcc` and selected `From`; continuing a draft also restores its attachments back into the compose session
- **Correct IMAP account for Sent/Drafts** — sent copies and saved drafts now use the IMAP account that matches the selected sending identity / `[[senders]]` alias instead of always using the currently active inbox account
- **Draft MIME keeps `Bcc`** — Drafts saved via IMAP now retain the `Bcc` header so reopening a draft does not silently lose hidden recipients
- **Search/Everything/Thread subjects no longer mutate** — folder prefixes are now display-only in list rendering, so reply/forward/thread logic keeps using the real RFC subject
- **Screener rollback safety** — screener actions now snapshot list state and roll back both list files and already-moved emails if a later move fails, keeping mailbox state and screener files consistent
- **`:search` help text fixed** — the command description now correctly says it searches across configured folders, not just the current folder

## Roborev
- **Security: path traversal vulnerability fixed** — inline image handling (`O` browser preview) now sanitizes `ContentID` and `Filename` from email MIME headers to prevent attackers from writing files outside `/tmp/neomd/` via malicious `cid:` references (e.g. `../../etc/cron.d/evil`); all attachment paths now use `filepath.Base()` and verify the result stays under temp directory before writing
- **Fix: conversation view navigation** — pressing `T` (thread view) now correctly shows error messages and empty-result warnings; `imapSearchResults` flag is cleared immediately so the status bar appears instead of the search bar; added general Esc handler for `offTabFolder` views that preserves search context: pressing Esc from thread view returns to IMAP search results if that's where you came from (checked via `imapSearchText`), otherwise returns to active folder; `imapSearchText` is cleared when navigating away from search via tab, clicks, or go-to commands (gi/ga/etc) to prevent stale search context from affecting unrelated views; search retry errors are now visible because `imapSearchResults` is only set by the handler on success
- **Fix: Work folder move guard** — `Mb` (move to Work) is now disabled when the Work folder is not configured, preventing moves to an empty folder name; previously caused silent failures
- **Fix: Work folder keybindings in help** — `gb` (go to Work) and `Mb` (move to Work) now appear in the `?` help overlay and generated keybindings documentation, marked as "(if configured)" to indicate they're optional
- **Test coverage: expandEnv edge cases** — added unit tests for environment variable expansion covering unset variables (silently return empty), bare `$` alone, empty `${}`, whitespace trimming, and variables with text suffixes/prefixes; documents current behavior for config password/user fields
- **Fix: welcome message formatting** — onboarding screen instruction now reads clearly ("Go to Inbox tab; once screener is active, use ToScreen") instead of the previous formatting regression ("ToScreentab")


# 2026-04-09
- **Fix: non-standard IMAP/SMTP ports** — neomd now correctly handles non-standard ports (e.g., Proton Mail Bridge on `127.0.0.1:1143` and `127.0.0.1:1025`); previously hardcoded port-based logic ignored the user's `starttls` config and refused unencrypted connections to any port other than 993/143 (IMAP) or 465/587 (SMTP); new behavior: user's explicit `starttls = true` always forces STARTTLS, standard ports use their defaults (993→TLS, 143→STARTTLS, 465→TLS, 587→STARTTLS), non-standard ports default to TLS for security (user must set `starttls = true` if their provider uses STARTTLS on a custom port); fixes "refusing unencrypted connection to 127.0.0.1:1143" error reported by Proton Bridge users; comprehensive test coverage added for all port/config combinations

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ Or in Gmail:

**Prerequisites:** [Go 1.22+](https://go.dev/doc/install) and `make`.

> [!NOTE]
> **Optional attachment helpers:**
> - `yazi` enables the built-in file picker used by pre-send `a`
> - custom Neovim integration in `custom.lua` enables inline `<leader>a` attachment insertion inside `neomd-*.md` buffers
> - without these, neomd still works; the inline Neovim attachment workflow just won't be available

```sh
git clone https://github.com/ssp-data/neomd
cd neomd
Expand All @@ -122,9 +128,11 @@ yay -S neomd-bin

On first run, neomd:
1. Creates `~/.config/neomd/config.toml` with placeholders — fill in your IMAP/SMTP credentials
- Important: Make sure that the Capitalization and naming of folder in `config.toml` is accroding to webmail IMAP, e.g. [Gmails](docs/gmail.md) uses `sent = "[Gmail]/Sent Mail"` and not `sent` etc.
2. Creates `~/.config/neomd/lists/` for screener allowlists (or uses your custom paths from config)
3. Creates any missing IMAP folders (ToScreen, Feed, PaperTrail, etc.) automatically


Neomd also runs on Android (more for fun) — see [docs/android.md](docs/android.md).

## Configuration
Expand Down Expand Up @@ -160,6 +168,8 @@ For the full configuration reference including multiple accounts, OAuth2 authent

On first launch, **auto-screening is paused** because your screener lists are empty — neomd won't move anything until you've classified your first sender. Your Inbox loads normally so you can explore.

By default, neomd loads and auto-screens only the newest `200` Inbox emails (`[ui].inbox_count`). This keeps startup predictable. If you want to re-screen the entire Inbox on the IMAP server, run `:screen-all` inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes.

**Getting started with the screener:**

1. From your Inbox, pick an email and press `I` (screen **in**) to approve the sender, or `O` (screen **out**) to block them. This creates your first screener list entry.
Expand All @@ -169,7 +179,7 @@ On first launch, **auto-screening is paused** because your screener lists are em
- `O` screen **out** — sender never reaches Inbox again
- `F` **feed** — newsletters go to the Feed tab
- `P` **papertrail** — receipts go to the PaperTrail tab
4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once.
4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender.

**The best part:** all classifications are saved permanently in your screener lists (`screened_in.txt`, `screened_out.txt`, etc.). An email address screened in will automatically go to your Inbox, and any email screened out will never be in your Inbox again.

Expand All @@ -178,6 +188,9 @@ You choose who can land in your Inbox. Bye-bye spam. This is the beauty of [HEY-
> [!TIP]
> To disable auto-screening entirely, set `auto_screen_on_load = false` in `[ui]` config. Run `:debug` inside neomd if something isn't working.

> [!WARNING]
> `:screen-all` operates on the full Inbox mailbox on the server, not just the emails currently loaded in the UI. Use it when you intentionally want a mailbox-wide reclassification pass.

### Screener Workflow

Find full Screener Workflow at [docs/screener.md](docs/screener.md), classification tables, and bulk re-classification instructions.
Expand All @@ -191,6 +204,8 @@ See the [full keybindings reference](docs/keybindings.md) (auto-generated from [

Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported.

Discarding unsent mail now asks for confirmation in compose/pre-send, and `:recover` reopens the latest backup if you want to resume after an abort.

- See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts.
- See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation.

Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ spam = "spam" #check capitalization of your pre-existing Spam folder, so

[ui]
theme = "dark" # dark | light | auto
inbox_count = 50
inbox_count = 200 # how many newest emails neomd loads per folder/reload
auto_screen_on_load = true # screen inbox automatically on every load (default true)
bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5)
bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10)
Expand All @@ -83,6 +83,8 @@ Connect: [LinkedIn](https://example.com/)

Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password.

`inbox_count` is a fetch cap for normal folder loads and startup auto-screening. If you want to re-screen the entire Inbox on the IMAP server, use `:screen-all` from inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes.

### Environment Variables

The `password` and `user` fields support environment variable expansion. If the entire value is a single env var reference, neomd resolves it at startup:
Expand Down
3 changes: 3 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil
| Key | Action |
|-----|--------|
| `j / k` | move down / up |
| `d / u` | page down / up in inbox/help |
| `gg` | jump to top |
| `G` | jump to bottom |
| `enter / l` | open email |
Expand All @@ -34,6 +35,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil
| `gk` | go to ToScreen |
| `go` | go to ScreenedOut |
| `gw` | go to Waiting |
| `gb` | go to Work (if configured) |
| `gm` | go to Someday |
| `gd` | go to Drafts |
| `ge` | go to Everything — latest 50 emails across all folders |
Expand Down Expand Up @@ -65,6 +67,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil
| `Mt` | move to Trash |
| `Mo` | move to ScreenedOut |
| `Mw` | move to Waiting |
| `Mb` | move to Work (if configured) |
| `Mm` | move to Someday |
| `Mk` | move to ToScreen |

Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ func defaults() *Config {
},
UI: UIConfig{
Theme: "dark",
InboxCount: 50,
InboxCount: 200,
BgSyncInterval: 5,
Signature: "*sent from [neomd](https://neomd.ssp.sh)*",
},
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ func TestExpandEnv(t *testing.T) {
{"embedded dollar", "literal$value", "", "", "literal$value"},
{"multiple dollars", "pa$$word", "", "", "pa$$word"},
{"empty string", "", "", "", ""},
{"unset var bare", "$UNSET_NEOMD_VAR", "", "", ""},
{"unset var braced", "${UNSET_NEOMD_VAR}", "", "", ""},
{"bare $ alone", "$", "", "", ""},
{"empty braced ${}", "${}", "", "", ""},
{"whitespace trimmed", " $MY_VAR ", "MY_VAR", "trimmed", "trimmed"},
{"$VAR with suffix", "$MY_VAR-suffix", "", "", ""},
{"text before $VAR", "prefix-$MY_VAR", "", "", "prefix-$MY_VAR"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
36 changes: 17 additions & 19 deletions internal/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@ func View(content string) (*exec.Cmd, string, error) {
}

// Prelude builds the header shown at the top of a new compose buffer.
// cc may be empty. If signature is non-empty it is appended after a blank line separator.
func Prelude(to, cc, subject, signature string) string {
// cc, bcc, and from may be empty. If signature is non-empty it is appended
// after a blank line separator.
func Prelude(to, cc, bcc, from, subject, signature string) string {
s := fmt.Sprintf("# [neomd: to: %s]\n", to)
if cc != "" {
s += fmt.Sprintf("# [neomd: cc: %s]\n", cc)
}
if bcc != "" {
s += fmt.Sprintf("# [neomd: bcc: %s]\n", bcc)
}
if from != "" {
s += fmt.Sprintf("# [neomd: from: %s]\n", from)
}
s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject)
if signature != "" {
s += "\n\n-- \n" + signature + "\n"
Expand All @@ -101,29 +108,18 @@ func Prelude(to, cc, subject, signature string) string {

// ReplyPrelude builds a quote block for replies. cc and from may be empty.
func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string {
s := fmt.Sprintf("# [neomd: to: %s]\n", to)
if cc != "" {
s += fmt.Sprintf("# [neomd: cc: %s]\n", cc)
}
if from != "" {
s += fmt.Sprintf("# [neomd: from: %s]\n", from)
}
s += fmt.Sprintf("# [neomd: subject: %s]\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
subject, originalFrom, quoteLines(originalBody))
return s
return Prelude(to, cc, "", from, subject, "") +
fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
originalFrom, quoteLines(originalBody))
}

// ForwardPrelude builds a quoted forward block. The To field is left empty for
// the user to fill in.
func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, originalBody string) string {
s := "# [neomd: to: ]\n"
if from != "" {
s += fmt.Sprintf("# [neomd: from: %s]\n", from)
}
if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
subject = "Fwd: " + subject
}
s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject)
s := Prelude("", "", "", from, subject, "")
s += "---------- Forwarded message ----------\n"
s += fmt.Sprintf("From: %s\n", originalFrom)
s += fmt.Sprintf("Date: %s\n", originalDate)
Expand All @@ -134,9 +130,9 @@ func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, origi
}

// ParseHeaders scans raw editor content for # [neomd: key: value] lines and
// returns the extracted to, cc, bcc, subject values and the remaining body
// returns the extracted to, cc, bcc, from, subject values and the remaining body
// (with header lines stripped). Any field not found is returned as "".
func ParseHeaders(raw string) (to, cc, bcc, subject, body string) {
func ParseHeaders(raw string) (to, cc, bcc, from, subject, body string) {
lines := splitLines(raw)
var kept []string
for _, line := range lines {
Expand All @@ -148,6 +144,8 @@ func ParseHeaders(raw string) (to, cc, bcc, subject, body string) {
cc = strings.TrimSpace(m[2])
case "bcc":
bcc = strings.TrimSpace(m[2])
case "from":
from = strings.TrimSpace(m[2])
case "subject":
subject = strings.TrimSpace(m[2])
}
Expand Down
Loading
Loading