Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/tcp/127.0.0.1/$port" 2>/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
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,7 +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 (planned): GreenMail in Docker for connect→list→fetch and send.
- 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

Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ 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 <NAME>` / `--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.
- 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

- 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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<p>Hi</p>'

# 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.
Expand Down Expand Up @@ -238,16 +255,22 @@ 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`, 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 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
Expand Down
96 changes: 96 additions & 0 deletions memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,102 @@
"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/<tag> 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<String> (raw env read; "Sent" DEFAULT lives at point-of-use in main.rs,
per ZoE) + save_sent()->Result<bool> (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<u8> (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).

- 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<Flag>. 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):
Expand Down
13 changes: 13 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<p>Hi</p>'
```
- **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

Expand Down
Loading
Loading