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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
52 changes: 51 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.8.0] - 2026-09-28

### Added

- `read --uid <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

### Changed

- **Breaking:** `move` takes its destination as `--dest <FOLDER>`; `--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
Expand Down Expand Up @@ -131,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
Expand Down
7 changes: 4 additions & 3 deletions Cargo.lock

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

13 changes: 10 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <mikewassmer@protonmail.com>"]
Expand All @@ -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"] }
Expand All @@ -47,6 +47,13 @@ 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

[profile.test]
incremental = false

[profile.release]
lto = true
Expand Down
82 changes: 66 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -70,7 +70,7 @@ Commands:
```
--host <HOST> IMAP host [default: 127.0.0.1]
--port <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 <USER> IMAP username (or SLASHMAIL_USER env)
--account <NAME> Use a named account from config
--all-accounts Query all configured accounts (read-only commands only)
Expand Down Expand Up @@ -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 <NAME>` 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 <PATH>` to specify an alternative config file location.

Expand Down Expand Up @@ -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

Expand All @@ -214,19 +216,57 @@ 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
not extracted.

### Reading one message by UID

`read --uid <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`, `subject`, and
`date` are the full decoded values; only the terminal table shortens them.

```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:

```
-f, --folder <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 <TEXT> Subject contains
--from <TEXT> From address contains
--to <TEXT> To address contains
Expand All @@ -246,7 +286,9 @@ Search, read, count, and bulk message commands share these filter options:
-n, --limit <N> 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

Expand All @@ -259,10 +301,16 @@ Commands that modify messages (`delete`, `move`, `mark`) support:

`delete` also supports `--trash-folder <NAME>` (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 <FOLDER>`; `--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 `<folder>_<uid>.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
Expand Down Expand Up @@ -293,7 +341,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

Expand All @@ -317,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
Expand Down Expand Up @@ -411,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.

Expand All @@ -444,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
Loading
Loading