From 104f112b8e8d35d91e70e97f7642d9ce84a3bf06 Mon Sep 17 00:00:00 2001 From: Exe Zulliger Date: Fri, 17 Jul 2026 16:12:57 +0200 Subject: [PATCH 1/2] save a copy of sent mail to the Sent folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMTP delivery never files a copy in the sender's Sent mailbox — that lives on IMAP and the client must APPEND it. `send` did neither, so sent mail vanished from Sent on every provider that doesn't auto-file (i.e. most of them). After a successful send, file an identical copy (the exact RFC822 bytes, flagged \Seen) into the Sent folder via IMAP APPEND. Folder precedence: --sent-folder > MAILCLI_SENT_FOLDER > \Sent special-use auto-discovery (RFC 6154) > the literal "Sent". Best-effort by design: the mail is already delivered when APPEND runs, so a failed copy warns and still exits 0 — a non-zero exit would invite a retry that double-sends. Default on; --no-save-sent / MAILCLI_SAVE_SENT=0 opt out; a one-line note (not a failure) when IMAP isn't configured. - config.rs: Env::sent_folder() (Option) + save_sent() (Result) - imap.rs: append_to_sent() + discover_sent() (\Sent special-use) - smtp.rs: formatted() exposes the sent bytes (single source of truth) - main.rs: --sent-folder / --no-save-sent; best-effort save_to_sent() - tests: config unit tests + tests/sent_integration.rs (GreenMail, MAILCLI_IT=1) - docs+skill synced (install-skill output byte-identical); CHANGELOG Co-authored by PeakBot! --- AGENTS.md | 9 ++- CHANGELOG.md | 24 ++++++ Cargo.lock | 1 + Cargo.toml | 4 + README.md | 21 +++++- memory.md | 69 +++++++++++++++++ skill/SKILL.md | 13 ++++ skill/evals/evals.json | 6 ++ skill/references/reference.md | 7 +- src/config.rs | 107 +++++++++++++++++++++++++++ src/imap.rs | 28 +++++++ src/lib.rs | 12 ++- src/main.rs | 69 ++++++++++++++++- src/smtp.rs | 6 ++ tests/sent_integration.rs | 134 ++++++++++++++++++++++++++++++++++ 15 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 tests/sent_integration.rs diff --git a/AGENTS.md b/AGENTS.md index 28e8811..a308801 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,8 @@ src/ main.rs // clap surface, Selector enum, live selector resolution, dispatch config.rs // the ONLY boundary that reads env; per-command validation; // Env account-suffix resolver + accounts scan - imap.rs // the ONLY place that talks IMAP (connect/auth/list/fetch) + imap.rs // the ONLY place that talks IMAP (connect/auth/list/fetch; + // append-to-Sent + \Sent special-use discovery) parse.rs // mail-parser wrappers; Summary/Rendered/Attachment structs smtp.rs // lettre message build (+attachments) and send skill.rs // embeds skill/ assets; install-skill writes them to disk @@ -51,7 +52,11 @@ inside `imap.rs` so a future async swap stays contained. - Unit tests sit beside code in `mod tests`. Cover parsing (inline `.eml` fixtures), `Selector` parsing, and `Config` env cases. - **Every bug fix starts with a failing test** that reproduces it, then the fix. -- Integration (planned): GreenMail in Docker for connect→list→fetch and send. +- Integration: GreenMail in Docker/podman, gated on `MAILCLI_IT=1`. Covers the + `search` query builder (`tests/search_integration.rs`) and the save-to-Sent + `APPEND`/`\Sent` discovery paths (`tests/sent_integration.rs`). GreenMail must + bind `0.0.0.0` (`-Dgreenmail.hostname=0.0.0.0`) or podman forwarding drops the + connection. The lib crate exposes `imap`/`config`/`parse` for these tests. ## Commit & Pull Request Guidelines diff --git a/CHANGELOG.md b/CHANGELOG.md index 494d744..21dca96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `mail-cli` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`send` saves a copy to the Sent folder.** Previously a sent message was + delivered over SMTP but never filed in Sent (SMTP delivery does not touch the + IMAP Sent mailbox). Now, after a successful send, `mail-cli` files an identical + copy into the Sent folder over IMAP (flagged `\Seen`). The folder is resolved + as `--sent-folder` > `MAILCLI_SENT_FOLDER` > the server's `\Sent` special-use + mailbox (auto-discovered, RFC 6154) > the literal `Sent`. +- New `--sent-folder ` / `--no-save-sent` flags on `send`, and + `MAILCLI_SENT_FOLDER` / `MAILCLI_SAVE_SENT` env vars (per-account, suffixable). +- Integration tests (`tests/sent_integration.rs`, gated on `MAILCLI_IT=1`) + covering the `APPEND`-to-Sent and `\Sent` discovery paths against GreenMail. + +### Behavior + +- Saving to Sent is **best-effort and default-on**: a failed copy prints a + `warning:` and still exits `0` (the mail already went out; a non-zero exit + would invite a retry that double-sends). If IMAP is not configured, saving is + skipped with a one-line `note:` rather than failing the send. Because the + default degrades to a skip when IMAP is absent, existing send-only setups keep + working — this is a minor, additive change. + ## [0.2.0] - 2026-07-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1c07451..197abd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,6 +537,7 @@ dependencies = [ "chrono", "clap", "imap", + "imap-proto", "lettre", "mail-parser", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index 1719ec1..97c39ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,10 @@ edition = "2024" [dependencies] clap = { version = "4", features = ["derive"] } imap = { version = "3.0.0-alpha.15", default-features = false, features = ["rustls-tls"] } +# Re-used for the `\Sent` special-use LIST attribute (RFC 6154) when +# auto-discovering the Sent folder. Pinned to the version `imap` resolves to so +# the `NameAttribute` type matches what `imap::types::Name::attributes()` returns. +imap-proto = "=0.16.7" lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "pool", "rustls-tls", "hostname"] } mail-parser = "0.11" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 94ba2e0..9bd3749 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ cargo build --release # binary at target/release/mail-cli | `MAILCLI_PASSWORD` | yes | — | Password / app-password / (with `MAILCLI_AUTH=xoauth2`) an OAuth access token. | | `MAILCLI_TLS` | no | `implicit` | `implicit` (TLS on connect) or `starttls`. | | `MAILCLI_AUTH` | no | `login` | `login` (PLAIN/LOGIN) or `xoauth2` (token passthrough). | +| `MAILCLI_SENT_FOLDER` | no | auto → `Sent` | Folder to save sent mail into. If unset, `send` auto-discovers the server's `\Sent` special-use mailbox, else falls back to `Sent`. | +| `MAILCLI_SAVE_SENT` | no | `1` | Whether `send` files a copy in the Sent folder (`0/false/no/off` to disable). Requires IMAP config. | | `MAILCLI_ACCOUNT` | no | — | Default account selector when `--account` is omitted (see [Multiple accounts](#multiple-accounts)). | Only the variables a given command needs are required. Switch accounts by @@ -192,8 +194,23 @@ echo "hi" | mail-cli send --to a@x.com --subject S --body-file - # HTML body mail-cli send --to a@x.com --subject S --html --body '

