From c13616e765b4e7108a53c7a6ca287b2911ba1f2c Mon Sep 17 00:00:00 2001 From: Mike Wassmer Date: Thu, 10 Sep 2026 20:26:08 +0200 Subject: [PATCH 1/5] Update slashmail skill for attachments --- skills/slashmail/SKILL.md | 40 ++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/skills/slashmail/SKILL.md b/skills/slashmail/SKILL.md index 9545c01..f19abf7 100644 --- a/skills/slashmail/SKILL.md +++ b/skills/slashmail/SKILL.md @@ -1,15 +1,15 @@ --- name: slashmail -description: "Interact with email via the slashmail IMAP CLI. Use when the user asks to draft, reply to, check, search, read, delete, move, mark, or count email, or check mailbox quota. Triggers on: email, mail, inbox, messages, draft email, reply to email, check my email, search email, delete email, unread messages, slashmail." +description: "Interact with email via the slashmail IMAP CLI. Use when the user asks to draft, reply to, check, search, read, delete, move, mark, count, export, list or save attachments, inspect mailbox status, or check quota. Triggers on: email, mail, inbox, messages, draft email, reply to email, email attachments, save attachments, check my email, search email, delete email, unread messages, slashmail." --- # Slashmail Email interaction via the `slashmail` CLI, an IMAP client. -**Prerequisites**: Verify `slashmail` is installed with `command -v slashmail`. If not found, install from https://github.com/mwmdev/slashmail (Rust binary — `cargo install slashmail` or download from releases). Before drafting, run `slashmail draft --help`; before replying, run `slashmail reply --help`. If the required command is unavailable, stop, report that the installed binary is stale, and suggest upgrading from a release or with Cargo. Do not substitute an arbitrary development build. +**Prerequisites**: Verify `slashmail` is installed with `command -v slashmail`. If not found, install from https://github.com/mwmdev/slashmail (Rust binary — `cargo install slashmail` or download from releases). Before drafting, run `slashmail draft --help`; before replying, run `slashmail reply --help`; before listing or saving received attachments, run `slashmail attachments --help`. If the required command or documented flag is unavailable, stop, report that the installed binary is stale, and suggest upgrading from a release or with Cargo. Do not substitute an arbitrary development build. -**Configuration**: Config file location is OS-dependent (Linux: `~/.config/slashmail/config.toml`, macOS: `~/Library/Application Support/slashmail/config.toml`, Windows: `%APPDATA%\slashmail\config.toml`). Direct/legacy connections use `SLASHMAIL_PASS`. Named accounts define `pass_env` and can be selected with `--account NAME`; read-only commands can use `--all-accounts`. +**Configuration**: Config file location is OS-dependent (Linux: `~/.config/slashmail/config.toml`, macOS: `~/Library/Application Support/slashmail/config.toml`, Windows: `%APPDATA%\slashmail\config.toml`). Direct/legacy connections use `SLASHMAIL_PASS`. Named accounts define `pass_env` and can be selected with `--account NAME`. Only `search`, `read`, `count`, `status`, and `quota` support `--all-accounts`. Slashmail automatically loads `.env` beside the selected `config.toml` without overriding values already present in the process environment. Store each named account password under the variable named by `pass_env`. Keep `.env` private and out of version control. @@ -51,23 +51,27 @@ Date formats: `YYYY-MM-DD` or relative (`7d`, `2w`, `3m`, `1y`). All filters com |---------|-------------|-------------| | `draft` | Save a new unsent draft; body is read from stdin | repeatable `--to`, `--cc`, `--bcc`, `--attach PATH`; `--subject`, `--html`, `--drafts-folder` | | `reply UID` | Save an unsent reply-all draft; body is read from stdin | repeatable `--attach PATH`; `--folder`, `--html`, `--no-quote`, `--drafts-folder` | +| `attachments UID` | List or save received attachments without marking the message seen | `--folder`, `--json`, `--save`, repeatable `--part PART`, `-o DIR`, `--force` | | `search` | Retrieve messages (sorted newest-first) | `--json` | | `read` | Display message content in terminal | — | | `count` | Fast count without fetching content | `--json` | | `delete` | Move to Trash | `--trash-folder NAME`, `--dry-run`, `--yes` | | `move` | Move to folder | `--to DEST`, `--dry-run`, `--yes` | -| `mark` | Set/unset flags | `--read/--unread`, `--flagged/--unflagged`, `--dry-run`, `--yes` | +| `mark` | Set/unset flags | `--read/--unread`, `--set-flagged/--clear-flagged`, `--dry-run`, `--yes` | | `export` | Save as `.eml` files | `-o DIR`, `--force`, `--yes` | | `status` | Per-folder message stats | — | | `quota` | Mailbox capacity usage | — | +| `completions SHELL` | Generate shell completions | `bash`, `zsh`, `fish`, `powershell`, or `elvish` | ## Safety Rules - **Always `--dry-run` first** for delete, move, and bulk mark operations. Show the user what will be affected before executing. -- **Never pass `--yes`** without showing the dry-run results to the user first and getting confirmation. +- **Never pass `--yes` to delete, move, or mark** without showing the matching dry-run results to the user first and getting confirmation. +- **Preview exports with equivalent `search` filters** before using noninteractive `export --yes`. Use `--force` only when the user explicitly authorizes replacing the displayed destination files. - **Use `--limit`** when the user asks for "recent" or "latest" messages to avoid fetching everything. - **Draft and reply save immediately but never send.** Do not describe a saved draft as sent. - **Attach only exact user-authorized paths.** Pass each literal local file path in its own `--attach` occurrence. Never expand globs, search directories, auto-discover files, or substitute a similarly named file. +- **List received attachments before saving** unless the user already specified exact MIME part IDs. Confirm the output directory before `--save`, and use `--force` only after the user explicitly authorizes replacing existing destination files or symlinks. - **Inspect Drafts before retrying an ambiguous save.** If slashmail says the draft was saved but its UID is unresolved, or the APPEND outcome is unknown, retrying may create a duplicate. - **Treat receipts as sensitive.** The stable success line includes Account, Folder, UID, To, Cc, Bcc, and Subject; do not place recipient metadata in public logs. @@ -122,17 +126,39 @@ Confirmed saves print exactly one control-free receipt line: Draft saved: Account=work | Folder=Drafts | UID=1843 | To=alice@example.com | Cc=bob@example.com | Bcc= | Subject=Re: Project update ``` +## Received Attachment Rules + +- Select one account, source `--folder` (default: the account's configured default folder), and positive source UID. The `attachments` command does not support `--all-accounts`. +- Listing is the default and does not mark the message as seen. Add `--json` only for machine-readable metadata; it conflicts with `--save`. +- Add `--save` to write all declared attachments, or repeat `--part PART` to save specific canonical MIME part IDs such as `2` or `2.1`. `--part`, `--output-dir`, and `--force` require `--save`. +- Prefer an explicit `--output-dir`; otherwise files are written to the current directory. Slashmail creates the output directory when needed. +- Slashmail sanitizes filenames, resolves same-message name collisions deterministically, and refuses existing destinations unless `--force` is supplied. With `--force`, it replaces regular files or symlinks but rejects other filesystem objects. +- Only MIME parts declared as attachments are exposed. Inline/CID parts and attachments nested inside another attached message are not extracted. + +```bash +# List received attachments +slashmail attachments --account work --folder INBOX 1842 + +# Inspect attachment metadata as JSON +slashmail attachments --account work --folder INBOX --json 1842 + +# Save selected parts after reviewing the listing +slashmail attachments --account work --folder INBOX --save \ + --part 2.1 --part 3 --output-dir './received files' 1842 +``` + ## Common Patterns **Check inbox**: `slashmail search --limit 10` -**Unread count**: `slashmail count` (shows total in INBOX) +**Unread count**: `slashmail count --unseen` **Find emails from someone**: `slashmail search --from "name@example.com" --limit 20` **Recent emails**: `slashmail search --since 1d --limit 20` **Mailbox overview**: `slashmail status` **Search email content**: `slashmail search --body "invoice" --since 1m` -**Search everywhere**: `slashmail search --text "quarterly report"` +**Search headers and body**: `slashmail search --text "quarterly report"` **Search all accounts**: `slashmail search --all-accounts --text "quarterly report"` **Read a message**: `slashmail read --from "boss@example.com" --limit 1` **Draft a new message**: `printf '%s\n' 'Draft body' | slashmail draft --to recipient@example.com --subject "Subject"` **Draft a reply**: `printf '%s\n' 'Reply body' | slashmail reply --folder INBOX 1842` +**List received attachments**: `slashmail attachments --folder INBOX 1842` **Clean up old newsletters**: `slashmail delete --from "newsletter@" --before 3m --dry-run` then confirm with user before running without `--dry-run` From 9e517b523fc49b371f643c2a6bb590e9b8b0d045 Mon Sep 17 00:00:00 2001 From: Mike Wassmer Date: Fri, 25 Sep 2026 08:36:15 +0200 Subject: [PATCH 2/5] Disable incremental builds to save disk space --- Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4484690..b73042e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,12 @@ integration-tests = [] lettre = { version = "=0.11.19", default-features = false, features = ["builder", "mime03", "smtp-transport", "native-tls"] } tempfile = ">=3,<3.25" +[profile.dev] +incremental = false + +[profile.test] +incremental = false + [profile.release] lto = true codegen-units = 1 From b186c7cad5526ed067f2499aa53729b6d705a716 Mon Sep 17 00:00:00 2001 From: Mike Wassmer Date: Fri, 25 Sep 2026 08:53:05 +0200 Subject: [PATCH 3/5] Add read --uid, JSON thread ids and flags, and JSON draft receipts --- CHANGELOG.md | 7 ++ README.md | 48 ++++++++- skills/slashmail/SKILL.md | 16 ++- src/attachment.rs | 26 +++++ src/display.rs | 50 +++++---- src/draft.rs | 4 +- src/main.rs | 106 ++++++++++++++---- src/read.rs | 181 +++++++++++++++++++++++++++---- src/search.rs | 135 ++++++++++++++++++++++- tests/integration.rs | 219 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 722 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f4b9a..701a296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `read --uid ` reads one exact message from the selected folder and fails when that UID does not exist +- `read --json` prints full headers, thread identifiers, flags, decoded text body, and attachment part metadata +- `search --json` rows include `message_id`, `in_reply_to`, `references`, and `seen`/`answered`/`flagged` state +- `draft --json` and `reply --json` print the saved-draft receipt, including the draft UID and Message-ID, as JSON + ## [0.7.0] - 2026-09-10 ### Added diff --git a/README.md b/README.md index 1cde017..ff32499 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ SLASHMAIL_WORK_PASS=your-work-password When `[[accounts]]` is configured, slashmail uses `default_account` by default, or the first account if `default_account` is omitted. Use `--account ` to select one account, or `--all-accounts` to aggregate read-only commands across every account. -`--all-accounts` is supported for `search`, `read`, `count`, `status`, and `quota`. Mutating commands (`delete`, `move`, `mark`), `export`, drafts, replies, and received-attachment inspection require a single account. +`--all-accounts` is supported for `search`, `read` (without `--uid`), `count`, `status`, and `quota`. Mutating commands (`delete`, `move`, `mark`), `export`, drafts, replies, and received-attachment inspection require a single account. Use `--config ` to specify an alternative config file location. @@ -191,9 +191,11 @@ built, so large attachments may be limited by available memory or the mailbox provider. On success, slashmail prints the account, Drafts folder, UID, recipients, and -subject. This receipt may contain Bcc addresses, so avoid copying it into -public logs. If the APPEND outcome is reported as unknown, inspect the Drafts -folder before retrying to avoid creating a duplicate. +subject. Add `--json` to print the same receipt, plus the draft's +`message_id`, as one JSON object. This receipt may contain Bcc addresses, so +avoid copying it into public logs. If the APPEND outcome is reported as +unknown, inspect the Drafts folder before retrying to avoid creating a +duplicate. ### Received attachments @@ -220,6 +222,42 @@ the output directory. Only parts declared as attachments are exposed; inline/CID parts and attachments nested inside another attached message are not extracted. +### Reading one message by UID + +`read --uid ` displays exactly that message from the selected folder +instead of the newest filter match. The folder is `--folder`, or else the +account's configured `default_folder` (INBOX if unset); UIDs are only unique +within one folder, so pass `--folder` explicitly when reusing a UID from +`search`. It fails when no message with that UID exists and cannot be combined +with `--all-folders` or `--all-accounts`. Like every read, it does not mark the +message as seen. + +```bash +slashmail read --account work --folder INBOX --uid 1842 +slashmail read --account work --folder INBOX --uid 1842 --json +``` + +`read --json` prints an array with one object per message: `uid`, `folder`, +`message_id`, `in_reply_to`, `references`, full `from`/`to`/`cc`/`date`/ +`subject` headers, `timestamp`, `seen`/`answered`/`flagged`, the decoded text +`body`, and `attachments` (MIME `part`, `filename`, `content_type`, `size`). +Pass an attachment's `part` to `attachments --save --part`. + +### JSON search fields + +Each `search --json` row contains `uid`, `folder` (set with `--all-folders`), +`from`, `subject`, `date`, `timestamp`, `size`, the thread identifiers +`message_id` (or `null`), `in_reply_to`, and `references` (arrays of +angle-bracketed IDs), and the `seen`, `answered`, and `flagged` booleans. +Rows include `account` when a named account is used. `from` and `subject` are +shortened for display; use `read --json` for the full headers. + +```bash +# Messages you have not answered yet +slashmail search --since 7d --json | + jq '.[] | select(.answered | not) | {uid, subject, message_id}' +``` + ### Filter options Search, read, count, and bulk message commands share these filter options: @@ -293,7 +331,7 @@ slashmail search -u user@example.com --body "invoice attached" # Search everywhere (headers + body) slashmail search -u user@example.com --text "quarterly report" -# JSON output for scripting (search and count only) +# JSON output for scripting slashmail search -u user@example.com --from "alerts" --json | jq '.[].subject' slashmail count -u user@example.com --json diff --git a/skills/slashmail/SKILL.md b/skills/slashmail/SKILL.md index f19abf7..5db84c6 100644 --- a/skills/slashmail/SKILL.md +++ b/skills/slashmail/SKILL.md @@ -49,11 +49,11 @@ Date formats: `YYYY-MM-DD` or relative (`7d`, `2w`, `3m`, `1y`). All filters com | Command | Description | Extra flags | |---------|-------------|-------------| -| `draft` | Save a new unsent draft; body is read from stdin | repeatable `--to`, `--cc`, `--bcc`, `--attach PATH`; `--subject`, `--html`, `--drafts-folder` | -| `reply UID` | Save an unsent reply-all draft; body is read from stdin | repeatable `--attach PATH`; `--folder`, `--html`, `--no-quote`, `--drafts-folder` | +| `draft` | Save a new unsent draft; body is read from stdin | repeatable `--to`, `--cc`, `--bcc`, `--attach PATH`; `--subject`, `--html`, `--drafts-folder`, `--json` | +| `reply UID` | Save an unsent reply-all draft; body is read from stdin | repeatable `--attach PATH`; `--folder`, `--html`, `--no-quote`, `--drafts-folder`, `--json` | | `attachments UID` | List or save received attachments without marking the message seen | `--folder`, `--json`, `--save`, repeatable `--part PART`, `-o DIR`, `--force` | -| `search` | Retrieve messages (sorted newest-first) | `--json` | -| `read` | Display message content in terminal | — | +| `search` | Retrieve messages (sorted newest-first); JSON rows include `message_id`, `in_reply_to`, `references`, `seen`, `answered`, `flagged` | `--json` | +| `read` | Display message content without marking it seen | `--uid UID` (exact message in `--folder`; single account), `--json` | | `count` | Fast count without fetching content | `--json` | | `delete` | Move to Trash | `--trash-folder NAME`, `--dry-run`, `--yes` | | `move` | Move to folder | `--to DEST`, `--dry-run`, `--yes` | @@ -126,6 +126,14 @@ Confirmed saves print exactly one control-free receipt line: Draft saved: Account=work | Folder=Drafts | UID=1843 | To=alice@example.com | Cc=bob@example.com | Bcc= | Subject=Re: Project update ``` +With `--json`, the receipt is one object with `account`, `folder`, `uid`, `message_id`, `to`, `cc`, `bcc`, and `subject`. Prefer it when recording the draft UID. + +## Reading by UID + +- Once `search` has identified a message, use `read --folder FOLDER --uid UID` rather than re-filtering by sender, subject, or date. It fails if the UID no longer exists in that folder, and it rejects `--all-folders` and `--all-accounts`. +- `read --json` returns an array of objects with full `from`/`to`/`cc`/`date`/`subject`, `message_id`, `in_reply_to`, `references`, flags, decoded `body`, and `attachments` (`part`, `filename`, `content_type`, `size`). Pass a `part` to `attachments --save --part`. +- In `search --json`, `from` and `subject` are shortened for display; match duplicates and follow-ups by `message_id`, `in_reply_to`, and `references` (angle-bracketed IDs), and use `answered` to skip mail already replied to. + ## Received Attachment Rules - Select one account, source `--folder` (default: the account's configured default folder), and positive source UID. The `attachments` command does not support `--all-accounts`. diff --git a/src/attachment.rs b/src/attachment.rs index c878424..5568d1c 100644 --- a/src/attachment.rs +++ b/src/attachment.rs @@ -215,6 +215,32 @@ pub fn render_attachments_json(attachments: &[ReceivedAttachment]) -> Result, +} + +pub fn attachment_summaries(parsed: &mailparse::ParsedMail<'_>) -> Vec { + let mut summaries = Vec::new(); + walk_attachment_parts(parsed, |part, parsed_part| { + summaries.push(AttachmentSummary { + part: part.to_string(), + filename: attachment_filename(parsed_part), + content_type: parsed_part.ctype.mimetype.to_lowercase(), + size: parsed_part + .get_body_raw() + .ok() + .map(|bytes| bytes.len() as u64), + }); + }); + summaries +} + pub fn render_saved_receipt(saved: &SavedAttachment) -> String { format!( "Saved attachment: Part={} | File={} | Size={}", diff --git a/src/display.rs b/src/display.rs index 3bdb077..15051c1 100644 --- a/src/display.rs +++ b/src/display.rs @@ -11,6 +11,12 @@ pub struct MessageRow { pub date: String, pub timestamp: i64, pub size: u32, + pub message_id: Option, + pub in_reply_to: Vec, + pub references: Vec, + pub seen: bool, + pub answered: bool, + pub flagged: bool, } pub fn format_size(bytes: u64) -> String { @@ -136,18 +142,28 @@ mod tests { assert_eq!(json, "[]"); } - #[test] - fn json_single_message() { - let messages = vec![MessageRow { + fn row(uid: u32) -> MessageRow { + MessageRow { account: None, - uid: 42, + uid, folder: None, from: "alice@example.com".into(), subject: "Test".into(), date: "Mon, 1 Apr 2026".into(), timestamp: 1774000000, size: 1024, - }]; + message_id: None, + in_reply_to: Vec::new(), + references: Vec::new(), + seen: false, + answered: false, + flagged: false, + } + } + + #[test] + fn json_single_message() { + let messages = vec![row(42)]; let json = serde_json::to_string(&messages).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed[0]["uid"], 42); @@ -155,20 +171,20 @@ mod tests { assert_eq!(parsed[0]["subject"], "Test"); assert_eq!(parsed[0]["size"], 1024); assert!(parsed[0]["folder"].is_null()); - assert!(parsed[0]["account"].is_null()); + assert!(parsed[0].get("account").is_none()); + assert!(parsed[0]["message_id"].is_null()); + assert_eq!(parsed[0]["in_reply_to"], serde_json::json!([])); + assert_eq!(parsed[0]["references"], serde_json::json!([])); + assert_eq!(parsed[0]["seen"], false); + assert_eq!(parsed[0]["answered"], false); + assert_eq!(parsed[0]["flagged"], false); } #[test] fn json_with_folder() { let messages = vec![MessageRow { - account: None, - uid: 1, folder: Some("INBOX".into()), - from: "bob@example.com".into(), - subject: "Hi".into(), - date: "Tue, 2 Apr 2026".into(), - timestamp: 1774100000, - size: 512, + ..row(1) }]; let json = serde_json::to_string(&messages).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); @@ -179,13 +195,7 @@ mod tests { fn json_with_account() { let messages = vec![MessageRow { account: Some("work".into()), - uid: 1, - folder: None, - from: "bob@example.com".into(), - subject: "Hi".into(), - date: "Tue, 2 Apr 2026".into(), - timestamp: 1774100000, - size: 512, + ..row(1) }]; let json = serde_json::to_string(&messages).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); diff --git a/src/draft.rs b/src/draft.rs index 017a024..30c2857 100644 --- a/src/draft.rs +++ b/src/draft.rs @@ -73,11 +73,12 @@ pub enum SaveOutcome { Unknown, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct DraftReceipt { pub account: String, pub folder: String, pub uid: u32, + pub message_id: String, pub to: Vec, pub cc: Vec, pub bcc: Vec, @@ -1656,6 +1657,7 @@ Content-Transfer-Encoding: base64\r\n\r\n\ account: "\u{1b}[31mwork\u{1b}[0m".to_string(), folder: "Drafts\u{1b}]0;malicious title\u{7}\nInjected".to_string(), uid: 42, + message_id: "".to_string(), to: vec!["A \tB".to_string()], cc: Vec::new(), bcc: vec!["secret@example.com\u{9b}2J".to_string()], diff --git a/src/main.rs b/src/main.rs index fd60860..509d0e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,6 +146,10 @@ struct DraftArgs { /// Destination Drafts mailbox #[arg(long)] drafts_folder: Option, + + /// Print the saved-draft receipt as JSON + #[arg(long)] + json: bool, } #[derive(Parser)] @@ -173,6 +177,10 @@ struct ReplyArgs { /// Destination Drafts mailbox #[arg(long)] drafts_folder: Option, + + /// Print the saved-draft receipt as JSON + #[arg(long)] + json: bool, } #[derive(Parser)] @@ -303,6 +311,14 @@ struct ReadArgs { /// Limit number of messages to display [default: 1] #[arg(short = 'n', long)] limit: Option, + + /// Read the message with this UID in the selected folder + #[arg(long, value_parser = clap::value_parser!(u32).range(1..), conflicts_with = "all_folders")] + uid: Option, + + /// Output as JSON + #[arg(long)] + json: bool, } #[derive(Parser)] @@ -423,6 +439,7 @@ impl FilterArgs { .clone() .unwrap_or_else(|| default_folder.to_string()), all_folders: self.all_folders, + uid: None, subject: self.subject.clone(), from: self.from.clone(), to: self.to.clone(), @@ -760,6 +777,7 @@ fn draft_receipt( account: account.label().to_string(), folder: folder.to_string(), uid, + message_id: composed.message_id.clone(), to: composed.to.iter().map(ToString::to_string).collect(), cc: composed.cc.iter().map(ToString::to_string).collect(), bcc: composed.bcc.iter().map(ToString::to_string).collect(), @@ -772,13 +790,20 @@ fn report_save_outcome( account: &config::ResolvedAccount, folder: &str, composed: &draft::ComposedDraft, + json: bool, ) -> Result<()> { match outcome { draft::SaveOutcome::Saved { uid } => { - println!( - "{}", - draft::render_receipt(&draft_receipt(account, folder, uid, composed)) - ); + let receipt = draft_receipt(account, folder, uid, composed); + if json { + println!( + "{}", + serde_json::to_string(&receipt) + .context("Failed to serialize draft receipt as JSON")? + ); + } else { + println!("{}", draft::render_receipt(&receipt)); + } Ok(()) } draft::SaveOutcome::SavedUidUnresolved => { @@ -847,7 +872,7 @@ fn cmd_draft(account: &config::ResolvedAccount, args: &DraftArgs) -> Result<()> )?; let outcome = save_draft(&factory, &mut session, &folder, &composed); let _ = session.logout(); - report_save_outcome(outcome?, account, &folder, &composed) + report_save_outcome(outcome?, account, &folder, &composed, args.json) } fn cmd_reply(account: &config::ResolvedAccount, args: &ReplyArgs) -> Result<()> { @@ -882,7 +907,7 @@ fn cmd_reply(account: &config::ResolvedAccount, args: &ReplyArgs) -> Result<()> )?; let outcome = save_draft(&factory, &mut session, &folder, &composed); let _ = session.logout(); - report_save_outcome(outcome?, account, &folder, &composed) + report_save_outcome(outcome?, account, &folder, &composed, args.json) } fn cmd_attachments( @@ -999,23 +1024,23 @@ fn search_accounts( } fn command_supports_all_accounts(command: &Commands) -> bool { - matches!( - command, - Commands::Search(_) - | Commands::Read(_) - | Commands::Count(_) - | Commands::Quota - | Commands::Status - ) + match command { + Commands::Read(args) => args.uid.is_none(), + Commands::Search(_) | Commands::Count(_) | Commands::Quota | Commands::Status => true, + _ => false, + } } fn reject_all_accounts_if_unsupported(cli: &Cli) -> Result<()> { - if cli.all_accounts && !command_supports_all_accounts(&cli.command) { - bail!( - "--all-accounts is only supported for search, read, count, status, and quota; use --account for this command" - ); + if !cli.all_accounts || command_supports_all_accounts(&cli.command) { + return Ok(()); } - Ok(()) + if matches!(&cli.command, Commands::Read(_)) { + bail!("read --uid selects one message in one account; use --account instead of --all-accounts"); + } + bail!( + "--all-accounts is only supported for search, read, count, status, and quota; use --account for this command" + ); } #[derive(Debug, Clone)] @@ -1717,12 +1742,21 @@ fn cmd_read_accounts(accounts: &[config::ResolvedAccount], args: &ReadArgs) -> R for account in accounts { let (mut account_messages, fetched, folder) = with_account_session(account, |session| { - let criteria = args.filter.to_criteria(limit, &account.default_folder); + let mut criteria = args.filter.to_criteria(limit, &account.default_folder); + criteria.uid = args.uid; let sp = spinner(&format!("Searching {}...", account.label())); let result = search::search(session, &criteria); sp.finish_and_clear(); let mut account_messages = result?; + if let Some(uid) = args.uid { + if account_messages.is_empty() { + bail!( + "No message with UID {uid} matches in '{}'", + display::sanitize_terminal_field(&criteria.folder) + ); + } + } tag_messages(&mut account_messages, account); @@ -1741,6 +1775,13 @@ fn cmd_read_accounts(accounts: &[config::ResolvedAccount], args: &ReadArgs) -> R } sort_and_limit_messages(&mut messages, limit); + if args.json { + println!( + "{}", + read::render_messages_json(&messages, &defaults, &bodies)? + ); + return Ok(()); + } if messages.is_empty() { println!("No messages found."); return Ok(()); @@ -2363,7 +2404,8 @@ mod tests { draft::SaveOutcome::SavedUidUnresolved, draft::SaveOutcome::Unknown, ] { - let error = report_save_outcome(outcome, &account, "Drafts", &composed).unwrap_err(); + let error = + report_save_outcome(outcome, &account, "Drafts", &composed, false).unwrap_err(); let rendered = error.to_string(); assert!(!rendered.contains("private body")); assert!(!rendered.contains("Content-Type")); @@ -2567,4 +2609,26 @@ mod tests { Cli::try_parse_from(["slashmail", "--all-accounts", "attachments", "42"]).unwrap(); assert!(reject_all_accounts_if_unsupported(&cli).is_err()); } + + #[test] + fn read_uid_selects_one_folder_in_one_account() { + let cli = + Cli::try_parse_from(["slashmail", "read", "--uid", "42", "-f", "Archive"]).unwrap(); + let Commands::Read(args) = &cli.command else { + panic!("expected read command") + }; + assert_eq!(args.uid, Some(42)); + assert!(reject_all_accounts_if_unsupported(&cli).is_ok()); + + assert!(Cli::try_parse_from(["slashmail", "read", "--uid", "0"]).is_err()); + assert!( + Cli::try_parse_from(["slashmail", "read", "--uid", "42", "--all-folders"]).is_err() + ); + + let all = + Cli::try_parse_from(["slashmail", "--all-accounts", "read", "--uid", "42"]).unwrap(); + assert!(reject_all_accounts_if_unsupported(&all).is_err()); + let all = Cli::try_parse_from(["slashmail", "--all-accounts", "read"]).unwrap(); + assert!(reject_all_accounts_if_unsupported(&all).is_ok()); + } } diff --git a/src/read.rs b/src/read.rs index 5b824d9..18efe6a 100644 --- a/src/read.rs +++ b/src/read.rs @@ -1,5 +1,7 @@ use crate::attachment; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; +use mailparse::MailHeaderMap; +use serde::Serialize; use std::collections::HashMap; use crate::connection::ImapSession; @@ -108,6 +110,74 @@ pub fn print_messages_with_bodies( } } +#[derive(Serialize)] +struct ReadMessageJson<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + account: Option<&'a str>, + folder: String, + uid: u32, + message_id: Option<&'a str>, + in_reply_to: &'a [String], + references: &'a [String], + from: String, + to: String, + cc: String, + date: String, + timestamp: i64, + subject: String, + seen: bool, + answered: bool, + flagged: bool, + body: String, + attachments: Vec, +} + +/// Render fetched messages as a JSON array, in the order given. +/// +/// Unlike terminal output, a missing, unparseable, or undecodable body is an +/// error so scripted callers never mistake raw or partial content for a +/// decoded result. +pub fn render_messages_json( + messages: &[MessageRow], + default_folders: &DefaultFolderMap, + bodies: &MessageBodyMap, +) -> Result { + let mut rendered = Vec::with_capacity(messages.len()); + for msg in messages { + let key = message_key(msg, default_folders); + let raw = bodies + .get(&key) + .ok_or_else(|| anyhow!("Could not fetch body for UID {}", msg.uid))?; + let parsed = mailparse::parse_mail(raw) + .with_context(|| format!("Failed to parse message UID {}", msg.uid))?; + let header = |name: &str| parsed.headers.get_first_value(name).unwrap_or_default(); + rendered.push(ReadMessageJson { + account: msg.account.as_deref(), + folder: key.1, + uid: msg.uid, + message_id: msg.message_id.as_deref(), + in_reply_to: &msg.in_reply_to, + references: &msg.references, + from: header("From"), + to: header("To"), + cc: header("Cc"), + date: header("Date"), + timestamp: msg.timestamp, + subject: header("Subject"), + seen: msg.seen, + answered: msg.answered, + flagged: msg.flagged, + body: decoded_text_body(&parsed) + .with_context(|| format!("Failed to decode body of message UID {}", msg.uid))? + .unwrap_or_default() + .trim_end() + .to_string(), + attachments: attachment::attachment_summaries(&parsed), + }); + } + serde_json::to_string(&rendered).context("Failed to serialize messages as JSON") +} + pub fn message_key( msg: &MessageRow, default_folders: &DefaultFolderMap, @@ -319,20 +389,30 @@ mod tests { assert!(attachments.is_empty()); } - #[test] - fn message_key_uses_account_fallback_folder() { - let mut defaults = DefaultFolderMap::new(); - defaults.insert(Some("work".to_string()), "Sent".to_string()); - let msg = MessageRow { + fn row(folder: Option<&str>) -> MessageRow { + MessageRow { account: Some("work".to_string()), uid: 42, - folder: None, + folder: folder.map(str::to_string), from: String::new(), subject: String::new(), date: String::new(), timestamp: 0, size: 0, - }; + message_id: None, + in_reply_to: Vec::new(), + references: Vec::new(), + seen: false, + answered: false, + flagged: false, + } + } + + #[test] + fn message_key_uses_account_fallback_folder() { + let mut defaults = DefaultFolderMap::new(); + defaults.insert(Some("work".to_string()), "Sent".to_string()); + let msg = row(None); assert_eq!( message_key(&msg, &defaults), @@ -344,16 +424,7 @@ mod tests { fn message_key_prefers_explicit_folder() { let mut defaults = DefaultFolderMap::new(); defaults.insert(Some("work".to_string()), "Sent".to_string()); - let msg = MessageRow { - account: Some("work".to_string()), - uid: 42, - folder: Some("Archive".to_string()), - from: String::new(), - subject: String::new(), - date: String::new(), - timestamp: 0, - size: 0, - }; + let msg = row(Some("Archive")); assert_eq!( message_key(&msg, &defaults), @@ -361,6 +432,80 @@ mod tests { ); } + #[test] + fn json_reports_full_headers_resolved_folder_body_and_attachments() { + let long_subject = "S".repeat(120); + let raw = format!( + "From: A Very Long Sender Name \r\n\ +To: me@example.com\r\n\ +Subject: {long_subject}\r\n\ +Date: Mon, 1 Jun 2026 10:00:00 +0200\r\n\ +Content-Type: multipart/mixed; boundary=b\r\n\r\n\ +--b\r\nContent-Type: text/plain\r\n\r\nSee screenshot\r\n\ +--b\r\nContent-Type: image/png\r\nContent-Disposition: attachment; filename=shot.png\r\n\ +Content-Transfer-Encoding: base64\r\n\r\niVBORw0K\r\n--b--\r\n" + ); + let mut defaults = DefaultFolderMap::new(); + defaults.insert(Some("work".to_string()), "INBOX".to_string()); + let mut bodies = MessageBodyMap::new(); + bodies.insert( + (Some("work".to_string()), "INBOX".to_string(), 42), + raw.into_bytes(), + ); + let msg = MessageRow { + message_id: Some("".to_string()), + references: vec!["".to_string()], + answered: true, + ..row(None) + }; + + let json: serde_json::Value = + serde_json::from_str(&render_messages_json(&[msg], &defaults, &bodies).unwrap()) + .unwrap(); + let message = &json[0]; + assert_eq!(message["folder"], "INBOX"); + assert_eq!(message["account"], "work"); + assert_eq!(message["subject"], long_subject); + assert_eq!( + message["from"], + "A Very Long Sender Name " + ); + assert_eq!(message["message_id"], ""); + assert_eq!(message["references"][0], ""); + assert_eq!(message["answered"], true); + assert_eq!(message["body"].as_str().unwrap().trim(), "See screenshot"); + assert_eq!( + message["attachments"], + serde_json::json!([{ + "part": "2", + "filename": "shot.png", + "content_type": "image/png", + "size": 6 + }]) + ); + } + + #[test] + fn json_fails_instead_of_omitting_unfetched_message() { + let defaults = DefaultFolderMap::new(); + let error = render_messages_json(&[row(Some("INBOX"))], &defaults, &MessageBodyMap::new()) + .unwrap_err(); + assert!(error.to_string().contains("UID 42")); + } + + #[test] + fn json_fails_instead_of_returning_undecodable_body_as_text() { + let mut bodies = MessageBodyMap::new(); + bodies.insert( + (Some("work".to_string()), "INBOX".to_string(), 42), + b"Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n%%%invalid%%%" + .to_vec(), + ); + let error = render_messages_json(&[row(Some("INBOX"))], &DefaultFolderMap::new(), &bodies) + .unwrap_err(); + assert!(format!("{error:#}").contains("Failed to decode body of message UID 42")); + } + #[test] fn extract_body_html_only() { let raw = b"Content-Type: text/html\r\n\r\n

Hello world

"; diff --git a/src/search.rs b/src/search.rs index 69c27d4..a79852e 100644 --- a/src/search.rs +++ b/src/search.rs @@ -3,10 +3,12 @@ use regex::Regex; use crate::connection::ImapSession; use crate::display::MessageRow; +use imap::types::Flag; pub struct SearchCriteria { pub folder: String, pub all_folders: bool, + pub uid: Option, pub subject: Option, pub from: Option, pub to: Option, @@ -158,6 +160,9 @@ fn parse_date(s: &str) -> Result { pub fn build_query(criteria: &SearchCriteria) -> Result { let mut parts = Vec::new(); + if let Some(uid) = criteria.uid { + parts.push(format!("UID {uid}")); + } if let Some(ref subj) = criteria.subject { parts.push(format!("SUBJECT {}", imap_quote(subj))); } @@ -402,7 +407,7 @@ fn fetch_messages( let fetches = session .uid_fetch( chunk, - "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Subject From Date)])", + "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (Subject From Date Message-ID In-Reply-To References)])", ) .context("IMAP FETCH failed")?; @@ -424,6 +429,7 @@ fn fetch_messages( let header_str = String::from_utf8_lossy(header_bytes); let (mut subject, mut from, mut date) = (String::new(), String::new(), String::new()); + let (mut message_id, mut in_reply_to, mut references) = (None, Vec::new(), Vec::new()); let parsed = mailparse::parse_headers(header_bytes); if let Ok((headers, _)) = parsed { @@ -432,6 +438,9 @@ fn fetch_messages( "subject" => subject = h.get_value(), "from" => from = h.get_value(), "date" => date = h.get_value(), + "message-id" => message_id = message_ids(&h.get_value()).into_iter().next(), + "in-reply-to" => in_reply_to = message_ids(&h.get_value()), + "references" => references = message_ids(&h.get_value()), _ => {} } } @@ -443,10 +452,19 @@ fn fetch_messages( from = v.to_string(); } else if let Some(v) = line.strip_prefix("Date: ") { date = v.to_string(); + } else if let Some(v) = line.strip_prefix("Message-ID: ") { + message_id = message_ids(v).into_iter().next(); + } else if let Some(v) = line.strip_prefix("In-Reply-To: ") { + in_reply_to = message_ids(v); + } else if let Some(v) = line.strip_prefix("References: ") { + references = message_ids(v); } } } + let flags = fetch.flags(); + let has_flag = |wanted, name| has_system_flag(flags, wanted, name); + from = truncate_str(&from, 40); subject = truncate_str(&subject, 60); let timestamp = mailparse::dateparse(&date).unwrap_or(0); @@ -470,6 +488,12 @@ fn fetch_messages( date, timestamp, size, + message_id, + in_reply_to, + references, + seen: has_flag(Flag::Seen, "\\Seen"), + answered: has_flag(Flag::Answered, "\\Answered"), + flagged: has_flag(Flag::Flagged, "\\Flagged"), }, ); } @@ -491,6 +515,72 @@ fn fetch_messages( } } +/// IMAP flag names are case-insensitive, but the imap crate only maps the +/// canonical spellings to system variants; others arrive as `Flag::Custom`. +fn has_system_flag(flags: &[Flag<'_>], wanted: Flag<'_>, name: &str) -> bool { + flags.iter().any(|flag| { + *flag == wanted || matches!(flag, Flag::Custom(custom) if custom.eq_ignore_ascii_case(name)) + }) +} + +/// Extract every angle-bracketed message ID from a Message-ID, In-Reply-To, +/// or References header value, keeping the brackets. Brackets inside +/// comments `(...)` or quoted strings outside an ID are ignored, as are +/// commas, legacy phrases, and malformed tokens. +pub fn message_ids(value: &str) -> Vec { + let mut ids = Vec::new(); + let mut comment_depth = 0usize; + let mut in_quote = false; + let mut escaped = false; + // Byte offset just after the current '<', and whether the ID so far is valid. + let mut open: Option<(usize, bool)> = None; + + for (index, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' && (in_quote || comment_depth > 0) { + escaped = true; + continue; + } + if comment_depth > 0 { + match character { + '(' => comment_depth += 1, + ')' => comment_depth -= 1, + _ => {} + } + continue; + } + if character == '"' { + in_quote = !in_quote; + continue; + } + if in_quote { + if character.is_control() { + if let Some((_, valid)) = open.as_mut() { + *valid = false; + } + } + continue; + } + match (character, open.as_mut()) { + ('<', _) => open = Some((index + 1, true)), + ('>', Some((start, valid))) => { + let inner = &value[*start..index]; + if *valid && !inner.is_empty() { + ids.push(format!("<{inner}>")); + } + open = None; + } + ('(', None) => comment_depth = 1, + (c, Some((_, valid))) if c.is_whitespace() || c.is_control() => *valid = false, + _ => {} + } + } + ids +} + pub fn folders_to_skip(name: &str) -> bool { let lower = name.to_lowercase(); lower == "trash" @@ -918,11 +1008,54 @@ mod tests { ); } + #[test] + fn build_query_uid_narrows_other_filters() { + let mut c = default_test_criteria(); + c.uid = Some(42); + assert_eq!(build_query(&c).unwrap(), "UID 42"); + c.subject = Some("report".into()); + assert_eq!(build_query(&c).unwrap(), "UID 42 SUBJECT \"report\""); + } + + #[test] + fn system_flags_match_case_insensitively() { + let flags = [Flag::Custom("\\SEEN".into()), Flag::Answered]; + assert!(has_system_flag(&flags, Flag::Seen, "\\Seen")); + assert!(has_system_flag(&flags, Flag::Answered, "\\Answered")); + assert!(!has_system_flag(&flags, Flag::Flagged, "\\Flagged")); + } + + #[test] + fn message_ids_extracts_bracketed_ids_and_skips_noise() { + assert_eq!( + message_ids(" "), + ["", ""] + ); + assert_eq!( + message_ids(", (Alice's message of Monday)"), + ["", ""] + ); + assert_eq!( + message_ids("junk <> "] + ); + assert!(message_ids("not-a-message-id").is_empty()); + assert_eq!( + message_ids(r#" (from "Bob" ) "Re: " "#), + ["", ""] + ); + assert_eq!( + message_ids(r#"<"john doe"@example.com> (nested () \) ok) "#), + [r#"<"john doe"@example.com>"#, ""] + ); + } + /// Helper to build a default SearchCriteria with all fields set to None/false. fn default_test_criteria() -> SearchCriteria { SearchCriteria { folder: "INBOX".into(), all_folders: false, + uid: None, subject: None, from: None, to: None, diff --git a/tests/integration.rs b/tests/integration.rs index aaceba6..cee2177 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -146,6 +146,7 @@ fn default_criteria(folder: &str) -> SearchCriteria { SearchCriteria { folder: folder.to_string(), all_folders: false, + uid: None, subject: None, from: None, to: None, @@ -2326,3 +2327,221 @@ fn cli_sanitizes_received_attachment_paths_without_mutating_source() { .any(|flag| flag.eq_ignore_ascii_case("\\Seen"))); session.logout().unwrap(); } + +fn uid_with_subject(session: &mut ImapSession, subject: &str) -> u32 { + let mut criteria = default_criteria("INBOX"); + criteria.subject = Some(subject.to_string()); + let messages = search::search(session, &criteria).unwrap(); + assert_eq!(messages.len(), 1, "expected one message titled {subject}"); + messages[0].uid +} + +fn personal_cmd(owner: &str, dir: &Path) -> Command { + let config = dir.join("config.toml"); + let accounts = [("personal", owner)]; + write_multi_account_config(&config, &accounts); + let mut command = slashmail_cmd(&config, &accounts); + command.args(["--account", "personal"]); + command +} + +#[test] +fn cli_search_json_reports_thread_ids_and_flags() { + let owner = unique_user(); + let root_id = format!(""); + let follow_id = format!(""); + let origin_id = format!(""); + let message = |subject: &str, id: &str| { + Message::builder() + .from("alice@localhost".parse().unwrap()) + .to(user_email(&owner).parse().unwrap()) + .subject(subject) + .message_id(Some(id.to_string())) + .header(ContentType::TEXT_PLAIN) + }; + send_test_message( + &message("Root topic", &root_id) + .body("root".to_string()) + .unwrap(), + ); + send_test_message( + &message("Follow-up note", &follow_id) + .in_reply_to(root_id.clone()) + .references(format!("{origin_id} {root_id}")) + .body("follow".to_string()) + .unwrap(), + ); + sleep_for_delivery(); + + let mut session = imap_connect(&owner); + let root_uid = uid_with_subject(&mut session, "Root topic"); + let follow_uid = uid_with_subject(&mut session, "Follow-up note"); + session.select("INBOX").unwrap(); + session + .uid_store(&root_uid.to_string(), "+FLAGS (\\Seen \\Answered)") + .unwrap(); + session + .uid_store(&follow_uid.to_string(), "+FLAGS (\\Flagged)") + .unwrap(); + session.logout().unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let mut command = personal_cmd(&owner, tmp.path()); + command.args(["search", "--json"]); + let rows: serde_json::Value = + serde_json::from_str(&assert_cmd_success(command.output().unwrap())).unwrap(); + let row = |uid: u32| { + rows.as_array() + .unwrap() + .iter() + .find(|row| row["uid"] == uid) + .unwrap() + .clone() + }; + + let root = row(root_uid); + assert_eq!(root["message_id"], root_id.as_str()); + assert_eq!(root["in_reply_to"], serde_json::json!([])); + assert_eq!(root["references"], serde_json::json!([])); + assert_eq!( + (&root["seen"], &root["answered"], &root["flagged"]), + (&true.into(), &true.into(), &false.into()) + ); + + let follow = row(follow_uid); + assert_eq!(follow["message_id"], follow_id.as_str()); + assert_eq!(follow["in_reply_to"], serde_json::json!([root_id])); + assert_eq!( + follow["references"], + serde_json::json!([origin_id, root_id]) + ); + assert_eq!( + (&follow["seen"], &follow["answered"], &follow["flagged"]), + (&false.into(), &false.into(), &true.into()) + ); +} + +#[test] +fn cli_read_uid_json_returns_exact_message_without_marking_seen() { + let owner = unique_user(); + send_email_with_attachments( + &owner, + "Same subject", + "evidence body", + &[("screenshot.png", b"\x89PNG-bytes")], + ); + send_email(&owner, "Same subject", "other body"); + sleep_for_delivery(); + + let mut session = imap_connect(&owner); + let messages = search::search(&mut session, &default_criteria("INBOX")).unwrap(); + assert_eq!(messages.len(), 2); + let uid = messages.iter().map(|message| message.uid).min().unwrap(); + let missing_uid = messages.iter().map(|message| message.uid).max().unwrap() + 100; + session.logout().unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let mut command = personal_cmd(&owner, tmp.path()); + command.args([ + "read", + "--folder", + "INBOX", + "--json", + "--uid", + &uid.to_string(), + ]); + let json: serde_json::Value = + serde_json::from_str(&assert_cmd_success(command.output().unwrap())).unwrap(); + let read = json.as_array().unwrap(); + assert_eq!(read.len(), 1); + assert_eq!(read[0]["uid"], uid); + assert_eq!(read[0]["folder"], "INBOX"); + assert_eq!(read[0]["seen"], false); + assert!(read[0]["body"].as_str().unwrap().contains("evidence body")); + assert_eq!(read[0]["attachments"][0]["filename"], "screenshot.png"); + assert_eq!(read[0]["attachments"][0]["size"], 10); + + let mut command = personal_cmd(&owner, tmp.path()); + command.args(["read", "--uid", &uid.to_string()]); + let text = assert_cmd_success(command.output().unwrap()); + assert!(text.contains("evidence body")); + assert!(!text.contains("other body")); + + let mut command = personal_cmd(&owner, tmp.path()); + command.args(["read", "--json", "--uid", &missing_uid.to_string()]); + let output = command.output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains(&format!("No message with UID {missing_uid}"))); + + let mut session = imap_connect(&owner); + session.select("INBOX").unwrap(); + let fetches = session.uid_fetch(&uid.to_string(), "FLAGS").unwrap(); + assert!(!fetches + .get(0) + .unwrap() + .flags() + .contains(&imap::types::Flag::Seen)); + session.logout().unwrap(); +} + +#[test] +fn cli_draft_and_reply_json_receipts_identify_the_saved_drafts() { + let owner = unique_user(); + send_email(&owner, "Please advise", "question"); + sleep_for_delivery(); + create_drafts_folder(&owner); + let mut session = imap_connect(&owner); + let source_uid = uid_with_subject(&mut session, "Please advise"); + session.logout().unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let mut command = personal_cmd(&owner, tmp.path()); + command.args([ + "draft", + "--json", + "--to", + "friend@example.com", + "--subject", + "Fresh draft", + "--drafts-folder", + "Drafts", + ]); + let draft: serde_json::Value = + serde_json::from_str(&assert_cmd_success(output_with_stdin(command, "hello"))).unwrap(); + + let mut command = personal_cmd(&owner, tmp.path()); + command.args([ + "reply", + "--json", + "--drafts-folder", + "Drafts", + &source_uid.to_string(), + ]); + let reply: serde_json::Value = + serde_json::from_str(&assert_cmd_success(output_with_stdin(command, "answer"))).unwrap(); + + assert_eq!(draft["account"], "personal"); + assert_eq!(draft["folder"], "Drafts"); + assert_eq!(draft["to"], serde_json::json!(["friend@example.com"])); + assert_eq!(draft["subject"], "Fresh draft"); + assert_eq!(reply["subject"], "Re: Please advise"); + assert_eq!(reply["to"], serde_json::json!(["sender@localhost"])); + + let mut session = imap_connect(&owner); + session.select("Drafts").unwrap(); + for receipt in [&draft, &reply] { + let uid = receipt["uid"].as_u64().unwrap(); + let fetches = session + .uid_fetch(&uid.to_string(), "BODY.PEEK[HEADER.FIELDS (Message-ID)]") + .unwrap(); + let (headers, _) = + mailparse::parse_headers(fetches.get(0).unwrap().header().unwrap()).unwrap(); + assert_eq!( + headers.get_first_value("Message-ID").as_deref(), + receipt["message_id"].as_str() + ); + } + session.logout().unwrap(); +} From b93ddd387c30895fc42e7e08aede770bd59b15e1 Mon Sep 17 00:00:00 2001 From: Mike Wassmer Date: Mon, 28 Sep 2026 13:30:36 +0200 Subject: [PATCH 4/5] Harden mail actions, exports, and terminal output after audit --- .github/workflows/ci.yml | 4 +- README.md | 38 +- skills/slashmail/SKILL.md | 10 +- src/attachment.rs | 278 ++++++-- src/connection.rs | 486 +++++++++---- src/delete.rs | 85 ++- src/display.rs | 391 ++++++++--- src/draft.rs | 130 +++- src/export.rs | 553 +++++++++++++-- src/main.rs | 337 +++++---- src/read.rs | 332 ++++++--- src/search.rs | 468 ++++++++++--- tests/integration.rs | 265 ++++++- tests/scripted_cli.rs | 1383 +++++++++++++++++++++++++++++++++++++ 14 files changed, 3963 insertions(+), 797 deletions(-) create mode 100644 tests/scripted_cli.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 177ac02..2598d17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo fmt --all -- --check - - run: cargo clippy -- -D warnings + - run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo test - name: Verify publishable crate @@ -47,7 +47,7 @@ jobs: - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev - - uses: dtolnay/rust-toolchain@1.83 + - uses: dtolnay/rust-toolchain@1.88 - uses: Swatinem/rust-cache@v2 diff --git a/README.md b/README.md index ff32499..9bcf8f3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/mwmdev/slashmail/actions/workflows/ci.yml/badge.svg)](https://github.com/mwmdev/slashmail/actions/workflows/ci.yml) [![Crates.io](https://img.shields.io/crates/v/slashmail)](https://crates.io/crates/slashmail) -[![MSRV](https://img.shields.io/badge/MSRV-1.83-blue)](https://www.rust-lang.org) +[![MSRV](https://img.shields.io/badge/MSRV-1.88-blue)](https://www.rust-lang.org) [![Crate Size](https://img.shields.io/crates/size/slashmail)](https://crates.io/crates/slashmail) [![License](https://img.shields.io/crates/l/slashmail)](LICENSE-MIT) @@ -70,7 +70,7 @@ Commands: ``` --host IMAP host [default: 127.0.0.1] --port IMAP port [default: 1143 plain, 993 TLS] ---tls Use TLS (required for remote IMAP servers) +--tls Use TLS (required for every non-loopback IMAP host) -u, --user IMAP username (or SLASHMAIL_USER env) --account Use a named account from config --all-accounts Query all configured accounts (read-only commands only) @@ -216,7 +216,9 @@ slashmail attachments --account work --save \ --part 2.1 --part 3 --output-dir './received files' 1842 ``` -Saving aborts before writing anything if a destination already exists. Add +Saving aborts before writing anything if a destination already exists, and +stops rather than overwrite an earlier part when two names resolve to the same +file (case- or accent-insensitive filesystems). Add `--force` to replace existing files. Filenames are sanitized and kept inside the output directory. Only parts declared as attachments are exposed; inline/CID parts and attachments nested inside another attached message are @@ -249,8 +251,8 @@ Each `search --json` row contains `uid`, `folder` (set with `--all-folders`), `from`, `subject`, `date`, `timestamp`, `size`, the thread identifiers `message_id` (or `null`), `in_reply_to`, and `references` (arrays of angle-bracketed IDs), and the `seen`, `answered`, and `flagged` booleans. -Rows include `account` when a named account is used. `from` and `subject` are -shortened for display; use `read --json` for the full headers. +Rows include `account` when a named account is used. `from`, `subject`, and +`date` are the full decoded values; only the terminal table shortens them. ```bash # Messages you have not answered yet @@ -264,7 +266,7 @@ Search, read, count, and bulk message commands share these filter options: ``` -f, --folder Folder to search [default: INBOX] - --all-folders Search across all folders (excludes Trash, Spam) + --all-folders Search across all folders (excludes Trash, Junk/Spam, All Mail) --subject Subject contains --from From address contains --to To address contains @@ -284,7 +286,9 @@ Search, read, count, and bulk message commands share these filter options: -n, --limit Limit number of results ``` -All filter criteria are AND'd together. Omitting all criteria matches all messages. +All filter criteria are AND'd together. Omitting all criteria matches all messages. Text filters (`--subject`, `--from`, `--to`, `--cc`, `--body`, `--text`) must not be empty, so an unset shell variable cannot turn a filter into "match everything". `--folder` and `--all-folders` cannot be combined. + +`--all-folders` skips mailboxes the server marks `\All`, `\Trash`, or `\Junk` (for example `Deleted Items` and `Junk Email`), plus folders named `Trash`, `Spam`, `Junk`, or `All Mail` for servers without those markers. `delete` and `move` also never search their destination folder, and naming the destination as the only source folder is an error. ### Action options @@ -297,10 +301,16 @@ Commands that modify messages (`delete`, `move`, `mark`) support: `delete` also supports `--trash-folder ` (default: `Trash`) for servers that use a different name (e.g. `Deleted Items`, `[Gmail]/Trash`). -`export` supports `--yes`, `--force` (overwrite existing files), and `-o, --output-dir`. +`move` requires `--dest `; `--to` stays the recipient filter, as in every other command. + +`delete` and `move` require the server to advertise `MOVE` or `UIDPLUS`. Without `MOVE`, messages are copied, flagged `\Deleted`, and removed with `UID EXPUNGE` of exactly those UIDs; other messages already flagged `\Deleted` are never expunged. This fallback is not atomic: if a step fails, slashmail stops and reports it without retrying, including how many messages were already moved or updated. Every mutating command and `export`/`read` refuse to act if the folder's `UIDVALIDITY` changed since the search. Immediately before `delete`, `move`, and `mark` act, slashmail asks the server which searched messages still exist; their receipts count only those and report any another client removed in the meantime. + +`export` supports `--yes`, `--force` (replace existing files), and `-o, --output-dir`. Files are named `_.eml`, where the folder name is percent-encoded: ASCII letters, digits, and `-` are kept and every other byte becomes `%XX` (so on Linux and macOS `Work/Projects` is `Work%2FProjects_1.eml` and `Work_Projects` is `Work%5FProjects_1.eml`). On Windows, lowercase letters are also encoded so folders differing only by case stay distinct (`Work/P` is `W%6F%72%6B%2FP_1.eml`). Without `--force`, an existing file is skipped only when it already holds the same message (identical bytes or the same Message-ID). UIDs restart when a mailbox is recreated or migrated, so an existing file holding a different message is left unchanged and reported as an error after the other messages are exported; use `--force` or a new output directory. `--force` replaces only a regular file or symlink entry and never follows symlinks. New exports and saved attachments are created owner-only (`0600`) on Unix. `mark` takes one or more actions: `--read`, `--unread`, `--set-flagged`, `--clear-flagged`. +Search terms containing non-ASCII text are sent as UTF-8 literals and require the server to advertise `LITERAL+`; otherwise the search fails before any mailbox is searched. + ## Examples ```bash @@ -355,7 +365,7 @@ slashmail delete -u user@example.com --subject "unsubscribe" --yes slashmail delete -u user@example.com --from "old-list" --dry-run # Move messages to a folder -slashmail move -u user@example.com --from "receipts" --to Archive +slashmail move -u user@example.com --from "receipts" --dest Archive # Export messages as .eml files slashmail export -u user@example.com --subject "contract" -o ./backup @@ -449,14 +459,16 @@ Destructive operations always dry-run first and ask for confirmation. - With SORT, `--limit` truncates results before fetching (fewer bytes over the wire) - `search`, `delete`, `move`, `mark`, `count` only fetch headers and size -- never full messages - `export` fetches full message bodies via `BODY.PEEK[]` -- Uses `BODY.PEEK` to avoid marking messages as read +- Uses `BODY.PEEK` to avoid marking messages as read, and opens folders read-only (`EXAMINE`) for `search`, `read`, `count`, `export`, and `--dry-run`, so they do not clear the `\Recent` flag - UID sets are compressed into ranges and chunked to stay within IMAP command length limits -- Passwords are securely zeroed from memory after login +- Message content, headers, folder names, and server errors are rendered inert in the terminal: escape sequences and control characters are removed, and invisible formatting characters (such as zero-width spaces) are shown as spaces in names and dropped from bodies, so look-alike names stay distinguishable. `--json` output and exported `.eml` files keep the original data. +- The password buffer slashmail reads is zeroed after login. Copies held by the process environment, `.env` loading, or the IMAP library's LOGIN command are not guaranteed to be wiped. ## Exit codes - `0` — Success -- `1` — Error (connection failure, invalid credentials, bad arguments, etc.) +- `1` — Error (connection failure, invalid credentials, refused operation, etc.) +- `2` — Invalid command-line usage All errors print to stderr. Combine `--yes` with cron or scripts for unattended operation. @@ -482,5 +494,5 @@ All errors print to stderr. Combine `--yes` with cron or scripts for unattended ### TLS errors -- Use `--tls` for all remote (non-localhost) IMAP servers +- `--tls` is required for all non-loopback IMAP hosts; plaintext is only allowed for `localhost` and loopback addresses (for example ProtonMail Bridge) - If you get certificate errors, ensure your system CA certificates are up to date diff --git a/skills/slashmail/SKILL.md b/skills/slashmail/SKILL.md index 5db84c6..c5fcc47 100644 --- a/skills/slashmail/SKILL.md +++ b/skills/slashmail/SKILL.md @@ -25,8 +25,8 @@ Optional `sender` and `drafts_folder` values can be set at the top level or per | Flag | Description | |------|-------------| -| `-f, --folder FOLDER` | Target folder (default: INBOX) | -| `--all-folders` | Search all folders (excludes Trash, Spam) | +| `-f, --folder FOLDER` | Target folder (default: INBOX); cannot be combined with `--all-folders` | +| `--all-folders` | Search all folders (excludes Trash, Junk/Spam, All Mail, and the move/delete destination) | | `--subject TEXT` | Filter by subject | | `--from TEXT` | Filter by sender | | `--to TEXT` | Filter by recipient | @@ -56,7 +56,7 @@ Date formats: `YYYY-MM-DD` or relative (`7d`, `2w`, `3m`, `1y`). All filters com | `read` | Display message content without marking it seen | `--uid UID` (exact message in `--folder`; single account), `--json` | | `count` | Fast count without fetching content | `--json` | | `delete` | Move to Trash | `--trash-folder NAME`, `--dry-run`, `--yes` | -| `move` | Move to folder | `--to DEST`, `--dry-run`, `--yes` | +| `move` | Move to folder | `--dest FOLDER` (required; `--to` filters by recipient), `--dry-run`, `--yes` | | `mark` | Set/unset flags | `--read/--unread`, `--set-flagged/--clear-flagged`, `--dry-run`, `--yes` | | `export` | Save as `.eml` files | `-o DIR`, `--force`, `--yes` | | `status` | Per-folder message stats | — | @@ -132,7 +132,9 @@ With `--json`, the receipt is one object with `account`, `folder`, `uid`, `messa - Once `search` has identified a message, use `read --folder FOLDER --uid UID` rather than re-filtering by sender, subject, or date. It fails if the UID no longer exists in that folder, and it rejects `--all-folders` and `--all-accounts`. - `read --json` returns an array of objects with full `from`/`to`/`cc`/`date`/`subject`, `message_id`, `in_reply_to`, `references`, flags, decoded `body`, and `attachments` (`part`, `filename`, `content_type`, `size`). Pass a `part` to `attachments --save --part`. -- In `search --json`, `from` and `subject` are shortened for display; match duplicates and follow-ups by `message_id`, `in_reply_to`, and `references` (angle-bracketed IDs), and use `answered` to skip mail already replied to. +- In `search --json`, `from`, `subject`, and `date` are full values. Match duplicates and follow-ups by `message_id`, `in_reply_to`, and `references` (angle-bracketed IDs), and use `answered` to skip mail already replied to. +- Never pass an empty text filter (for example an unset variable as `--from`); slashmail rejects it rather than matching every message. +- If `export` reports existing files that hold a different message, do not add `--force` without the user's approval; suggest a new output directory instead. ## Received Attachment Rules diff --git a/src/attachment.rs b/src/attachment.rs index 5568d1c..dcbcab2 100644 --- a/src/attachment.rs +++ b/src/attachment.rs @@ -1,7 +1,9 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -142,13 +144,30 @@ pub fn attachment_names(parsed: &mailparse::ParsedMail<'_>) -> Vec { names } -pub fn attachments_from_message(raw: &[u8]) -> Result> { +pub fn attachments_from_message( + raw: &[u8], + selected: &[PartId], +) -> Result> { let parsed = mailparse::parse_mail(raw).context("Failed to parse message MIME")?; + let mut requested = HashSet::new(); + for part in selected { + if !requested.insert(part) { + bail!("Duplicate attachment part {part}"); + } + } + if !selected.is_empty() { + walk_attachment_parts(&parsed, |part, _| { + requested.remove(&part); + }); + if let Some(part) = requested.into_iter().next() { + bail!("Attachment part {part} not found"); + } + } let mut attachments = Vec::new(); let mut decode_error = None; walk_attachment_parts(&parsed, |part, parsed_part| { - if decode_error.is_some() { + if decode_error.is_some() || (!selected.is_empty() && !selected.contains(&part)) { return; } let filename = attachment_filename(parsed_part); @@ -261,16 +280,23 @@ fn is_windows_invalid(character: char) -> bool { ) } +/// Reserved Win32 device names. Win32 ignores an extension and trailing +/// spaces after the device name, so `NUL .txt` and `COM¹.log` are devices too. fn is_windows_device_name(name: &str) -> bool { - let stem = name.split('.').next().unwrap_or(name); + let stem = name.split('.').next().unwrap_or(name).trim_end_matches(' '); let upper = stem.to_ascii_uppercase(); - matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || upper - .strip_prefix("COM") - .or_else(|| upper.strip_prefix("LPT")) - .is_some_and(|number| { - matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") - }) + matches!( + upper.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|number| { + matches!( + number, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) } fn truncate_utf8(value: &str, max_bytes: usize) -> String { @@ -297,7 +323,11 @@ fn filename_with_suffix(name: &str, duplicate: Option) -> String { .map(|number| format!(" ({number})")) .unwrap_or_default(); let max_stem = MAX_FILENAME_BYTES - extension.len() - suffix.len(); - format!("{}{}{}", truncate_utf8(stem, max_stem), suffix, extension) + let mut filename = format!("{}{}{}", truncate_utf8(stem, max_stem), suffix, extension); + // Windows drops trailing spaces and dots, which truncation can expose; + // two planned names must not collapse into one file there. + filename.truncate(filename.trim_end_matches([' ', '.']).len()); + filename } fn sanitized_filename(attachment: &ReceivedAttachment) -> String { @@ -318,13 +348,17 @@ fn sanitized_filename(attachment: &ReceivedAttachment) -> String { .collect::(); name.truncate(name.trim_end_matches([' ', '.']).len()); + // Checks run on the bounded name: truncation and trimming can empty it + // or leave a device name (`CON` followed by many spaces). + let mut name = filename_with_suffix(&name, None); if name.is_empty() || matches!(name.as_str(), "." | "..") { name = format!("attachment-{}", attachment.part); } if name.starts_with('.') || is_windows_device_name(&name) { name.insert(0, '_'); + name = filename_with_suffix(&name, None); } - filename_with_suffix(&name, None) + name } fn selected_attachments<'a>( @@ -425,49 +459,30 @@ pub fn save_attachments( } } + // Filesystems that fold case or Unicode normalization can resolve two + // planned names to one file; never let a later part replace an earlier one. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut created: HashSet<(u64, u64)> = HashSet::new(); let mut saved = Vec::with_capacity(planned.len()); for (attachment, path) in planned { - if force { - match fs::symlink_metadata(&path) { - Ok(metadata) - if metadata.file_type().is_file() || metadata.file_type().is_symlink() => - { - fs::remove_file(&path).with_context(|| { - format!( - "Failed to replace attachment destination {}", - safe_path(&path) - ) - })?; - } - Ok(_) => { - bail!( - "Attachment destination is not a replaceable file: {}", - safe_path(&path) - ); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error).with_context(|| { - format!( - "Failed to inspect attachment destination {}", - safe_path(&path) - ) - }); - } - } + if created_this_run(&created, &path)? { + bail!( + "Attachment destinations alias the same file: {}", + safe_path(&path) + ); } - - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) + let file = create_output_file(&path, force) .with_context(|| format!("Failed to create attachment {}", safe_path(&path)))?; - if let Err(error) = file.write_all(&attachment.bytes) { - drop(file); - let _ = fs::remove_file(&path); - return Err(error) - .with_context(|| format!("Failed to write attachment {}", safe_path(&path))); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file + .metadata() + .with_context(|| format!("Failed to inspect attachment {}", safe_path(&path)))?; + created.insert((metadata.dev(), metadata.ino())); } + write_created_file(file, &path, &attachment.bytes) + .with_context(|| format!("Failed to write attachment {}", safe_path(&path)))?; saved.push(SavedAttachment { part: attachment.part.clone(), path, @@ -478,6 +493,70 @@ pub fn save_attachments( Ok(saved) } +/// Whether the entry at `path` (not following symlinks) is a file this run +/// already created. File identity is only available on Unix; elsewhere the +/// case-folded name planning is the only guard. +fn created_this_run(created: &HashSet<(u64, u64)>, path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(created.contains(&(metadata.dev(), metadata.ino()))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| { + format!( + "Failed to inspect attachment destination {}", + safe_path(path) + ) + }), + } + } + #[cfg(not(unix))] + { + let _ = (created, path); + Ok(false) + } +} + +/// Exclusively create an owner-only output file. With `force`, an existing +/// regular file or symlink entry is removed first (never followed); other +/// entry types are refused. Without `force`, an existing entry yields +/// `AlreadyExists` so callers choose between skipping and failing. +pub(crate) fn create_output_file(path: &Path, force: bool) -> io::Result { + if force { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + fs::remove_file(path)?; + } + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "destination is not a replaceable file", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + options.open(path) +} + +/// Write a file returned by [`create_output_file`], removing it if the write +/// fails so no partial output remains. +pub(crate) fn write_created_file(mut file: File, path: &Path, bytes: &[u8]) -> io::Result<()> { + if let Err(error) = file.write_all(bytes) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -495,6 +574,40 @@ mod tests { } } + #[test] + fn selected_attachment_ignores_unselected_corrupt_payload_and_validates_first() { + let raw = b"Content-Type: multipart/mixed; boundary=x\r\n\r\n\ +--x\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=bad.bin\r\nContent-Transfer-Encoding: base64\r\n\r\n%%%\r\n\ +--x\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=good.bin\r\nContent-Transfer-Encoding: base64\r\n\r\nZ29vZA==\r\n--x--"; + let valid = attachments_from_message(raw, &[part("2")]).unwrap(); + assert_eq!(valid[0].bytes, b"good"); + assert_eq!(valid.len(), 1); + assert!(attachments_from_message(raw, &[part("1")]) + .unwrap_err() + .to_string() + .contains("bad.bin")); + assert!(attachments_from_message(raw, &[part("2"), part("2")]) + .unwrap_err() + .to_string() + .contains("Duplicate")); + assert!(attachments_from_message(raw, &[part("1"), part("3")]) + .unwrap_err() + .to_string() + .contains("not found")); + assert!(attachments_from_message(raw, &[part("3")]).is_err()); + } + + #[test] + fn upstream_depth_limit_controls_attachment_traversal() { + let accepted = crate::read::nested_multipart_fixture(100); + assert!(attachments_from_message(&accepted, &[]).unwrap().is_empty()); + let rejected = crate::read::nested_multipart_fixture(101); + assert!(attachments_from_message(&rejected, &[]) + .unwrap_err() + .to_string() + .contains("Failed to parse message MIME")); + } + #[test] fn part_ids_are_canonical_one_based_paths() { for valid in ["1", "2", "2.1"] { @@ -542,7 +655,7 @@ Content-Disposition: inline; filename=inline.jpg\r\n\ jpeg\r\n\ --outer--\r\n"; - let attachments = attachments_from_message(raw).unwrap(); + let attachments = attachments_from_message(raw, &[]).unwrap(); assert_eq!( attachments .iter() @@ -569,7 +682,7 @@ jpeg\r\n\ .iter() .map(ReceivedAttachment::size) .collect::>(), - [2, 5, 3] + [2, 3, 1] ); let parsed = mailparse::parse_mail(raw).unwrap(); @@ -586,7 +699,7 @@ Content-Disposition: attachment; filename=data.bin\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ AP8RgA0K"; - let attachments = attachments_from_message(raw).unwrap(); + let attachments = attachments_from_message(raw, &[]).unwrap(); assert_eq!(attachments[0].bytes, [0x00, 0xff, 0x11, 0x80, 0x0d, 0x0a]); let malformed = b"Content-Type: application/octet-stream\r\n\ @@ -594,7 +707,9 @@ Content-Disposition: attachment; filename=bad.bin\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ %%%"; - let error = attachments_from_message(malformed).unwrap_err().to_string(); + let error = attachments_from_message(malformed, &[]) + .unwrap_err() + .to_string(); assert!(error.contains("part 1")); assert!(error.contains("bad.bin")); } @@ -632,6 +747,9 @@ Content-Transfer-Encoding: base64\r\n\ #[test] fn planned_names_are_direct_bounded_nonhidden_and_case_unique() { let overlong = "éééééééééé".repeat(30); + let spaced = format!("{} {}", "a".repeat(199), "b".repeat(10)); + let all_spaces = format!("{}x", " ".repeat(200)); + let padded_device = format!("CON{}x", " ".repeat(197)); let names = [ "../escape", "a/b", @@ -648,6 +766,12 @@ Content-Transfer-Encoding: base64\r\n\ "con.TXT", "same.txt", "SAME.TXT", + "NUL .txt", + "COM¹.log", + "CONOUT$", + spaced.as_str(), + all_spaces.as_str(), + padded_device.as_str(), ]; let attachments = names .iter() @@ -664,6 +788,7 @@ Content-Transfer-Encoding: base64\r\n\ assert!(!name.starts_with('.'), "{name}"); assert!(name.len() <= MAX_FILENAME_BYTES, "{name}"); assert!(lowercase.insert(name.to_lowercase()), "{name}"); + assert!(!name.ends_with([' ', '.']), "{name:?}"); } assert_eq!(planned[0].1.file_name().unwrap(), "escape"); assert_eq!(planned[1].1.file_name().unwrap(), "b"); @@ -673,6 +798,35 @@ Content-Transfer-Encoding: base64\r\n\ assert_eq!(planned[11].1.file_name().unwrap(), "_CON.tar.gz"); assert_eq!(planned[12].1.file_name().unwrap(), "_con (2).TXT"); assert_eq!(planned[14].1.file_name().unwrap(), "SAME (2).TXT"); + assert_eq!(planned[15].1.file_name().unwrap(), "_NUL .txt"); + assert_eq!(planned[16].1.file_name().unwrap(), "_COM¹.log"); + assert_eq!(planned[17].1.file_name().unwrap(), "_CONOUT$"); + assert_eq!( + planned[18].1.file_name().unwrap().to_str().unwrap(), + "a".repeat(199) + ); + assert_eq!(planned[19].1.file_name().unwrap(), "attachment-20"); + assert_eq!(planned[20].1.file_name().unwrap(), "_CON"); + } + + #[cfg(unix)] + #[test] + fn a_destination_aliasing_a_file_saved_this_run_is_detected() { + use std::os::unix::fs::MetadataExt; + + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.bin"); + let alias = directory.path().join("alias.bin"); + fs::write(&first, b"first").unwrap(); + // A hard link stands in for a name a case- or normalization-folding + // filesystem resolves to the same file. + fs::hard_link(&first, &alias).unwrap(); + let metadata = fs::metadata(&first).unwrap(); + let created = HashSet::from([(metadata.dev(), metadata.ino())]); + + assert!(created_this_run(&created, &alias).unwrap()); + assert!(!created_this_run(&created, &directory.path().join("new.bin")).unwrap()); + assert!(!created_this_run(&HashSet::new(), &alias).unwrap()); } #[test] @@ -710,6 +864,15 @@ Content-Transfer-Encoding: base64\r\n\ assert_eq!(saved.len(), 2); assert_eq!(fs::read(directory.path().join("one.bin")).unwrap(), b"one"); assert_eq!(fs::read(directory.path().join("two.bin")).unwrap(), b"two"); + #[cfg(unix)] + for name in ["one.bin", "two.bin"] { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(directory.path().join(name)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "{name} mode {mode:o}"); + } } #[test] @@ -759,8 +922,7 @@ Content-Transfer-Encoding: base64\r\n\ .unwrap() .file_type() .is_symlink()); + assert_eq!(fs::read(destination).unwrap(), [0x00, 0xff, 0x11]); } - - assert_eq!(fs::read(destination).unwrap(), [0x00, 0xff, 0x11]); } } diff --git a/src/connection.rs b/src/connection.rs index 9737149..656511b 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -24,15 +24,11 @@ pub struct ImapSession { } impl ImapSession { - pub fn list( - &mut self, - reference: Option<&str>, - pattern: Option<&str>, - ) -> imap::error::Result { - match &mut self.inner { - Inner::Plain(s) => s.list(reference, pattern), - Inner::Tls(s) => s.list(reference, pattern), - } + /// List every mailbox (`LIST "" *`). Quoted names are unescaped; names + /// sent as literals are kept byte-for-byte. + pub fn list_all(&mut self) -> Result> { + let data = self.run_command_and_read_response(r#"LIST "" *"#)?; + parse_list_response(&data) } pub fn create(&mut self, mailbox: &str) -> imap::error::Result<()> { @@ -56,14 +52,22 @@ impl ImapSession { } } - pub fn uid_search( - &mut self, - query: &str, - ) -> imap::error::Result> { - match &mut self.inner { - Inner::Plain(s) => s.uid_search(query), - Inner::Tls(s) => s.uid_search(query), - } + /// Run `UID SEARCH`. Queries containing UTF-8 literals declare + /// `CHARSET UTF-8` and require LITERAL+ so no continuation is needed. + pub fn uid_search(&mut self, query: &str) -> Result> { + let command = if query.is_ascii() { + std::borrow::Cow::Borrowed(query) + } else { + if !self.has_capability("LITERAL+") { + anyhow::bail!(search::LITERAL_PLUS_REQUIRED); + } + std::borrow::Cow::Owned(format!("CHARSET UTF-8 {query}")) + }; + let uids = match &mut self.inner { + Inner::Plain(s) => s.uid_search(command.as_ref()), + Inner::Tls(s) => s.uid_search(command.as_ref()), + }?; + Ok(uids) } pub fn uid_fetch( @@ -84,17 +88,15 @@ impl ImapSession { } } - pub fn uid_copy(&mut self, uid_set: &str, dest: &str) -> imap::error::Result<()> { + /// UID COPY with the destination sent as an escaped quoted string (the + /// dependency passes this argument through verbatim). + pub fn uid_copy(&mut self, uid_set: &str, dest: &str) -> Result<()> { + let mailbox = format!("\"{}\"", escape_mailbox_name(dest)?); match &mut self.inner { - Inner::Plain(s) => { - s.uid_copy(uid_set, dest)?; - Ok(()) - } - Inner::Tls(s) => { - s.uid_copy(uid_set, dest)?; - Ok(()) - } + Inner::Plain(s) => s.uid_copy(uid_set, &mailbox)?, + Inner::Tls(s) => s.uid_copy(uid_set, &mailbox)?, } + Ok(()) } pub fn uid_store(&mut self, uid_set: &str, query: &str) -> imap::error::Result<()> { @@ -110,19 +112,41 @@ impl ImapSession { } } - pub fn expunge(&mut self) -> imap::error::Result<()> { + /// Expunge only the given UIDs (RFC 4315 UID EXPUNGE). + pub fn uid_expunge(&mut self, uid_set: &str) -> imap::error::Result<()> { match &mut self.inner { Inner::Plain(s) => { - s.expunge()?; + s.uid_expunge(uid_set)?; Ok(()) } Inner::Tls(s) => { - s.expunge()?; + s.uid_expunge(uid_set)?; Ok(()) } } } + /// Message, unseen, and recent counts for one mailbox. Counts the server + /// omits stay `None`. + pub fn status_counts(&mut self, mailbox: &str) -> Result { + let command = format!( + "STATUS \"{}\" (MESSAGES UNSEEN RECENT)", + escape_mailbox_name(mailbox)? + ); + let data = self.run_command_and_read_response(&command)?; + parse_status_response(&data) + } + + pub fn get_quota_root( + &mut self, + mailbox: &str, + ) -> imap::error::Result { + match &mut self.inner { + Inner::Plain(s) => s.get_quota_root(mailbox), + Inner::Tls(s) => s.get_quota_root(mailbox), + } + } + pub fn logout(&mut self) -> imap::error::Result<()> { match &mut self.inner { Inner::Plain(s) => s.logout(), @@ -141,15 +165,28 @@ impl ImapSession { } } - /// Move UIDs to dest, falling back to COPY+DELETE+EXPUNGE if MOVE is unsupported. + /// Fail unless the server can move messages without touching unrelated + /// `\Deleted` messages: MOVE, or UIDPLUS for a UID-scoped fallback. + pub fn ensure_safe_move_supported(&self) -> anyhow::Result<()> { + if self.has_capability("MOVE") || self.has_capability("UIDPLUS") { + return Ok(()); + } + anyhow::bail!("Safe move requires server support for MOVE or UIDPLUS") + } + + /// Move UIDs to dest. Without MOVE, falls back to UID COPY, STORE + /// `\Deleted`, and UID EXPUNGE of exactly the same UID set. The fallback is + /// not atomic: a failure is reported with its phase and never retried. pub fn uid_move_or_fallback(&mut self, uid_set: &str, dest: &str) -> anyhow::Result<()> { + self.ensure_safe_move_supported()?; if self.has_capability("MOVE") { self.uid_mv(uid_set, dest).context("UID MOVE failed")?; } else { self.uid_copy(uid_set, dest).context("UID COPY failed")?; self.uid_store(uid_set, "+FLAGS (\\Deleted)") - .context("UID STORE +FLAGS failed")?; - self.expunge().context("EXPUNGE failed")?; + .context("UID STORE +FLAGS (\\Deleted) failed after COPY")?; + self.uid_expunge(uid_set) + .context("UID EXPUNGE failed after COPY and STORE")?; } Ok(()) } @@ -157,24 +194,12 @@ impl ImapSession { impl DraftMailboxSession for ImapSession { fn list_mailboxes(&mut self) -> Result> { - let listed = self - .list(Some(""), Some("*")) - .map_err(|_| anyhow::anyhow!("Failed to enumerate mailboxes"))?; - Ok(listed - .iter() - .map(|mailbox| MailboxListing { - name: mailbox.name().to_string(), - attributes: mailbox - .attributes() - .iter() - .map(name_attribute_text) - .collect(), - }) - .collect()) + self.list_all() + .map_err(|_| anyhow::anyhow!("Failed to enumerate mailboxes")) } fn append_draft(&mut self, folder: &str, bytes: &[u8]) -> AppendAttempt { - let mailbox = match prepare_append_mailbox(folder) { + let mailbox = match escape_mailbox_name(folder) { Ok(mailbox) => mailbox, Err(_) => return AppendAttempt::PreLiteralFailure, }; @@ -249,12 +274,14 @@ fn name_attribute_text(attribute: &imap_proto::NameAttribute<'_>) -> String { } } -fn prepare_append_mailbox(mailbox: &str) -> Result { +/// Escape a mailbox name for the inside of an IMAP quoted string, rejecting +/// control characters rather than altering the name. +fn escape_mailbox_name(mailbox: &str) -> Result { let mut escaped = String::with_capacity(mailbox.len()); for character in mailbox.chars() { match character { character if character.is_control() => { - anyhow::bail!("Drafts folder contains a control character"); + anyhow::bail!("Mailbox name contains a control character"); } '\\' => escaped.push_str("\\\\"), '"' => escaped.push_str("\\\""), @@ -264,6 +291,105 @@ fn prepare_append_mailbox(mailbox: &str) -> Result { Ok(escaped) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MailboxCounts { + pub messages: Option, + pub unseen: Option, + pub recent: Option, +} + +/// Parse untagged responses one at a time, keeping each response's raw bytes. +fn parse_responses<'a>( + data: &'a [u8], + mut visit: impl FnMut(&'a [u8], imap_proto::Response<'a>) -> bool, +) -> Result<()> { + let mut rest = data; + while !rest.is_empty() { + let (remaining, response) = imap_proto::parser::parse_response(rest) + .map_err(|_| anyhow::anyhow!("Failed to parse the server response"))?; + if !visit(&rest[..rest.len() - remaining.len()], response) { + break; + } + rest = remaining; + } + Ok(()) +} + +fn parse_list_response(data: &[u8]) -> Result> { + let mut listed = Vec::new(); + parse_responses(data, |raw, response| { + if let imap_proto::Response::MailboxData(imap_proto::MailboxDatum::List { + name_attributes, + name, + .. + }) = response + { + listed.push(MailboxListing { + name: listed_mailbox_name(raw, &name), + attributes: name_attributes.iter().map(name_attribute_text).collect(), + }); + } + true + })?; + Ok(listed) +} + +/// imap-proto returns a quoted mailbox name with its `\\` and `\"` escapes +/// still in place but a literal name verbatim, without recording which form +/// was used. Decide from the raw response line, which ends with the name. +fn listed_mailbox_name(raw: &[u8], name: &str) -> String { + let line = raw.strip_suffix(b"\r\n").unwrap_or(raw); + let is_literal = line + .strip_suffix(name.as_bytes()) + .and_then(|prefix| prefix.strip_suffix(b"}\r\n")) + .and_then(|prefix| { + let open = prefix.iter().rposition(|byte| *byte == b'{')?; + let digits = std::str::from_utf8(&prefix[open + 1..]).ok()?; + // Literal lengths may be zero-padded (`{04}`). + let length: usize = digits.parse().ok()?; + (digits.bytes().all(|byte| byte.is_ascii_digit()) && length == name.len()).then_some(()) + }) + .is_some(); + if is_literal || !line.ends_with(b"\"") { + return name.to_string(); + } + let mut decoded = String::with_capacity(name.len()); + let mut characters = name.chars(); + while let Some(character) = characters.next() { + match character { + '\\' => decoded.extend(characters.next()), + character => decoded.push(character), + } + } + decoded +} + +/// Take the counts from the STATUS response to the single STATUS command +/// just sent (its echoed name is not compared: it may be escaped). +fn parse_status_response(data: &[u8]) -> Result { + let mut counts = None; + parse_responses(data, |_, response| { + if let imap_proto::Response::MailboxData(imap_proto::MailboxDatum::Status { + status, .. + }) = response + { + let mut found = MailboxCounts::default(); + for attribute in status { + match attribute { + imap_proto::StatusAttribute::Messages(value) => found.messages = Some(value), + imap_proto::StatusAttribute::Unseen(value) => found.unseen = Some(value), + imap_proto::StatusAttribute::Recent(value) => found.recent = Some(value), + _ => {} + } + } + counts = Some(found); + return false; + } + true + })?; + counts.ok_or_else(|| anyhow::anyhow!("Server sent no STATUS response")) +} + fn single_append_uid(uids: Option<&[imap_proto::UidSetMember]>) -> Result, ()> { match uids { None => Ok(None), @@ -284,6 +410,105 @@ pub fn is_loopback_host(host: &str) -> bool { .is_ok_and(|address| address.is_loopback()) } +/// Refuse plaintext IMAP to any host that is not loopback. Callers invoke +/// this before prompting for passwords or opening sockets. +pub fn ensure_secure_transport(host: &str, tls: bool) -> Result<()> { + if tls || is_loopback_host(host) { + return Ok(()); + } + anyhow::bail!("IMAP connections to non-loopback hosts require TLS") +} + +pub fn connect(host: &str, port: u16, tls: bool, user: &str, pass: &str) -> Result { + ensure_secure_transport(host, tls)?; + + let mut session = if tls { + let tls_connector = native_tls::TlsConnector::builder() + .min_protocol_version(Some(native_tls::Protocol::Tlsv12)) + .danger_accept_invalid_certs(false) + .danger_accept_invalid_hostnames(false) + .build() + .context("Failed to create TLS connector")?; + let tcp = connect_tcp(host, port, IMAP_CONNECT_TIMEOUT, IMAP_IO_TIMEOUT) + .with_context(|| format!("Failed to connect to {host}:{port}"))?; + let tls = tls_connector + .connect(host, tcp) + .with_context(|| format!("Failed to TLS-connect to {host}:{port}"))?; + let mut client = imap::Client::new(tls); + client + .read_greeting() + .context("Failed to read the IMAP greeting")?; + let s = client + .login(user, pass) + .map_err(|e| e.0) + .context("IMAP login failed")?; + Inner::Tls(s) + } else { + let tcp = connect_tcp(host, port, IMAP_CONNECT_TIMEOUT, IMAP_IO_TIMEOUT) + .with_context(|| format!("Failed to connect to {host}:{port}"))?; + let mut client = imap::Client::new(tcp); + client + .read_greeting() + .context("Failed to read the IMAP greeting")?; + let s = client + .login(user, pass) + .map_err(|e| e.0) + .context("IMAP login failed")?; + Inner::Plain(s) + }; + + let caps = match &mut session { + Inner::Plain(s) => s.capabilities(), + Inner::Tls(s) => s.capabilities(), + } + .context("Failed to fetch capabilities")?; + let capabilities = ["SORT", "MOVE", "QUOTA", "UIDPLUS", "LITERAL+"] + .iter() + .filter(|c| caps.has_str(**c)) + .map(|c| c.to_string()) + .collect(); + drop(caps); + + Ok(ImapSession { + inner: session, + capabilities, + }) +} + +fn connect_tcp( + host: &str, + port: u16, + connect_timeout: Duration, + io_timeout: Duration, +) -> io::Result { + let addresses = (host, port).to_socket_addrs()?; + let started = Instant::now(); + let mut last_error = None; + + for address in addresses { + let remaining = connect_timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + + match TcpStream::connect_timeout(&address, remaining) { + Ok(stream) => { + stream.set_read_timeout(Some(io_timeout))?; + stream.set_write_timeout(Some(io_timeout))?; + return Ok(stream); + } + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| { + io::Error::new( + ErrorKind::TimedOut, + format!("could not connect within {connect_timeout:?}"), + ) + })) +} + #[cfg(test)] mod tests { use super::*; @@ -372,7 +597,7 @@ mod tests { #[test] fn append_mailbox_is_escaped_for_the_upstream_builder() { assert_eq!( - prepare_append_mailbox("Drafts \"2026\"\\saved").unwrap(), + escape_mailbox_name("Drafts \"2026\"\\saved").unwrap(), "Drafts \\\"2026\\\"\\\\saved" ); } @@ -380,7 +605,7 @@ mod tests { #[test] fn append_mailbox_rejects_controls() { for mailbox in ["Drafts\nInjected", "Drafts\rInjected", "Drafts\0Injected"] { - assert!(prepare_append_mailbox(mailbox).is_err()); + assert!(escape_mailbox_name(mailbox).is_err()); } } @@ -472,6 +697,69 @@ mod tests { assert!(!is_loopback_host("192.168.1.1")); } + #[test] + fn listed_names_decode_quoted_escapes_but_keep_literals_verbatim() { + let response = b"* LIST (\\HasNoChildren) \"/\" \"Quote \\\"Q\\\" \\\\ back\"\r\n\ +* LIST () \"/\" {10}\r\nWork\\Plans\r\n\ +* LIST () \"/\" {5}\r\nEnds\"\r\n\ +* LIST () \"/\" {007}\r\nA\\B \\\"\"\r\n\ +* LIST (\\All) \"/\" Everything\r\n\ +* LIST () \"/\" inbox\r\n"; + let listed = parse_list_response(response).unwrap(); + let names: Vec<&str> = listed.iter().map(|m| m.name.as_str()).collect(); + assert_eq!( + names, + [ + r#"Quote "Q" \ back"#, + r"Work\Plans", + "Ends\"", + "A\\B \\\"\"", + "Everything", + "INBOX" + ] + ); + assert_eq!(listed[4].attributes, ["\\All"]); + } + + #[test] + fn status_counts_ignore_the_echoed_name_and_keep_missing_counts_unknown() { + let escaped = b"* STATUS \"A\\\"B\" (MESSAGES 5 UNSEEN 1 RECENT 0)\r\n"; + assert_eq!( + parse_status_response(escaped).unwrap(), + MailboxCounts { + messages: Some(5), + unseen: Some(1), + recent: Some(0) + } + ); + let partial = b"* STATUS \"Projects (2024)\" (UNSEEN 2)\r\n"; + assert_eq!( + parse_status_response(partial).unwrap(), + MailboxCounts { + messages: None, + unseen: Some(2), + recent: None + } + ); + assert!(parse_status_response(b"* STATUS \"B\" (MESSAGES x)\r\n").is_err()); + assert!(parse_status_response(b"").is_err()); + } + + #[test] + fn plaintext_is_refused_for_remote_hosts_before_resolution() { + for host in ["mail.example.invalid", "192.168.1.1", "10.0.0.1"] { + let error = connect(host, 143, false, "user", "password") + .err() + .unwrap() + .to_string(); + assert_eq!(error, "IMAP connections to non-loopback hosts require TLS"); + } + for host in ["localhost", "127.0.0.1", "::1"] { + assert!(ensure_secure_transport(host, false).is_ok()); + } + assert!(ensure_secure_transport("mail.example.invalid", true).is_ok()); + } + #[test] fn configured_tcp_stream_has_read_and_write_deadlines() { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -493,99 +781,3 @@ mod tests { accepting.join().unwrap(); } } - -pub fn connect(host: &str, port: u16, tls: bool, user: &str, pass: &str) -> Result { - if !tls && !is_loopback_host(host) { - eprintln!( - "Warning: connecting to {} without TLS. Credentials will be sent in plaintext.", - host - ); - eprintln!(" Use --tls for remote servers."); - } - - let mut session = if tls { - let tls_connector = native_tls::TlsConnector::builder() - .min_protocol_version(Some(native_tls::Protocol::Tlsv12)) - .danger_accept_invalid_certs(false) - .danger_accept_invalid_hostnames(false) - .build() - .context("Failed to create TLS connector")?; - let tcp = connect_tcp(host, port, IMAP_CONNECT_TIMEOUT, IMAP_IO_TIMEOUT) - .with_context(|| format!("Failed to connect to {host}:{port}"))?; - let tls = tls_connector - .connect(host, tcp) - .with_context(|| format!("Failed to TLS-connect to {host}:{port}"))?; - let mut client = imap::Client::new(tls); - client - .read_greeting() - .context("Failed to read the IMAP greeting")?; - let s = client - .login(user, pass) - .map_err(|e| e.0) - .context("IMAP login failed")?; - Inner::Tls(s) - } else { - let tcp = connect_tcp(host, port, IMAP_CONNECT_TIMEOUT, IMAP_IO_TIMEOUT) - .with_context(|| format!("Failed to connect to {host}:{port}"))?; - let mut client = imap::Client::new(tcp); - client - .read_greeting() - .context("Failed to read the IMAP greeting")?; - let s = client - .login(user, pass) - .map_err(|e| e.0) - .context("IMAP login failed")?; - Inner::Plain(s) - }; - - let caps = match &mut session { - Inner::Plain(s) => s.capabilities(), - Inner::Tls(s) => s.capabilities(), - } - .context("Failed to fetch capabilities")?; - let capabilities = ["SORT", "MOVE", "QUOTA", "UIDPLUS"] - .iter() - .filter(|c| caps.has_str(**c)) - .map(|c| c.to_string()) - .collect(); - drop(caps); - - Ok(ImapSession { - inner: session, - capabilities, - }) -} - -fn connect_tcp( - host: &str, - port: u16, - connect_timeout: Duration, - io_timeout: Duration, -) -> io::Result { - let addresses = (host, port).to_socket_addrs()?; - let started = Instant::now(); - let mut last_error = None; - - for address in addresses { - let remaining = connect_timeout.saturating_sub(started.elapsed()); - if remaining.is_zero() { - break; - } - - match TcpStream::connect_timeout(&address, remaining) { - Ok(stream) => { - stream.set_read_timeout(Some(io_timeout))?; - stream.set_write_timeout(Some(io_timeout))?; - return Ok(stream); - } - Err(error) => last_error = Some(error), - } - } - - Err(last_error.unwrap_or_else(|| { - io::Error::new( - ErrorKind::TimedOut, - format!("could not connect within {connect_timeout:?}"), - ) - })) -} diff --git a/src/delete.rs b/src/delete.rs index f81a7e3..63ef293 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -3,7 +3,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use std::time::Duration; use crate::connection::ImapSession; -use crate::display::display_messages; +use crate::display::{display_messages, sanitize_terminal_field}; use crate::search::{self, SearchCriteria}; fn spinner(msg: &str) -> ProgressBar { @@ -37,7 +37,7 @@ pub fn search_and_move_with_account( account_name: Option<&str>, ) -> Result<()> { let sp = spinner("Searching..."); - let mut messages = search::search(session, criteria)?; + let mut messages = search::search_excluding(session, criteria, Some(dest))?; sp.finish_and_clear(); if let Some(account) = account_name { for msg in &mut messages { @@ -52,22 +52,28 @@ pub fn search_and_move_with_account( display_messages(&messages); + let safe_dest = sanitize_terminal_field(dest); if dry_run { println!( - "Dry run: {} message(s) would be moved to {dest}.", + "Dry run: {} message(s) would be moved to {safe_dest}.", messages.len() ); return Ok(()); } + session.ensure_safe_move_supported()?; search::ensure_folder_exists(session, dest)?; + // Validate every row's mailbox identity before any mutation. + let groups = search::group_message_uids(&messages, &criteria.folder)?; if !yes { - let confirm = - inquire::Confirm::new(&format!("Move {} message(s) to {dest}?", messages.len())) - .with_default(false) - .prompt() - .context("Prompt failed")?; + let confirm = inquire::Confirm::new(&format!( + "Move {} message(s) to {safe_dest}?", + messages.len() + )) + .with_default(false) + .prompt() + .context("Prompt failed")?; if !confirm { println!("Aborted."); @@ -75,39 +81,50 @@ pub fn search_and_move_with_account( } } - let sp = spinner(&format!("Moving to {dest}...")); - - // Group by folder for multi-folder moves - let mut by_folder: std::collections::HashMap> = - std::collections::HashMap::new(); - for msg in &messages { - let folder = msg - .folder - .clone() - .unwrap_or_else(|| criteria.folder.clone()); - by_folder.entry(folder).or_default().push(msg.uid); - } + let sp = spinner(&format!("Moving to {safe_dest}...")); let mut total = 0usize; - for (folder, uids) in &by_folder { - session - .select(folder) - .with_context(|| format!("Failed to select '{folder}'"))?; - - for chunk in &search::build_uid_set(uids) { - session - .uid_move_or_fallback(chunk, dest) - .with_context(|| format!("Failed to move messages from '{folder}' to {dest}"))?; + let mut move_groups = || -> Result<()> { + for (folder, (uid_validity, uids)) in &groups { + let failed = |total: usize| { + format!( + "Failed to move messages from '{}' to '{safe_dest}' ({total} already moved)", + sanitize_terminal_field(folder) + ) + }; + search::select_verified(session, folder, *uid_validity) + .with_context(|| failed(total))?; + for chunk in &search::build_uid_set(uids) { + let present = + search::existing_uids(session, chunk).with_context(|| failed(total))?; + for set in &search::build_uid_set(&present) { + session + .uid_move_or_fallback(set, dest) + .with_context(|| failed(total))?; + total += search::uid_set_len(set); + } + } } - - total += uids.len(); - } - + Ok(()) + }; + let result = move_groups(); sp.finish_and_clear(); - println!("Moved {total} message(s) to {dest}."); + result?; + + println!("Moved {total} message(s) to {safe_dest}."); + report_vanished(messages.len(), total); Ok(()) } +/// Report searched messages that another client removed before the action, +/// so receipts count only messages the server still had. +pub fn report_vanished(searched: usize, acted: usize) { + let vanished = searched.saturating_sub(acted); + if vanished > 0 { + println!("{vanished} message(s) no longer existed and were skipped."); + } +} + pub fn delete( session: &mut ImapSession, criteria: &SearchCriteria, diff --git a/src/display.rs b/src/display.rs index 15051c1..179e1a9 100644 --- a/src/display.rs +++ b/src/display.rs @@ -17,6 +17,10 @@ pub struct MessageRow { pub seen: bool, pub answered: bool, pub flagged: bool, + /// UIDVALIDITY of the mailbox the UID was searched in. Internal only: + /// UID actions refuse to run if the mailbox identity has changed. + #[serde(skip)] + pub uid_validity: Option, } pub fn format_size(bytes: u64) -> String { @@ -29,74 +33,259 @@ pub fn format_size(bytes: u64) -> String { } } +/// Render untrusted text as one inert terminal line: escape sequences are +/// removed with their payloads, every other control, C1, or bidi formatting +/// character becomes a space, and so does any invisible formatting character +/// that could make two different names look identical. pub fn sanitize_terminal_field(value: &str) -> String { + sanitize_terminal(value, false) +} + +/// Like [`sanitize_terminal_field`], but keeps multi-line structure: LF and +/// HTAB are preserved, CRLF or lone CR become LF, and invisible formatting +/// characters (soft hyphens, zero-width spaces) are dropped rather than +/// shown as spaces. +pub fn sanitize_terminal_body(value: &str) -> String { + sanitize_terminal(value, true) +} + +fn is_unsafe_format_character(character: char) -> bool { + matches!( + character, + '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) +} + +/// Characters that render as nothing. Joiners (U+200C/U+200D) and variation +/// selectors are kept because scripts and emoji need them; tag characters are +/// handled separately because emoji flag sequences use them. +fn is_invisible_character(character: char) -> bool { + matches!( + character, + '\u{00ad}' + | '\u{180e}' + | '\u{200b}' + | '\u{2060}'..='\u{2064}' + | '\u{206a}'..='\u{206f}' + | '\u{feff}' + | '\u{fff9}'..='\u{fffb}' + ) +} + +const BLACK_FLAG: char = '\u{1f3f4}'; +const CANCEL_TAG: char = '\u{e007f}'; + +fn is_tag_character(character: char) -> bool { + matches!(character, '\u{e0000}'..='\u{e007f}') +} + +fn sanitize_terminal(value: &str, multiline: bool) -> String { #[derive(Clone, Copy)] - enum EscapeState { + enum State { Text, + /// After ESC, possibly followed by intermediate bytes. Escape, + /// Control sequence; discarded through its final byte. Csi, - Osc, - OscEscape, + /// OSC/DCS/SOS/PM/APC payload; discarded through ST (or BEL for OSC). + ControlString { + bell_terminates: bool, + }, + /// ESC seen inside a control string; `\` completes ST. + ControlStringEscape { + bell_terminates: bool, + }, } let mut output = String::with_capacity(value.len()); - let mut state = EscapeState::Text; + let mut state = State::Text; + let mut after_cr = false; + // Set after a black flag or a tag inside an emoji flag sequence. + let mut in_flag_tags = false; for character in value.chars() { + let was_after_cr = std::mem::take(&mut after_cr); + let was_in_flag_tags = std::mem::take(&mut in_flag_tags); state = match state { - EscapeState::Text => match character { - '\u{1b}' => EscapeState::Escape, - '\u{009b}' => EscapeState::Csi, - '\u{009d}' => EscapeState::Osc, - '\u{061c}' - | '\u{200e}' - | '\u{200f}' - | '\u{2028}'..='\u{202e}' - | '\u{2066}'..='\u{2069}' => { + State::Text if is_tag_character(character) && was_in_flag_tags => { + output.push(character); + in_flag_tags = character != CANCEL_TAG; + State::Text + } + State::Text if is_tag_character(character) || is_invisible_character(character) => { + if !multiline { output.push(' '); - EscapeState::Text } - character if character.is_control() || matches!(character as u32, 0x80..=0x9f) => { + State::Text + } + State::Text => match character { + '\u{1b}' => State::Escape, + '\u{009b}' => State::Csi, + '\u{009d}' => State::ControlString { + bell_terminates: true, + }, + '\u{0090}' | '\u{0098}' | '\u{009e}' | '\u{009f}' => State::ControlString { + bell_terminates: false, + }, + '\n' if multiline => { + if !was_after_cr { + output.push('\n'); + } + State::Text + } + '\r' if multiline => { + output.push('\n'); + after_cr = true; + State::Text + } + '\t' if multiline => { + output.push('\t'); + State::Text + } + character + if character.is_control() + || matches!(character as u32, 0x80..=0x9f) + || is_unsafe_format_character(character) => + { output.push(' '); - EscapeState::Text + State::Text } character => { output.push(character); - EscapeState::Text + in_flag_tags = character == BLACK_FLAG; + State::Text } }, - EscapeState::Escape => match character { - '[' => EscapeState::Csi, - ']' => EscapeState::Osc, - _ => EscapeState::Text, + State::Escape => match character { + '[' => State::Csi, + ']' => State::ControlString { + bell_terminates: true, + }, + 'P' | 'X' | '^' | '_' => State::ControlString { + bell_terminates: false, + }, + '\u{20}'..='\u{2f}' => State::Escape, + _ => State::Text, }, - EscapeState::Csi => { + State::Csi => { if matches!(character as u32, 0x40..=0x7e) { - EscapeState::Text + State::Text } else { - EscapeState::Csi + State::Csi } } - EscapeState::Osc => match character { - '\u{7}' => EscapeState::Text, - '\u{1b}' => EscapeState::OscEscape, - _ => EscapeState::Osc, + State::ControlString { bell_terminates } => match character { + '\u{7}' if bell_terminates => State::Text, + '\u{9c}' => State::Text, + '\u{1b}' => State::ControlStringEscape { bell_terminates }, + _ => State::ControlString { bell_terminates }, + }, + State::ControlStringEscape { bell_terminates } => match character { + '\\' => State::Text, + '\u{1b}' => State::ControlStringEscape { bell_terminates }, + _ => State::ControlString { bell_terminates }, }, - EscapeState::OscEscape => { - if character == '\\' { - EscapeState::Text - } else { - EscapeState::Osc - } - } }; } output } +/// Truncate a string to at most `max` characters, appending "..." if truncated. +/// Safe for multi-byte UTF-8. +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{truncated}...") + } +} + +/// Table dates omit the numeric timezone offset; JSON keeps the full value. +fn table_date(date: &str) -> &str { + match date.find(" +").or_else(|| date.find(" -")) { + Some(position) => &date[..position], + None => date, + } +} + +pub fn display_messages_json(messages: &[MessageRow]) { + println!("{}", serde_json::to_string(messages).unwrap()); +} + +pub fn display_messages(messages: &[MessageRow]) { + if messages.is_empty() { + println!("No messages found."); + return; + } + + let has_account = messages.iter().any(|m| m.account.is_some()); + let has_folder = messages.iter().any(|m| m.folder.is_some()); + let mut table = Table::new(); + table.load_preset(UTF8_FULL_CONDENSED); + table.set_content_arrangement(ContentArrangement::Dynamic); + + let mut header = vec!["UID", "From", "Subject", "Date", "Size"]; + if has_account { + header.insert(1, "Account"); + } + if has_folder { + header.insert(if has_account { 2 } else { 1 }, "Folder"); + } + table.set_header(header); + + for msg in messages { + let mut row: Vec = vec![Cell::new(msg.uid)]; + if has_account { + row.push(Cell::new(sanitize_terminal_field( + msg.account.as_deref().unwrap_or(""), + ))); + } + if has_folder { + row.push(Cell::new(sanitize_terminal_field( + msg.folder.as_deref().unwrap_or(""), + ))); + } + row.push(Cell::new(truncate_str( + &sanitize_terminal_field(&msg.from), + 40, + ))); + row.push(Cell::new(truncate_str( + &sanitize_terminal_field(&msg.subject), + 60, + ))); + row.push(Cell::new(sanitize_terminal_field(table_date(&msg.date)))); + row.push(Cell::new(format_size(msg.size as u64))); + table.add_row(row); + } + + println!("{table}"); + println!("{} message(s)", messages.len()); +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn truncate_str_short_unchanged() { + assert_eq!(truncate_str("hello", 10), "hello"); + } + + #[test] + fn truncate_str_exact_length_unchanged() { + assert_eq!(truncate_str("hello", 5), "hello"); + } + + #[test] + fn truncate_str_long_adds_ellipsis() { + assert_eq!(truncate_str("hello world", 8), "hello..."); + } + + #[test] + fn truncate_str_empty() { + assert_eq!(truncate_str("", 10), ""); + } + #[test] fn format_size_zero() { assert_eq!(format_size(0), "0B"); @@ -135,6 +324,93 @@ mod tests { assert!(!rendered.chars().any(char::is_control)); } + #[test] + fn invisible_characters_cannot_disguise_names() { + let trash_lookalike = "Tra\u{200b}sh"; + assert_ne!(sanitize_terminal_field(trash_lookalike), "Trash"); + assert_eq!(sanitize_terminal_field(trash_lookalike), "Tra sh"); + assert_eq!( + sanitize_terminal_field("a\u{ad}b\u{feff}c\u{2060}d\u{e0041}e"), + "a b c d e" + ); + // Bodies drop invisible characters instead of spacing words apart. + assert_eq!( + sanitize_terminal_body("co\u{ad}operate\u{200b}"), + "cooperate" + ); + + // Joiners and emoji tag sequences (flag of England) survive. + let england = "\u{1f3f4}\u{e0067}\u{e0062}\u{e0065}\u{e006e}\u{e0067}\u{e007f}"; + for text in [ + england, + "\u{1f469}\u{200d}\u{1f4bb}", + "\u{645}\u{200c}\u{645}", + ] { + assert_eq!(sanitize_terminal_field(text), text); + } + // Tags after a completed flag sequence are not part of it. + assert_eq!( + sanitize_terminal_field(&format!("{england}\u{e0041}")), + format!("{england} ") + ); + } + + #[test] + fn terminal_sequences_are_removed_with_their_payloads() { + let cases = [ + ("a\u{1b}[31;1mred\u{1b}[0mb", "aredb"), + ("a\u{9b}2Jb", "ab"), + ( + "a\u{1b}]8;;https://evil\u{1b}\\link\u{1b}]8;;\u{1b}\\b", + "alinkb", + ), + ("a\u{9d}0;title\u{9c}b", "ab"), + ("a\u{1b}Pq#0;2;0;0;0\u{1b}\\b", "ab"), + ("a\u{1b}_apc payload\u{7}still\u{1b}\\b", "ab"), + ("a\u{90}dcs\u{7}more\u{9c}b", "ab"), + ("a\u{1b}(Bb", "ab"), + ("a\u{1b}]52;c;unterminated secret", "a"), + ("a\u{1b}[?25", "a"), + ]; + for (input, expected) in cases { + assert_eq!(sanitize_terminal_field(input), expected, "{input:?}"); + assert_eq!(sanitize_terminal_body(input), expected, "{input:?}"); + } + } + + #[test] + fn body_sanitizer_keeps_line_structure_and_unicode() { + let body = "Grüße 世界\tcol\r\nline two\rline three\n\u{1b}[2Jline four\u{7}\u{202e}"; + assert_eq!( + sanitize_terminal_body(body), + "Grüße 世界\tcol\nline two\nline three\nline four " + ); + assert_eq!( + sanitize_terminal_field(body), + "Grüße 世界 col line two line three line four " + ); + } + + #[test] + fn table_keeps_json_values_complete() { + let subject = "S".repeat(70); + let from = format!("{}@example.com", "f".repeat(50)); + let message = MessageRow { + subject: subject.clone(), + from: from.clone(), + date: "Mon, 1 Apr 2026 10:00:00 +0200".into(), + ..row(1) + }; + assert_eq!(truncate_str(&message.subject, 60).chars().count(), 60); + assert_eq!(table_date(&message.date), "Mon, 1 Apr 2026 10:00:00"); + let parsed: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&[message]).unwrap()).unwrap(); + assert_eq!(parsed[0]["subject"], subject); + assert_eq!(parsed[0]["from"], from); + assert_eq!(parsed[0]["date"], "Mon, 1 Apr 2026 10:00:00 +0200"); + assert!(parsed[0].get("uid_validity").is_none()); + } + #[test] fn json_empty() { let messages: Vec = vec![]; @@ -158,6 +434,7 @@ mod tests { seen: false, answered: false, flagged: false, + uid_validity: None, } } @@ -202,47 +479,3 @@ mod tests { assert_eq!(parsed[0]["account"], "work"); } } - -pub fn display_messages_json(messages: &[MessageRow]) { - println!("{}", serde_json::to_string(messages).unwrap()); -} - -pub fn display_messages(messages: &[MessageRow]) { - if messages.is_empty() { - println!("No messages found."); - return; - } - - let has_account = messages.iter().any(|m| m.account.is_some()); - let has_folder = messages.iter().any(|m| m.folder.is_some()); - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - - let mut header = vec!["UID", "From", "Subject", "Date", "Size"]; - if has_account { - header.insert(1, "Account"); - } - if has_folder { - header.insert(if has_account { 2 } else { 1 }, "Folder"); - } - table.set_header(header); - - for msg in messages { - let mut row: Vec = vec![Cell::new(msg.uid)]; - if has_account { - row.push(Cell::new(msg.account.as_deref().unwrap_or(""))); - } - if has_folder { - row.push(Cell::new(msg.folder.as_deref().unwrap_or(""))); - } - row.push(Cell::new(&msg.from)); - row.push(Cell::new(&msg.subject)); - row.push(Cell::new(&msg.date)); - row.push(Cell::new(format_size(msg.size as u64))); - table.add_row(row); - } - - println!("{table}"); - println!("{} message(s)", messages.len()); -} diff --git a/src/draft.rs b/src/draft.rs index 30c2857..fc96871 100644 --- a/src/draft.rs +++ b/src/draft.rs @@ -267,7 +267,7 @@ fn parsed_message_id(header: &[u8]) -> Option { if values.len() != 1 { return None; } - let ids = mailparse::msgidparse(&values[0]).ok()?; + let ids = parse_message_ids_with_comments(&values[0])?; if ids.len() != 1 || !is_valid_message_id(&ids[0]) { return None; } @@ -606,9 +606,8 @@ fn parse_message_id_list(source: &ParsedMail<'_>, field: &str) -> Result ids, - Err(_) => return Ok(None), + let Some(ids) = parse_message_ids_with_comments(&value) else { + return Ok(None); }; if ids.is_empty() || ids.iter().any(|id| !is_valid_message_id(id)) { return Ok(None); @@ -618,6 +617,49 @@ fn parse_message_id_list(source: &ParsedMail<'_>, field: &str) -> Result Option> { + let mut cleaned = String::with_capacity(value.len()); + let mut chars = value.chars(); + let mut in_id = false; + while let Some(character) = chars.next() { + match character { + '<' if !in_id => { + in_id = true; + cleaned.push(character); + } + '>' if in_id => { + in_id = false; + cleaned.push(character); + } + '(' if !in_id => { + let mut depth = 1; + while let Some(character) = chars.next() { + match character { + '\\' => { + chars.next()?; + } + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + if depth != 0 { + return None; + } + cleaned.push(' '); + } + ')' if !in_id => return None, + _ => cleaned.push(character), + } + } + mailparse::msgidparse(&cleaned).ok().map(|ids| ids.to_vec()) +} + fn is_valid_message_id(id: &str) -> bool { if !id.is_ascii() || id.bytes().any(|byte| byte.is_ascii_control()) { return false; @@ -798,7 +840,7 @@ fn validate_mailbox(mailbox: &Mailbox, field: &str) -> Result<()> { fn validate_header_text(value: &str, field: &str) -> Result<()> { if value .chars() - .any(|character| character == '\0' || character.is_control()) + .any(|character| character.is_control() && character != '\t') { bail!("{field} contains a disallowed header control character"); } @@ -1253,6 +1295,84 @@ References: \r\n\r\nBody"; assert_eq!(header_value(&parsed, "References"), None); } + #[test] + fn reply_preserves_commented_thread_ids_and_rejects_broken_comments() { + let source = b"From: sender@example.com\r\n\ +Message-ID: (parent (nested \\) note)) \r\n\ +References: (escaped \\( note) \r\n\r\nBody"; + let composed = compose_reply(source, BodyFormat::Plain, false).unwrap(); + let parsed = mailparse::parse_mail(&composed.bytes).unwrap(); + assert_eq!( + header_value(&parsed, "In-Reply-To").as_deref(), + Some("") + ); + assert_eq!( + header_value(&parsed, "References").as_deref(), + Some(" ") + ); + assert!(parse_message_ids_with_comments(" (unterminated").is_none()); + assert!(parse_message_ids_with_comments(" )").is_none()); + assert!(parse_message_ids_with_comments(" garbage").is_none()); + let bad_id = b"From: sender@example.com\r\nMessage-ID: \r\n\r\nBody"; + let composed = compose_reply(bad_id, BodyFormat::Plain, false).unwrap(); + let parsed = mailparse::parse_mail(&composed.bytes).unwrap(); + assert_eq!(header_value(&parsed, "In-Reply-To"), None); + } + + #[test] + fn legal_header_tabs_survive_drafting_but_other_controls_fail() { + let mut input = new_input(); + input.subject = "First\tSecond".to_string(); + input.to = vec![parse_mailbox("Alice\tSmith ", "To").unwrap()]; + let composed = compose_new_draft(input).unwrap(); + let parsed = mailparse::parse_mail(&composed.bytes).unwrap(); + assert_eq!( + header_value(&parsed, "Subject").as_deref(), + Some("First\tSecond") + ); + assert!(header_value(&parsed, "To") + .unwrap() + .contains("Alice\tSmith")); + for control in ['\0', '\r', '\n'] { + assert!(validate_header_text(&format!("a{control}b"), "subject").is_err()); + } + } + + #[test] + fn reply_quote_contains_every_mixed_text_segment() { + let source = + b"From: sender@example.com\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nIntroduction\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nFinal instructions\r\n--x--"; + let composed = compose_reply(source, BodyFormat::Plain, true).unwrap(); + let parsed = mailparse::parse_mail(&composed.bytes).unwrap(); + let body = parsed.get_body().unwrap(); + assert!( + body.replace("\r\n", "\n") + .contains("> Introduction\n>\n> Final instructions"), + "{body}" + ); + } + + #[test] + fn reply_depth_limit_rejects_before_composition() { + for (depth, accepted) in [(100, true), (101, false)] { + let mut source = b"From: sender@example.com\r\n".to_vec(); + source.extend_from_slice(&crate::read::nested_multipart_fixture(depth)); + let result = compose_reply(&source, BodyFormat::Plain, true); + if accepted { + let composed = result.unwrap(); + assert!(mailparse::parse_mail(&composed.bytes) + .unwrap() + .get_body() + .unwrap() + .contains("Deep body")); + } else { + assert!(result.is_err()); + } + } + } + #[test] fn reply_preserves_available_references_without_parent_id() { let source = b"From: sender@example.com\r\n\ diff --git a/src/export.rs b/src/export.rs index a91e8d4..80bf18e 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1,25 +1,279 @@ -use anyhow::{Context, Result}; -use std::path::Path; +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use crate::attachment::{create_output_file, write_created_file}; use crate::connection::ImapSession; -use crate::display::MessageRow; +use crate::display::{sanitize_terminal_field, MessageRow}; use crate::search; -/// Sanitize folder name for use in filenames: keep alphanumerics and hyphens, replace rest with `_`. -pub fn sanitize_folder_name(folder: &str) -> String { - folder - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' { - c - } else { - '_' +/// Longest file name component accepted by common filesystems. +const MAX_FILENAME_BYTES: usize = 255; + +/// Reversibly encode a mailbox name for use as a file name component. +/// +/// ASCII letters, digits, and `-` are kept; every other UTF-8 byte becomes +/// `%XX` (uppercase hex), including `%` and `_`, so distinct mailboxes never +/// share a file name and the `_` before the UID stays unambiguous. On +/// Windows, lowercase letters are also encoded so names that differ only by +/// case stay distinct on case-insensitive filesystems. +pub fn encode_folder_name(folder: &str) -> String { + let mut encoded = String::with_capacity(folder.len()); + for byte in folder.bytes() { + let keep = byte.is_ascii_digit() + || byte == b'-' + || byte.is_ascii_uppercase() + || (byte.is_ascii_lowercase() && !cfg!(windows)); + if keep { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn export_filename(folder: &str, uid: u32) -> Result { + let filename = format!("{}_{uid}.eml", encode_folder_name(folder)); + if filename.len() > MAX_FILENAME_BYTES { + bail!( + "Export file name for UID {uid} in '{}' exceeds {MAX_FILENAME_BYTES} bytes", + sanitize_terminal_field(folder) + ); + } + Ok(filename) +} + +type MessageKey<'a> = (&'a str, u32); + +/// Writes export files while making sure no two planned messages resolve to +/// the same filesystem object (case-insensitive aliases, hard links). +#[derive(Default)] +struct ExportWriter<'a> { + #[cfg(unix)] + identities: HashMap<(u64, u64), MessageKey<'a>>, + #[cfg(not(unix))] + identities: std::marker::PhantomData>, +} + +#[derive(Debug)] +enum WriteOutcome { + Exported, + /// The existing file already holds this message. + Skipped, + /// The existing file holds something else (for example a message from an + /// earlier UIDVALIDITY epoch or another account); it is left untouched. + Conflict, +} + +impl<'a> ExportWriter<'a> { + /// Associate the entry at `path` (not following symlinks) with `owner`, + /// failing if it already belongs to a different planned message. + fn claim_existing(&mut self, path: &Path, owner: MessageKey<'a>) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match std::fs::symlink_metadata(path) { + Ok(metadata) => self.claim((metadata.dev(), metadata.ino()), owner)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to inspect '{}'", safe_path(path))) + } + } + } + #[cfg(not(unix))] + let _ = (path, owner); + Ok(()) + } + + #[cfg(unix)] + fn claim(&mut self, identity: (u64, u64), owner: MessageKey<'a>) -> Result<()> { + match self.identities.get(&identity) { + Some(existing) if *existing != owner => { + bail!("Export destinations alias different messages"); + } + Some(_) => {} + None => { + self.identities.insert(identity, owner); + } + } + Ok(()) + } + + fn write( + &mut self, + path: &Path, + owner: MessageKey<'a>, + bytes: &[u8], + force: bool, + ) -> Result { + if force { + // Never replace an entry that another message in this run owns. + self.claim_existing(path, owner)?; + } + let file = match create_output_file(path, force) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists && !force => { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("Failed to inspect '{}'", safe_path(path)))?; + let file_type = metadata.file_type(); + if !file_type.is_file() && !file_type.is_symlink() { + bail!( + "Export destination is not a regular file: '{}'", + safe_path(path) + ); + } + self.claim_existing(path, owner)?; + // A symlink is never followed, so its content cannot be + // verified as this message. + if file_type.is_file() && holds_same_message(path, bytes)? { + return Ok(WriteOutcome::Skipped); + } + return Ok(WriteOutcome::Conflict); + } + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to create '{}'", safe_path(path))); + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata = file + .metadata() + .with_context(|| format!("Failed to inspect '{}'", safe_path(path)))?; + // A fresh inode cannot be owned yet; record it for later aliases. + self.identities + .insert((metadata.dev(), metadata.ino()), owner); + } + write_created_file(file, path, bytes) + .with_context(|| format!("Failed to write '{}'", safe_path(path)))?; + Ok(WriteOutcome::Exported) + } +} + +/// Header bytes read from an existing export to find its Message-ID. Larger +/// header sections are treated as having none, which yields a conflict. +const MAX_EXISTING_HEADER_BYTES: u64 = 1 << 20; + +/// Whether the existing export at `path` holds the fetched message: identical +/// bytes, or the same Message-ID (servers may rewrite headers on migration). +/// File names carry only folder and UID, so after a mailbox is recreated a +/// new message can reuse an old message's name. +/// +/// The entry is opened without following symlinks or blocking on special +/// files, and at most the fetched size plus the header section is read. +fn holds_same_message(path: &Path, fetched: &[u8]) -> Result { + use std::io::{BufRead, Read, Seek}; + + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Open a symlink itself (its metadata is then not a regular file). + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = match options.open(path) { + Ok(file) => file, + // A symlink swapped in after the entry was inspected is not followed. + #[cfg(unix)] + Err(error) if error.raw_os_error() == Some(libc::ELOOP) => return Ok(false), + Err(error) => { + return Err(error).with_context(|| format!("Failed to open '{}'", safe_path(path))) + } + }; + let read_error = || format!("Failed to read '{}'", safe_path(path)); + let metadata = file.metadata().with_context(read_error)?; + if !metadata.is_file() { + return Ok(false); + } + let mut reader = io::BufReader::new(file); + + if metadata.len() == fetched.len() as u64 { + let mut existing = Vec::with_capacity(fetched.len()); + reader + .by_ref() + .take(fetched.len() as u64) + .read_to_end(&mut existing) + .with_context(read_error)?; + if existing == fetched { + return Ok(true); + } + reader.rewind().with_context(read_error)?; + } + + let mut headers = Vec::new(); + let mut limited = reader.take(MAX_EXISTING_HEADER_BYTES); + let complete = loop { + let start = headers.len(); + if limited + .read_until(b'\n', &mut headers) + .with_context(read_error)? + == 0 + { + // End of file inside the cap: a header-only message. + break limited.limit() > 0; + } + if matches!(&headers[start..], b"\r\n" | b"\n") { + break true; + } + }; + if !complete { + return Ok(false); + } + Ok(match (message_id(&headers), message_id(fetched)) { + (Some(existing), Some(fetched)) => existing == fetched, + _ => false, + }) +} + +fn message_id(raw: &[u8]) -> Option { + use mailparse::MailHeaderMap; + let (headers, _) = mailparse::parse_headers(raw).ok()?; + let value = headers.get_first_value("Message-ID")?; + search::message_ids(&value).into_iter().next() +} + +fn safe_path(path: &Path) -> String { + sanitize_terminal_field(&path.display().to_string()) +} + +/// Plan every destination before touching the server or filesystem. +fn plan_paths<'a>( + groups: &std::collections::BTreeMap<&'a str, (u32, Vec)>, + out_dir: &Path, +) -> Result, PathBuf>> { + let mut planned = HashMap::new(); + #[cfg(windows)] + let mut folded = HashMap::new(); + for (folder, (_, uids)) in groups { + for uid in uids { + let filename = export_filename(folder, *uid)?; + #[cfg(windows)] + if let Some(other) = folded.insert(filename.to_lowercase(), (*folder, *uid)) { + if other != (*folder, *uid) { + bail!("Export destinations alias different messages"); + } } - }) - .collect() + planned.insert((*folder, *uid), out_dir.join(filename)); + } + } + Ok(planned) } -/// Export messages to .eml files. Returns (exported, skipped) counts. +/// Export messages to .eml files. Returns (exported, skipped) counts, where +/// skipped files already hold the same message. Existing files holding a +/// different message are never overwritten without `force`; they are +/// reported as an error after every other message has been exported. pub fn export_messages( session: &mut ImapSession, messages: &[MessageRow], @@ -27,54 +281,51 @@ pub fn export_messages( out_dir: &Path, force: bool, ) -> Result<(usize, usize)> { - std::fs::create_dir_all(out_dir) - .with_context(|| format!("Failed to create directory '{}'", out_dir.display()))?; + let groups = search::group_message_uids(messages, default_folder)?; + let planned = plan_paths(&groups, out_dir)?; - // Group by folder - let mut by_folder: std::collections::HashMap> = - std::collections::HashMap::new(); - for msg in messages { - let folder = msg - .folder - .clone() - .unwrap_or_else(|| default_folder.to_string()); - by_folder.entry(folder).or_default().push(msg.uid); - } + std::fs::create_dir_all(out_dir) + .with_context(|| format!("Failed to create directory '{}'", safe_path(out_dir)))?; + let mut writer = ExportWriter::default(); let mut exported = 0usize; let mut skipped = 0usize; + let mut conflicts = 0usize; - for (folder, uids) in &by_folder { - session - .select(folder) - .with_context(|| format!("Failed to select '{folder}'"))?; - - let safe_folder = sanitize_folder_name(folder); + for (folder, (uid_validity, uids)) in &groups { + search::examine_verified(session, folder, *uid_validity)?; for chunk in &search::build_uid_set(uids) { - let fetches = session - .uid_fetch(chunk, "BODY.PEEK[]") - .with_context(|| format!("Failed to fetch messages from '{folder}'"))?; + let fetches = session.uid_fetch(chunk, "BODY.PEEK[]").with_context(|| { + format!( + "Failed to fetch messages from '{}'", + sanitize_terminal_field(folder) + ) + })?; for fetch in fetches.iter() { - let uid = match fetch.uid { - Some(u) => u, - None => continue, + let (Some(uid), Some(body)) = (fetch.uid, fetch.body()) else { + continue; + }; + let Some(path) = planned.get(&(*folder, uid)) else { + continue; }; - if let Some(body) = fetch.body() { - let path = out_dir.join(format!("{safe_folder}_{uid}.eml")); - if path.exists() && !force { - skipped += 1; - continue; - } - std::fs::write(&path, body) - .with_context(|| format!("Failed to write '{}'", path.display()))?; - exported += 1; + match writer.write(path, (folder, uid), body, force)? { + WriteOutcome::Exported => exported += 1, + WriteOutcome::Skipped => skipped += 1, + WriteOutcome::Conflict => conflicts += 1, } } } } + if conflicts > 0 { + bail!( + "{conflicts} existing export file(s) hold a different message and were left \ + unchanged ({exported} exported, {skipped} already exported); use --force to \ + replace them or export to a new directory" + ); + } Ok((exported, skipped)) } @@ -82,41 +333,213 @@ pub fn export_messages( mod tests { use super::*; + #[cfg(not(windows))] + #[test] + fn encoded_folder_names_keep_letters_on_unix() { + assert_eq!(encode_folder_name("Work/Projects"), "Work%2FProjects"); + assert_eq!(encode_folder_name("Work_Projects"), "Work%5FProjects"); + assert_eq!(encode_folder_name("Work%2FProjects"), "Work%252FProjects"); + assert_eq!(encode_folder_name("my-folder"), "my-folder"); + assert_eq!(encode_folder_name("Café"), "Caf%C3%A9"); + } + + #[cfg(windows)] + #[test] + fn encoded_folder_names_encode_lowercase_on_windows() { + assert_eq!(encode_folder_name("Work/P"), "W%6F%72%6B%2FP"); + } + + #[test] + fn encoded_folder_names_do_not_collide_even_case_insensitively() { + assert_eq!(encode_folder_name("INBOX"), "INBOX"); + let folders = [ + "Work/Projects", + "Work_Projects", + "Work%2FProjects", + "work/projects", + "INBOX.Drafts", + "[Gmail]/All Mail", + ]; + let encoded: std::collections::HashSet = + folders.iter().map(|f| encode_folder_name(f)).collect(); + assert_eq!(encoded.len(), folders.len()); + if cfg!(windows) { + let folded: std::collections::HashSet = + encoded.iter().map(|e| e.to_lowercase()).collect(); + assert_eq!(folded.len(), folders.len()); + } + } + #[test] - fn sanitize_folder_name_simple() { - assert_eq!(sanitize_folder_name("INBOX"), "INBOX"); + fn encoded_folder_names_round_trip() { + fn decode(encoded: &str) -> String { + let bytes = encoded.as_bytes(); + let mut out = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap(); + out.push(u8::from_str_radix(hex, 16).unwrap()); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).unwrap() + } + for folder in ["Work/Projects", "Work_Projects", "100%", "Ärger ☃", "a.b-c"] { + let encoded = encode_folder_name(folder); + assert!(!encoded.contains('_')); + assert_eq!(decode(&encoded), folder); + } } #[test] - fn sanitize_folder_name_with_slash() { - assert_eq!(sanitize_folder_name("[Gmail]/Trash"), "_Gmail__Trash"); + fn overlong_export_name_is_an_error_not_a_truncation() { + let folder = "é".repeat(60); + let error = export_filename(&folder, 1).unwrap_err(); + assert!(error.to_string().contains("exceeds")); } #[test] - fn sanitize_folder_name_with_spaces() { - assert_eq!(sanitize_folder_name("Sent Items"), "Sent_Items"); + fn existing_file_is_skipped_only_when_it_holds_the_same_message() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("INBOX_1.eml"); + let mail = b"Message-ID: \r\nSubject: A\r\n\r\nbody"; + let rewritten = b"X-Migrated: yes\r\nMessage-ID: \r\nSubject: A\r\n\r\nbody"; + let other = b"Message-ID: \r\nSubject: B\r\n\r\nother"; + std::fs::write(&path, mail).unwrap(); + + for same in [&mail[..], &rewritten[..]] { + let mut writer = ExportWriter::default(); + assert!(matches!( + writer.write(&path, ("INBOX", 1), same, false).unwrap(), + WriteOutcome::Skipped + )); + } + for different in [&other[..], b"no headers"] { + let mut writer = ExportWriter::default(); + assert!(matches!( + writer.write(&path, ("INBOX", 1), different, false).unwrap(), + WriteOutcome::Conflict + )); + } + assert_eq!(std::fs::read(&path).unwrap(), mail); + + let mut writer = ExportWriter::default(); + assert!(matches!( + writer.write(&path, ("INBOX", 1), other, true).unwrap(), + WriteOutcome::Exported + )); + assert_eq!(std::fs::read(&path).unwrap(), other); } #[test] - fn sanitize_folder_name_preserves_hyphens() { - assert_eq!(sanitize_folder_name("my-folder"), "my-folder"); + fn message_id_check_reads_headers_up_to_the_cap_exactly() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("INBOX_1.eml"); + let fetched = b"Message-ID: \r\n\r\nrewritten body"; + let header_with_padding = |total: usize| { + let prefix = b"Message-ID: \r\nX-Pad: "; + let padding = total - prefix.len() - b"\r\n\r\n".len(); + let mut raw = prefix.to_vec(); + raw.extend(std::iter::repeat_n(b'x', padding)); + raw.extend_from_slice(b"\r\n\r\nold body"); + raw + }; + let cap = MAX_EXISTING_HEADER_BYTES as usize; + + std::fs::write(&path, header_with_padding(cap)).unwrap(); + assert!(holds_same_message(&path, fetched).unwrap()); + std::fs::write(&path, header_with_padding(cap + 1)).unwrap(); + assert!(!holds_same_message(&path, fetched).unwrap()); } + #[cfg(unix)] #[test] - fn sanitize_folder_name_empty() { - assert_eq!(sanitize_folder_name(""), ""); + fn new_exports_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("INBOX_1.eml"); + let mut writer = ExportWriter::default(); + writer.write(&path, ("INBOX", 1), b"body", false).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "mode {mode:o} grants group/other access"); } + #[cfg(unix)] #[test] - fn sanitize_folder_name_dots_and_special() { - assert_eq!(sanitize_folder_name("INBOX.Drafts"), "INBOX_Drafts"); - assert_eq!(sanitize_folder_name("Work/Projects"), "Work_Projects"); + fn symlinks_are_never_followed() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let sentinel = directory.path().join("sentinel"); + std::fs::write(&sentinel, b"sentinel").unwrap(); + let dangling_target = directory.path().join("missing-target"); + + for (name, target) in [ + ("live_1.eml", &sentinel), + ("dangling_1.eml", &dangling_target), + ] { + let link = directory.path().join(name); + symlink(target, &link).unwrap(); + + let mut writer = ExportWriter::default(); + assert!(matches!( + writer.write(&link, ("INBOX", 1), b"mail", false).unwrap(), + WriteOutcome::Conflict + )); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"sentinel"); + assert!(!dangling_target.exists()); + + let mut writer = ExportWriter::default(); + writer.write(&link, ("INBOX", 1), b"mail", true).unwrap(); + assert!(!std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(std::fs::read(&link).unwrap(), b"mail"); + assert_eq!(std::fs::read(&sentinel).unwrap(), b"sentinel"); + assert!(!dangling_target.exists()); + } } #[test] - fn eml_filename_format() { - let safe = sanitize_folder_name("[Gmail]/All Mail"); - let filename = format!("{safe}_{}.eml", 42); - assert_eq!(filename, "_Gmail__All_Mail_42.eml"); + fn directories_are_not_replaced() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("INBOX_1.eml"); + std::fs::create_dir(&path).unwrap(); + for force in [false, true] { + let mut writer = ExportWriter::default(); + assert!(writer.write(&path, ("INBOX", 1), b"mail", force).is_err()); + assert!(path.is_dir()); + } + } + + #[cfg(unix)] + #[test] + fn hard_linked_destinations_for_different_messages_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("A_1.eml"); + let second = directory.path().join("B_1.eml"); + std::fs::write(&first, b"original").unwrap(); + std::fs::hard_link(&first, &second).unwrap(); + + for force in [false, true] { + let mut writer = ExportWriter::default(); + writer.write(&first, ("A", 1), b"a", force).unwrap(); + let error = writer.write(&second, ("B", 1), b"b", force).unwrap_err(); + assert!(error + .to_string() + .contains("Export destinations alias different messages")); + std::fs::remove_file(&second).ok(); + std::fs::hard_link(&first, &second).unwrap(); + } + assert_eq!( + std::fs::read(&second).unwrap(), + std::fs::read(&first).unwrap() + ); } } diff --git a/src/main.rs b/src/main.rs index 509d0e3..9b0df9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,11 @@ use clap::parser::ValueSource; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, Table}; use indicatif::{ProgressBar, ProgressStyle}; -use regex::Regex; use std::fs::{File, OpenOptions}; use std::io::{ErrorKind, Read}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; -use std::sync::OnceLock; use std::time::Duration; use zeroize::Zeroize; @@ -27,21 +25,6 @@ fn spinner(msg: &str) -> ProgressBar { pb } -fn quota_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?i)\*\s+QUOTA\s+.*?\(([^)]+)\)").unwrap()) -} - -fn quota_resource_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(\w+)\s+(\d+)\s+(\d+)").unwrap()) -} - -fn status_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"(?i)\*\s+STATUS\s+.*?\(([^)]*)\)").unwrap()) -} - #[derive(Parser)] #[command( name = "slashmail", @@ -217,10 +200,10 @@ struct AttachmentsArgs { #[derive(Parser)] struct FilterArgs { /// Folder to search [default: INBOX] - #[arg(short, long)] + #[arg(short, long, conflicts_with = "all_folders")] folder: Option, - /// Search across all folders (excludes Trash, Spam) + /// Search across all folders (excludes Trash, Junk/Spam, and All Mail) #[arg(long)] all_folders: bool, @@ -350,7 +333,7 @@ struct MoveArgs { /// Destination folder #[arg(long)] - to: String, + dest: String, /// Limit number of messages to act on #[arg(short = 'n', long)] @@ -570,7 +553,7 @@ where R: Read, F: FnMut(&str) -> Option, { - ensure_secure_draft_transport(account)?; + connection::ensure_secure_transport(&account.host, account.tls)?; let credential = draft_credential_with(account, environment)?; let attachments = load_attachments(attachment_paths)?; let mut body = String::new(); @@ -684,14 +667,6 @@ fn safe_attachment_path(path: &Path) -> String { escaped } -fn ensure_secure_draft_transport(account: &config::ResolvedAccount) -> Result<()> { - if account.tls || connection::is_loopback_host(&account.host) { - return Ok(()); - } - - bail!("Draft and reply commands require TLS for non-loopback IMAP servers") -} - struct DraftSessionFactory<'a> { account: &'a config::ResolvedAccount, credential: DraftCredential, @@ -748,6 +723,7 @@ fn fetch_source_message( folder: &str, uid: u32, ) -> Result> { + search::validate_folder_name(folder)?; let safe_folder = display::sanitize_terminal_field(folder); session .examine(folder) @@ -919,12 +895,13 @@ fn cmd_attachments( let safe_folder = display::sanitize_terminal_field(folder); let sp = spinner(&format!("Inspecting attachments for UID {}...", args.uid)); let inspected = fetch_source_message(session, folder, args.uid).and_then(|raw| { - attachment::attachments_from_message(&raw).with_context(|| { - format!( - "Failed to inspect attachments for UID {} in '{}'", - args.uid, safe_folder - ) - }) + attachment::attachments_from_message(&raw, if args.save { &args.part } else { &[] }) + .with_context(|| { + format!( + "Failed to inspect attachments for UID {} in '{}'", + args.uid, safe_folder + ) + }) }); sp.finish_and_clear(); let attachments = inspected?; @@ -960,9 +937,11 @@ fn with_account_session(account: &config::ResolvedAccount, f: F) -> Result where F: FnOnce(&mut connection::ImapSession) -> Result, { + connection::ensure_secure_transport(&account.host, account.tls) + .with_context(|| format!("Account '{}'", safe_label(account)))?; let mut pass = get_password_for_account(account)?; - let sp = spinner(&format!("Connecting to {}...", account.label())); + let sp = spinner(&format!("Connecting to {}...", safe_label(account))); let session_result = connection::connect( &account.host, account.port, @@ -970,7 +949,7 @@ where &account.user, &pass, ) - .with_context(|| format!("Account '{}'", account.label())); + .with_context(|| format!("Account '{}'", safe_label(account))); sp.finish_and_clear(); // Clear password from memory on both success and error paths. @@ -982,6 +961,10 @@ where result } +fn safe_label(account: &config::ResolvedAccount) -> String { + display::sanitize_terminal_field(account.label()) +} + fn tag_messages(messages: &mut [display::MessageRow], account: &config::ResolvedAccount) { if let Some(name) = &account.name { for msg in messages { @@ -1006,7 +989,7 @@ fn search_accounts( for account in accounts { let mut messages = with_account_session(account, |session| { - let sp = spinner(&format!("Searching {}...", account.label())); + let sp = spinner(&format!("Searching {}...", safe_label(account))); let criteria = filter.to_criteria(limit, &account.default_folder); let result = search::search(session, &criteria); sp.finish_and_clear(); @@ -1063,28 +1046,20 @@ fn fetch_quota_rows( } let response = session - .run_command_and_read_response("GETQUOTAROOT INBOX") + .get_quota_root("INBOX") .context("GETQUOTAROOT failed")?; - let text = String::from_utf8_lossy(&response); - - // Parse: * QUOTA "root" (STORAGE used limit) (MESSAGE used limit) ... - let mut rows = Vec::new(); - for cap in quota_regex().captures_iter(&text) { - let inner = &cap[1]; - if let Some(m) = quota_resource_regex().captures(inner) { - let used: u64 = m[2].parse().unwrap_or(0); - let limit: u64 = m[3].parse().unwrap_or(0); - rows.push(QuotaRow { - account: account.name.clone(), - resource: m[1].to_string(), - used, - limit, - }); - } - } - - Ok(rows) + Ok(response + .quotas() + .iter() + .flat_map(|quota| quota.resources.iter()) + .map(|resource| QuotaRow { + account: account.name.clone(), + resource: resource.name.to_string(), + used: resource.usage, + limit: resource.limit, + }) + .collect()) } fn display_quota_rows(rows: &[QuotaRow], include_account: bool) { @@ -1121,9 +1096,11 @@ fn display_quota_rows(rows: &[QuotaRow], include_account: bool) { let mut cells = Vec::new(); if include_account { - cells.push(Cell::new(row.account.as_deref().unwrap_or(""))); + cells.push(Cell::new(display::sanitize_terminal_field( + row.account.as_deref().unwrap_or(""), + ))); } - cells.push(Cell::new(&row.resource)); + cells.push(Cell::new(display::sanitize_terminal_field(&row.resource))); cells.push(Cell::new(&used_str)); cells.push(Cell::new(&limit_str)); let pct_cell = if pct >= 90.0 { @@ -1146,7 +1123,7 @@ fn cmd_quota_accounts(accounts: &[config::ResolvedAccount]) -> Result<()> { let mut rows = Vec::new(); for account in accounts { let mut account_rows = with_account_session(account, |session| { - let sp = spinner(&format!("Fetching quota for {}...", account.label())); + let sp = spinner(&format!("Fetching quota for {}...", safe_label(account))); let result = fetch_quota_rows(session, account); sp.finish_and_clear(); result @@ -1170,64 +1147,22 @@ fn fetch_status_rows( session: &mut connection::ImapSession, account: &config::ResolvedAccount, ) -> Result> { - let folders = session - .list(Some(""), Some("*")) - .context("Failed to list folders")?; - let folder_names: Vec = folders.iter().map(|f| f.name().to_string()).collect(); - - let mut rows = Vec::new(); - - for name in &folder_names { - // Folder names are server-controlled, so always quote via imap_quote() - // which strips control chars and escapes IMAP-special characters. - let quoted = search::imap_quote(name); - let cmd = format!("STATUS {quoted} (MESSAGES UNSEEN RECENT)"); - let response = match session.run_command_and_read_response(&cmd) { - Ok(r) => r, - Err(_) => { - rows.push(StatusRow { - account: account.name.clone(), - folder: name.clone(), - messages: None, - unseen: None, - recent: None, - }); - continue; - } - }; - - let text = String::from_utf8_lossy(&response); - let mut messages: u32 = 0; - let mut unseen: u32 = 0; - let mut recent: u32 = 0; - - if let Some(cap) = status_regex().captures(&text) { - let attrs = &cap[1]; - // Parse key-value pairs: MESSAGES 142 UNSEEN 12 RECENT 3 - let tokens: Vec<&str> = attrs.split_whitespace().collect(); - for pair in tokens.chunks(2) { - if pair.len() == 2 { - let val: u32 = pair[1].parse().unwrap_or(0); - match pair[0].to_uppercase().as_str() { - "MESSAGES" => messages = val, - "UNSEEN" => unseen = val, - "RECENT" => recent = val, - _ => {} - } - } + let folders = session.list_all().context("Failed to list folders")?; + + Ok(folders + .into_iter() + .map(|mailbox| { + // A failed or malformed STATUS leaves counts unknown rather than zero. + let counts = session.status_counts(&mailbox.name).unwrap_or_default(); + StatusRow { + account: account.name.clone(), + folder: mailbox.name, + messages: counts.messages, + unseen: counts.unseen, + recent: counts.recent, } - } - - rows.push(StatusRow { - account: account.name.clone(), - folder: name.clone(), - messages: Some(messages), - unseen: Some(unseen), - recent: Some(recent), - }); - } - - Ok(rows) + }) + .collect()) } fn status_cell(value: Option) -> Cell { @@ -1254,9 +1189,11 @@ fn display_status_rows(rows: &[StatusRow], include_account: bool) { let mut cells = Vec::new(); if include_account { - cells.push(Cell::new(row.account.as_deref().unwrap_or(""))); + cells.push(Cell::new(display::sanitize_terminal_field( + row.account.as_deref().unwrap_or(""), + ))); } - cells.push(Cell::new(&row.folder)); + cells.push(Cell::new(display::sanitize_terminal_field(&row.folder))); cells.push(status_cell(row.messages)); cells.push(status_cell(row.unseen)); cells.push(status_cell(row.recent)); @@ -1287,7 +1224,7 @@ fn cmd_status_accounts(accounts: &[config::ResolvedAccount]) -> Result<()> { let mut account_rows = with_account_session(account, |session| { let sp = spinner(&format!( "Fetching folder status for {}...", - account.label() + safe_label(account) )); let result = fetch_status_rows(session, account); sp.finish_and_clear(); @@ -1331,7 +1268,7 @@ fn cmd_export( let confirm = inquire::Confirm::new(&format!( "Export {} message(s) to {}?", messages.len(), - out_dir.display() + display::sanitize_terminal_field(&out_dir.display().to_string()) )) .with_default(false) .prompt() @@ -1348,9 +1285,12 @@ fn cmd_export( export::export_messages(session, &messages, &criteria.folder, &out_dir, args.force)?; sp.finish_and_clear(); - print!("Exported {exported} message(s) to {}", out_dir.display()); + print!( + "Exported {exported} message(s) to {}", + display::sanitize_terminal_field(&out_dir.display().to_string()) + ); if skipped > 0 { - print!(" ({skipped} skipped, already exist)"); + print!(" ({skipped} skipped, already exported)"); } println!(); Ok(()) @@ -1453,39 +1393,52 @@ fn cmd_mark( } let store_ops = mark_store_ops(args.read, args.unread, args.set_flagged, args.clear_flagged); + // Validate every row's mailbox identity before any mutation. + let groups = search::group_message_uids(&messages, &criteria.folder)?; let sp = spinner("Updating flags..."); - // Group by folder - let mut by_folder: std::collections::HashMap> = - std::collections::HashMap::new(); - for msg in &messages { - let folder = msg - .folder - .clone() - .unwrap_or_else(|| criteria.folder.clone()); - by_folder.entry(folder).or_default().push(msg.uid); - } - let mut total = 0usize; - for (folder, uids) in &by_folder { - session - .select(folder) - .with_context(|| format!("Failed to select '{folder}'"))?; - - for chunk in &search::build_uid_set(uids) { - for op in &store_ops { - session - .uid_store(chunk, op) - .with_context(|| format!("Failed to store flags in '{folder}'"))?; + let mut update_groups = || -> Result<()> { + for (folder, (uid_validity, uids)) in &groups { + // `partly` counts messages in the failing batch that already + // received some, but not all, of the requested flag changes. + let failed = |total: usize, partly: usize| { + let partial = if partly > 0 { + format!("; {partly} more partly updated") + } else { + String::new() + }; + format!( + "Failed to store flags in '{}' ({total} already updated{partial})", + display::sanitize_terminal_field(folder) + ) + }; + search::select_verified(session, folder, *uid_validity) + .with_context(|| failed(total, 0))?; + for chunk in &search::build_uid_set(uids) { + let present = + search::existing_uids(session, chunk).with_context(|| failed(total, 0))?; + for set in &search::build_uid_set(&present) { + let count = search::uid_set_len(set); + for (applied, op) in store_ops.iter().enumerate() { + let partly = if applied > 0 { count } else { 0 }; + session + .uid_store(set, op) + .with_context(|| failed(total, partly))?; + } + total += count; + } } } - - total += uids.len(); - } - + Ok(()) + }; + let result = update_groups(); sp.finish_and_clear(); + result?; + println!("Updated {total} message(s)."); + delete::report_vanished(messages.len(), total); Ok(()) } @@ -1504,26 +1457,25 @@ fn count_rows_for_account( let default_folder = &account.default_folder; let criteria = args.filter.to_criteria(None, default_folder); let query = search::build_query(&criteria)?; + search::ensure_query_supported(session, &query)?; if criteria.all_folders { - let folders = session - .list(Some(""), Some("*")) - .context("Failed to list folders")?; - let folder_names: Vec = folders - .iter() - .map(|f| f.name().to_string()) - .filter(|n| !search::folders_to_skip(n)) - .collect(); + let folder_names = search::searchable_folders(session)?; let mut results = Vec::new(); for folder in &folder_names { - match session.select(folder) { - Ok(_) => {} - Err(e) => { - eprintln!("Warning: skipping folder '{folder}': {e}"); - continue; - } + let safe_folder = display::sanitize_terminal_field(folder); + if let Err(e) = search::validate_folder_name(folder) { + eprintln!("Warning: skipping folder '{safe_folder}': {e}"); + continue; + } + if let Err(e) = session.examine(folder) { + eprintln!( + "Warning: skipping folder '{safe_folder}': {}", + display::sanitize_terminal_field(&e.to_string()) + ); + continue; } match session.uid_search(&query) { Ok(uids) => { @@ -1537,16 +1489,23 @@ fn count_rows_for_account( } } Err(e) => { - eprintln!("Warning: search failed in '{folder}': {e}"); + eprintln!( + "Warning: search failed in '{safe_folder}': {}", + display::sanitize_terminal_field(&format!("{e:#}")) + ); } } } Ok(results) } else { - session - .select(&criteria.folder) - .with_context(|| format!("Failed to select '{}'", criteria.folder))?; + search::validate_folder_name(&criteria.folder)?; + session.examine(&criteria.folder).with_context(|| { + format!( + "Failed to examine '{}'", + display::sanitize_terminal_field(&criteria.folder) + ) + })?; let uids = session.uid_search(&query).context("IMAP SEARCH failed")?; Ok(vec![CountRow { @@ -1652,7 +1611,7 @@ fn display_count_text(rows: &[CountRow], all_folders: bool, include_account: boo println!( "{} message(s) in {}", row.count, - row.folder.as_deref().unwrap_or("") + display::sanitize_terminal_field(row.folder.as_deref().unwrap_or("")) ); } if rows.len() > 1 { @@ -1662,7 +1621,7 @@ fn display_count_text(rows: &[CountRow], all_folders: bool, include_account: boo println!( "{} message(s) in {}", row.count, - row.folder.as_deref().unwrap_or("") + display::sanitize_terminal_field(row.folder.as_deref().unwrap_or("")) ); } return; @@ -1682,9 +1641,13 @@ fn display_count_text(rows: &[CountRow], all_folders: bool, include_account: boo table.set_header(header); for row in rows { - let mut cells = vec![Cell::new(row.account.as_deref().unwrap_or(""))]; + let mut cells = vec![Cell::new(display::sanitize_terminal_field( + row.account.as_deref().unwrap_or(""), + ))]; if all_folders { - cells.push(Cell::new(row.folder.as_deref().unwrap_or(""))); + cells.push(Cell::new(display::sanitize_terminal_field( + row.folder.as_deref().unwrap_or(""), + ))); } cells.push(Cell::new(row.count)); table.add_row(cells); @@ -1706,7 +1669,7 @@ fn cmd_count_accounts(accounts: &[config::ResolvedAccount], args: &CountArgs) -> let mut rows = Vec::new(); for account in accounts { let mut account_rows = with_account_session(account, |session| { - let sp = spinner(&format!("Counting {}...", account.label())); + let sp = spinner(&format!("Counting {}...", safe_label(account))); let result = count_rows_for_account(session, account, args); sp.finish_and_clear(); result @@ -1745,7 +1708,7 @@ fn cmd_read_accounts(accounts: &[config::ResolvedAccount], args: &ReadArgs) -> R let mut criteria = args.filter.to_criteria(limit, &account.default_folder); criteria.uid = args.uid; - let sp = spinner(&format!("Searching {}...", account.label())); + let sp = spinner(&format!("Searching {}...", safe_label(account))); let result = search::search(session, &criteria); sp.finish_and_clear(); let mut account_messages = result?; @@ -1760,7 +1723,7 @@ fn cmd_read_accounts(accounts: &[config::ResolvedAccount], args: &ReadArgs) -> R tag_messages(&mut account_messages, account); - let sp = spinner(&format!("Fetching from {}...", account.label())); + let sp = spinner(&format!("Fetching from {}...", safe_label(account))); let fetched = read::fetch_message_bodies(session, &account_messages, &criteria.folder); sp.finish_and_clear(); @@ -1791,7 +1754,23 @@ fn cmd_read_accounts(accounts: &[config::ResolvedAccount], args: &ReadArgs) -> R Ok(()) } -fn main() -> Result<()> { +fn main() -> std::process::ExitCode { + match run() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("Error: {}", render_error(&error)); + std::process::ExitCode::FAILURE + } + } +} + +/// Error chains can carry server- or message-controlled text; render them +/// as one inert terminal line. +fn render_error(error: &anyhow::Error) -> String { + display::sanitize_terminal_field(&format!("{error:#}")) +} + +fn run() -> Result<()> { let matches = Cli::command().get_matches(); let user_explicit = matches.value_source("user") == Some(ValueSource::CommandLine); let tls_explicit = matches.value_source("tls") == Some(ValueSource::CommandLine); @@ -1875,7 +1854,7 @@ fn main() -> Result<()> { delete::search_and_move_with_account( session, &criteria, - &args.to, + &args.dest, args.yes, args.dry_run, account.name.as_deref(), @@ -2162,7 +2141,11 @@ mod tests { fn directory_is_rejected_as_a_non_regular_attachment() { let directory = tempfile::tempdir().unwrap(); let error = load_attachments(&[directory.path().to_path_buf()]).unwrap_err(); - assert!(error.to_string().contains("not a regular file")); + // Windows refuses to open a directory as a file; Unix opens it and + // the regular-file check rejects it. + if cfg!(unix) { + assert!(error.to_string().contains("not a regular file")); + } } #[cfg(unix)] diff --git a/src/read.rs b/src/read.rs index 18efe6a..54651f0 100644 --- a/src/read.rs +++ b/src/read.rs @@ -5,7 +5,7 @@ use serde::Serialize; use std::collections::HashMap; use crate::connection::ImapSession; -use crate::display::MessageRow; +use crate::display::{sanitize_terminal_body, sanitize_terminal_field, MessageRow}; use crate::search; pub type MessageBodyMap = HashMap<(Option, String, u32), Vec>; @@ -47,26 +47,21 @@ pub fn fetch_message_bodies( messages: &[MessageRow], default_folder: &str, ) -> Result>> { - let mut by_folder: HashMap> = HashMap::new(); - for msg in messages { - let folder = msg - .folder - .clone() - .unwrap_or_else(|| default_folder.to_string()); - by_folder.entry(folder).or_default().push(msg.uid); - } + // Validate every row's mailbox identity before fetching any body. + let groups = search::group_message_uids(messages, default_folder)?; let mut uid_bodies: HashMap<(String, u32), Vec> = HashMap::new(); - for (folder, uids) in &by_folder { - session - .select(folder) - .with_context(|| format!("Failed to select '{folder}'"))?; + for (folder, (uid_validity, uids)) in &groups { + search::examine_verified(session, folder, *uid_validity)?; for chunk in &search::build_uid_set(uids) { - let fetches = session - .uid_fetch(chunk, "BODY.PEEK[]") - .with_context(|| format!("Failed to fetch messages from '{folder}'"))?; + let fetches = session.uid_fetch(chunk, "BODY.PEEK[]").with_context(|| { + format!( + "Failed to fetch messages from '{}'", + sanitize_terminal_field(folder) + ) + })?; for fetch in fetches.iter() { let uid = match fetch.uid { @@ -74,7 +69,7 @@ pub fn fetch_message_bodies( None => continue, }; if let Some(body) = fetch.body() { - uid_bodies.insert((folder.clone(), uid), body.to_vec()); + uid_bodies.insert((folder.to_string(), uid), body.to_vec()); } } } @@ -99,7 +94,7 @@ pub fn print_messages_with_bodies( let account = msg .account .as_deref() - .map(|name| format!(" in account '{name}'")) + .map(|name| format!(" in account '{}'", sanitize_terminal_field(name))) .unwrap_or_default(); eprintln!("Warning: could not fetch body for UID {}{account}", msg.uid); } @@ -195,9 +190,12 @@ fn print_message(raw: &[u8]) { let parsed = match mailparse::parse_mail(raw) { Ok(m) => m, Err(e) => { - eprintln!("Warning: failed to parse message: {e}"); + eprintln!( + "Warning: failed to parse message: {}", + sanitize_terminal_field(&e.to_string()) + ); let text = String::from_utf8_lossy(raw); - println!("{text}"); + println!("{}", sanitize_terminal_body(&text)); return; } }; @@ -206,7 +204,7 @@ fn print_message(raw: &[u8]) { let get_header = |name: &str| -> String { for h in &parsed.headers { if h.get_key().eq_ignore_ascii_case(name) { - return h.get_value(); + return sanitize_terminal_field(&h.get_value()); } } String::new() @@ -233,7 +231,7 @@ fn print_message(raw: &[u8]) { if text.is_empty() { println!("[No text content]"); } else { - println!("{}", text.trim_end()); + println!("{}", sanitize_terminal_body(text.trim_end())); } if let Some(summary) = render_attachment_summary(&attachments) { @@ -265,51 +263,156 @@ fn extract_body(parsed: &mailparse::ParsedMail) -> (String, Vec) { } fn display_text_body(parsed: &mailparse::ParsedMail) -> String { - let mut text_plain = None; - let mut text_html = None; - collect_display_text_parts(parsed, &mut text_plain, &mut text_html); + collect_body_text(parsed, BodyMode::Display) + .expect("display mode handles decode errors") + .unwrap_or_default() +} - if let Some(text) = text_plain { - return text; - } +#[derive(Clone, Copy)] +enum BodyMode { + Display, + Strict, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Representation { + Plain, + Html, +} - match text_html { - Some(html) => match html2text::from_read(html.as_bytes(), 80) { - Ok(converted) => converted, - Err(error) => { - eprintln!("Warning: failed to convert HTML body to text: {error}"); - html +fn body_representation(part: &mailparse::ParsedMail<'_>) -> Option { + if attachment::is_attachment(part) { + return None; + } + match part.ctype.mimetype.to_ascii_lowercase().as_str() { + "text/plain" => Some(Representation::Plain), + "text/html" => Some(Representation::Html), + "multipart/related" => related_root(part).and_then(body_representation), + "multipart/alternative" => { + let mut preferred = None; + for child in &part.subparts { + match body_representation(child) { + Some(Representation::Plain) => preferred = Some(Representation::Plain), + Some(Representation::Html) if preferred.is_none() => { + preferred = Some(Representation::Html); + } + _ => {} + } + } + preferred + } + mime if mime.starts_with("multipart/") => { + let mut representation = None; + for child in &part.subparts { + match body_representation(child) { + Some(Representation::Plain) => return Some(Representation::Plain), + Some(Representation::Html) => representation = Some(Representation::Html), + None => {} + } } - }, - None => String::new(), + representation + } + _ => None, } } -fn collect_display_text_parts( - part: &mailparse::ParsedMail, - text_plain: &mut Option, - text_html: &mut Option, -) { - let mime = part.ctype.mimetype.to_lowercase(); +fn related_root<'a>(part: &'a mailparse::ParsedMail<'_>) -> Option<&'a mailparse::ParsedMail<'a>> { + let start = part.ctype.params.get("start"); + start + .and_then(|start| { + let wanted = start.trim().trim_start_matches('<').trim_end_matches('>'); + part.subparts.iter().find(|child| { + child + .headers + .get_first_value("Content-ID") + .is_some_and(|id| { + id.trim().trim_start_matches('<').trim_end_matches('>') == wanted + }) + }) + }) + .or_else(|| part.subparts.first()) +} +fn collect_body_text(part: &mailparse::ParsedMail<'_>, mode: BodyMode) -> Result> { if attachment::is_attachment(part) { - return; - } - - if mime.starts_with("multipart/") { - for sub in &part.subparts { - collect_display_text_parts(sub, text_plain, text_html); + return Ok(None); + } + let mime = part.ctype.mimetype.to_ascii_lowercase(); + match mime.as_str() { + "text/plain" | "text/html" => { + let text = match mode { + BodyMode::Display => display_part_text(part, &mime), + BodyMode::Strict => part + .get_body() + .with_context(|| format!("Failed to decode {mime} body"))?, + }; + if mime == "text/plain" { + return Ok(Some(text)); + } + let converted = match html2text::from_read(text.as_bytes(), 80) { + Ok(converted) => converted, + Err(error) => match mode { + BodyMode::Strict => { + return Err(error).context("Failed to convert HTML body to text"); + } + BodyMode::Display => { + eprintln!( + "Warning: failed to convert HTML body to text: {}", + sanitize_terminal_field(&error.to_string()) + ); + text + } + }, + }; + Ok(Some(converted)) } - } else if mime == "text/plain" && text_plain.is_none() { - *text_plain = Some(display_part_text(part, "text/plain")); - } else if mime == "text/html" && text_html.is_none() { - *text_html = Some(display_part_text(part, "text/html")); + "multipart/related" => related_root(part) + .map(|root| collect_body_text(root, mode)) + .unwrap_or(Ok(None)), + "multipart/alternative" => { + let mut plain = None; + let mut html = None; + for child in &part.subparts { + match body_representation(child) { + Some(Representation::Plain) => plain = Some(child), + Some(Representation::Html) => html = Some(child), + None => {} + } + } + match plain.or(html) { + Some(child) => collect_body_text(child, mode), + None => Ok(None), + } + } + mime if mime.starts_with("multipart/") => { + let mut combined: Option = None; + for child in &part.subparts { + if let Some(text) = collect_body_text(child, mode)? { + if text.trim_end_matches(['\r', '\n']).is_empty() { + continue; + } + match &mut combined { + Some(previous) => { + previous.truncate(previous.trim_end_matches(['\r', '\n']).len()); + previous.push_str("\n\n"); + previous.push_str(&text); + } + None => combined = Some(text), + } + } + } + Ok(combined) + } + _ => Ok(None), } } fn display_part_text(part: &mailparse::ParsedMail, mime: &str) -> String { part.get_body().unwrap_or_else(|error| { - eprintln!("Warning: failed to decode {mime} body: {error}"); + eprintln!( + "Warning: failed to decode {mime} body: {}", + sanitize_terminal_field(&error.to_string()) + ); raw_part_text(part) }) } @@ -326,59 +429,97 @@ fn raw_part_text(part: &mailparse::ParsedMail) -> String { String::from_utf8_lossy(raw).into_owned() } -/// Return the first usable, decoded, non-attachment text body. -/// -/// Plain text is preferred across the full MIME tree. When only HTML is -/// available it is converted to text. Decode and conversion failures are -/// returned to the caller so stateful draft orchestration can fail before -/// mutating a mailbox. +/// Decode the selected MIME body without silently substituting undecodable alternatives. pub(crate) fn decoded_text_body(parsed: &mailparse::ParsedMail) -> Result> { - let mut text_plain = None; - let mut text_html = None; - collect_text_parts(parsed, &mut text_plain, &mut text_html)?; - - if let Some(text) = text_plain { - return Ok(Some(text)); - } + collect_body_text(parsed, BodyMode::Strict) +} - match text_html { - Some(html) => html2text::from_read(html.as_bytes(), 80) - .context("Failed to convert HTML body to text") - .map(Some), - None => Ok(None), - } +#[cfg(test)] +pub(crate) fn nested_multipart_fixture(depth: usize) -> Vec { + let mut message = b"Content-Type: text/plain\r\n\r\nDeep body".to_vec(); + for index in 0..depth { + let boundary = format!("depth{index}"); + let mut outer = + format!("Content-Type: multipart/mixed; boundary={boundary}\r\n\r\n--{boundary}\r\n") + .into_bytes(); + outer.extend_from_slice(&message); + outer.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + message = outer; + } + message } -fn collect_text_parts( - part: &mailparse::ParsedMail, - text_plain: &mut Option, - text_html: &mut Option, -) -> Result<()> { - let mime = part.ctype.mimetype.to_lowercase(); +#[cfg(test)] +mod tests { + use super::*; - if attachment::is_attachment(part) { - return Ok(()); + #[test] + fn structural_body_selection_composes_mixed_and_limits_alternatives_and_related() { + let raw = b"Content-Type: multipart/mixed; boundary=outer\r\n\r\n\ +--outer\r\nContent-Type: text/plain\r\n\r\nIntroduction\r\n\ +--outer\r\nContent-Type: multipart/alternative; boundary=alt\r\n\r\n\ +--alt\r\nContent-Type: text/html\r\n\r\n

Wrong alternative

\r\n\ +--alt\r\nContent-Type: text/plain\r\n\r\nFinal instructions\r\n--alt--\r\n\ +--outer\r\nContent-Type: multipart/related; boundary=rel; start=\"\"\r\n\r\n\ +--rel\r\nContent-Type: text/plain\r\nContent-ID: \r\n\r\nResource must not leak\r\n\ +--rel\r\nContent-Type: text/html\r\nContent-ID: \r\n\r\n

Root content

\r\n\ +--rel--\r\n--outer--"; + let parsed = mailparse::parse_mail(raw).unwrap(); + for text in [ + decoded_text_body(&parsed).unwrap().unwrap(), + display_text_body(&parsed), + ] { + assert!( + text.starts_with("Introduction\n\nFinal instructions\n\nRoot content"), + "{text}" + ); + assert!(!text.contains("Wrong alternative")); + assert!(!text.contains("Resource must not leak")); + } } - if mime.starts_with("multipart/") { - for sub in &part.subparts { - collect_text_parts(sub, text_plain, text_html)?; - } - } else if mime == "text/plain" && text_plain.is_none() { - *text_plain = Some( - part.get_body() - .context("Failed to decode text/plain body")?, + #[test] + fn unselected_alternative_decode_error_is_not_visited() { + let raw = b"Content-Type: multipart/alternative; boundary=x\r\n\r\n\ +--x\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n%%%\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nSelected\r\n--x--"; + let parsed = mailparse::parse_mail(raw).unwrap(); + assert_eq!( + decoded_text_body(&parsed).unwrap().as_deref(), + Some("Selected") ); - } else if mime == "text/html" && text_html.is_none() { - *text_html = Some(part.get_body().context("Failed to decode text/html body")?); } - Ok(()) -} + #[test] + fn alternative_picks_last_plain_and_selected_decode_failure_is_strict() { + let raw = b"Content-Type: multipart/alternative; boundary=x\r\n\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nOld\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nNew\r\n--x--"; + let parsed = mailparse::parse_mail(raw).unwrap(); + assert_eq!(decoded_text_body(&parsed).unwrap().as_deref(), Some("New")); -#[cfg(test)] -mod tests { - use super::*; + let broken = b"Content-Type: multipart/alternative; boundary=x\r\n\r\n\ +--x\r\nContent-Type: text/plain\r\n\r\nOld\r\n\ +--x\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n%%%\r\n--x--"; + let parsed = mailparse::parse_mail(broken).unwrap(); + assert!(decoded_text_body(&parsed) + .unwrap_err() + .to_string() + .contains("text/plain")); + assert!(display_text_body(&parsed).contains("%%%")); + } + + #[test] + fn upstream_depth_limit_controls_decoded_body() { + let accepted = nested_multipart_fixture(100); + let parsed = mailparse::parse_mail(&accepted).unwrap(); + assert_eq!( + decoded_text_body(&parsed).unwrap().as_deref(), + Some("Deep body") + ); + let rejected = nested_multipart_fixture(101); + assert!(mailparse::parse_mail(&rejected).is_err()); + } #[test] fn extract_body_plain_text() { @@ -405,6 +546,7 @@ mod tests { seen: false, answered: false, flagged: false, + uid_validity: None, } } diff --git a/src/search.rs b/src/search.rs index a79852e..662ee59 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,10 +1,14 @@ use anyhow::{bail, Context, Result}; use regex::Regex; +use std::collections::BTreeMap; use crate::connection::ImapSession; -use crate::display::MessageRow; +use crate::display::{sanitize_terminal_field, MessageRow}; +use crate::draft::MailboxListing; use imap::types::Flag; +pub const LITERAL_PLUS_REQUIRED: &str = "Non-ASCII search requires server support for LITERAL+"; + pub struct SearchCriteria { pub folder: String, pub all_folders: bool, @@ -40,6 +44,40 @@ pub fn imap_quote(s: &str) -> String { format!("\"{escaped}\"") } +/// Serialize a user search term. ASCII terms become quoted strings; other +/// terms become UTF-8 non-synchronizing literals (RFC 7888), whose length +/// is the byte count of the sanitized text. +fn quote_search_term(value: &str) -> String { + let clean = sanitize(value); + if clean.is_ascii() { + imap_quote(&clean) + } else { + format!("{{{}+}}\r\n{clean}", clean.len()) + } +} + +/// Reject a built query the connected server cannot receive. Non-ASCII +/// terms are sent as LITERAL+ literals; without that extension the search +/// fails here rather than silently matching nothing. +pub fn ensure_query_supported(session: &ImapSession, query: &str) -> Result<()> { + if !query.is_ascii() && !session.has_capability("LITERAL+") { + bail!(LITERAL_PLUS_REQUIRED); + } + Ok(()) +} + +/// Mailbox names are sent to the server unchanged; reject names that cannot +/// be sent safely instead of silently altering them. +pub fn validate_folder_name(folder: &str) -> Result<()> { + if folder.is_empty() { + bail!("Folder name must not be empty"); + } + if folder.chars().any(char::is_control) { + bail!("Folder name must not contain control characters"); + } + Ok(()) +} + const MONTH_ABBRS: [&str; 12] = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; @@ -163,23 +201,24 @@ pub fn build_query(criteria: &SearchCriteria) -> Result { if let Some(uid) = criteria.uid { parts.push(format!("UID {uid}")); } - if let Some(ref subj) = criteria.subject { - parts.push(format!("SUBJECT {}", imap_quote(subj))); - } - if let Some(ref from) = criteria.from { - parts.push(format!("FROM {}", imap_quote(from))); - } - if let Some(ref to) = criteria.to { - parts.push(format!("TO {}", imap_quote(to))); - } - if let Some(ref cc) = criteria.cc { - parts.push(format!("CC {}", imap_quote(cc))); - } - if let Some(ref body) = criteria.body { - parts.push(format!("BODY {}", imap_quote(body))); - } - if let Some(ref text) = criteria.text { - parts.push(format!("TEXT {}", imap_quote(text))); + let text_terms = [ + ("SUBJECT", "subject", &criteria.subject), + ("FROM", "from", &criteria.from), + ("TO", "to", &criteria.to), + ("CC", "cc", &criteria.cc), + ("BODY", "body", &criteria.body), + ("TEXT", "text", &criteria.text), + ]; + for (key, flag, value) in text_terms { + if let Some(value) = value { + // An empty IMAP substring matches every message, so a filter + // emptied by an unset variable or stripped controls must not + // silently widen the selection. + if sanitize(value).trim().is_empty() { + bail!("--{flag} must not be empty"); + } + parts.push(format!("{key} {}", quote_search_term(value))); + } } if criteria.seen { parts.push("SEEN".to_string()); @@ -246,17 +285,6 @@ fn parse_size(s: &str) -> Result { .ok_or_else(|| anyhow::anyhow!("Size '{s}' is too large")) } -/// Truncate a string to at most `max` characters, appending "..." if truncated. -/// Safe for multi-byte UTF-8. -fn truncate_str(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else { - let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); - format!("{truncated}...") - } -} - /// Parse SORT response bytes into a Vec of UIDs (preserving server order). fn parse_sort_response(data: &[u8]) -> Result> { let text = String::from_utf8_lossy(data); @@ -306,12 +334,29 @@ fn try_uid_sort(session: &mut ImapSession, query: &str) -> Result { - eprintln!("SORT failed, falling back to SEARCH: {e}"); + eprintln!( + "SORT failed, falling back to SEARCH: {}", + sanitize_terminal_field(&e.to_string()) + ); Ok(None) } } } +/// Number of UIDs in a set built by `build_uid_set` (sorted, deduplicated). +pub fn uid_set_len(set: &str) -> usize { + set.split(',') + .map(|part| match part.split_once(':') { + Some((start, end)) => { + let start: usize = start.parse().unwrap_or(0); + let end: usize = end.parse().unwrap_or(0); + end.saturating_sub(start) + 1 + } + None => 1, + }) + .sum() +} + /// Build UID set strings with range compression, chunked to stay under IMAP command length limits. /// Consecutive UIDs are compressed into `start:end` ranges. /// Each returned string stays under MAX_UID_SET_LENGTH chars. @@ -371,11 +416,12 @@ fn fetch_messages( include_folder: bool, limit: Option, ) -> Result> { - // Sanitize folder name for the raw SORT path - let clean_folder = sanitize(folder); - session - .select(&clean_folder) - .with_context(|| format!("Failed to select folder '{clean_folder}'"))?; + validate_folder_name(folder)?; + let safe_folder = sanitize_terminal_field(folder); + let opened = session + .examine(folder) + .with_context(|| format!("Failed to examine folder '{safe_folder}'"))?; + let uid_validity = opened.uid_validity; // Try server-side SORT first, fall back to SEARCH + client sort let (ordered_uids, pre_sorted) = match try_uid_sort(session, query)? { @@ -399,6 +445,7 @@ fn fetch_messages( } let uid_chunks = build_uid_set(&ordered_uids); + let requested: std::collections::HashSet = ordered_uids.iter().copied().collect(); // FETCH results may come back in arbitrary order; index by UID let mut by_uid = std::collections::HashMap::new(); @@ -417,13 +464,20 @@ fn fetch_messages( _ => { if !warned_invalid_uid { eprintln!( - "Warning: skipping fetched message(s) with missing/invalid UID in '{clean_folder}'" + "Warning: skipping fetched message(s) with missing/invalid UID in '{safe_folder}'" ); warned_invalid_uid = true; } continue; } }; + // Servers may interleave unsolicited FETCH responses (flag changes + // made by other clients). Only searched UIDs become rows, and a + // flags-only response never replaces a row that has headers. + if !requested.contains(&uid) || (fetch.header().is_none() && by_uid.contains_key(&uid)) + { + continue; + } let size = fetch.size.unwrap_or(0); let header_bytes = fetch.header().unwrap_or(b""); let header_str = String::from_utf8_lossy(header_bytes); @@ -465,21 +519,14 @@ fn fetch_messages( let flags = fetch.flags(); let has_flag = |wanted, name| has_system_flag(flags, wanted, name); - from = truncate_str(&from, 40); - subject = truncate_str(&subject, 60); let timestamp = mailparse::dateparse(&date).unwrap_or(0); - - if let Some(pos) = date.find(" +").or_else(|| date.find(" -")) { - date.truncate(pos); - } - by_uid.insert( uid, MessageRow { account: None, uid, folder: if include_folder { - Some(clean_folder.clone()) + Some(folder.to_string()) } else { None }, @@ -494,6 +541,7 @@ fn fetch_messages( seen: has_flag(Flag::Seen, "\\Seen"), answered: has_flag(Flag::Answered, "\\Answered"), flagged: has_flag(Flag::Flagged, "\\Flagged"), + uid_validity, }, ); } @@ -581,36 +629,76 @@ pub fn message_ids(value: &str) -> Vec { ids } -pub fn folders_to_skip(name: &str) -> bool { - let lower = name.to_lowercase(); - lower == "trash" - || lower == "spam" - || lower == "junk" - || lower.contains("all mail") - || lower == "[gmail]/all mail" - || lower == "[gmail]/spam" - || lower == "[gmail]/trash" +/// Mailboxes excluded from `--all-folders`: special-use `\All`, `\Trash`, and +/// `\Junk` mailboxes, and well-known aggregate, trash, and spam names (exact, +/// case-insensitive) for servers without special-use attributes. +pub fn folders_to_skip(mailbox: &MailboxListing) -> bool { + if mailbox.attributes.iter().any(|attribute| { + ["\\All", "\\Trash", "\\Junk"] + .iter() + .any(|special| attribute.eq_ignore_ascii_case(special)) + }) { + return true; + } + let lower = mailbox.name.to_lowercase(); + matches!( + lower.as_str(), + "all mail" + | "[gmail]/all mail" + | "trash" + | "spam" + | "junk" + | "[gmail]/spam" + | "[gmail]/trash" + ) +} + +/// Whether two mailbox names refer to the same mailbox: exact comparison, +/// except INBOX, which IMAP defines as case-insensitive. +pub fn same_mailbox(a: &str, b: &str) -> bool { + a == b || (a.eq_ignore_ascii_case("INBOX") && b.eq_ignore_ascii_case("INBOX")) +} + +/// List every mailbox name included in `--all-folders` operations. +pub fn searchable_folders(session: &mut ImapSession) -> Result> { + let folders = session.list_all().context("Failed to list folders")?; + Ok(folders + .into_iter() + .filter(|mailbox| !folders_to_skip(mailbox)) + .map(|mailbox| mailbox.name) + .collect()) } pub fn search(session: &mut ImapSession, criteria: &SearchCriteria) -> Result> { + search_excluding(session, criteria, None) +} + +/// Search for messages to move to `exclude`: with `--all-folders` that +/// mailbox is not searched, and naming it as the single source is an error. +pub fn search_excluding( + session: &mut ImapSession, + criteria: &SearchCriteria, + exclude: Option<&str>, +) -> Result> { let query = build_query(criteria)?; + ensure_query_supported(session, &query)?; if criteria.all_folders { - let folders = session - .list(Some(""), Some("*")) - .context("Failed to list folders")?; - let folder_names: Vec = folders - .iter() - .map(|f| f.name().to_string()) - .filter(|n| !folders_to_skip(n)) - .collect(); + let folder_names = searchable_folders(session)?; let mut all_messages = Vec::new(); for folder in &folder_names { + if exclude.is_some_and(|excluded| same_mailbox(folder, excluded)) { + continue; + } match fetch_messages(session, folder, &query, true, None) { Ok(msgs) => all_messages.extend(msgs), Err(e) => { - eprintln!("Warning: skipping folder '{folder}': {e}"); + eprintln!( + "Warning: skipping folder '{}': {}", + sanitize_terminal_field(folder), + sanitize_terminal_field(&format!("{e:#}")) + ); } } } @@ -620,24 +708,121 @@ pub fn search(session: &mut ImapSession, criteria: &SearchCriteria) -> Result Result<()> { - let folders = session - .list(Some(""), Some(folder)) - .context("Failed to list folders")?; - let exists = folders.iter().any(|f| f.name() == folder); + validate_folder_name(folder)?; + let folders = session.list_all().context("Failed to list folders")?; + let exists = folders + .iter() + .any(|mailbox| same_mailbox(&mailbox.name, folder)); if !exists { bail!( - "Folder '{folder}' does not exist. Use `slashmail status` to list available folders." + "Folder '{}' does not exist. Use `slashmail status` to list available folders.", + sanitize_terminal_field(folder) ); } Ok(()) } +/// Group searched rows by source mailbox with the UIDVALIDITY captured at +/// search time. Every row of a group must carry the same nonzero identity. +pub fn group_message_uids<'a>( + messages: &'a [MessageRow], + default_folder: &'a str, +) -> Result)>> { + let mut groups: BTreeMap<&'a str, (u32, Vec)> = BTreeMap::new(); + for message in messages { + let folder = message.folder.as_deref().unwrap_or(default_folder); + let uid_validity = match message.uid_validity { + Some(value) if value != 0 => value, + _ => bail!( + "Missing UIDVALIDITY for searched mailbox '{}'", + sanitize_terminal_field(folder) + ), + }; + let (expected, uids) = groups + .entry(folder) + .or_insert_with(|| (uid_validity, Vec::new())); + if *expected != uid_validity { + bail!( + "Searched messages from '{}' have inconsistent UIDVALIDITY", + sanitize_terminal_field(folder) + ); + } + uids.push(message.uid); + } + Ok(groups) +} + +/// SELECT `folder` read-write and require the UIDVALIDITY recorded when it +/// was searched, so UID mutations never act on a different mailbox incarnation. +pub fn select_verified( + session: &mut ImapSession, + folder: &str, + expected_uid_validity: u32, +) -> Result<()> { + open_verified(session, folder, expected_uid_validity, true) +} + +/// EXAMINE `folder` read-only with the same UIDVALIDITY check, for body +/// fetches that must not change mailbox state. +pub fn examine_verified( + session: &mut ImapSession, + folder: &str, + expected_uid_validity: u32, +) -> Result<()> { + open_verified(session, folder, expected_uid_validity, false) +} + +fn open_verified( + session: &mut ImapSession, + folder: &str, + expected_uid_validity: u32, + writable: bool, +) -> Result<()> { + validate_folder_name(folder)?; + let safe_folder = sanitize_terminal_field(folder); + let opened = if writable { + session.select(folder) + } else { + session.examine(folder) + }; + let opened = opened.with_context(|| { + let verb = if writable { "select" } else { "examine" }; + format!("Failed to {verb} '{safe_folder}'") + })?; + if opened.uid_validity != Some(expected_uid_validity) { + bail!("Mailbox UIDVALIDITY changed; search again before acting ('{safe_folder}')"); + } + Ok(()) +} + +/// The UIDs of `uid_set` that still exist in the selected mailbox. UID +/// commands silently ignore missing UIDs, so actions run on and report only +/// these. +pub fn existing_uids(session: &mut ImapSession, uid_set: &str) -> Result> { + let mut uids: Vec = session + .uid_search(&format!("UID {uid_set}")) + .context("IMAP SEARCH failed")? + .into_iter() + .collect(); + uids.sort_unstable(); + Ok(uids) +} + #[cfg(test)] mod tests { use super::*; @@ -799,26 +984,6 @@ mod tests { assert!(parse_size("18446744073709551615M").is_err()); } - #[test] - fn truncate_str_short_unchanged() { - assert_eq!(truncate_str("hello", 10), "hello"); - } - - #[test] - fn truncate_str_exact_length_unchanged() { - assert_eq!(truncate_str("hello", 5), "hello"); - } - - #[test] - fn truncate_str_long_adds_ellipsis() { - assert_eq!(truncate_str("hello world", 8), "hello..."); - } - - #[test] - fn truncate_str_empty() { - assert_eq!(truncate_str("", 10), ""); - } - #[test] fn build_query_no_criteria_returns_all() { let c = default_test_criteria(); @@ -934,19 +1099,6 @@ mod tests { assert_eq!(uids, vec![1, 2, 3]); } - #[test] - fn folders_to_skip_filters_correctly() { - assert!(folders_to_skip("Trash")); - assert!(folders_to_skip("Spam")); - assert!(folders_to_skip("Junk")); - assert!(folders_to_skip("[Gmail]/All Mail")); - assert!(folders_to_skip("[Gmail]/Spam")); - assert!(folders_to_skip("[Gmail]/Trash")); - assert!(!folders_to_skip("INBOX")); - assert!(!folders_to_skip("Archive")); - assert!(!folders_to_skip("Sent")); - } - #[test] fn build_uid_set_empty() { assert!(build_uid_set(&[]).is_empty()); @@ -1017,6 +1169,79 @@ mod tests { assert_eq!(build_query(&c).unwrap(), "UID 42 SUBJECT \"report\""); } + #[test] + fn non_ascii_terms_become_byte_counted_literals() { + let mut c = default_test_criteria(); + c.subject = Some("café".into()); + assert_eq!(build_query(&c).unwrap(), "SUBJECT {5+}\r\ncafé"); + + c.from = Some("Zoë \"Q\"\r\n".into()); + c.to = Some("a\"b\\c".into()); + c.unseen = true; + assert_eq!( + build_query(&c).unwrap(), + "SUBJECT {5+}\r\ncafé FROM {8+}\r\nZoë \"Q\" TO \"a\\\"b\\\\c\" UNSEEN" + ); + } + + #[test] + fn folder_names_are_validated_not_rewritten() { + for valid in ["INBOX", "Projects \"Q1\" *%", "Back\\slash", "Ärger"] { + assert!(validate_folder_name(valid).is_ok(), "{valid:?}"); + } + for invalid in ["", "Bad\rName", "Bad\nName", "Bad\0Name", "Bad\u{85}Name"] { + assert!(validate_folder_name(invalid).is_err(), "{invalid:?}"); + } + } + + fn searched_row(folder: Option<&str>, uid: u32, uid_validity: Option) -> MessageRow { + MessageRow { + account: None, + uid, + folder: folder.map(str::to_string), + from: String::new(), + subject: String::new(), + date: String::new(), + timestamp: 0, + size: 0, + message_id: None, + in_reply_to: Vec::new(), + references: Vec::new(), + seen: false, + answered: false, + flagged: false, + uid_validity, + } + } + + #[test] + fn grouping_binds_uids_to_their_searched_mailbox_identity() { + assert!(group_message_uids(&[], "INBOX").unwrap().is_empty()); + + let rows = [ + searched_row(None, 3, Some(10)), + searched_row(Some("Archive"), 3, Some(20)), + searched_row(None, 5, Some(10)), + ]; + let groups = group_message_uids(&rows, "INBOX").unwrap(); + assert_eq!(groups["INBOX"], (10, vec![3, 5])); + assert_eq!(groups["Archive"], (20, vec![3])); + + for identity in [None, Some(0)] { + let rows = [searched_row(None, 1, identity)]; + let error = group_message_uids(&rows, "INBOX").unwrap_err(); + assert!(error + .to_string() + .contains("Missing UIDVALIDITY for searched mailbox")); + } + + let inconsistent = [ + searched_row(None, 1, Some(10)), + searched_row(None, 2, Some(11)), + ]; + assert!(group_message_uids(&inconsistent, "INBOX").is_err()); + } + #[test] fn system_flags_match_case_insensitively() { let flags = [Flag::Custom("\\SEEN".into()), Flag::Answered]; @@ -1118,4 +1343,47 @@ mod tests { c.draft = true; assert_eq!(build_query(&c).unwrap(), "DRAFT"); } + + #[test] + fn empty_or_control_only_text_filters_are_rejected() { + for value in ["", " ", "\u{1b}", "\t\r\n"] { + let mut c = default_test_criteria(); + c.from = Some(value.into()); + let error = build_query(&c).unwrap_err(); + assert_eq!(error.to_string(), "--from must not be empty", "{value:?}"); + } + let mut c = default_test_criteria(); + c.text = Some("\u{0}".into()); + assert_eq!( + build_query(&c).unwrap_err().to_string(), + "--text must not be empty" + ); + c.text = Some(" x ".into()); + assert_eq!(build_query(&c).unwrap(), "TEXT \" x \""); + } + + #[test] + fn all_folders_skips_special_use_trash_and_junk_by_attribute() { + let listing = |name: &str, attributes: &[&str]| MailboxListing { + name: name.into(), + attributes: attributes.iter().map(|a| a.to_string()).collect(), + }; + assert!(folders_to_skip(&listing("Deleted Items", &["\\Trash"]))); + assert!(folders_to_skip(&listing( + "Junk Email", + &["\\HasNoChildren", "\\junk"] + ))); + assert!(folders_to_skip(&listing("Everything", &["\\All"]))); + assert!(folders_to_skip(&listing("Trash", &[]))); + assert!(!folders_to_skip(&listing("Deleted Items", &[]))); + assert!(!folders_to_skip(&listing("Archive", &["\\Archive"]))); + assert!(!folders_to_skip(&listing("Small mail", &[]))); + } + + #[test] + fn mailbox_identity_is_exact_except_inbox() { + assert!(same_mailbox("inbox", "INBOX")); + assert!(same_mailbox("Archive", "Archive")); + assert!(!same_mailbox("archive", "Archive")); + } } diff --git a/tests/integration.rs b/tests/integration.rs index cee2177..c60c512 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -15,6 +15,7 @@ use mailparse::MailHeaderMap; use slashmail::connection::{self, ImapSession}; use slashmail::delete; +use slashmail::draft::{AppendAttempt, DraftMailboxSession}; use slashmail::export; use slashmail::read; use slashmail::search::{self, SearchCriteria}; @@ -1265,27 +1266,28 @@ fn count_via_uid_search() { #[test] fn status_command() { let user = unique_user(); + send_email(&user, "Status one", "body"); + send_email(&user, "Status two", "body"); + sleep_for_delivery(); let mut session = imap_connect(&user); + // GreenMail does not escape quotes or backslashes in LIST responses; + // tests/scripted_cli.rs covers those names. + let special = "Projects (2024)"; + session.create(special).unwrap(); - let folders = session.list(Some(""), Some("*")).unwrap(); - let names: Vec = folders.iter().map(|f| f.name().to_string()).collect(); + let names: Vec = session + .list_all() + .unwrap() + .into_iter() + .map(|mailbox| mailbox.name) + .collect(); + assert!(names.contains(&"INBOX".to_string()), "{names:?}"); + assert!(names.contains(&special.to_string()), "{names:?}"); - assert!( - names.contains(&"INBOX".to_string()), - "INBOX should be listed" - ); - - // Run a STATUS command on INBOX - let cmd = format!( - "STATUS {} (MESSAGES UNSEEN RECENT)", - search::imap_quote("INBOX") - ); - let response = session.run_command_and_read_response(&cmd).unwrap(); - let text = String::from_utf8_lossy(&response); - assert!( - text.contains("STATUS") || text.contains("MESSAGES"), - "STATUS response should be parseable" - ); + let inbox = session.status_counts("INBOX").unwrap(); + assert_eq!(inbox.messages, Some(2)); + assert_eq!(inbox.unseen, Some(2)); + assert_eq!(session.status_counts(special).unwrap().messages, Some(0)); session.logout().unwrap(); } @@ -2545,3 +2547,230 @@ fn cli_draft_and_reply_json_receipts_identify_the_saved_drafts() { } session.logout().unwrap(); } + +fn subjects_in(session: &mut ImapSession, folder: &str) -> Vec { + let mut subjects: Vec = search::search(session, &default_criteria(folder)) + .unwrap() + .into_iter() + .map(|message| message.subject) + .collect(); + subjects.sort(); + subjects +} + +fn append_raw(session: &mut ImapSession, folder: &str, raw: &[u8]) { + assert!(matches!( + session.append_draft(folder, raw), + AppendAttempt::Saved { .. } + )); +} + +#[test] +fn cli_move_selects_by_recipient_filter_and_moves_to_dest() { + let owner = unique_user(); + let other = unique_user(); + send_email(&owner, "Addressed to owner", "body"); + send_email_with_cc( + "sender@localhost", + &other, + &owner, + "Addressed to other", + "body", + ); + sleep_for_delivery(); + let mut session = imap_connect(&owner); + session.create("Archive").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let other_address = user_email(&other); + let move_args = ["move", "--to", other_address.as_str(), "--dest", "Archive"]; + + let dry_run = assert_cmd_success( + personal_cmd(&owner, dir.path()) + .args(move_args) + .arg("--dry-run") + .output() + .unwrap(), + ); + assert!( + dry_run.contains("1 message(s) would be moved to Archive"), + "{dry_run}" + ); + assert_eq!( + subjects_in(&mut session, "INBOX"), + ["Addressed to other", "Addressed to owner"] + ); + assert!(subjects_in(&mut session, "Archive").is_empty()); + + let moved = assert_cmd_success( + personal_cmd(&owner, dir.path()) + .args(move_args) + .arg("--yes") + .output() + .unwrap(), + ); + assert!(moved.contains("Moved 1 message(s) to Archive"), "{moved}"); + assert_eq!(subjects_in(&mut session, "INBOX"), ["Addressed to owner"]); + assert_eq!(subjects_in(&mut session, "Archive"), ["Addressed to other"]); + session.logout().unwrap(); +} + +#[test] +fn cli_terminal_output_is_inert_while_json_and_export_keep_original_data() { + let owner = unique_user(); + let long_name = "Mallory Example With A Deliberately Long Display Name"; + let subject_tail = "quarterly results for the whole organisation and more"; + let raw = format!( + "From: \"{long_name}\x1b]52;c;cHduZWQ=\x07\" \r\n\ + To: {owner}@localhost\r\n\ + Subject: Inert \x1b[2J\x1b]0;pwned\x07{subject_tail}\r\n\ + Date: Mon, 1 Apr 2026 10:00:00 +0200\r\n\ + Message-ID: \r\n\ + Content-Type: text/plain; charset=utf-8\r\n\r\n\ + Line one\ttabbed \u{fc}n\u{ef}code\r\n\ + \x1b]8;;http://evil.example\x1b\\click here\x1b]8;;\x1b\\\x1b[31m\r\n\ + Line three\x1bP+q\x1b\\\r\n" + ) + .into_bytes(); + let mut session = imap_connect(&owner); + append_raw(&mut session, "INBOX", &raw); + let dir = tempfile::tempdir().unwrap(); + let is_inert = |text: &str| { + !text + .chars() + .any(|c| matches!(c, '\u{1b}' | '\u{7}' | '\u{9b}' | '\u{9d}')) + }; + + let search = personal_cmd(&owner, dir.path()) + .args(["search", "--subject", "Inert"]) + .output() + .unwrap(); + let table = assert_cmd_success(search); + assert!(is_inert(&table), "{table:?}"); + assert!(table.contains("Inert"), "{table}"); + + let json = assert_cmd_success( + personal_cmd(&owner, dir.path()) + .args(["search", "--subject", "Inert", "--json"]) + .output() + .unwrap(), + ); + let rows: serde_json::Value = serde_json::from_str(&json).unwrap(); + let subject = rows[0]["subject"].as_str().unwrap(); + assert!( + subject.ends_with(subject_tail) && subject.chars().count() > 60, + "{subject:?}" + ); + assert!( + subject.contains('\u{1b}'), + "JSON must keep the decoded original" + ); + let from = rows[0]["from"].as_str().unwrap(); + assert!( + from.contains(long_name) && from.chars().count() > 40, + "{from:?}" + ); + assert_eq!(rows[0]["date"], "Mon, 1 Apr 2026 10:00:00 +0200"); + + let read = assert_cmd_success( + personal_cmd(&owner, dir.path()) + .args(["read", "--subject", "Inert"]) + .output() + .unwrap(), + ); + assert!(is_inert(&read), "{read:?}"); + assert!( + read.contains("Line one\ttabbed \u{fc}n\u{ef}code\nclick here\nLine three"), + "{read:?}" + ); + + let dry_run = personal_cmd(&owner, dir.path()) + .args(["delete", "--subject", "Inert", "--dry-run"]) + .output() + .unwrap(); + assert!(is_inert(&String::from_utf8_lossy(&dry_run.stderr))); + assert!(is_inert(&assert_cmd_success(dry_run))); + + let out = dir.path().join("out"); + let export = personal_cmd(&owner, dir.path()) + .args(["export", "--subject", "Inert", "--yes", "-o"]) + .arg(&out) + .output() + .unwrap(); + assert_cmd_success(export); + let exported: Vec<_> = std::fs::read_dir(&out).unwrap().collect(); + assert_eq!(exported.len(), 1); + let path = exported[0].as_ref().unwrap().path(); + assert_eq!(std::fs::read(path).unwrap(), raw); + session.logout().unwrap(); +} + +#[test] +fn cli_export_keeps_collision_prone_folders_distinct_and_private() { + let owner = unique_user(); + let mut session = imap_connect(&owner); + let mut expected = Vec::new(); + for folder in ["Work/Projects", "Work_Projects"] { + // Exact spellings are unit-tested per platform in src/export.rs. + let file = format!("{}_1.eml", export::encode_folder_name(folder)); + session.create(folder).unwrap(); + let raw = format!( + "From: a@localhost\r\nSubject: Export from {folder}\r\n\ + Date: Mon, 1 Apr 2026 10:00:00 +0000\r\n\r\nBody for {folder}\r\n" + ) + .into_bytes(); + append_raw(&mut session, folder, &raw); + session.select(folder).unwrap(); + assert_eq!( + session + .uid_search("ALL") + .unwrap() + .into_iter() + .collect::>(), + [1], + "{folder} must reuse UID 1" + ); + expected.push((file, raw)); + } + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out"); + let export = |force: bool| { + let mut command = personal_cmd(&owner, dir.path()); + command + .args([ + "export", + "--all-folders", + "--subject", + "Export from", + "--yes", + "-o", + ]) + .arg(&out); + if force { + command.arg("--force"); + } + assert_cmd_success(command.output().unwrap()) + }; + + let first = export(false); + assert!(first.contains("Exported 2 message(s)"), "{first}"); + for (file, raw) in &expected { + let path = out.join(file); + assert_eq!(&std::fs::read(&path).unwrap(), raw, "{file}"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "{file} mode {mode:o}"); + } + } + + let second = export(false); + assert!( + second.contains("Exported 0 message(s)") && second.contains("2 skipped"), + "{second}" + ); + let forced = export(true); + assert!(forced.contains("Exported 2 message(s)"), "{forced}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 2); + session.logout().unwrap(); +} diff --git a/tests/scripted_cli.rs b/tests/scripted_cli.rs new file mode 100644 index 0000000..1616df3 --- /dev/null +++ b/tests/scripted_cli.rs @@ -0,0 +1,1383 @@ +//! Real-binary tests against a scripted, stateful IMAP server on loopback. +//! +//! These cover protocol boundaries GreenMail cannot exercise on demand: +//! missing capabilities, changing UIDVALIDITY, injected failures, and +//! malformed STATUS/QUOTA data. Every test runs the compiled CLI. + +use parking_lot::Mutex; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +#[derive(Clone)] +struct Message { + uid: u32, + raw: Vec, + deleted: bool, + seen: bool, + flagged: bool, +} + +struct Mailbox { + name: String, + attributes: String, + /// Send the LIST name as a literal instead of a quoted string. + literal: bool, + /// UIDVALIDITY reported by the 1st, 2nd, ... SELECT (last value repeats). + uid_validity: Vec>, + selects: usize, + /// `(n, uid)`: another client expunges `uid` just before the nth open. + vanish: Vec<(usize, u32)>, + next_uid: u32, + messages: Vec, +} + +#[derive(Default)] +struct State { + capabilities: String, + mailboxes: Vec, + /// Raw untagged STATUS lines per mailbox; absent means `NO`. + status: HashMap, + /// Raw untagged GETQUOTAROOT lines. + quota: String, + /// Command prefixes answered with `NO `. + fail: Vec<(String, String)>, + commands: Vec, + selected: Option, + /// The selected mailbox was opened with EXAMINE. + read_only: bool, + /// Raw untagged lines appended to every UID FETCH response, as a server + /// reporting flag changes made by other clients would. + unsolicited_fetch: String, +} + +impl State { + fn new(capabilities: &str) -> Self { + State { + capabilities: capabilities.to_string(), + ..State::default() + } + } + + fn mailbox(mut self, name: &str, uid_validity: &[Option], messages: &[Message]) -> Self { + self.mailboxes.push(Mailbox { + name: name.to_string(), + attributes: String::new(), + literal: false, + uid_validity: uid_validity.to_vec(), + selects: 0, + vanish: Vec::new(), + next_uid: messages.iter().map(|m| m.uid).max().unwrap_or(0) + 1, + messages: messages.to_vec(), + }); + self + } + + /// Add an empty mailbox whose LIST name is sent as an IMAP literal. + fn literal(mut self, name: &str) -> Self { + self = self.mailbox(name, &[Some(1)], &[]); + self.mailboxes.last_mut().unwrap().literal = true; + self + } + + fn special(mut self, name: &str, attributes: &str) -> Self { + self = self.mailbox(name, &[Some(1)], &[]); + self.mailboxes.last_mut().unwrap().attributes = attributes.to_string(); + self + } + + fn fail(mut self, prefix: &str, text: &str) -> Self { + self.fail.push((prefix.to_string(), text.to_string())); + self + } + + fn find(&self, name: &str) -> Option { + self.mailboxes.iter().position(|mailbox| { + mailbox.name == name || (name.eq_ignore_ascii_case("INBOX") && mailbox.name == "INBOX") + }) + } + + fn uids(&self, name: &str) -> Vec { + let index = self.find(name).unwrap(); + self.mailboxes[index] + .messages + .iter() + .map(|m| m.uid) + .collect() + } + + /// `(seen, flagged)` of one message. + fn flags(&self, name: &str, uid: u32) -> (bool, bool) { + let index = self.find(name).unwrap(); + let message = self.mailboxes[index] + .messages + .iter() + .find(|m| m.uid == uid) + .unwrap(); + (message.seen, message.flagged) + } +} + +fn message(uid: u32, subject: &str) -> Message { + Message { + uid, + raw: format!( + "From: sender@example.com\r\nTo: user@example.com\r\nSubject: {subject}\r\n\ + Date: Mon, 1 Apr 2026 10:00:0{} +0000\r\nMessage-ID: <{uid}@example.com>\r\n\r\n\ + Body of {subject}\r\n", + uid % 10 + ) + .into_bytes(), + deleted: false, + seen: false, + flagged: false, + } +} + +fn raw_message(uid: u32, raw: &[u8]) -> Message { + Message { + uid, + raw: raw.to_vec(), + deleted: false, + seen: false, + flagged: false, + } +} + +struct Server { + port: u16, + state: Arc>, + /// Set once the CLI has exited, so a server that was never connected to + /// stops waiting for a client. + stop: Arc, + handle: thread::JoinHandle<()>, +} + +impl Server { + fn start(state: State) -> Server { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let state = Arc::new(Mutex::new(state)); + let stop = Arc::new(AtomicBool::new(false)); + let shared = Arc::clone(&state); + let stopped = Arc::clone(&stop); + let handle = thread::spawn(move || loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + serve(stream, &shared); + return; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + // The CLI waits for the greeting, so once it has exited + // no connection can still be pending. + if stopped.load(Ordering::SeqCst) { + return; + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept failed: {error}"), + } + }); + Server { + port, + state, + stop, + handle, + } + } + + /// Call after the CLI has exited: wait for the session (if any) to end + /// and return the server state. + fn finish(self) -> State { + self.stop.store(true, Ordering::SeqCst); + self.handle.join().unwrap(); + Arc::try_unwrap(self.state).ok().unwrap().into_inner() + } +} + +fn read_command(reader: &mut BufReader, writer: &mut TcpStream) -> Option> { + let mut command = Vec::new(); + loop { + let mut line = Vec::new(); + if reader.read_until(b'\n', &mut line).ok()? == 0 { + return None; + } + command.extend_from_slice(&line); + let Some(length) = literal_length(&line) else { + break; + }; + if !line.ends_with(b"+}\r\n") { + writer.write_all(b"+ go ahead\r\n").ok()?; + } + let mut literal = vec![0; length]; + reader.read_exact(&mut literal).ok()?; + command.extend_from_slice(&literal); + } + while command + .last() + .is_some_and(|byte| *byte == b'\n' || *byte == b'\r') + { + command.pop(); + } + Some(command) +} + +fn literal_length(line: &[u8]) -> Option { + let text = std::str::from_utf8(line).ok()?.strip_suffix("}\r\n")?; + let (_, digits) = text.rsplit_once('{')?; + digits.trim_end_matches('+').parse().ok() +} + +/// Parse one IMAP astring (quoted, literal, or atom) from the front of `input`. +fn take_astring(input: &str) -> (String, &str) { + if let Some(rest) = input.strip_prefix('"') { + let mut value = String::new(); + let mut chars = rest.char_indices(); + while let Some((index, character)) = chars.next() { + match character { + '\\' => value.push(chars.next().unwrap().1), + '"' => return (value, &rest[index + 1..]), + other => value.push(other), + } + } + panic!("unterminated quoted string: {input}"); + } + if input.starts_with('{') { + let close = input.find('}').unwrap(); + let length: usize = input[1..close].trim_end_matches('+').parse().unwrap(); + let body = &input[close + 3..]; + return (body[..length].to_string(), &body[length..]); + } + let end = input.find(' ').unwrap_or(input.len()); + (input[..end].to_string(), &input[end..]) +} + +fn uid_set(set: &str) -> Vec { + set.split(',') + .flat_map(|part| match part.split_once(':') { + Some((start, end)) => { + (start.parse::().unwrap()..=end.parse::().unwrap()).collect() + } + None => vec![part.parse::().unwrap()], + }) + .collect() +} + +fn subject_of(raw: &[u8]) -> String { + String::from_utf8_lossy(raw) + .lines() + .find_map(|line| line.strip_prefix("Subject: ").map(str::to_string)) + .unwrap_or_default() +} + +fn matching_uids(messages: &[Message], query: &str) -> Vec { + let query = query.strip_prefix("CHARSET UTF-8 ").unwrap_or(query); + let mut subject = None; + let mut uids = None; + let mut rest = query; + while !rest.is_empty() { + rest = rest.trim_start(); + if let Some(after) = rest.strip_prefix("SUBJECT ") { + let (value, remaining) = take_astring(after); + subject = Some(value.to_lowercase()); + rest = remaining; + } else if let Some(after) = rest.strip_prefix("UID ") { + let (value, remaining) = take_astring(after); + uids = Some(uid_set(&value)); + rest = remaining; + } else { + let (_, remaining) = take_astring(rest); + rest = remaining; + } + } + messages + .iter() + .filter(|m| { + subject + .as_ref() + .is_none_or(|s| subject_of(&m.raw).to_lowercase().contains(s)) + }) + .filter(|m| uids.as_ref().is_none_or(|set| set.contains(&m.uid))) + .map(|m| m.uid) + .collect() +} + +fn serve(stream: TcpStream, shared: &Mutex) { + stream + .set_read_timeout(Some(Duration::from_secs(20))) + .unwrap(); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + writer.write_all(b"* OK scripted IMAP ready\r\n").unwrap(); + + while let Some(raw) = read_command(&mut reader, &mut writer) { + let command = String::from_utf8(raw).unwrap(); + let (tag, rest) = command.split_once(' ').unwrap(); + let mut state = shared.lock(); + state.commands.push(rest.to_string()); + + if let Some((_, text)) = state + .fail + .iter() + .find(|(prefix, _)| rest.starts_with(prefix)) + { + let reply = format!("{tag} NO {text}\r\n"); + drop(state); + writer.write_all(reply.as_bytes()).unwrap(); + continue; + } + + let mut out = Vec::new(); + let verb = rest.split(' ').next().unwrap().to_ascii_uppercase(); + let uid_verb = rest + .strip_prefix("UID ") + .map(|r| r.split(' ').next().unwrap().to_ascii_uppercase()); + let mutates = matches!( + (verb.as_str(), uid_verb.as_deref()), + ("EXPUNGE", _) | ("UID", Some("STORE" | "MOVE" | "EXPUNGE")) + ); + if mutates && state.read_only { + // Like a real server: EXAMINE opens the mailbox read-only. + out.extend(format!("{tag} NO [READ-ONLY] mailbox is read-only\r\n").bytes()); + drop(state); + writer.write_all(&out).unwrap(); + continue; + } + match (verb.as_str(), uid_verb.as_deref()) { + ("LOGIN", _) => out.extend(format!("{tag} OK logged in\r\n").bytes()), + ("CAPABILITY", _) => out.extend( + format!("* CAPABILITY {}\r\n{tag} OK done\r\n", state.capabilities).bytes(), + ), + ("LIST", _) => { + for mailbox in &state.mailboxes { + let name = if mailbox.literal { + format!("{{{}}}\r\n{}", mailbox.name.len(), mailbox.name) + } else { + let quoted = mailbox.name.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{quoted}\"") + }; + out.extend(format!("* LIST ({}) \"/\" {name}\r\n", mailbox.attributes).bytes()); + } + out.extend(format!("{tag} OK listed\r\n").bytes()); + } + ("SELECT" | "EXAMINE", _) => { + let (name, _) = take_astring(&rest[verb.len() + 1..]); + match state.find(&name) { + Some(index) => { + state.selected = Some(index); + state.read_only = verb == "EXAMINE"; + let access = if state.read_only { + "READ-ONLY" + } else { + "READ-WRITE" + }; + let mailbox = &mut state.mailboxes[index]; + mailbox.selects += 1; + let opened = mailbox.selects; + let gone: Vec = mailbox + .vanish + .iter() + .filter(|(n, _)| *n == opened) + .map(|(_, uid)| *uid) + .collect(); + mailbox.messages.retain(|m| !gone.contains(&m.uid)); + let validity = mailbox + .uid_validity + .get(mailbox.selects - 1) + .or(mailbox.uid_validity.last()) + .copied() + .flatten(); + out.extend(format!("* {} EXISTS\r\n", mailbox.messages.len()).bytes()); + out.extend(b"* 0 RECENT\r\n"); + if let Some(value) = validity { + out.extend(format!("* OK [UIDVALIDITY {value}] ok\r\n").bytes()); + } + out.extend(format!("* OK [UIDNEXT {}] ok\r\n", mailbox.next_uid).bytes()); + out.extend(format!("{tag} OK [{access}] selected\r\n").bytes()); + } + None => out.extend(format!("{tag} NO no such mailbox\r\n").bytes()), + } + } + ("UID", Some("SEARCH")) => { + let index = state.selected.unwrap(); + let uids = matching_uids(&state.mailboxes[index].messages, &rest[11..]); + let list: Vec = uids.iter().map(u32::to_string).collect(); + out.extend(format!("* SEARCH {}\r\n{tag} OK searched\r\n", list.join(" ")).bytes()); + } + ("UID", Some("SORT")) => { + let index = state.selected.unwrap(); + let query = rest.strip_prefix("UID SORT (REVERSE DATE) UTF-8 ").unwrap(); + let mut uids = matching_uids(&state.mailboxes[index].messages, query); + uids.sort_unstable_by(|a, b| b.cmp(a)); + let list: Vec = uids.iter().map(u32::to_string).collect(); + out.extend(format!("* SORT {}\r\n{tag} OK sorted\r\n", list.join(" ")).bytes()); + } + ("UID", Some("FETCH")) => { + let index = state.selected.unwrap(); + let mut parts = rest[10..].splitn(2, ' '); + let wanted = uid_set(parts.next().unwrap()); + let items = parts.next().unwrap(); + let section = items + .split_once("BODY.PEEK[") + .map(|(_, after)| after.split_once(']').unwrap().0.to_string()) + .unwrap(); + for (position, message) in state.mailboxes[index].messages.iter().enumerate() { + if !wanted.contains(&message.uid) { + continue; + } + let payload: &[u8] = if section.is_empty() { + &message.raw + } else { + let end = message + .raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(message.raw.len(), |p| p + 4); + &message.raw[..end] + }; + let flags: Vec<&str> = [ + (message.deleted, "\\Deleted"), + (message.seen, "\\Seen"), + (message.flagged, "\\Flagged"), + ] + .into_iter() + .filter_map(|(set, name)| set.then_some(name)) + .collect(); + let flags = flags.join(" "); + out.extend( + format!( + "* {} FETCH (UID {} FLAGS ({flags}) RFC822.SIZE {} BODY[{section}] {{{}}}\r\n", + position + 1, + message.uid, + message.raw.len(), + payload.len() + ) + .bytes(), + ); + out.extend_from_slice(payload); + out.extend(b")\r\n"); + } + out.extend(state.unsolicited_fetch.bytes()); + out.extend(format!("{tag} OK fetched\r\n").bytes()); + } + ("UID", Some("STORE")) => { + let index = state.selected.unwrap(); + let mut parts = rest[10..].splitn(2, ' '); + let wanted = uid_set(parts.next().unwrap()); + let operation = parts.next().unwrap(); + let (value, flag) = match operation.split_once(" (") { + Some(("+FLAGS", flag)) => (true, flag.trim_end_matches(')')), + Some(("-FLAGS", flag)) => (false, flag.trim_end_matches(')')), + _ => panic!("unsupported STORE: {operation}"), + }; + for message in &mut state.mailboxes[index].messages { + if wanted.contains(&message.uid) { + match flag { + "\\Deleted" => message.deleted = value, + "\\Seen" => message.seen = value, + "\\Flagged" => message.flagged = value, + other => panic!("unsupported flag: {other}"), + } + } + } + out.extend(format!("{tag} OK stored\r\n").bytes()); + } + ("UID", Some(verb @ ("COPY" | "MOVE"))) => { + let index = state.selected.unwrap(); + let after = &rest[verb.len() + 5..]; + let (set, target) = after.split_once(' ').unwrap(); + let wanted = uid_set(set); + let (target, _) = take_astring(target); + let target = state.find(&target).unwrap(); + let moved: Vec = state.mailboxes[index] + .messages + .iter() + .filter(|m| wanted.contains(&m.uid)) + .cloned() + .collect(); + for mut copy in moved { + copy.uid = state.mailboxes[target].next_uid; + copy.deleted = false; + state.mailboxes[target].next_uid += 1; + state.mailboxes[target].messages.push(copy); + } + if verb == "MOVE" { + state.mailboxes[index] + .messages + .retain(|m| !wanted.contains(&m.uid)); + } + out.extend(format!("{tag} OK done\r\n").bytes()); + } + ("UID", Some("EXPUNGE")) => { + let index = state.selected.unwrap(); + let wanted = uid_set(&rest[12..]); + state.mailboxes[index] + .messages + .retain(|m| !(m.deleted && wanted.contains(&m.uid))); + out.extend(format!("{tag} OK expunged\r\n").bytes()); + } + ("EXPUNGE", _) => { + let index = state.selected.unwrap(); + state.mailboxes[index].messages.retain(|m| !m.deleted); + out.extend(format!("{tag} OK expunged\r\n").bytes()); + } + ("STATUS", _) => { + let (name, _) = take_astring(&rest[7..]); + match state.status.get(&name) { + Some(line) => out.extend(format!("{line}\r\n{tag} OK status\r\n").bytes()), + None => out.extend(format!("{tag} NO no status\r\n").bytes()), + } + } + ("GETQUOTAROOT", _) => { + out.extend(format!("{}{tag} OK quota\r\n", state.quota).bytes()); + } + ("LOGOUT", _) => { + out.extend(format!("* BYE\r\n{tag} OK bye\r\n").bytes()); + drop(state); + let _ = writer.write_all(&out); + return; + } + _ => out.extend(format!("{tag} BAD unsupported\r\n").bytes()), + } + drop(state); + writer.write_all(&out).unwrap(); + } +} + +fn slashmail(port: u16, dir: &Path, args: &[&str]) -> Output { + let config = dir.join("config.toml"); + std::fs::write(&config, "").unwrap(); + Command::new(env!("CARGO_BIN_EXE_slashmail")) + .current_dir(dir) + .arg("--config") + .arg(&config) + .args(["--host", "127.0.0.1", "--port", &port.to_string()]) + .args(["-u", "user@example.com"]) + .args(args) + .env("SLASHMAIL_PASS", "secret") + .env_remove("SLASHMAIL_USER") + .stdin(Stdio::null()) + .output() + .unwrap() +} + +fn run(state: State, args: &[&str]) -> (Output, State) { + let dir = tempfile::tempdir().unwrap(); + run_in(state, dir.path(), args) +} + +fn run_in(state: State, dir: &Path, args: &[&str]) -> (Output, State) { + let server = Server::start(state); + let output = slashmail(server.port, dir, args); + (output, server.finish()) +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + stdout(output), + stderr(output) + ); +} + +fn assert_failure(output: &Output, message: &str) { + assert!( + !output.status.success(), + "unexpected success:\n{}", + stdout(output) + ); + assert!( + stderr(output).contains(message), + "stderr lacks {message:?}:\n{}", + stderr(output) + ); +} + +/// A handled error: exit status 1 with a rendered `Error:` line (a panic or +/// signal would exit differently). +fn assert_controlled_failure(output: &Output) { + assert_eq!(output.status.code(), Some(1), "{}", stderr(output)); + assert!(stderr(output).starts_with("Error: "), "{}", stderr(output)); + assert!(!stderr(output).contains("panicked"), "{}", stderr(output)); +} + +fn mutations(commands: &[String]) -> Vec<&String> { + commands + .iter() + .filter(|c| { + [ + "UID STORE", + "UID COPY", + "UID MOVE", + "UID EXPUNGE", + "EXPUNGE", + "APPEND", + ] + .iter() + .any(|verb| c.starts_with(verb)) + }) + .collect() +} + +fn opens_mailbox(command: &str) -> bool { + command.starts_with("SELECT") || command.starts_with("EXAMINE") +} + +fn inbox_with_target_and_deleted_neighbor(capabilities: &str) -> State { + let mut neighbor = message(9, "Unrelated"); + neighbor.deleted = true; + State::new(capabilities) + .mailbox("INBOX", &[Some(10)], &[message(7, "Target"), neighbor]) + .mailbox("Trash", &[Some(20)], &[]) + .mailbox("Archive", &[Some(30)], &[]) +} + +#[test] +fn move_help_and_missing_destination_are_parsed_without_connecting() { + let help = Command::new(env!("CARGO_BIN_EXE_slashmail")) + .args(["move", "--help"]) + .output() + .unwrap(); + assert_success(&help); + let text = stdout(&help); + assert!(text.contains("--dest "), "{text}"); + assert!(text.contains("--to "), "{text}"); + + let server = Server::start(State::new("IMAP4rev1 MOVE")); + let dir = tempfile::tempdir().unwrap(); + let output = slashmail( + server.port, + dir.path(), + &["move", "--to", "Archive", "--yes"], + ); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!(stderr(&output).contains("--dest")); + assert!(server.finish().commands.is_empty()); +} + +#[test] +fn remote_plaintext_is_refused_before_password_or_network() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.toml"); + std::fs::write(&config, "").unwrap(); + for args in [vec!["search"], vec!["draft", "--to", "someone@example.com"]] { + let output = Command::new(env!("CARGO_BIN_EXE_slashmail")) + .arg("--config") + .arg(&config) + .args(["--host", "mail.example.invalid", "-u", "user@example.com"]) + .args(&args) + .env_remove("SLASHMAIL_PASS") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_failure( + &output, + "IMAP connections to non-loopback hosts require TLS", + ); + assert!(!stderr(&output).contains("password"), "{}", stderr(&output)); + } +} + +#[test] +fn move_capability_moves_only_the_selected_uid() { + let (output, state) = run( + inbox_with_target_and_deleted_neighbor("IMAP4rev1 MOVE UIDPLUS"), + &["move", "--subject", "Target", "--dest", "Archive", "--yes"], + ); + assert_success(&output); + assert_eq!(mutations(&state.commands), [r#"UID MOVE 7 "Archive""#]); + assert_eq!(state.uids("INBOX"), [9]); + assert_eq!(state.uids("Archive"), [1]); +} + +#[test] +fn uidplus_fallback_expunges_only_the_moved_uid() { + let (output, state) = run( + inbox_with_target_and_deleted_neighbor("IMAP4rev1 UIDPLUS"), + &["delete", "--subject", "Target", "--yes"], + ); + assert_success(&output); + assert_eq!( + mutations(&state.commands), + [ + r#"UID COPY 7 "Trash""#, + r"UID STORE 7 +FLAGS (\Deleted)", + "UID EXPUNGE 7" + ] + ); + assert_eq!( + state.uids("INBOX"), + [9], + "unrelated \\Deleted message was expunged" + ); + assert_eq!(state.uids("Trash"), [1]); +} + +#[test] +fn servers_without_move_or_uidplus_are_refused_before_mutation() { + let (output, state) = run( + inbox_with_target_and_deleted_neighbor("IMAP4rev1"), + &["move", "--subject", "Target", "--dest", "Archive", "--yes"], + ); + assert_failure( + &output, + "Safe move requires server support for MOVE or UIDPLUS", + ); + assert!( + mutations(&state.commands).is_empty(), + "{:?}", + state.commands + ); + assert_eq!(state.uids("INBOX"), [7, 9]); +} + +#[test] +fn failure_after_copy_is_reported_once_without_retry() { + let (output, state) = run( + inbox_with_target_and_deleted_neighbor("IMAP4rev1 UIDPLUS") + .fail("UID STORE", "store refused"), + &["delete", "--subject", "Target", "--yes"], + ); + assert_failure(&output, "failed after COPY"); + let copies = state + .commands + .iter() + .filter(|c| c.starts_with("UID COPY")) + .count(); + assert_eq!(copies, 1); + assert!(!state.commands.iter().any(|c| c.contains("EXPUNGE"))); +} + +#[test] +fn changed_or_missing_uidvalidity_blocks_every_uid_action() { + for later in [Some(11), None] { + for args in [ + vec!["delete", "--subject", "Target", "--yes"], + vec!["move", "--subject", "Target", "--dest", "Archive", "--yes"], + vec!["mark", "--subject", "Target", "--read", "--yes"], + vec!["export", "--subject", "Target", "--yes", "-o", "out"], + vec!["read", "--subject", "Target"], + ] { + let state = State::new("IMAP4rev1 MOVE UIDPLUS") + .mailbox("INBOX", &[Some(10), later], &[message(7, "Target")]) + .mailbox("Trash", &[Some(20)], &[]) + .mailbox("Archive", &[Some(30)], &[]); + let dir = tempfile::tempdir().unwrap(); + let (output, state) = run_in(state, dir.path(), &args); + assert_failure( + &output, + "Mailbox UIDVALIDITY changed; search again before acting", + ); + assert!( + mutations(&state.commands).is_empty(), + "{args:?}: {:?}", + state.commands + ); + assert!( + !state.commands.iter().any(|c| c.contains("BODY.PEEK[]")), + "{args:?} fetched a body after the identity changed" + ); + assert!(!dir.path().join("out").join("INBOX_7.eml").exists()); + } + } +} + +#[test] +fn unchanged_uidvalidity_allows_the_action() { + let state = State::new("IMAP4rev1 MOVE") + .mailbox("INBOX", &[Some(10), Some(10)], &[message(7, "Target")]) + .mailbox("Archive", &[Some(30)], &[]); + let (output, state) = run( + state, + &["move", "--subject", "Target", "--dest", "Archive", "--yes"], + ); + assert_success(&output); + assert_eq!(state.uids("Archive"), [1]); +} + +#[test] +fn non_ascii_search_uses_utf8_literals_with_literal_plus() { + let target = message(3, "café menu"); + let state = || { + State::new("IMAP4rev1 LITERAL+ SORT").mailbox( + "INBOX", + &[Some(1)], + &[target.clone(), message(4, "cafe menu")], + ) + }; + + let (output, server) = run(state(), &["search", "--subject", "café", "--json"]); + assert_success(&output); + assert!(server + .commands + .contains(&"UID SORT (REVERSE DATE) UTF-8 SUBJECT {5+}\r\ncafé".to_string())); + let rows: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["uid"], 3); + + // SORT failure falls back to SEARCH with the same encoding and filters. + let (output, server) = run( + state().fail("UID SORT", "sort unavailable"), + &["search", "--subject", "café", "--to", "a\"b", "--json"], + ); + assert_success(&output); + assert!(server + .commands + .contains(&"UID SEARCH CHARSET UTF-8 SUBJECT {5+}\r\ncafé TO \"a\\\"b\"".to_string())); + + let (output, server) = run( + State::new("IMAP4rev1 LITERAL+").mailbox( + "INBOX", + &[Some(1)], + std::slice::from_ref(&target), + ), + &["count", "--subject", "café", "--from", "Zoë"], + ); + assert_success(&output); + assert!(server + .commands + .contains(&"UID SEARCH CHARSET UTF-8 SUBJECT {5+}\r\ncafé FROM {4+}\r\nZoë".to_string())); +} + +#[test] +fn non_ascii_search_without_literal_plus_fails_before_searching() { + for args in [ + vec!["search", "--subject", "café"], + vec!["search", "--subject", "café", "--all-folders"], + vec!["count", "--subject", "café", "--all-folders"], + ] { + let (output, state) = run( + State::new("IMAP4rev1 SORT").mailbox("INBOX", &[Some(1)], &[message(3, "café")]), + &args, + ); + assert_failure( + &output, + "Non-ASCII search requires server support for LITERAL+", + ); + assert!( + !state + .commands + .iter() + .any(|c| c.starts_with("LIST") || opens_mailbox(c) || c.starts_with("UID")), + "{args:?}: {:?}", + state.commands + ); + } + + let (output, _) = run( + State::new("IMAP4rev1 SORT").mailbox("INBOX", &[Some(1)], &[message(3, "cafe")]), + &["search", "--subject", "cafe"], + ); + assert_success(&output); + assert!(stdout(&output).contains("1 message(s)")); +} + +#[test] +fn folder_lookup_is_exact_and_rejects_control_characters() { + let name = r#"Projects "Q1" \ *%"#; + let (output, state) = run( + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(1)], &[]) + .mailbox("Projects", &[Some(2)], &[]) + .mailbox(name, &[Some(3)], &[message(5, "Quarterly")]), + &["search", "-f", name], + ); + assert_success(&output); + assert!(stdout(&output).contains("Quarterly")); + assert!(state.commands.contains(&r#"LIST "" *"#.to_string())); + assert!(state + .commands + .contains(&r#"EXAMINE "Projects \"Q1\" \\ *%""#.to_string())); + + let (output, state) = run( + State::new("IMAP4rev1").mailbox("INBOX", &[Some(1)], &[]), + &["search", "-f", "Projects"], + ); + assert_failure(&output, "does not exist"); + assert!(!state.commands.iter().any(|c| opens_mailbox(c))); + + for folder in ["INBOX\r\nA1 DELETE INBOX", "INBOX\u{85}", "INBOX\u{1b}"] { + for command in ["count", "search"] { + let (output, state) = run( + State::new("IMAP4rev1").mailbox("INBOX", &[Some(1)], &[]), + &[command, "-f", folder], + ); + assert_failure(&output, "Folder name must not contain control characters"); + assert!(!state + .commands + .iter() + .any(|c| c.contains("DELETE") || opens_mailbox(c))); + } + } +} + +#[test] +fn all_folders_skips_special_use_mailboxes_but_not_substring_matches() { + let state = || { + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(1)], &[message(1, "Hello")]) + .mailbox("Small mail", &[Some(2)], &[message(1, "Hello small")]) + .special("Everything", "\\All") + .special("Deleted Items", "\\Trash") + .special("Junk Email", "\\HasNoChildren \\Junk") + }; + let (output, server) = run(state(), &["count", "--all-folders", "--subject", "Hello"]); + assert_success(&output); + let opened: Vec<&String> = server + .commands + .iter() + .filter(|c| opens_mailbox(c)) + .collect(); + // Read-only commands never open a mailbox read-write. + assert_eq!(opened, [r#"EXAMINE "INBOX""#, r#"EXAMINE "Small mail""#]); + assert!(stdout(&output).contains("1 message(s) in Small mail")); + + let (output, _) = run(state(), &["search", "--all-folders", "--json"]); + assert_success(&output); + let rows: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let folders: Vec<&str> = rows + .as_array() + .unwrap() + .iter() + .map(|row| row["folder"].as_str().unwrap()) + .collect(); + assert!(folders.contains(&"Small mail")); + assert!(!folders.contains(&"Everything")); +} + +#[test] +fn literal_and_escaped_mailbox_names_round_trip() { + let literal = r"Work\Plans"; + let quoted = r#"A"B"#; + let mut state = State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(1)], &[]) + .literal(literal) + .mailbox(quoted, &[Some(2)], &[]); + state.status.insert( + literal.into(), + "* STATUS {10}\r\nWork\\Plans (MESSAGES 3 UNSEEN 2 RECENT 1)".into(), + ); + state.status.insert( + quoted.into(), + r#"* STATUS "A\"B" (MESSAGES 5 UNSEEN 1 RECENT 0)"#.into(), + ); + let (output, server) = run(state, &["status"]); + assert_success(&output); + assert!(server + .commands + .contains(&r#"STATUS "Work\\Plans" (MESSAGES UNSEEN RECENT)"#.to_string())); + let table = stdout(&output); + let cells = |name: &str| -> Vec { + table + .lines() + .find(|line| line.contains(name)) + .unwrap_or_else(|| panic!("no row for {name}:\n{table}")) + .split(['│', '┆']) + .map(str::trim) + .filter(|cell| !cell.is_empty()) + .map(str::to_string) + .collect() + }; + assert_eq!(cells(literal), [literal, "3", "2", "1"]); + assert_eq!(cells(quoted), [quoted, "5", "1", "0"]); + + let (output, server) = run( + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(1)], &[]) + .literal(literal), + &["count", "--all-folders"], + ); + assert_success(&output); + assert!(server + .commands + .contains(&r#"EXAMINE "Work\\Plans""#.to_string())); +} + +#[test] +fn status_reports_exact_counts_and_unknown_for_malformed_responses() { + let mut state = State::new("IMAP4rev1") + .mailbox("Projects (2024)", &[Some(1)], &[]) + .mailbox("Broken", &[Some(2)], &[]) + .mailbox("Refused", &[Some(3)], &[]); + state.status.insert( + "Projects (2024)".into(), + r#"* STATUS "Projects (2024)" (MESSAGES 5 UNSEEN 1 RECENT 0)"#.into(), + ); + state + .status + .insert("Broken".into(), r#"* STATUS "Broken" (MESSAGES x)"#.into()); + let (output, _) = run(state, &["status"]); + assert_success(&output); + let table = stdout(&output); + let row = |name: &str| { + table + .lines() + .find(|line| line.contains(name)) + .unwrap_or_else(|| panic!("no row for {name}:\n{table}")) + .split(['│', '┆']) + .map(str::trim) + .filter(|cell| !cell.is_empty()) + .map(str::to_string) + .collect::>() + }; + assert_eq!(row("Projects (2024)"), ["Projects (2024)", "5", "1", "0"]); + assert_eq!(row("Broken"), ["Broken", "?", "?", "?"]); + assert_eq!(row("Refused"), ["Refused", "?", "?", "?"]); +} + +#[test] +fn quota_reports_every_resource_and_propagates_malformed_responses() { + let quota = |lines: &str| { + let mut state = State::new("IMAP4rev1 QUOTA").mailbox("INBOX", &[Some(1)], &[]); + state.quota = lines.to_string(); + state + }; + + let (output, _) = run( + quota("* QUOTAROOT INBOX \"\"\r\n* QUOTA \"\" (STORAGE 100 1000 MESSAGE 999 1000)\r\n"), + &["quota"], + ); + assert_success(&output); + let table = stdout(&output); + let storage = table.lines().find(|l| l.contains("STORAGE")).unwrap(); + assert!(storage.contains("100K") && storage.contains("1000K") && storage.contains("10.0%")); + let messages = table.lines().find(|l| l.contains("MESSAGE")).unwrap(); + assert!(messages.contains("999") && messages.contains("1000") && messages.contains("99.9%")); + + let (output, _) = run( + quota("* QUOTAROOT INBOX \"\"\r\n* QUOTA \"\" ()\r\n"), + &["quota"], + ); + assert_success(&output); + assert!(stdout(&output).contains("No quota information available.")); + + let (output, _) = run(quota("* QUOTA \"\" (STORAGE ten 1000)\r\n"), &["quota"]); + assert!(!output.status.success()); +} + +#[test] +fn server_errors_are_rendered_inertly() { + let (output, _) = run( + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(1)], &[]) + .fail("EXAMINE", "\u{1b}]52;c;cHduZWQ=\u{7}\u{1b}[2Jdenied"), + &["count"], + ); + assert!(!output.status.success()); + let text = stderr(&output); + assert!(text.starts_with("Error: "), "{text}"); + assert!(text.contains("denied")); + assert!( + !text.contains('\u{1b}') && !text.contains('\u{7}'), + "{text:?}" + ); +} + +fn nested_multipart(depth: usize) -> Vec { + let mut message = b"Content-Type: text/plain\r\n\r\nDeep body".to_vec(); + for index in 0..depth { + let boundary = format!("depth{index}"); + let mut outer = + format!("Content-Type: multipart/mixed; boundary={boundary}\r\n\r\n--{boundary}\r\n") + .into_bytes(); + outer.extend_from_slice(&message); + outer.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + message = outer; + } + let mut raw = b"From: sender@example.com\r\nSubject: Deep\r\nDate: Mon, 1 Apr 2026 10:00:00 +0000\r\nMessage-ID: \r\nMIME-Version: 1.0\r\n".to_vec(); + raw.extend_from_slice(&message); + raw +} + +#[test] +fn parser_depth_limit_is_a_controlled_failure_on_every_path() { + let state = |depth| { + State::new("IMAP4rev1 UIDPLUS") + .mailbox( + "INBOX", + &[Some(1)], + &[raw_message(4, &nested_multipart(depth))], + ) + .special("Drafts", "\\Drafts") + }; + + let (output, _) = run(state(100), &["read", "--uid", "4", "--json"]); + assert_success(&output); + assert!(stdout(&output).contains("Deep body")); + + let (output, _) = run(state(101), &["read", "--uid", "4", "--json"]); + assert_controlled_failure(&output); + assert!( + output.stdout.is_empty(), + "partial JSON: {}", + stdout(&output) + ); + + let (output, _) = run(state(101), &["read", "--uid", "4"]); + assert_success(&output); + assert!(stderr(&output).contains("Warning: failed to parse message")); + assert!(stdout(&output).contains("Deep body")); + + let dir = tempfile::tempdir().unwrap(); + let (output, _) = run_in( + state(101), + dir.path(), + &["attachments", "4", "--save", "-o", "saved"], + ); + assert_controlled_failure(&output); + assert!(!dir.path().join("saved").exists()); + + let server = Server::start(state(101)); + let mut child = Command::new(env!("CARGO_BIN_EXE_slashmail")) + .arg("--config") + .arg({ + let config = dir.path().join("config.toml"); + std::fs::write(&config, "").unwrap(); + config + }) + .current_dir(dir.path()) + .args(["--host", "127.0.0.1", "--port", &server.port.to_string()]) + .args(["-u", "user@example.com", "reply", "4"]) + .env("SLASHMAIL_PASS", "secret") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(b"Thanks").unwrap(); + let output = child.wait_with_output().unwrap(); + let state = server.finish(); + assert_controlled_failure(&output); + assert!(!state.commands.iter().any(|c| c.starts_with("APPEND"))); +} + +#[test] +fn unsolicited_fetch_responses_never_join_the_action_set() { + // Without SORT the result set comes from SEARCH plus FETCH, so a flag + // change reported for another message must not become a row. + let mut state = State::new("IMAP4rev1 MOVE") + .mailbox( + "INBOX", + &[Some(10)], + &[message(7, "Target"), message(9, "Unrelated")], + ) + .mailbox("Archive", &[Some(30)], &[]); + state.unsolicited_fetch = "* 2 FETCH (UID 9 FLAGS (\\Seen))\r\n".into(); + let (output, state) = run( + state, + &["move", "--subject", "Target", "--dest", "Archive", "--yes"], + ); + assert_success(&output); + assert_eq!(mutations(&state.commands), [r#"UID MOVE 7 "Archive""#]); + assert_eq!(state.uids("INBOX"), [9]); + assert!(stdout(&output).contains("Moved 1 message(s)")); + + // A late flags-only response for a matched message keeps its headers. + let mut state = + State::new("IMAP4rev1 SORT").mailbox("INBOX", &[Some(10)], &[message(7, "Target")]); + state.unsolicited_fetch = "* 1 FETCH (UID 7 FLAGS (\\Seen))\r\n".into(); + let (output, _) = run(state, &["search", "--subject", "Target", "--json"]); + assert_success(&output); + let rows: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["subject"], "Target"); + assert_eq!(rows[0]["message_id"], "<7@example.com>"); +} + +#[test] +fn receipts_count_only_messages_the_server_still_has() { + let mut state = State::new("IMAP4rev1 MOVE") + .mailbox( + "INBOX", + &[Some(10)], + &[message(7, "Target"), message(8, "Target")], + ) + .mailbox("Archive", &[Some(30)], &[]); + // Another client expunges UID 8 between the search and the move. + state.mailboxes[0].vanish.push((2, 8)); + let (output, state) = run( + state, + &["move", "--subject", "Target", "--dest", "Archive", "--yes"], + ); + assert_success(&output); + assert_eq!(mutations(&state.commands), [r#"UID MOVE 7 "Archive""#]); + let text = stdout(&output); + assert!(text.contains("Moved 1 message(s) to Archive."), "{text}"); + assert!(text.contains("1 message(s) no longer existed"), "{text}"); + + // A failure in a later folder reports the updates already made. + let (output, state) = run( + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(10)], &[message(7, "Target")]) + .mailbox("Work", &[Some(20)], &[message(8, "Target")]) + .fail("UID STORE 8 ", "store refused"), + &[ + "mark", + "--all-folders", + "--subject", + "Target", + "--read", + "--yes", + ], + ); + assert_failure(&output, "(1 already updated)"); + assert_eq!(state.flags("INBOX", 7), (true, false)); + assert_eq!(state.flags("Work", 8), (false, false)); + + // A second flag change failing after the first succeeded is reported. + let (output, state) = run( + State::new("IMAP4rev1") + .mailbox("INBOX", &[Some(10)], &[message(7, "Target")]) + .fail("UID STORE 7 +FLAGS (\\Flagged)", "store refused"), + &[ + "mark", + "--subject", + "Target", + "--read", + "--set-flagged", + "--yes", + ], + ); + assert_failure(&output, "(0 already updated; 1 more partly updated)"); + assert_eq!(state.flags("INBOX", 7), (true, false)); + + // Without failures every requested change is applied to the matches only. + let (output, state) = run( + State::new("IMAP4rev1").mailbox( + "INBOX", + &[Some(10)], + &[message(7, "Target"), message(9, "Other")], + ), + &[ + "mark", + "--subject", + "Target", + "--read", + "--set-flagged", + "--yes", + ], + ); + assert_success(&output); + assert!(stdout(&output).contains("Updated 1 message(s).")); + assert_eq!(state.flags("INBOX", 7), (true, true)); + assert_eq!(state.flags("INBOX", 9), (false, false)); +} + +#[test] +fn empty_text_filters_are_rejected_before_opening_a_mailbox() { + for args in [ + vec!["delete", "--from", "", "--yes"], + vec!["mark", "--subject", "\u{1b}", "--read", "--yes"], + vec!["search", "--to", " "], + ] { + let (output, state) = run( + inbox_with_target_and_deleted_neighbor("IMAP4rev1 MOVE"), + &args, + ); + assert_failure(&output, "must not be empty"); + assert!( + !state.commands.iter().any(|c| opens_mailbox(c)), + "{args:?}: {:?}", + state.commands + ); + } +} + +#[test] +fn moves_never_take_messages_from_their_destination() { + let state = || { + State::new("IMAP4rev1 MOVE") + .mailbox("INBOX", &[Some(10)], &[message(7, "Target")]) + .mailbox("Archive", &[Some(30)], &[message(3, "Target")]) + .special("Deleted Items", "\\Trash") + }; + let (output, server) = run( + state(), + &[ + "move", + "--all-folders", + "--subject", + "Target", + "--dest", + "Archive", + "--dry-run", + ], + ); + assert_success(&output); + assert!(stdout(&output).contains("1 message(s) would be moved")); + // A dry run only examines mailboxes. + let opened: Vec<&String> = server + .commands + .iter() + .filter(|c| opens_mailbox(c)) + .collect(); + assert_eq!(opened, [r#"EXAMINE "INBOX""#]); + + let (output, server) = run( + state(), + &[ + "move", + "--all-folders", + "--subject", + "Target", + "--dest", + "Archive", + "--yes", + ], + ); + assert_success(&output); + assert_eq!(mutations(&server.commands), [r#"UID MOVE 7 "Archive""#]); + assert_eq!(server.uids("Archive"), [3, 4]); + + let (output, server) = run( + state(), + &[ + "move", + "-f", + "Archive", + "--subject", + "Target", + "--dest", + "Archive", + "--yes", + ], + ); + assert_failure(&output, "Source and destination folder are the same"); + assert!(mutations(&server.commands).is_empty()); +} + +#[test] +fn folder_and_all_folders_conflict_without_connecting() { + let server = Server::start(State::new("IMAP4rev1 MOVE")); + let dir = tempfile::tempdir().unwrap(); + let output = slashmail( + server.port, + dir.path(), + &["delete", "-f", "Newsletters", "--all-folders", "--yes"], + ); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!( + stderr(&output).contains("--all-folders"), + "{}", + stderr(&output) + ); + assert!(server.finish().commands.is_empty()); +} From 999644866dd6f526394de290733aca2de19ed583 Mon Sep 17 00:00:00 2001 From: Mike Wassmer Date: Mon, 28 Sep 2026 13:30:36 +0200 Subject: [PATCH 5/5] Prepare 0.8.0 release --- CHANGELOG.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- Cargo.lock | 7 ++++--- Cargo.toml | 7 ++++--- static/index.html | 28 ++++++++++++++-------------- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701a296..4eae0c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.8.0] - 2026-09-28 + ### Added - `read --uid ` reads one exact message from the selected folder and fails when that UID does not exist @@ -14,6 +16,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `search --json` rows include `message_id`, `in_reply_to`, `references`, and `seen`/`answered`/`flagged` state - `draft --json` and `reply --json` print the saved-draft receipt, including the draft UID and Message-ID, as JSON +### Changed + +- **Breaking:** `move` takes its destination as `--dest `; `--to` is the recipient filter for every command. `move --to X` previously failed to parse in every release since 0.3.0 +- **Breaking:** plaintext IMAP is refused for every non-loopback host (previously a warning). Loopback plaintext, such as ProtonMail Bridge, still works +- **Breaking:** `export` percent-encodes folder names in file names (`Work/Projects` → `Work%2FProjects_1.eml`, `Work_Projects` → `Work%5FProjects_1.eml`; on Windows lowercase letters are encoded too). Existing exports are not renamed +- **Breaking:** minimum supported Rust version is 1.88, required by mailparse 0.17 +- **Breaking:** `-f/--folder` and `--all-folders` can no longer be combined; `--folder` was previously ignored silently +- Empty or whitespace/control-only text filters (`--subject`, `--from`, `--to`, `--cc`, `--body`, `--text`) are rejected instead of matching every message +- `--all-folders` also skips mailboxes marked `\Trash` or `\Junk` (such as "Deleted Items" and "Junk Email"); `delete` and `move` never search their destination, and naming the destination as the source folder is an error +- `search`, `read`, `count`, `export`, and `--dry-run` open folders read-only with `EXAMINE`, so they no longer clear `\Recent` +- `export` without `--force` skips an existing file only when it holds the same message (identical bytes or Message-ID); an existing file holding a different message, such as after a mailbox was recreated, is left unchanged and reported as an error once the other messages are exported +- `delete` and `move` require server support for `MOVE` or `UIDPLUS` and fail before changing anything otherwise +- Non-ASCII search terms are sent as UTF-8 literals and require `LITERAL+`; without it the search fails instead of silently matching nothing +- `search --json` returns full From, Subject, and Date values; only the terminal table truncates them +- `status` and `quota` use the IMAP library's typed response parsers and report every quota resource +- Received attachment bytes no longer include the line break that belongs to the MIME boundary + +### Fixed + +- The COPY fallback for servers without `MOVE` expunges only the moved UIDs instead of every message flagged `\Deleted` in the folder +- Actions on searched UIDs (`delete`, `move`, `mark`, `export`, `read`) fail if the folder's `UIDVALIDITY` changed since the search +- Folder lookup compares names exactly instead of using the folder name as a LIST pattern, so names with spaces, quotes, backslashes, `*`, or `%` work; folder names with control characters are rejected instead of silently altered +- `--all-folders` skips the special-use `\All` mailbox and no longer skips folders merely containing "all mail" (such as "Small mail") +- `status` shows `?` instead of zero counts for malformed responses, and folder names with parentheses no longer confuse the counts +- Message bodies combine every `multipart/mixed` text segment in order, choose one `multipart/alternative` representation, and follow the `multipart/related` root, in `read`, `read --json`, and reply quotes +- Replies keep In-Reply-To and References when the source Message-ID or References headers contain comments; header values may contain tabs +- `attachments --save --part` decodes only the selected parts, so a corrupt unselected attachment no longer blocks saving +- Unsolicited FETCH responses (flag changes by other clients during a search) no longer add unrelated messages to `delete`, `move`, `mark`, or `export`, and no longer blank out a matched message's headers +- `delete`, `move`, and `mark` act on and count only messages that still exist; receipts report messages another client removed, and failures report the messages already moved or updated, including messages that received only some of several requested flag changes +- Saving attachments stops instead of overwriting an earlier part when two planned names resolve to the same file on case- or normalization-insensitive filesystems; truncated names no longer end in a space or dot + +### Security + +- Headers, bodies, folder names, file paths, and server or parser errors are rendered inert in the terminal, including the final error message; `--json` output and exported bytes are unchanged +- Exported messages use exclusive creation, never follow symlinks, refuse to replace directories or special files, detect destinations that alias another message, and are created owner-only (`0600`) on Unix, like saved attachments +- Deeply nested MIME messages are rejected by mailparse 0.17's recursion limit instead of risking stack exhaustion +- The destination mailbox of the COPY fallback is now quoted and validated +- Attachment names such as `COM¹.txt`, `CONIN$`, `CONOUT$`, and `NUL .txt` are treated as Windows device names +- Invisible formatting characters (zero-width space, soft hyphen, BOM, word joiner, tag characters outside emoji flags) are shown as spaces in terminal fields and dropped from bodies, so look-alike folder and sender names stay distinguishable + ## [0.7.0] - 2026-09-10 ### Added @@ -138,7 +180,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Plaintext connection warning for non-loopback hosts - Passwords securely zeroed from memory after login -[Unreleased]: https://github.com/mwmdev/slashmail/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/mwmdev/slashmail/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/mwmdev/slashmail/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/mwmdev/slashmail/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/mwmdev/slashmail/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/mwmdev/slashmail/compare/v0.4.0...v0.5.0 diff --git a/Cargo.lock b/Cargo.lock index 559f6af..6cdd322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,9 +872,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mailparse" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e" +checksum = "8469955699e572455f8e1a40689e07ab309911d659632f65062598192254c091" dependencies = [ "charset", "data-encoding", @@ -1509,7 +1509,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slashmail" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "clap", @@ -1529,6 +1529,7 @@ dependencies = [ "mime_guess", "native-tls", "openssl", + "parking_lot", "regex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b73042e..71e70f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "slashmail" -version = "0.7.0" +version = "0.8.0" edition = "2021" -rust-version = "1.83" +rust-version = "1.88" description = "CLI for searching, managing, drafting, and bulk-operating on emails via IMAP" license = "MIT OR Apache-2.0" authors = ["Michael Wassmer "] @@ -22,7 +22,7 @@ clap_complete = "4" clap_mangen = "0.2" inquire = "0.9" comfy-table = "=7.1.3" -mailparse = "0.15" +mailparse = "=0.17.0" mime_guess = { version = "2.0.5", default-features = false } anyhow = "1" serde = { version = "1", features = ["derive"] } @@ -47,6 +47,7 @@ integration-tests = [] [dev-dependencies] lettre = { version = "=0.11.19", default-features = false, features = ["builder", "mime03", "smtp-transport", "native-tls"] } tempfile = ">=3,<3.25" +parking_lot = "0.12" [profile.dev] incremental = false diff --git a/static/index.html b/static/index.html index 5326199..280ddb5 100644 --- a/static/index.html +++ b/static/index.html @@ -185,7 +185,7 @@

Save an email with an attachment

Inbox zero

Preview an archive sweep

-
slashmail move --from "notifications@" --before 1m --to Archive --dry-run
+
slashmail move --from "notifications@" --before 1m --dest Archive --dry-run

Back up

@@ -232,7 +232,7 @@

Your inbox toolbox

read -

Display matching message content directly in the terminal.

+

Display matching messages, or one exact message by UID, in the terminal or as JSON.

delete @@ -301,7 +301,7 @@

Speak with your mailbox.

02
- slashmail read --from acme --subject invoice --limit 1 + slashmail read --folder INBOX --uid 1842

Inspect the exact message

@@ -329,20 +329,20 @@

Pick your release.

Latest stable -

slashmail v0.7.0

+

slashmail v0.8.0

- +
-

Automatic .env password loading and reliable starred-message actions, with clearer real-world examples.

+

Safer bulk actions: move --dest, exact reads with read --uid, UID-scoped moves, TLS for every remote server, and collision-proof exports.

-
+
Linux @@ -350,10 +350,10 @@

slashmail v0.7.0

macOS @@ -361,7 +361,7 @@

slashmail v0.7.0

Windows @@ -369,7 +369,7 @@

slashmail v0.7.0

crates.io @@ -377,7 +377,7 @@

slashmail v0.7.0