···11# Changelog
2233+# 2026-04-10
44+- **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
55+- **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`
66+- **Fix: committed `/` filter now clears with `esc`** — pressing `esc` now reliably clears the in-memory inbox filter even after the filter was already applied
77+- **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
88+- **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
99+- **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
1010+- **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
1111+- **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
1212+- **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
1313+- **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`
1414+- **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
1515+- **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
1616+- **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
1717+- **Draft MIME keeps `Bcc`** — Drafts saved via IMAP now retain the `Bcc` header so reopening a draft does not silently lose hidden recipients
1818+- **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
1919+- **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
2020+- **`:search` help text fixed** — the command description now correctly says it searches across configured folders, not just the current folder
2121+2222+## Roborev
2323+- **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
2424+- **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
2525+- **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
2626+- **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
2727+- **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
2828+- **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")
2929+3030+331# 2026-04-09
432- **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
533
+16-1
README.md
···101101102102**Prerequisites:** [Go 1.22+](https://go.dev/doc/install) and `make`.
103103104104+> [!NOTE]
105105+> **Optional attachment helpers:**
106106+> - `yazi` enables the built-in file picker used by pre-send `a`
107107+> - custom Neovim integration in `custom.lua` enables inline `<leader>a` attachment insertion inside `neomd-*.md` buffers
108108+> - without these, neomd still works; the inline Neovim attachment workflow just won't be available
109109+104110```sh
105111git clone https://github.com/ssp-data/neomd
106112cd neomd
···122128123129On first run, neomd:
1241301. Creates `~/.config/neomd/config.toml` with placeholders — fill in your IMAP/SMTP credentials
131131+ - 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.
1251322. Creates `~/.config/neomd/lists/` for screener allowlists (or uses your custom paths from config)
1261333. Creates any missing IMAP folders (ToScreen, Feed, PaperTrail, etc.) automatically
134134+127135128136Neomd also runs on Android (more for fun) — see [docs/android.md](docs/android.md).
129137···160168161169On 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.
162170171171+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.
172172+163173**Getting started with the screener:**
1641741651751. 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.
···169179 - `O` screen **out** — sender never reaches Inbox again
170180 - `F` **feed** — newsletters go to the Feed tab
171181 - `P` **papertrail** — receipts go to the PaperTrail tab
172172-4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once.
182182+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.
173183174184**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.
175185···178188> [!TIP]
179189> To disable auto-screening entirely, set `auto_screen_on_load = false` in `[ui]` config. Run `:debug` inside neomd if something isn't working.
180190191191+> [!WARNING]
192192+> `: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.
193193+181194### Screener Workflow
182195183196Find full Screener Workflow at [docs/screener.md](docs/screener.md), classification tables, and bulk re-classification instructions.
···190203### How Sending Works
191204192205Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported.
206206+207207+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.
193208194209- See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts.
195210- See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation.
+3-1
docs/configuration.md
···64646565[ui]
6666theme = "dark" # dark | light | auto
6767-inbox_count = 50
6767+inbox_count = 200 # how many newest emails neomd loads per folder/reload
6868auto_screen_on_load = true # screen inbox automatically on every load (default true)
6969bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5)
7070bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10)
···8282> **Gmail** uses different IMAP folder names (`[Gmail]/Sent Mail`, `[Gmail]/Trash`, etc.). See [Gmail Configuration](gmail.md) for the correct mapping.
83838484Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password.
8585+8686+`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.
85878688### Environment Variables
8789
+3
docs/keybindings.md
···1212| Key | Action |
1313|-----|--------|
1414| `j / k` | move down / up |
1515+| `d / u` | page down / up in inbox/help |
1516| `gg` | jump to top |
1617| `G` | jump to bottom |
1718| `enter / l` | open email |
···3435| `gk` | go to ToScreen |
3536| `go` | go to ScreenedOut |
3637| `gw` | go to Waiting |
3838+| `gb` | go to Work (if configured) |
3739| `gm` | go to Someday |
3840| `gd` | go to Drafts |
3941| `ge` | go to Everything — latest 50 emails across all folders |
···6567| `Mt` | move to Trash |
6668| `Mo` | move to ScreenedOut |
6769| `Mw` | move to Waiting |
7070+| `Mb` | move to Work (if configured) |
6871| `Mm` | move to Someday |
6972| `Mk` | move to ToScreen |
7073
···8686}
87878888// Prelude builds the header shown at the top of a new compose buffer.
8989-// cc may be empty. If signature is non-empty it is appended after a blank line separator.
9090-func Prelude(to, cc, subject, signature string) string {
8989+// cc, bcc, and from may be empty. If signature is non-empty it is appended
9090+// after a blank line separator.
9191+func Prelude(to, cc, bcc, from, subject, signature string) string {
9192 s := fmt.Sprintf("# [neomd: to: %s]\n", to)
9293 if cc != "" {
9394 s += fmt.Sprintf("# [neomd: cc: %s]\n", cc)
9495 }
9696+ if bcc != "" {
9797+ s += fmt.Sprintf("# [neomd: bcc: %s]\n", bcc)
9898+ }
9999+ if from != "" {
100100+ s += fmt.Sprintf("# [neomd: from: %s]\n", from)
101101+ }
95102 s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject)
96103 if signature != "" {
97104 s += "\n\n-- \n" + signature + "\n"
···101108102109// ReplyPrelude builds a quote block for replies. cc and from may be empty.
103110func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string {
104104- s := fmt.Sprintf("# [neomd: to: %s]\n", to)
105105- if cc != "" {
106106- s += fmt.Sprintf("# [neomd: cc: %s]\n", cc)
107107- }
108108- if from != "" {
109109- s += fmt.Sprintf("# [neomd: from: %s]\n", from)
110110- }
111111- s += fmt.Sprintf("# [neomd: subject: %s]\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
112112- subject, originalFrom, quoteLines(originalBody))
113113- return s
111111+ return Prelude(to, cc, "", from, subject, "") +
112112+ fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
113113+ originalFrom, quoteLines(originalBody))
114114}
115115116116// ForwardPrelude builds a quoted forward block. The To field is left empty for
117117// the user to fill in.
118118func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, originalBody string) string {
119119- s := "# [neomd: to: ]\n"
120120- if from != "" {
121121- s += fmt.Sprintf("# [neomd: from: %s]\n", from)
122122- }
123119 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
124120 subject = "Fwd: " + subject
125121 }
126126- s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject)
122122+ s := Prelude("", "", "", from, subject, "")
127123 s += "---------- Forwarded message ----------\n"
128124 s += fmt.Sprintf("From: %s\n", originalFrom)
129125 s += fmt.Sprintf("Date: %s\n", originalDate)
···134130}
135131136132// ParseHeaders scans raw editor content for # [neomd: key: value] lines and
137137-// returns the extracted to, cc, bcc, subject values and the remaining body
133133+// returns the extracted to, cc, bcc, from, subject values and the remaining body
138134// (with header lines stripped). Any field not found is returned as "".
139139-func ParseHeaders(raw string) (to, cc, bcc, subject, body string) {
135135+func ParseHeaders(raw string) (to, cc, bcc, from, subject, body string) {
140136 lines := splitLines(raw)
141137 var kept []string
142138 for _, line := range lines {
···148144 cc = strings.TrimSpace(m[2])
149145 case "bcc":
150146 bcc = strings.TrimSpace(m[2])
147147+ case "from":
148148+ from = strings.TrimSpace(m[2])
151149 case "subject":
152150 subject = strings.TrimSpace(m[2])
153151 }
+55-26
internal/editor/editor_test.go
···7788func TestParseHeaders(t *testing.T) {
99 tests := []struct {
1010- name string
1111- input string
1212- wantTo, wantCC, wantBCC, wantSub string
1313- wantBodyContains string // substring the body must contain
1414- wantBodyNotContains string // substring the body must NOT contain
1010+ name string
1111+ input string
1212+ wantTo, wantCC, wantBCC, wantFrom, wantSub string
1313+ wantBodyContains string // substring the body must contain
1414+ wantBodyNotContains string // substring the body must NOT contain
1515 }{
1616 {
1717 name: "all fields present",
1818 input: "# [neomd: to: alice@example.com]\n" +
1919 "# [neomd: cc: bob@example.com]\n" +
2020 "# [neomd: bcc: secret@example.com]\n" +
2121+ "# [neomd: from: Me <me@example.com>]\n" +
2122 "# [neomd: subject: Hello World]\n" +
2223 "\n" +
2324 "Body text here.\n",
2425 wantTo: "alice@example.com",
2526 wantCC: "bob@example.com",
2627 wantBCC: "secret@example.com",
2828+ wantFrom: "Me <me@example.com>",
2729 wantSub: "Hello World",
2830 wantBodyContains: "Body text here.",
2931 wantBodyNotContains: "neomd:",
···5557 wantBodyContains: "## Heading",
5658 },
5759 {
5858- name: "no headers at all",
5959- input: "Just plain text\nwith multiple lines.\n",
6060- wantTo: "",
6161- wantCC: "",
6262- wantBCC: "",
6363- wantSub: "",
6464- wantBodyContains: "Just plain text",
6060+ name: "no headers at all",
6161+ input: "Just plain text\nwith multiple lines.\n",
6262+ wantTo: "",
6363+ wantCC: "",
6464+ wantBCC: "",
6565+ wantSub: "",
6666+ wantBodyContains: "Just plain text",
6567 wantBodyNotContains: "",
6668 },
6769 {
···77797880 for _, tt := range tests {
7981 t.Run(tt.name, func(t *testing.T) {
8080- to, cc, bcc, subject, body := ParseHeaders(tt.input)
8282+ to, cc, bcc, from, subject, body := ParseHeaders(tt.input)
8183 if to != tt.wantTo {
8284 t.Errorf("to = %q, want %q", to, tt.wantTo)
8385 }
···8688 }
8789 if bcc != tt.wantBCC {
8890 t.Errorf("bcc = %q, want %q", bcc, tt.wantBCC)
9191+ }
9292+ if from != tt.wantFrom {
9393+ t.Errorf("from = %q, want %q", from, tt.wantFrom)
8994 }
9095 if subject != tt.wantSub {
9196 t.Errorf("subject = %q, want %q", subject, tt.wantSub)
···102107103108func TestPrelude(t *testing.T) {
104109 tests := []struct {
105105- name string
106106- to, cc string
107107- subject string
108108- signature string
109109- wantHas []string // substrings that must appear
110110- wantNot []string // substrings that must NOT appear
110110+ name string
111111+ to, cc, bcc, from string
112112+ subject string
113113+ signature string
114114+ wantHas []string // substrings that must appear
115115+ wantNot []string // substrings that must NOT appear
111116 }{
112117 {
113118 name: "basic without cc or sig",
···117122 "# [neomd: to: alice@example.com]",
118123 "# [neomd: subject: Greetings]",
119124 },
120120- wantNot: []string{"# [neomd: cc:", "-- \n"},
125125+ wantNot: []string{"# [neomd: cc:", "# [neomd: bcc:", "# [neomd: from:", "-- \n"},
121126 },
122127 {
123128 name: "with cc",
···131136 },
132137 },
133138 {
139139+ name: "with bcc and from",
140140+ to: "alice@example.com",
141141+ bcc: "secret@example.com",
142142+ from: "Me <me@example.com>",
143143+ subject: "Private",
144144+ wantHas: []string{
145145+ "# [neomd: bcc: secret@example.com]",
146146+ "# [neomd: from: Me <me@example.com>]",
147147+ },
148148+ },
149149+ {
134150 name: "with signature",
135151 to: "a@b.com",
136152 subject: "Sig test",
···147163148164 for _, tt := range tests {
149165 t.Run(tt.name, func(t *testing.T) {
150150- got := Prelude(tt.to, tt.cc, tt.subject, tt.signature)
166166+ got := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, tt.signature)
151167 for _, want := range tt.wantHas {
152168 if !strings.Contains(got, want) {
153169 t.Errorf("Prelude missing %q, got:\n%s", want, got)
···231247232248func TestPreludeParseHeadersRoundTrip(t *testing.T) {
233249 tests := []struct {
234234- name string
235235- to, cc string
236236- subject string
250250+ name string
251251+ to, cc, bcc, from string
252252+ subject string
237253 }{
238254 {
239255 name: "to and subject only",
···245261 to: "alice@example.com",
246262 cc: "bob@example.com",
247263 subject: "With CC",
264264+ },
265265+ {
266266+ name: "with bcc and from",
267267+ to: "alice@example.com",
268268+ bcc: "secret@example.com",
269269+ from: "Me <me@example.com>",
270270+ subject: "With hidden recipients",
248271 },
249272 }
250273251274 for _, tt := range tests {
252275 t.Run(tt.name, func(t *testing.T) {
253253- prelude := Prelude(tt.to, tt.cc, tt.subject, "")
254254- gotTo, gotCC, _, gotSubject, _ := ParseHeaders(prelude)
276276+ prelude := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, "")
277277+ gotTo, gotCC, gotBCC, gotFrom, gotSubject, _ := ParseHeaders(prelude)
255278 if gotTo != tt.to {
256279 t.Errorf("round-trip to = %q, want %q", gotTo, tt.to)
257280 }
258281 if gotCC != tt.cc {
259282 t.Errorf("round-trip cc = %q, want %q", gotCC, tt.cc)
283283+ }
284284+ if gotBCC != tt.bcc {
285285+ t.Errorf("round-trip bcc = %q, want %q", gotBCC, tt.bcc)
286286+ }
287287+ if gotFrom != tt.from {
288288+ t.Errorf("round-trip from = %q, want %q", gotFrom, tt.from)
260289 }
261290 if gotSubject != tt.subject {
262291 t.Errorf("round-trip subject = %q, want %q", gotSubject, tt.subject)
+16-1
internal/imap/client.go
···3737 From string
3838 To string
3939 CC string // comma-separated CC addresses (may be empty)
4040+ BCC string // comma-separated BCC addresses (mainly useful for Drafts)
4041 ReplyTo string // Reply-To address if present (may be empty)
4142 Subject string
4243 Date time.Time
4344 Seen bool
4444- Answered bool // \Answered flag — set when replied to from any client
4545+ Answered bool // \Answered flag — set when replied to from any client
4546 Folder string
4647 Size uint32 // RFC822 size in bytes
4748 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part
···294295 cc = append(cc, a.Addr())
295296 }
296297 e.CC = strings.Join(cc, ", ")
298298+ }
299299+ if len(m.Envelope.Bcc) > 0 {
300300+ bcc := make([]string, 0, len(m.Envelope.Bcc))
301301+ for _, a := range m.Envelope.Bcc {
302302+ bcc = append(bcc, a.Addr())
303303+ }
304304+ e.BCC = strings.Join(bcc, ", ")
297305 }
298306 if len(m.Envelope.ReplyTo) > 0 {
299307 e.ReplyTo = m.Envelope.ReplyTo[0].Addr()
···670678 cc = append(cc, a.Addr())
671679 }
672680 e.CC = strings.Join(cc, ", ")
681681+ }
682682+ if len(m.Envelope.Bcc) > 0 {
683683+ bcc := make([]string, 0, len(m.Envelope.Bcc))
684684+ for _, a := range m.Envelope.Bcc {
685685+ bcc = append(bcc, a.Addr())
686686+ }
687687+ e.BCC = strings.Join(bcc, ", ")
673688 }
674689 }
675690 e.Size = uint32(m.RFC822Size)
+72-6
internal/screener/screener.go
···1515type Category int
16161717const (
1818- CategoryToScreen Category = iota // unknown — awaiting decision
1919- CategoryInbox // approved sender
2020- CategoryScreenedOut // blocked (known human/company)
2121- CategoryFeed // newsletter / feed
2222- CategoryPaperTrail // receipts / notifications
2323- CategorySpam // actual spam — never needs review
1818+ CategoryToScreen Category = iota // unknown — awaiting decision
1919+ CategoryInbox // approved sender
2020+ CategoryScreenedOut // blocked (known human/company)
2121+ CategoryFeed // newsletter / feed
2222+ CategoryPaperTrail // receipts / notifications
2323+ CategorySpam // actual spam — never needs review
2424)
25252626func (c Category) String() string {
···5757 feed map[string]bool
5858 paperTrail map[string]bool
5959 spam map[string]bool
6060+}
6161+6262+// Snapshot is a point-in-time copy of all screener list files and in-memory sets.
6363+// It is used to roll back a failed screener operation.
6464+type Snapshot struct {
6565+ ScreenedIn map[string]bool
6666+ ScreenedOut map[string]bool
6767+ Feed map[string]bool
6868+ PaperTrail map[string]bool
6969+ Spam map[string]bool
6070}
61716272// New loads all lists from the paths in cfg.
···164174// MarkPaperTrail adds addr to papertrail.txt and updates the in-memory set.
165175func (s *Screener) MarkPaperTrail(from string) error {
166176 return s.addToList(s.cfg.PaperTrail, s.paperTrail, from)
177177+}
178178+179179+func cloneSet(src map[string]bool) map[string]bool {
180180+ dst := make(map[string]bool, len(src))
181181+ for k, v := range src {
182182+ dst[k] = v
183183+ }
184184+ return dst
185185+}
186186+187187+// Snapshot captures the current screener state so a caller can roll back.
188188+func (s *Screener) Snapshot() Snapshot {
189189+ return Snapshot{
190190+ ScreenedIn: cloneSet(s.screenedIn),
191191+ ScreenedOut: cloneSet(s.screenedOut),
192192+ Feed: cloneSet(s.feed),
193193+ PaperTrail: cloneSet(s.paperTrail),
194194+ Spam: cloneSet(s.spam),
195195+ }
196196+}
197197+198198+func writeSet(path string, m map[string]bool) error {
199199+ lines := make([]string, 0, len(m))
200200+ for addr := range m {
201201+ lines = append(lines, addr)
202202+ }
203203+ content := ""
204204+ if len(lines) > 0 {
205205+ content = strings.Join(lines, "\n") + "\n"
206206+ }
207207+ return os.WriteFile(path, []byte(content), 0600)
208208+}
209209+210210+// Restore rewrites all screener list files and in-memory sets from a snapshot.
211211+func (s *Screener) Restore(snapshot Snapshot) error {
212212+ if err := writeSet(s.cfg.ScreenedIn, snapshot.ScreenedIn); err != nil {
213213+ return err
214214+ }
215215+ if err := writeSet(s.cfg.ScreenedOut, snapshot.ScreenedOut); err != nil {
216216+ return err
217217+ }
218218+ if err := writeSet(s.cfg.Feed, snapshot.Feed); err != nil {
219219+ return err
220220+ }
221221+ if err := writeSet(s.cfg.PaperTrail, snapshot.PaperTrail); err != nil {
222222+ return err
223223+ }
224224+ if err := writeSet(s.cfg.Spam, snapshot.Spam); err != nil {
225225+ return err
226226+ }
227227+ s.screenedIn = cloneSet(snapshot.ScreenedIn)
228228+ s.screenedOut = cloneSet(snapshot.ScreenedOut)
229229+ s.feed = cloneSet(snapshot.Feed)
230230+ s.paperTrail = cloneSet(snapshot.PaperTrail)
231231+ s.spam = cloneSet(snapshot.Spam)
232232+ return nil
167233}
168234169235// removeFromList deletes addr from the file and in-memory set if present.