Hi

' + +# Sent copy: by default `send` files a copy in the Sent folder over IMAP. +mail-cli send --to a@x.com --subject S --body hi --sent-folder "Sent Items" +mail-cli send --to a@x.com --subject S --body hi --no-save-sent ``` +**Saving to Sent.** SMTP delivery does not put a copy in your Sent mailbox — +that lives on the IMAP side. After a successful send, `mail-cli` files an +identical copy into your Sent folder (flagged read) via IMAP `APPEND`. The +folder is resolved as `--sent-folder` > `MAILCLI_SENT_FOLDER` > the server's +`\Sent` special-use mailbox (auto-discovered) > the literal `Sent`. + +This is **best-effort**: the message is already delivered, so a failed copy only +prints a `warning:` and still exits `0` (a non-zero exit would invite a retry +that double-sends). Disable it with `--no-save-sent` or `MAILCLI_SAVE_SENT=0`. +If IMAP isn't configured, saving is skipped with a one-line `note:`. + ### install-skill Install the agent usage skill that ships **inside the binary** — no repo clone needed. It writes `SKILL.md` plus its `references/` and `evals/` to disk. @@ -238,7 +255,8 @@ cargo test ``` The `search` query builder and parsing/selector logic are fully covered here. -Integration tests exercise `search` against a real IMAP server +Integration tests exercise `search` and the save-to-Sent `APPEND`/discovery +paths against a real IMAP server ([GreenMail](https://greenmail-mail-test.github.io/greenmail/)). They are **gated on `MAILCLI_IT=1`** so a plain `cargo test` never needs a server: ```sh @@ -247,6 +265,7 @@ podman run -d --name greenmail-mailcli -p 3025:3025 -p 3143:3143 \ docker.io/greenmail/standalone:2.1.0 MAILCLI_IT=1 cargo test --test search_integration +MAILCLI_IT=1 cargo test --test sent_integration ``` (`docker` works too if you prefer it to `podman`.) diff --git a/memory.md b/memory.md index 539475c..e66989f 100644 --- a/memory.md +++ b/memory.md @@ -174,6 +174,75 @@ "dropped --account/profiles" resolved: this env-only suffix form stays inside env-only+stateless, so it supersedes that line for THIS form only (user confirmed). +- 2026-07-09 (rev 14): RELEASED v0.2.0. Changes since v0.1.0: install.sh + one-liner (PR #3, already on master) + multi-account (PR #4, already + merged). Wrote CHANGELOG.md (Keep a Changelog format; v0.1.0 baseline + so the file stands alone) + bumped Cargo.toml 0.1.0 → 0.2.0 on master. + Release commit `c484886`. Tagged v0.2.0 annotated; pushed; release + workflow queued as run 29002861696. Default-account = bare MAILCLI_* = + zero breakage, so this is a MINOR not MAJOR bump. Release workflow + builds linux-amd64, macos-amd64, macos-arm64, windows-amd64 binaries + and publishes as a GitHub release. +- 2026-07-09 (rev 15): RELEASE COMPLETE. All 4 build jobs green, publish + job 12s, run 29002861696 finished in ~3 min total. Verified via REST + /releases/tags/v0.2.0 (gh CLI hung twice; curl + gh auth token + + python parse = clean summary). 4 assets, all state=uploaded, sizes + 5.5/4.9/4.5/5.5 MiB for linux-amd64/macos-amd64/macos-arm64/windows- + amd64.exe. Published 2026-07-09T07:55:29Z; draft=false, prerelease= + false. Telegram ping sent via skill script (success). NOTES for next + release: gh release view hung at 30s AND 120s — fall back to + /releases/tags/ JSON endpoint with gh auth token if it hangs + again; gh run watch REQUIRES a run-id arg when started non-interactive + (got "run ID required when not running interactively" — fixed on + second attempt). + +- 2026-07-17 (rev 16): PLANNED save-to-Sent (plan only, not implemented). + Wrote plans/SAVE-TO-SENT-PLAN.md. ROOT CAUSE: `send` only calls smtp::send + (SMTP relay); it never IMAP-APPENDs a copy into Sent. SMTP delivery != Sent + mailbox; Gmail auto-appends but most providers don't. FIX: after SMTP ok, + APPEND lettre `Message::formatted()` bytes into Sent, flagged \Seen, + INTERNALDATE=now, via new imap::append_to_sent (crate AppendCmd, same path as + the search integration test). DESIGN: folder = --sent-folder > MAILCLI_SENT_ + FOLDER > SPECIAL-USE \Sent autodiscover > "Sent"; default ON but best-effort: + skip silently if IMAP not configured & not explicitly requested, else loud; + APPEND failure = WARN + exit 0 (mail already went out — never fail a delivered + send or a retry double-sends); --json gains saved_to_sent/sent_folder/ + save_error. config.rs stays sole env boundary (adds sent_folder()/save_sent() + methods, no new REQUIRED vars = zero breakage); IMAP calls stay in imap.rs. + Non-goals v1: no auto-CREATE, no Fcc/bcc-self, no offline retry queue. 3 open + Qs for user (default on?, autodiscover in v1?, warn-vs-fail on APPEND error). + +- 2026-07-17 (rev 17): IMPLEMENTED save-to-Sent (SAVE-TO-SENT-PLAN). User + answered: (1) default ON, (2) DO the SPECIAL-USE autodiscovery in v1, (3) WARN + (not fail). Ran ZoE review first → 2 cuts adopted: (a) DROPPED --json-on-send + entirely (send had no JSON today; unrequested feature = YAGNI), (b) DROPPED the + --save-sent force flag → single bool: default on, --no-save-sent / + MAILCLI_SAVE_SENT=0 to opt out; if IMAP unconfigured print a one-line note: + (not silent, not fatal) since a silent skip looks like the very bug. TDD: + wrote failing tests FIRST (config unit tests for sent_folder()/save_sent(); + tests/sent_integration.rs), confirmed red (missing methods + private + mail_cli::imap), then went green. IMPL: config.rs adds Env::sent_folder()-> + Option (raw env read; "Sent" DEFAULT lives at point-of-use in main.rs, + per ZoE) + save_sent()->Result (1/0,true/false,yes/no,on/off; garbage + rejected naming resolved var). imap.rs adds append_to_sent (session.append(). + flag(Seen).finish()) + discover_sent (LIST, match imap_proto::NameAttribute:: + Sent). Added imap-proto="=0.16.7" as a DIRECT dep (pin to imap's resolved + version) to name that enum — imap doesn't re-export NameAttribute. smtp.rs + adds formatted(&Message)->Vec (single source = the built Message; no + double-format cleverness, no send_raw). main.rs: send gains --sent-folder / + --no-save-sent; cmd_send does primary send (fatal on fail) then best-effort + save_to_sent() helper that NEVER returns Err (all paths warn/note + return); + folder precedence --sent-folder > env.sent_folder() > discover_sent > + "Sent". lib.rs now exposes pub mod config/imap/parse (was search-only) for the + integration test — FIXED its now-false doc comment (misleading-comment rule). + VERIFY: 38 lib + 33 bin unit pass; clippy clean; fmt clean. Integration ran + under GreenMail (podman, MUST bind 0.0.0.0 + auth.disabled per rev 6) — both + sent tests PASS (copy lands in Sent flagged \Seen; discovery selects a valid + mailbox). install-skill --force output BYTE-IDENTICAL to skill/ (diff -rq). + Docs synced: README (config table +2 rows, Saving-to-Sent section, testing + cmd), AGENTS.md (imap.rs desc, testing guidelines de-"planned"), SKILL.md + + reference.md + evals.json (+eval 8). CHANGELOG [Unreleased] added (MINOR: + additive, degrades to skip when IMAP absent). Opened PR (see below). ## Semantic (facts learned) - Rust email crate landscape (mid-2026): diff --git a/skill/SKILL.md b/skill/SKILL.md index cf2f253..ce3911d 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -37,6 +37,8 @@ given command needs are required; a missing one fails loud, naming the variable. | `MAILCLI_PASSWORD` | yes | — | Password / app-password / (with `xoauth2`) an OAuth access token | | `MAILCLI_TLS` | no | `implicit` | `implicit` (TLS on connect) or `starttls` | | `MAILCLI_AUTH` | no | `login` | `login` (PLAIN/LOGIN) or `xoauth2` (token passthrough) | +| `MAILCLI_SENT_FOLDER` | no | auto → `Sent` | Folder `send` files a copy into; unset = auto-discover `\Sent` special-use, else `Sent` | +| `MAILCLI_SAVE_SENT` | no | `1` | `send` saves a copy to Sent by default; `0/false/no/off` disables it | | `MAILCLI_ACCOUNT` | no | — | Default account selector when `--account` is omitted | Switch accounts by switching environment (shell export, `direnv`, a wrapper @@ -164,6 +166,17 @@ mail-cli send --to a@x.com --subject S --body-file note.txt echo "hi" | mail-cli send --to a@x.com --subject S --body-file - mail-cli send --to a@x.com --subject S --html --body '

Hi

' ``` +- **Saving to Sent (default on).** After delivery, `send` files an identical + copy into the Sent folder over IMAP (flagged read). Folder precedence: + `--sent-folder` > `MAILCLI_SENT_FOLDER` > auto-discovered `\Sent` mailbox > + `Sent`. It's **best-effort**: a failed copy prints a `warning:` but `send` + still exits `0` (retrying would double-send). Disable with `--no-save-sent` or + `MAILCLI_SAVE_SENT=0`; if IMAP isn't configured, saving is skipped with a + `note:`. +```sh +mail-cli send --to a@x.com --subject S --body hi --sent-folder "Sent Items" +mail-cli send --to a@x.com --subject S --body hi --no-save-sent +``` ## Recommended workflow for an agent diff --git a/skill/evals/evals.json b/skill/evals/evals.json index bd3a63e..110143b 100644 --- a/skill/evals/evals.json +++ b/skill/evals/evals.json @@ -42,6 +42,12 @@ "prompt": "How do I see which email accounts I've configured for mail-cli, and check that account 2 is fully set up? I don't want to accidentally print my passwords.", "expected_output": "Runs `mail-cli accounts` (optionally `--json`) to list configured accounts and their ok/incomplete status. Notes it never prints passwords (only has_password / a presence flag), reports whether account 2 shows as complete, and opens no connection. Does not cat env or echo MAILCLI_PASSWORD*.", "files": [] + }, + { + "id": 8, + "prompt": "When I send mail with mail-cli it goes out fine but nothing shows up in my Sent folder. My server's sent mailbox is actually called 'Sent Items'. How do I make mail-cli save a copy there?", + "expected_output": "Explains that `send` already saves a copy to Sent by default via IMAP APPEND (SMTP alone never files Sent), and that the folder name is configurable: pass `--sent-folder 'Sent Items'` or set `MAILCLI_SENT_FOLDER='Sent Items'`. Notes the save is best-effort (warns, exits 0, doesn't double-send) and needs IMAP env vars configured. Does not suggest a config file, BCC-to-self, or claim SMTP auto-files Sent.", + "files": [] } ] } diff --git a/skill/references/reference.md b/skill/references/reference.md index 9815898..20510be 100644 --- a/skill/references/reference.md +++ b/skill/references/reference.md @@ -230,6 +230,11 @@ These are deliberate; work with them rather than around them: flag but isn't, check whether it's a `MAILCLI_*` var before assuming it's missing. - **Read commands are IMAP; `send` is SMTP.** They validate independently, so - you can run reads with only IMAP vars set. + you can run reads with only IMAP vars set. Note `send` *also* touches IMAP as a + best-effort step: it files a copy in the Sent folder (`--sent-folder` > + `MAILCLI_SENT_FOLDER` > auto-discovered `\Sent` > `Sent`). That copy failing + never fails the send — it warns and exits `0`, because the mail already went + out and a retry would double-send. Disable with `--no-save-sent` / + `MAILCLI_SAVE_SENT=0`; skipped with a `note:` if IMAP isn't configured. - **`--raw` (RFC822) output is bytes on stdout.** Redirect to a file if you want to keep it; don't expect JSON. diff --git a/src/config.rs b/src/config.rs index 0957463..c76bdc8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -154,6 +154,33 @@ impl Env { None => self.required("MAILCLI_EMAIL"), } } + + /// The configured Sent-folder name from `MAILCLI_SENT_FOLDER[_N]`, or `None` + /// if unset. This is only the *configured* value; the caller (`main.rs`) + /// owns the precedence chain: `--sent-folder` > this > `\Sent` special-use + /// discovery > the literal `"Sent"` default. + pub fn sent_folder(&self) -> Option { + self.opt("MAILCLI_SENT_FOLDER") + } + + /// Whether to save a copy of sent mail to the Sent folder. Default **on**; + /// `MAILCLI_SAVE_SENT[_N]` set to a falsey value (`0/false/no/off`) turns it + /// off. Any other value is rejected loud, naming the resolved var. + pub fn save_sent(&self) -> Result { + match self.opt("MAILCLI_SAVE_SENT") { + None => Ok(true), + Some(v) => match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => { + let name = self.var("MAILCLI_SAVE_SENT"); + bail!( + "{name} must be a boolean (1/0, true/false, yes/no, on/off), got {other:?}" + ) + } + }, + } + } } impl ImapConfig { @@ -404,4 +431,84 @@ mod tests { let infos = scan_accounts_from(owned(&[("MAILCLI_EMAIL_3", " ")])); assert!(infos.is_empty()); } + + // ---- save-to-Sent config (SAVE-TO-SENT-PLAN §3.1/§3.2) ---- + + #[test] + fn sent_folder_none_when_unset() { + // No MAILCLI_SENT_FOLDER set → None (main.rs supplies the "Sent" default). + let env = Env { + suffix: None, + lookup: |_| None, + }; + assert_eq!(env.sent_folder(), None); + } + + #[test] + fn sent_folder_reads_env_override() { + let env = Env { + suffix: None, + lookup: |name| (name == "MAILCLI_SENT_FOLDER").then(|| "Sent Items".to_string()), + }; + assert_eq!(env.sent_folder().as_deref(), Some("Sent Items")); + } + + #[test] + fn sent_folder_is_account_suffixed() { + let env = Env { + suffix: Some("2".to_string()), + lookup: |name| (name == "MAILCLI_SENT_FOLDER_2").then(|| "INBOX.Sent".to_string()), + }; + assert_eq!(env.sent_folder().as_deref(), Some("INBOX.Sent")); + } + + #[test] + fn save_sent_defaults_on() { + let env = Env { + suffix: None, + lookup: |_| None, + }; + assert!(env.save_sent().unwrap()); + } + + #[test] + fn save_sent_off_when_env_is_zero_or_false() { + for val in ["0", "false", "no", "off"] { + let env = Env { + suffix: None, + // leak a &'static str per iteration is fine for a test. + lookup: match val { + "0" => |name: &str| (name == "MAILCLI_SAVE_SENT").then(|| "0".to_string()), + "false" => { + |name: &str| (name == "MAILCLI_SAVE_SENT").then(|| "false".to_string()) + } + "no" => |name: &str| (name == "MAILCLI_SAVE_SENT").then(|| "no".to_string()), + _ => |name: &str| (name == "MAILCLI_SAVE_SENT").then(|| "off".to_string()), + }, + }; + assert!(!env.save_sent().unwrap(), "{val:?} should disable saving"); + } + } + + #[test] + fn save_sent_on_when_env_is_one_or_true() { + let env = Env { + suffix: None, + lookup: |name| (name == "MAILCLI_SAVE_SENT").then(|| "1".to_string()), + }; + assert!(env.save_sent().unwrap()); + } + + #[test] + fn save_sent_rejects_garbage_naming_the_var() { + let env = Env { + suffix: Some("2".to_string()), + lookup: |name| (name == "MAILCLI_SAVE_SENT_2").then(|| "maybe".to_string()), + }; + let err = env.save_sent().unwrap_err(); + assert!( + err.to_string().contains("MAILCLI_SAVE_SENT_2"), + "error should name the suffixed var, got: {err}" + ); + } } diff --git a/src/imap.rs b/src/imap.rs index a91623f..c7a8bab 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -154,6 +154,34 @@ pub fn fetch_raw(session: &mut Conn, folder: &str, uid: u32) -> Result> } } +/// APPEND a copy of a just-sent message into the Sent folder, flagged `\Seen` +/// (we file our own outgoing mail as already read). The raw bytes are the exact +/// RFC822 payload that went over SMTP. GreenMail and most servers auto-create a +/// missing mailbox on APPEND; when they don't, this fails and the caller warns. +pub fn append_to_sent(session: &mut Conn, folder: &str, raw: &[u8]) -> Result<()> { + let mut cmd = session.append(folder, raw); + cmd.flag(Flag::Seen); + cmd.finish() + .with_context(|| format!("APPEND to {folder:?} failed"))?; + Ok(()) +} + +/// Discover the Sent folder via the RFC 6154 SPECIAL-USE `\Sent` attribute. +/// Returns the mailbox name flagged `\Sent`, or `None` if the server advertises +/// no such mailbox (or doesn't support special-use at all). Best-effort: a +/// failed LIST just yields `None` so the caller falls back to its default. +pub fn discover_sent(session: &mut Conn) -> Option { + let names = session.list(None, Some("*")).ok()?; + names + .iter() + .find(|n| { + n.attributes() + .iter() + .any(|a| matches!(a, imap_proto::NameAttribute::Sent)) + }) + .map(|n| n.name().to_string()) +} + /// Render IMAP flags to their canonical `\Seen`-style strings. fn format_flags(flags: &[Flag<'_>]) -> Vec { flags diff --git a/src/lib.rs b/src/lib.rs index e0b4742..944ef38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,11 @@ -//! Library facade exposing the pure, dependency-light pieces of `mail-cli` for -//! integration testing. Only `search` — the offline IMAP SEARCH query builder — -//! is public here; the I/O modules (imap/smtp/…) remain private to the binary. +//! Library facade exposing the pieces of `mail-cli` that integration tests drive +//! directly. `search` is the offline IMAP SEARCH query builder; `imap` is the +//! IMAP wire layer, exposed so `tests/` can exercise the real `append_to_sent` +//! and `discover_sent` paths against a GreenMail server. The remaining modules +//! (config/smtp/parse/output/skill) stay private to the binary. pub mod search; + +pub mod config; +pub mod imap; +pub mod parse; diff --git a/src/main.rs b/src/main.rs index c4951fe..0732e5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -192,6 +192,13 @@ enum Command { /// Attach a file (repeatable). #[arg(long)] attach: Vec, + /// Sent folder to file a copy into. Overrides MAILCLI_SENT_FOLDER and + /// special-use auto-discovery. + #[arg(long)] + sent_folder: Option, + /// Do not save a copy to the Sent folder (overrides MAILCLI_SAVE_SENT). + #[arg(long)] + no_save_sent: bool, }, /// Install the bundled agent skill (SKILL.md + references + evals). @@ -333,7 +340,21 @@ fn run() -> Result<()> { body_file, html, attach, - } => cmd_send(&env, to, cc, bcc, subject, body, body_file, html, attach), + sent_folder, + no_save_sent, + } => cmd_send( + &env, + to, + cc, + bcc, + subject, + body, + body_file, + html, + attach, + sent_folder, + no_save_sent, + ), Command::InstallSkill { dir, force } => cmd_install_skill(dir, force), } } @@ -519,6 +540,8 @@ fn cmd_send( body_file: Option, html: bool, attach: Vec, + sent_folder: Option, + no_save_sent: bool, ) -> Result<()> { if to.is_empty() { bail!("at least one --to recipient is required"); @@ -535,11 +558,55 @@ fn cmd_send( attachments: attach, }; let email = smtp::build(&cfg, &outgoing)?; + + // Primary effect: deliver. A failure here is fatal (nothing went out). smtp::send(&cfg, &email)?; println!("message sent"); + + // Secondary effect: file a copy in Sent. Best-effort — the mail is already + // delivered, so nothing below may fail the command (a non-zero exit would + // invite a retry, which double-sends). We warn and return Ok. + let save = !no_save_sent && env.save_sent()?; + if save { + save_to_sent(env, sent_folder, &smtp::formatted(&email)); + } Ok(()) } +/// Best-effort: IMAP-`APPEND` a copy of the just-sent message into the Sent +/// folder. Never returns an error — every failure path prints a `note:`/ +/// `warning:` to stderr and returns, because the message has already gone out. +fn save_to_sent(env: &config::Env, sent_folder: Option, raw: &[u8]) { + let imap_cfg = match ImapConfig::from_env(env) { + Ok(c) => c, + Err(e) => { + eprintln!( + "note: not saving a copy to Sent (IMAP not configured: {e:#}); \ + set MAILCLI_SAVE_SENT=0 to silence" + ); + return; + } + }; + let mut session = match imap::connect(&imap_cfg) { + Ok(s) => s, + Err(e) => { + eprintln!("warning: could not save a copy to Sent (IMAP connect failed: {e:#})"); + return; + } + }; + // Folder resolution: --sent-folder > MAILCLI_SENT_FOLDER > \Sent special-use + // discovery > the literal "Sent" default. + let folder = sent_folder + .or_else(|| env.sent_folder()) + .or_else(|| imap::discover_sent(&mut session)) + .unwrap_or_else(|| "Sent".to_string()); + match imap::append_to_sent(&mut session, &folder, raw) { + Ok(()) => println!("saved copy to {folder}"), + Err(e) => eprintln!("warning: could not save a copy to {folder:?}: {e:#}"), + } + let _ = session.logout(); +} + /// Install the bundled agent skill to disk. Local-only; reads no env. fn cmd_install_skill(dir: Option, force: bool) -> Result<()> { let dest = match dir { diff --git a/src/smtp.rs b/src/smtp.rs index 3174aa9..75f4213 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -121,3 +121,9 @@ pub fn send(cfg: &SmtpConfig, email: &Message) -> Result<()> { .map(|_| ()) .with_context(|| format!("failed to send message via {}:{}", cfg.host, cfg.port)) } + +/// The exact RFC822 bytes of a built message — the same payload `send` puts on +/// the wire. Used to file an identical copy into the Sent folder via IMAP. +pub fn formatted(email: &Message) -> Vec { + email.formatted() +} diff --git a/tests/sent_integration.rs b/tests/sent_integration.rs new file mode 100644 index 0000000..dbb3b3b --- /dev/null +++ b/tests/sent_integration.rs @@ -0,0 +1,134 @@ +//! GreenMail integration tests for the save-to-Sent feature +//! (SAVE-TO-SENT-PLAN §5). These assert that the NEW IMAP surface — +//! `imap::append_to_sent` and `imap::discover_sent` — actually lands a copy in +//! the Sent folder, flagged `\Seen`, and that special-use discovery finds a +//! mailbox flagged `\Sent`. +//! +//! Gated on `MAILCLI_IT=1`, same as `search_integration.rs`. Run with GreenMail: +//! +//! ```sh +//! podman run -d --name greenmail-mailcli -p 3025:3025 -p 3143:3143 \ +//! -e GREENMAIL_OPTS='-Dgreenmail.setup.test.imap -Dgreenmail.users.login=email' \ +//! docker.io/greenmail/standalone:2.1.0 +//! MAILCLI_IT=1 cargo test --test sent_integration -- --nocapture +//! ``` +//! +//! Design note (mirrors search_integration.rs): mail-cli's own `connect()` is +//! TLS-only, and GreenMail's plaintext port has no usable cert. We open a +//! plaintext `imap` session directly and drive the SAME functions the binary +//! uses (`mail_cli::imap::append_to_sent` / `discover_sent`). The functions are +//! the code under test; the transport is incidental. + +use std::sync::Mutex; + +use imap::types::Flag; +use imap::{ConnectionMode, Session}; + +const HOST: &str = "127.0.0.1"; +const IMAP_PORT: u16 = 3143; + +/// GreenMail shares global state; serialize so each test runs quiescent. +static SERIAL: Mutex<()> = Mutex::new(()); + +macro_rules! require_greenmail { + () => { + if std::env::var("MAILCLI_IT").ok().as_deref() != Some("1") { + eprintln!("skipping (set MAILCLI_IT=1 with GreenMail running)"); + return; + } + }; +} + +fn session_for(user: &str) -> Session { + let client = imap::ClientBuilder::new(HOST, IMAP_PORT) + .mode(ConnectionMode::Plaintext) + .connect() + .expect("connect to GreenMail plaintext IMAP"); + client + .login(user, "pw") + .map_err(|(e, _)| e) + .expect("login (GreenMail auto-creates the user)") +} + +fn unique_user(tag: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{tag}.{nanos}@example.com") +} + +/// A minimal RFC822 message (what lettre's `formatted()` would produce, shape- +/// wise). The APPEND path doesn't care who built the bytes. +fn raw_message(subject: &str) -> Vec { + format!( + "From: me@example.com\r\nTo: rcpt@example.com\r\nSubject: {subject}\r\n\r\nhello from mail-cli\r\n" + ) + .into_bytes() +} + +#[test] +fn append_lands_in_sent_flagged_seen() { + require_greenmail!(); + let _guard = SERIAL.lock().unwrap(); + let user = unique_user("sent"); + let mut s = session_for(&user); + + // GreenMail auto-creates mailboxes on APPEND, but be explicit so a missing + // "Sent" isn't what we're testing here. + let _ = s.create("Sent"); + + let subject = format!("saved-copy-{}", std::process::id()); + let raw = raw_message(&subject); + + // The function under test. + mail_cli::imap::append_to_sent(&mut s, "Sent", &raw).expect("append_to_sent"); + + // Verify exactly one copy landed in Sent... + s.select("Sent").expect("SELECT Sent"); + let hits = s + .uid_search(format!("SUBJECT \"{subject}\"")) + .expect("UID SEARCH by subject"); + assert_eq!( + hits.len(), + 1, + "expected exactly one saved copy in Sent, got {hits:?}" + ); + let uid = *hits.iter().next().unwrap(); + + // ...and that it is flagged \Seen (we file our own sent mail as read). + let fetches = s + .uid_fetch(uid.to_string(), "(FLAGS)") + .expect("UID FETCH FLAGS"); + let f = fetches.iter().next().expect("one fetch result"); + assert!( + f.flags().contains(&Flag::Seen), + "saved copy should be flagged \\Seen, got {:?}", + f.flags() + ); + + let _ = s.logout(); +} + +#[test] +fn discover_sent_finds_special_use_mailbox() { + require_greenmail!(); + let _guard = SERIAL.lock().unwrap(); + let user = unique_user("disc"); + let mut s = session_for(&user); + + // GreenMail advertises SPECIAL-USE for its default mailboxes; ensure a Sent + // mailbox exists. Discovery should return whatever the server flags \Sent. + let _ = s.create("Sent"); + + let found = mail_cli::imap::discover_sent(&mut s); + // Either the server flags a \Sent mailbox (Some) or it doesn't advertise + // special-use (None) — both are valid. When Some, it must be a real, + // selectable mailbox. + if let Some(name) = found { + s.select(&name) + .unwrap_or_else(|e| panic!("discovered Sent folder {name:?} must be selectable: {e}")); + } + + let _ = s.logout(); +} From 2ca9ece68f7e1485c6e1a60773c7a34d70665002 Mon Sep 17 00:00:00 2001 From: Exe Zulliger Date: Fri, 17 Jul 2026 16:42:15 +0200 Subject: [PATCH 2/2] =?UTF-8?q?add=20send=E2=86=92Sent=20e2e=20test=20and?= =?UTF-8?q?=20run=20integration=20suite=20on=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior integration tests self-skip without MAILCLI_IT=1 and CI never set it, so the IMAP/SMTP-backed tests were dead weight there. Fix both: - tests/send_e2e.rs: a real send→INBOX+Sent loop. Builds the message with mail-cli's real smtp::build, takes the real smtp::formatted bytes, delivers over GreenMail, then files the copy with mail-cli's real imap::append_to_sent and asserts it lands in Sent flagged \Seen. Only the encrypted transport hop is substituted: mail-cli's SMTP TLS (lettre) trusts bundled webpki roots and cannot validate GreenMail's self-signed cert, and there is no accept-invalid-certs escape hatch by design; the delivered payload is byte-identical. Also a binary smoke test asserting the new flags parse and that an unreachable SMTP host is a fatal error. - .github/workflows/ci.yml: new `integration (GreenMail)` job runs search_integration + sent_integration + send_e2e with MAILCLI_IT=1 against a GreenMail service container (waits for the ports first, serialized with --test-threads=1). - lib.rs exposes `smtp` for the e2e test; docs/CHANGELOG updated. Co-authored by PeakBot! --- .github/workflows/ci.yml | 41 ++++++++ AGENTS.md | 16 +++- CHANGELOG.md | 9 ++ README.md | 14 ++- memory.md | 27 ++++++ src/lib.rs | 8 +- tests/send_e2e.rs | 201 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 tests/send_e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b686f..302b7d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,44 @@ jobs: - run: cargo fmt --all -- --check - run: cargo clippy --all-targets --locked -- -D warnings - run: cargo test --locked + + integration: + name: integration (GreenMail) + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + greenmail: + image: greenmail/standalone:2.1.0 + ports: + - 3025:3025 + - 3143:3143 + env: + # Open plaintext SMTP (3025) + IMAP (3143); bind 0.0.0.0 so the mapped + # port is reachable; auth.disabled auto-provisions users on login. + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.smtp + -Dgreenmail.setup.test.imap + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.auth.disabled + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # GreenMail has no HTTP healthcheck; wait for its ports to accept TCP. + - name: Wait for GreenMail + run: | + for port in 3025 3143; do + for i in $(seq 1 30); do + if timeout 1 bash -c "/dev/null; then + echo "port $port is up"; break + fi + if [ "$i" -eq 30 ]; then echo "port $port never came up" >&2; exit 1; fi + sleep 1 + done + done + # Serialized (--test-threads=1): GreenMail with auth.disabled shares global + # state, and concurrent logins/APPENDs interfere. + - name: Integration + e2e tests + env: + MAILCLI_IT: "1" + run: cargo test --locked --test search_integration --test sent_integration --test send_e2e -- --test-threads=1 diff --git a/AGENTS.md b/AGENTS.md index a308801..7852d90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,11 +52,17 @@ inside `imap.rs` so a future async swap stays contained. - Unit tests sit beside code in `mod tests`. Cover parsing (inline `.eml` fixtures), `Selector` parsing, and `Config` env cases. - **Every bug fix starts with a failing test** that reproduces it, then the fix. -- Integration: GreenMail in Docker/podman, gated on `MAILCLI_IT=1`. Covers the - `search` query builder (`tests/search_integration.rs`) and the save-to-Sent - `APPEND`/`\Sent` discovery paths (`tests/sent_integration.rs`). GreenMail must - bind `0.0.0.0` (`-Dgreenmail.hostname=0.0.0.0`) or podman forwarding drops the - connection. The lib crate exposes `imap`/`config`/`parse` for these tests. +- Integration: GreenMail in Docker/podman, gated on `MAILCLI_IT=1`, and run on + every PR by the `integration (GreenMail)` CI job. Covers the `search` query + builder (`tests/search_integration.rs`), the save-to-Sent `APPEND`/`\Sent` + discovery paths (`tests/sent_integration.rs`), and a full send→INBOX+Sent e2e + loop (`tests/send_e2e.rs`) that drives mail-cli's real build/append code — only + the encrypted transport hop is substituted (SMTP TLS trusts bundled webpki + roots, so it can't validate GreenMail's self-signed cert; IMAP TLS uses OS + roots). GreenMail must bind `0.0.0.0` (`-Dgreenmail.hostname=0.0.0.0`) or + podman/CI port-forwarding drops the connection. Run serialized + (`--test-threads=1`). The lib crate exposes `imap`/`smtp`/`config`/`parse` for + these tests. ## Commit & Pull Request Guidelines diff --git a/CHANGELOG.md b/CHANGELOG.md index 21dca96..0222b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `MAILCLI_SENT_FOLDER` / `MAILCLI_SAVE_SENT` env vars (per-account, suffixable). - Integration tests (`tests/sent_integration.rs`, gated on `MAILCLI_IT=1`) covering the `APPEND`-to-Sent and `\Sent` discovery paths against GreenMail. +- A full **send→INBOX+Sent e2e test** (`tests/send_e2e.rs`) that builds a message + with mail-cli's real builder, delivers it, and files the copy with mail-cli's + real IMAP `APPEND`, asserting both landed (Sent copy flagged `\Seen`). Only the + encrypted transport hop is substituted (mail-cli's SMTP TLS trusts bundled + webpki roots and can't validate GreenMail's self-signed cert; the payload sent + is byte-identical). Plus a binary-level smoke test for the new flags and the + SMTP-failure-is-fatal path. +- CI: a new `integration (GreenMail)` job runs all three IMAP/SMTP-backed test + files on every PR, against a GreenMail service container. ### Behavior diff --git a/README.md b/README.md index 9bd3749..3a8c5a1 100644 --- a/README.md +++ b/README.md @@ -255,18 +255,22 @@ cargo test ``` The `search` query builder and parsing/selector logic are fully covered here. -Integration tests exercise `search` and the save-to-Sent `APPEND`/discovery -paths against a real IMAP server +Integration tests exercise `search`, the save-to-Sent `APPEND`/discovery paths, +and a full send→INBOX+Sent **e2e loop** against a real IMAP/SMTP server ([GreenMail](https://greenmail-mail-test.github.io/greenmail/)). They are **gated on `MAILCLI_IT=1`** so a plain `cargo test` never needs a server: ```sh podman run -d --name greenmail-mailcli -p 3025:3025 -p 3143:3143 \ - -e GREENMAIL_OPTS='-Dgreenmail.setup.test.imap -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled' \ + -e GREENMAIL_OPTS='-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled' \ docker.io/greenmail/standalone:2.1.0 -MAILCLI_IT=1 cargo test --test search_integration -MAILCLI_IT=1 cargo test --test sent_integration +MAILCLI_IT=1 cargo test --test search_integration --test sent_integration --test send_e2e -- --test-threads=1 ``` +These also run automatically on every PR via the `integration (GreenMail)` CI +job. `send_e2e` builds a message with mail-cli's real builder, delivers it, and +files the copy with mail-cli's real IMAP `APPEND` — only the encrypted transport +hop is substituted (mail-cli's SMTP TLS trusts bundled roots only, so it can't +validate GreenMail's self-signed cert; the payload sent is byte-identical). (`docker` works too if you prefer it to `podman`.) ## License diff --git a/memory.md b/memory.md index e66989f..0fc058c 100644 --- a/memory.md +++ b/memory.md @@ -244,6 +244,33 @@ reference.md + evals.json (+eval 8). CHANGELOG [Unreleased] added (MINOR: additive, degrades to skip when IMAP absent). Opened PR (see below). +- 2026-07-17 (rev 18): Follow-up on PR #5 (user asked: is the e2e actually run, + and is it on CI?). Answer was NO on CI. Added a TRUE e2e + CI. KEY FINDING via + Cargo.lock: mail-cli's TLS backends DIFFER — IMAP (imap crate → rustls-connector) + uses rustls-NATIVE-certs (OS trust store, injectable), but SMTP (lettre) is + pinned to WEBPKI-ROOTS (bundled Mozilla roots, NOT injectable, ignores + SSL_CERT_FILE/OS store). So a full-binary `mail-cli send` over TLS to GreenMail + (self-signed cert) is IMPOSSIBLE without weakening SMTP TLS — which the design + forbids (no accept-invalid-certs flag). So the honest e2e (tests/send_e2e.rs): + builds the msg with REAL smtp::build, gets REAL smtp::formatted bytes, delivers + over GreenMail PLAINTEXT SMTP via lettre SmtpTransport::builder_dangerous + (test-only, byte-identical payload — the ONE substituted piece), then files the + copy with REAL imap::append_to_sent and verifies BOTH INBOX delivery AND a Sent + copy flagged \Seen. Plus a binary smoke test (env!("CARGO_BIN_EXE_mail-cli")): + `send --help` advertises --sent-folder/--no-save-sent, and `send` to an + unroutable SMTP host exits NON-ZERO (SMTP-fatal path). Exposed `pub mod smtp` + in lib.rs (SmtpConfig fields already pub → construct directly in test). GOTCHA: + imap Flag::to_owned still borrows the Fetches → E0515/E0597; fixed by returning + a bool (\Seen present) not Vec. CLIPPY -D warnings (CI) tripped on the + doc-comment: a line starting with `+` and "(a)/(b)" parse as doc-list items → + reworded to avoid leading `+` and parenthesized letters. CI: added an + `integration (GreenMail)` job to ci.yml — GreenMail service container (SMTP + 3025 + IMAP 3143, 0.0.0.0, auth.disabled), a /dev/tcp port-wait step (GreenMail + has NO healthcheck), runs search+sent+send_e2e with MAILCLI_IT=1 + --test-threads=1. Verified locally: e2e 2/2 + all suites green; clippy+fmt + clean. Docs synced (README testing section, AGENTS.md, CHANGELOG). Pushed to + PR #5. + ## Semantic (facts learned) - Rust email crate landscape (mid-2026): - `imap` (jonhoo) = sync, simple, but stuck on 3.0.0-alpha, seeking maintainers. diff --git a/src/lib.rs b/src/lib.rs index 944ef38..ef3224f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,13 @@ //! Library facade exposing the pieces of `mail-cli` that integration tests drive //! directly. `search` is the offline IMAP SEARCH query builder; `imap` is the -//! IMAP wire layer, exposed so `tests/` can exercise the real `append_to_sent` -//! and `discover_sent` paths against a GreenMail server. The remaining modules -//! (config/smtp/parse/output/skill) stay private to the binary. +//! IMAP wire layer and `smtp` builds the outgoing message, both exposed so +//! `tests/` can exercise the real send→Sent path (`smtp::build`/`formatted` + +//! `imap::append_to_sent`/`discover_sent`) against a GreenMail server. The +//! remaining modules (output/skill) stay private to the binary. pub mod search; pub mod config; pub mod imap; pub mod parse; +pub mod smtp; diff --git a/tests/send_e2e.rs b/tests/send_e2e.rs new file mode 100644 index 0000000..71d768d --- /dev/null +++ b/tests/send_e2e.rs @@ -0,0 +1,201 @@ +//! End-to-end test for the save-to-Sent feature (SAVE-TO-SENT-PLAN §5). +//! +//! This drives mail-cli's **real** code paths (`smtp::build`, `smtp::formatted`, +//! `imap::append_to_sent`, `imap::discover_sent`) through a full +//! send/deliver/sent-copy loop against a live GreenMail server, then asserts the +//! message both reached the recipient's INBOX and has a copy in the Sent folder +//! flagged `\Seen`. +//! +//! TLS caveat (why the transport is the one substituted piece): mail-cli's SMTP +//! stack (lettre) trusts only the bundled webpki roots, so it cannot validate +//! GreenMail's self-signed cert — and the tool has no "accept invalid certs" +//! escape hatch by design. We therefore push the **byte-identical** +//! `smtp::formatted()` payload over GreenMail's plaintext SMTP with lettre's +//! explicit dangerous transport (test-only), then run the genuine IMAP +//! append/discover the binary uses. Everything except the encrypted transport +//! hop is mail-cli's own code. +//! +//! Gated on `MAILCLI_IT=1`. Run with GreenMail up: +//! +//! ```sh +//! podman run -d --name greenmail-mailcli -p 3025:3025 -p 3143:3143 \ +//! -e GREENMAIL_OPTS='-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap \ +//! -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled' \ +//! docker.io/greenmail/standalone:2.1.0 +//! MAILCLI_IT=1 cargo test --test send_e2e -- --nocapture --test-threads=1 +//! ``` + +use std::process::Command; +use std::sync::Mutex; + +use imap::types::Flag; +use imap::{ConnectionMode, Session}; +use lettre::{SmtpTransport, Transport}; +use mail_cli::config::{AuthMethod, SmtpConfig, TlsMode}; +use mail_cli::{imap as mc_imap, smtp}; + +const HOST: &str = "127.0.0.1"; +const SMTP_PORT: u16 = 3025; +const IMAP_PORT: u16 = 3143; + +/// GreenMail shares global state; serialize so each test runs quiescent. +static SERIAL: Mutex<()> = Mutex::new(()); + +macro_rules! require_greenmail { + () => { + if std::env::var("MAILCLI_IT").ok().as_deref() != Some("1") { + eprintln!("skipping (set MAILCLI_IT=1 with GreenMail running)"); + return; + } + }; +} + +fn unique_user(tag: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{tag}.{nanos}@example.com") +} + +fn imap_session(user: &str) -> Session { + let client = imap::ClientBuilder::new(HOST, IMAP_PORT) + .mode(ConnectionMode::Plaintext) + .connect() + .expect("connect to GreenMail plaintext IMAP"); + client + .login(user, "pw") + .map_err(|(e, _)| e) + .expect("login (GreenMail auto-creates the user)") +} + +/// The UID of the single message in `folder` whose Subject equals `subject` +/// (panics unless exactly one), plus whether it carries the `\Seen` flag. +fn find_one(session: &mut Session, folder: &str, subject: &str) -> (u32, bool) { + session.select(folder).expect("SELECT"); + let hits = session + .uid_search(format!("SUBJECT \"{subject}\"")) + .expect("UID SEARCH by subject"); + assert_eq!( + hits.len(), + 1, + "expected exactly one message with subject {subject:?} in {folder}, got {hits:?}" + ); + let uid = *hits.iter().next().unwrap(); + let fetches = session + .uid_fetch(uid.to_string(), "(FLAGS)") + .expect("UID FETCH FLAGS"); + let seen = fetches + .iter() + .next() + .expect("one fetch result") + .flags() + .contains(&Flag::Seen); + (uid, seen) +} + +/// Full loop: build a message with mail-cli's real builder, deliver it to the +/// recipient via SMTP, save a copy to Sent with mail-cli's real IMAP APPEND, and +/// assert both landed — the copy flagged `\Seen`. +#[test] +fn send_then_message_lands_in_inbox_and_sent() { + require_greenmail!(); + let _guard = SERIAL.lock().unwrap(); + let user = unique_user("e2e"); + + // A real mail-cli SmtpConfig (fields are public); From = the account address. + let cfg = SmtpConfig { + host: HOST.to_string(), + port: SMTP_PORT, + user: user.clone(), + password: "pw".to_string(), + email: user.clone(), + tls: TlsMode::Implicit, + auth: AuthMethod::Login, + }; + let subject = format!("e2e-{}", std::process::id()); + let outgoing = smtp::Outgoing { + to: vec![user.clone()], + cc: vec![], + bcc: vec![], + subject: subject.clone(), + body: "hello from the mail-cli e2e".to_string(), + html: false, + attachments: vec![], + }; + + // mail-cli's REAL message builder, and the REAL bytes it would put on the wire. + let email = smtp::build(&cfg, &outgoing).expect("smtp::build"); + let raw = smtp::formatted(&email); + assert!( + raw.windows(subject.len()).any(|w| w == subject.as_bytes()), + "formatted bytes must contain the subject header" + ); + + // Deliver over GreenMail's plaintext SMTP (test-only transport; identical + // payload). This stands in for the encrypted hop mail-cli can't do here. + let transport = SmtpTransport::builder_dangerous(HOST) + .port(SMTP_PORT) + .build(); + transport.send(&email).expect("SMTP delivery to GreenMail"); + + // mail-cli's REAL save-to-Sent path. + let mut session = imap_session(&user); + let _ = session.create("Sent"); + mc_imap::append_to_sent(&mut session, "Sent", &raw).expect("append_to_sent"); + + // (a) delivered to INBOX. + let (_uid, _seen) = find_one(&mut session, "INBOX", &subject); + + // (b) copied to Sent, flagged \Seen. + let (_uid, seen) = find_one(&mut session, "Sent", &subject); + assert!(seen, "the Sent copy must be flagged \\Seen"); + + let _ = session.logout(); +} + +/// Binary-level smoke: the compiled `mail-cli send` wires the new flags and +/// treats an SMTP failure as fatal (non-zero exit). No server needed — we point +/// it at an unroutable host so the SMTP connect fails fast. +#[test] +fn binary_send_smtp_failure_is_fatal_and_new_flags_parse() { + let bin = env!("CARGO_BIN_EXE_mail-cli"); + + // --help must advertise the new flags (advertise-what-you-deliver). + let help = Command::new(bin) + .args(["send", "--help"]) + .output() + .expect("run mail-cli send --help"); + assert!(help.status.success(), "send --help should exit 0"); + let help_txt = String::from_utf8_lossy(&help.stdout); + assert!(help_txt.contains("--sent-folder"), "--sent-folder in help"); + assert!( + help_txt.contains("--no-save-sent"), + "--no-save-sent in help" + ); + + // A send against an unroutable SMTP host must FAIL (non-zero) — the mail + // never went out, so exiting non-zero is correct (unlike a failed Sent copy). + let out = Command::new(bin) + .args([ + "send", + "--to", + "someone@example.com", + "--subject", + "S", + "--body", + "hi", + "--no-save-sent", + ]) + .env("MAILCLI_SMTP_HOST", "127.0.0.1") + .env("MAILCLI_SMTP_PORT", "9") // discard port: connect refused/closed + .env("MAILCLI_EMAIL", "me@example.com") + .env("MAILCLI_PASSWORD", "pw") + .output() + .expect("run mail-cli send"); + assert!( + !out.status.success(), + "send should fail non-zero when SMTP is unreachable; stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +}