feat(gmail): Gmail helpers rollup — mail-builder, --attachment, +read (#526)

* refactor(gmail): replace hand-rolled email construction with mail-builder

Replace custom MessageBuilder, RFC 2047 encoding, header sanitization,
and address encoding (including #482) with the mail-builder crate
(Stalwart Labs, 0 runtime deps). Each command builds a
mail_builder::MessageBuilder directly.

Introduce structured types throughout:
- Mailbox type (parsed display name + email) replaces raw string passing
- sanitize_control_chars strips ASCII control characters (CRLF, null,
  tab, etc.) at the parse boundary — defense-in-depth for mail-builder's
  structured header types, superseding sanitize_header_value,
  sanitize_component, and encode_address_header from #482
- OriginalMessage fields use Option<T> instead of empty-string sentinels
- parse_original_message returns Result with validation (threadId, From,
  Message-ID)
- Pre-parsed Config types (SendConfig, ForwardConfig, ReplyConfig) with
  Vec<Mailbox> — parse at the boundary, not downstream
- parse_forward_args and parse_send_args return Result with --to
  validation, consistent with parse_reply_args
- parse_optional_mailboxes helper normalizes Some(vec![]) to None for
  optional address fields (--cc, --bcc, --from)
- Envelope types borrow from Config + OriginalMessage with lifetimes
- Message IDs stored bare (no angle brackets), parsed once at boundary
- References stored as Vec<String> instead of space-separated string
- ThreadingHeaders bundles In-Reply-To + References with debug_assert
  for bare-ID convention
- Shared CLI arg builders (common_mail_args, common_reply_args)
  eliminate duplicated --cc/--bcc/--html/--dry-run definitions

Additional improvements:
- finalize_message returns Result instead of panicking via .expect()
- Mailbox::parse_list filters empty-email entries (trailing comma edge
  case)
- format_email_link percent-encodes mailto hrefs to prevent parameter
  injection
- Forward date handling: omits Date line when absent instead of showing
  empty "Date: "
- Dry-run auth: log skipped auth as diagnostic instead of silently
  discarding errors
- Restore --html tips in after_help strings (gmail_quote CSS, cid:
  image warnings, HTML fragment advice) lost in release PR #434
- Update execute_method call for upload_content_type parameter (#429)

Delete: MessageBuilder, encode_header_value, sanitize_header_value,
encode_address_header, sanitize_component, extract_email,
extract_display_name, split_mailbox_list, build_references.

* feat(gmail): add --from flag to +send for send-as alias support

Consistent with +reply, +reply-all, and +forward which already support
--from. Uses the same parse_optional_mailboxes path and
apply_optional_headers plumbing.

* fix: quote display names with RFC 2822 special characters in +reply

When replying to emails from corporate senders with display names like
"Anderson, Rich (CORP)" <email@adp.com>, the +reply command fails with
"Invalid To header" (400) from the Gmail API.

The root cause: encode_address_header() strips quotes from the display
name via extract_display_name(), then reconstructs the address without
re-quoting. When the display name contains RFC 2822 special characters
(commas, parentheses), the unquoted form is ambiguous — commas split
it into multiple malformed mailboxes and parentheses are interpreted
as RFC 2822 comments.

Fix: re-quote the display name when it contains any RFC 2822 special
characters, using a single-pass character iterator that preserves
already-escaped sequences and escapes bare quotes/backslashes.

Fixes #512

* feat(gmail): add --attachment flag, +read helper, and mail-builder migration

Consolidates PRs #491, #513, #517, and #502 into a single rollup:

- Migrate message construction to mail-builder crate (RFC-compliant MIME)
- Add --from flag to +send for send-as alias support
- Add --attachment flag to +send with MIME auto-detection and path validation
- Add +read helper for extracting message body/headers (text, HTML, JSON)
- Serialize support for OriginalMessage and Mailbox types
- Display name quoting handled natively by mail-builder

* chore: regenerate skills [skip ci]

* fix: use validate_safe_file_path for attachment path validation

Addresses Gemini review: validate_safe_dir_path hardcodes '--dir' in
error messages. validate_safe_file_path accepts the flag name, so errors
now correctly reference '--attachment'.

* refactor: make OriginalMessage.thread_id optional

The Gmail API does not guarantee threadId on all message resources
(e.g. drafts). Making it Option<String> prevents parse failures on
valid messages and avoids requiring thread_id in helpers like +read
that don't use it.

* fix: use canonicalized path for attachment file operations (TOCTOU)

validate_safe_file_path returns a canonicalized PathBuf. Use it for
exists/is_file checks and downstream file reads instead of the original
un-resolved path to prevent time-of-check/time-of-use races.

* feat(gmail): add --attach flag for file attachments

Add -a/--attach to +send, +reply, +reply-all, and +forward. Can be
specified multiple times for multiple attachments. MIME type is auto-
detected via mime_guess2. Closes #247.

Send via the Gmail API upload endpoint (multipart/related with
message/rfc822 media type) instead of base64-encoding into a JSON raw
field. This raises the size limit from ~5MB (metadata-only endpoint) to
35MB (upload endpoint, per discovery document).

Introduce UploadSource enum in the executor to consolidate upload_path,
upload_content_type, and upload_bytes into a single type-safe parameter.
File and Bytes variants make the two upload strategies (from disk vs.
from memory) mutually exclusive by construction.

Validates attachment paths (control characters, regular file, non-empty)
and total size (25MB raw limit, accounting for base64 expansion of
attachments within the MIME message against the 35MB API limit). Size
check uses actual bytes read to avoid TOCTOU race.

* chore: update changeset and fix integration with malob's attachment impl

Update changeset to reflect combined work. Fix thread_id type mismatches
in new tests from cherry-pick. Fix upload_path scope in main.rs. Make
reject_control_chars pub(crate) for attachment validation.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* chore: regenerate skills [skip ci]

* fix: restore MIME sanitization and terminal escape protection in executor

Restore two security features accidentally lost during the UploadSource
refactor:

1. resolve_upload_mime: restructure from early-returns to collect-then-
   sanitize pattern — strips control chars from user-supplied MIME types
   to prevent CRLF header injection.

2. Model Armor error path: restore sanitize_for_terminal on error messages
   to prevent terminal escape sequence injection from API responses.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* chore: remove duplicate changeset from cherry-pick

gmail-attach-flag.md duplicated content already in gmail-helpers-rollup.md.
Both were marked minor, which would cause a double version bump.

* fix: add path traversal protection to attachment validation

Replace reject_control_chars with validate_safe_file_path in
parse_attachments. All file operations (metadata, read, filename
extraction, MIME detection) now use the canonicalized path, preventing
path traversal attacks (e.g. ../../.ssh/id_rsa) and closing TOCTOU gaps.

Update tests to use CWD-relative temp directories (tempdir_in("."))
since validate_safe_file_path rejects paths outside the working directory.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* refactor: deduplicate terminal sanitizer in read.rs

Replace the local sanitize_terminal_output function with the existing
crate::error::sanitize_for_terminal via import alias. This eliminates
code duplication and provides consistent sanitization across the codebase.

The crate-wide sanitizer also correctly strips CR (carriage return) which
can be abused for terminal overwrite attacks.

---------

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>
Co-authored-by: Rich Anderson <richanderson00@gmail.com>
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-17 16:55:50 -06:00
committed by GitHub
parent 9c26e3c4a9
commit 6e4daaf4f9
24 changed files with 3237 additions and 2090 deletions
+13
View File
@@ -0,0 +1,13 @@
---
"@googleworkspace/cli": minor
---
Gmail helpers rollup: mail-builder migration, --attach flag (upload endpoint), +read helper
- Migrate `+send`, `+reply`, `+reply-all`, and `+forward` to the `mail-builder` crate for RFC-compliant MIME construction
- Add `--from` flag to `+send` for send-as alias support
- Add `-a`/`--attach` flag to all mail helpers (`+send`, `+reply`, `+reply-all`, `+forward`) with `mime_guess2` auto-detection, 25MB size validation, and upload endpoint support (35MB API limit vs 5MB metadata-only)
- Add `+read` helper to extract message body and headers (text, HTML, or JSON output)
- Make `OriginalMessage.thread_id` optional (`Option<String>`) for draft compatibility
- RFC 2822 display name quoting is handled natively by `mail-builder`
- Introduce `UploadSource` enum in executor for type-safe upload strategies
Generated
+47
View File
@@ -834,6 +834,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -904,6 +914,8 @@ dependencies = [
"hostname",
"iana-time-zone",
"keyring",
"mail-builder",
"mime_guess2",
"percent-encoding",
"rand 0.8.5",
"ratatui",
@@ -1448,6 +1460,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "mail-builder"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b"
dependencies = [
"gethostname",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1478,6 +1499,24 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf 0.11.3",
"phf_shared 0.11.3",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1724,6 +1763,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"unicase",
]
[[package]]
@@ -1733,6 +1773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"unicase",
]
[[package]]
@@ -2961,6 +3002,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
+2
View File
@@ -57,6 +57,7 @@ crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
mail-builder = "0.4"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
@@ -65,6 +66,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }
mime_guess2 = "2.3.1"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
+1
View File
@@ -41,6 +41,7 @@ Shortcut commands for common operations.
| [gws-gmail-reply](../skills/gws-gmail-reply/SKILL.md) | Gmail: Reply to a message (handles threading automatically). |
| [gws-gmail-reply-all](../skills/gws-gmail-reply-all/SKILL.md) | Gmail: Reply-all to a message (handles threading automatically). |
| [gws-gmail-forward](../skills/gws-gmail-forward/SKILL.md) | Gmail: Forward a message to new recipients. |
| [gws-gmail-read](../skills/gws-gmail-read/SKILL.md) | Gmail: Read a message and extract its body or headers. |
| [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. |
| [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. |
| [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. |
+7 -3
View File
@@ -29,10 +29,11 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
| `--message-id` | ✓ | — | Gmail message ID to forward |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--body` | — | — | Optional note to include above the forwarded message (plain text, or HTML with --html) |
| `--attach` | — | — | Attach a file (can be specified multiple times) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--body` | — | — | Optional note to include above the forwarded message (plain text, or HTML with --html) |
| `--html` | — | — | Send as HTML (formats forwarded block with Gmail styling; treat --body as HTML) |
| `--html` | — | — | Treat --body as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -41,13 +42,16 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
```
## Tips
- Includes the original message with sender, date, subject, and recipients.
- Use -a/--attach to add file attachments. Can be specified multiple times.
- With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
- With --html, inline images in the forwarded message (cid: references) will appear broken. Externally hosted images are unaffected.
## See Also
+51
View File
@@ -0,0 +1,51 @@
---
name: gws-gmail-read
version: 1.0.0
description: "Gmail: Read a message and extract its body or headers."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws gmail +read --help"
---
# gmail +read
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Read a message and extract its body or headers
## Usage
```bash
gws gmail +read --id <ID>
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--id` | ✓ | — | The Gmail message ID to read |
| `--headers` | — | — | Include headers (From, To, Subject, Date) in the output |
| `--format` | — | text | Output format (text, json) |
| `--html` | — | — | Return HTML body instead of plain text |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
```bash
gws gmail +read --id 18f1a2b3c4d
gws gmail +read --id 18f1a2b3c4d --headers
gws gmail +read --id 18f1a2b3c4d --format json | jq '.body'
```
## Tips
- Converts HTML-only messages to plain text automatically.
- Handles multipart/alternative and base64 decoding.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands
+8 -5
View File
@@ -30,11 +30,12 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
| `--body` | ✓ | — | Reply body (plain text, or HTML with --html) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--attach` | — | — | Attach a file (can be specified multiple times) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
| `--html` | — | — | Send as HTML (quotes original with Gmail styling; treat --body as HTML) |
| `--html` | — | — | Treat --body as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
## Examples
@@ -42,9 +43,8 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf
```
## Tips
@@ -55,6 +55,9 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
- Use --bcc for recipients who should not be visible to others.
- Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
- The command fails if no To recipient remains after exclusions and --to additions.
- Use -a/--attach to add file attachments. Can be specified multiple times.
- With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
## See Also
+7 -3
View File
@@ -30,9 +30,10 @@ gws gmail +reply --message-id <ID> --body <TEXT>
| `--body` | ✓ | — | Reply body (plain text, or HTML with --html) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--attach` | — | — | Attach a file (can be specified multiple times) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--html` | — | — | Send as HTML (quotes original with Gmail styling; treat --body as HTML) |
| `--html` | — | — | Treat --body as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -41,8 +42,8 @@ gws gmail +reply --message-id <ID> --body <TEXT>
gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx
```
## Tips
@@ -50,6 +51,9 @@ gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
- Automatically sets In-Reply-To, References, and threadId headers.
- Quotes the original message in the reply body.
- --to adds extra recipients to the To field.
- Use -a/--attach to add file attachments. Can be specified multiple times.
- With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. Use fragment tags (<p>, <b>, <a>, etc.) — no <html>/<body> wrapper needed.
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
- For reply-all, use +reply-all instead.
## See Also
+10 -3
View File
@@ -29,6 +29,9 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--subject` | ✓ | — | Email subject |
| `--body` | ✓ | — | Email body (plain text, or HTML with --html) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--attachment` | — | — | Attach a file (can be repeated for multiple files) |
| `--attach` | — | — | Attach a file (can be specified multiple times) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--html` | — | — | Treat --body as HTML content (default is plain text) |
@@ -39,14 +42,18 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
```bash
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!'
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> text' --html
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com
gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf
gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv
```
## Tips
- Handles RFC 2822 formatting and base64 encoding automatically.
- For attachments, use the raw API instead: gws gmail users messages send --json '...'
- Handles RFC 5322 formatting, MIME encoding, and base64 automatically.
- Use --from to send from a configured send-as alias instead of your primary address.
- Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB.
- With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.
> [!CAUTION]
> This is a **write** command — confirm with the user before executing.
+1
View File
@@ -27,6 +27,7 @@ gws gmail <resource> <method> [flags]
| [`+reply`](../gws-gmail-reply/SKILL.md) | Reply to a message (handles threading automatically) |
| [`+reply-all`](../gws-gmail-reply-all/SKILL.md) | Reply-all to a message (handles threading automatically) |
| [`+forward`](../gws-gmail-forward/SKILL.md) | Forward a message to new recipients |
| [`+read`](../gws-gmail-read/SKILL.md) | Read a message and extract its body or headers |
| [`+watch`](../gws-gmail-watch/SKILL.md) | Watch for new emails and stream them as NDJSON |
## API Resources
+107 -78
View File
@@ -39,6 +39,26 @@ pub enum AuthMethod {
None,
}
/// Source for media upload content.
///
/// Two mutually exclusive strategies: upload from a file on disk (for Drive,
/// Chat, etc.) or from in-memory bytes (for Gmail's constructed RFC 5322
/// messages). Using an enum makes illegal states (both set, or mismatched
/// content types) unrepresentable.
pub enum UploadSource<'a> {
/// Stream from a file on disk. Content type is inferred from the file
/// extension, overridden by metadata mimeType, or explicitly set.
File {
path: &'a str,
content_type: Option<&'a str>,
},
/// Upload from in-memory bytes with an explicit content type.
Bytes {
data: &'a [u8],
content_type: &'a str,
},
}
/// Configuration for auto-pagination.
#[derive(Debug, Clone)]
pub struct PaginationConfig {
@@ -76,7 +96,7 @@ fn parse_and_validate_inputs(
method: &RestMethod,
params_json: Option<&str>,
body_json: Option<&str>,
upload_path: Option<&str>,
is_media_upload: bool,
) -> Result<ExecutionInput, GwsError> {
let params: Map<String, Value> = if let Some(p) = params_json {
serde_json::from_str(p)
@@ -123,8 +143,8 @@ fn parse_and_validate_inputs(
}
}
let (full_url, query_params) = build_url(doc, method, &params, upload_path.is_some())?;
let is_upload = upload_path.is_some() && method.supports_media_upload;
let (full_url, query_params) = build_url(doc, method, &params, is_media_upload)?;
let is_upload = is_media_upload && method.supports_media_upload;
Ok(ExecutionInput {
params,
@@ -145,8 +165,7 @@ async fn build_http_request(
auth_method: &AuthMethod,
page_token: Option<&str>,
pages_fetched: u32,
upload_path: Option<&str>,
upload_content_type: Option<&str>,
upload: &Option<UploadSource<'_>>,
) -> Result<reqwest::RequestBuilder, GwsError> {
let mut request = match method.http_method.as_str() {
"GET" => client.get(&input.full_url),
@@ -181,22 +200,29 @@ async fn build_http_request(
}
if pages_fetched == 0 {
if input.is_upload {
let upload_path = upload_path.expect("upload_path must be Some when is_upload is true");
let file_meta = tokio::fs::metadata(upload_path).await.map_err(|e| {
GwsError::Validation(format!(
"Failed to get metadata for upload file '{}': {}",
upload_path, e
))
})?;
let file_size = file_meta.len();
if let Some(upload_source) = upload {
request = request.query(&[("uploadType", "multipart")]);
let media_mime =
resolve_upload_mime(upload_content_type, Some(upload_path), &input.body);
let (body, content_type, content_length) =
build_multipart_stream(&input.body, upload_path, file_size, &media_mime)?;
let (body, content_type, content_length) = match upload_source {
UploadSource::Bytes { data, content_type } => {
if content_type.contains('\r') || content_type.contains('\n') {
return Err(GwsError::Validation(
"Upload content type must not contain CR or LF".to_string(),
));
}
build_multipart_bytes(&input.body, data, content_type)?
}
UploadSource::File { path, content_type } => {
let file_meta = tokio::fs::metadata(path).await.map_err(|e| {
GwsError::Validation(format!(
"Failed to get metadata for upload file '{}': {}",
path, e
))
})?;
let file_size = file_meta.len();
let media_mime = resolve_upload_mime(*content_type, Some(path), &input.body);
build_multipart_stream(&input.body, path, file_size, &media_mime)?
}
};
request = request.header("Content-Type", content_type);
request = request.header("Content-Length", content_length);
request = request.body(body);
@@ -376,8 +402,7 @@ pub async fn execute_method(
token: Option<&str>,
auth_method: AuthMethod,
output_path: Option<&str>,
upload_path: Option<&str>,
upload_content_type: Option<&str>,
upload: Option<UploadSource<'_>>,
dry_run: bool,
pagination: &PaginationConfig,
sanitize_template: Option<&str>,
@@ -385,7 +410,7 @@ pub async fn execute_method(
output_format: &crate::formatter::OutputFormat,
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?;
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some())?;
if dry_run {
let dry_run_info = json!({
@@ -420,8 +445,7 @@ pub async fn execute_method(
&auth_method,
page_token.as_deref(),
pages_fetched,
upload_path,
upload_content_type,
&upload,
)
.await?;
@@ -801,7 +825,6 @@ fn handle_error_response<T>(
/// represents the *source* type (what the bytes are). When a user uploads
/// `notes.md` with `"mimeType":"application/vnd.google-apps.document"`, the
/// media part should be `text/markdown`, not a Google Workspace MIME type.
///
/// All returned MIME types have control characters stripped to prevent
/// MIME header injection via user-controlled metadata.
fn resolve_upload_mime(
@@ -812,9 +835,7 @@ fn resolve_upload_mime(
let raw = explicit
.map(|s| s.to_string())
.or_else(|| {
upload_path
.and_then(mime_from_extension)
.map(|s| s.to_string())
upload_path.and_then(|path| mime_guess2::from_path(path).first().map(|m| m.to_string()))
})
.or_else(|| {
metadata
@@ -834,33 +855,6 @@ fn resolve_upload_mime(
}
}
/// Infers a MIME type from a file path's extension.
fn mime_from_extension(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())?;
match ext.to_lowercase().as_str() {
"md" | "markdown" => Some("text/markdown"),
"html" | "htm" => Some("text/html"),
"txt" => Some("text/plain"),
"json" => Some("application/json"),
"csv" => Some("text/csv"),
"xml" => Some("application/xml"),
"pdf" => Some("application/pdf"),
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"svg" => Some("image/svg+xml"),
"doc" => Some("application/msword"),
"docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"xls" => Some("application/vnd.ms-excel"),
"xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
"ppt" => Some("application/vnd.ms-powerpoint"),
"pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"),
_ => None,
}
}
/// Builds a streaming multipart/related body for media upload requests.
///
/// Instead of reading the entire file into memory, this streams the file in
@@ -923,6 +917,41 @@ fn build_multipart_stream(
))
}
/// Builds a multipart/related body from in-memory bytes.
///
/// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322
/// message with attachments) rather than read from a file on disk.
fn build_multipart_bytes(
metadata: &Option<Value>,
data: &[u8],
media_mime: &str,
) -> Result<(reqwest::Body, String, u64), GwsError> {
let boundary = format!("gws_boundary_{:016x}", rand::random::<u64>());
let metadata_json = match metadata {
Some(m) => serde_json::to_string(m).map_err(|e| {
GwsError::Validation(format!("Failed to serialize upload metadata: {e}"))
})?,
None => "{}".to_string(),
};
let preamble = format!(
"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\
--{boundary}\r\nContent-Type: {media_mime}\r\n\r\n"
);
let postamble = format!("\r\n--{boundary}--\r\n");
let mut body = Vec::with_capacity(preamble.len() + data.len() + postamble.len());
body.extend_from_slice(preamble.as_bytes());
body.extend_from_slice(data);
body.extend_from_slice(postamble.as_bytes());
let content_length = body.len() as u64;
let content_type = format!("multipart/related; boundary={boundary}");
Ok((reqwest::Body::from(body), content_type, content_length))
}
/// Builds a buffered multipart/related body for media upload requests.
///
/// This is the legacy implementation retained for unit tests that need
@@ -1470,25 +1499,30 @@ mod tests {
}
#[test]
fn test_resolve_upload_mime_sanitizes_crlf_injection() {
// A malicious mimeType with CRLF should be stripped to prevent
// MIME header injection in the multipart body.
let metadata = Some(json!({
"mimeType": "text/plain\r\nX-Injected: malicious"
}));
let mime = resolve_upload_mime(None, None, &metadata);
fn test_build_multipart_bytes_with_metadata() {
let metadata = Some(json!({ "threadId": "thread-123" }));
let data = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody";
let (_, content_type, content_length) =
build_multipart_bytes(&metadata, data, "message/rfc822").unwrap();
assert!(
!mime.contains('\r') && !mime.contains('\n'),
"control characters must be stripped: got '{mime}'"
content_type.starts_with("multipart/related; boundary=gws_boundary_"),
"content_type should be multipart/related: {content_type}",
);
// Content-length should cover: preamble + data + postamble
assert!(
content_length > data.len() as u64,
"content_length should exceed raw data size: {content_length}",
);
assert_eq!(mime, "text/plainX-Injected: malicious");
}
#[test]
fn test_resolve_upload_mime_all_control_chars_fallback() {
let metadata = Some(json!({ "mimeType": "\r\n\t" }));
let mime = resolve_upload_mime(None, None, &metadata);
assert_eq!(mime, "application/octet-stream");
fn test_build_multipart_bytes_without_metadata() {
let (_, content_type, content_length) =
build_multipart_bytes(&None, b"test body", "message/rfc822").unwrap();
assert!(content_type.starts_with("multipart/related; boundary="));
assert!(content_length > 0);
}
#[tokio::test]
@@ -2056,7 +2090,6 @@ async fn test_execute_method_dry_run() {
AuthMethod::None,
None,
None,
None,
true, // dry_run
&pagination,
None,
@@ -2100,7 +2133,6 @@ async fn test_execute_method_missing_path_param() {
AuthMethod::None,
None,
None,
None,
true,
&PaginationConfig::default(),
None,
@@ -2277,8 +2309,7 @@ async fn test_post_without_body_sets_content_length_zero() {
&AuthMethod::None,
None,
0,
None,
None,
&None,
)
.await
.unwrap();
@@ -2318,8 +2349,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() {
&AuthMethod::None,
None,
0,
None,
None,
&None,
)
.await
.unwrap();
@@ -2357,8 +2387,7 @@ async fn test_get_does_not_set_content_length_zero() {
&AuthMethod::None,
None,
0,
None,
None,
&None,
)
.await
.unwrap();
-1
View File
@@ -187,7 +187,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
-1
View File
@@ -110,7 +110,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
-1
View File
@@ -100,7 +100,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
+4 -2
View File
@@ -110,8 +110,10 @@ TIPS:
token.as_deref(),
auth_method,
None,
Some(file_path),
None,
Some(executor::UploadSource::File {
path: file_path,
content_type: None,
}),
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
+379 -197
View File
@@ -19,7 +19,8 @@ pub(super) async fn handle_forward(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_forward_args(matches);
let config = parse_forward_args(matches)?;
let dry_run = matches.get_flag("dry-run");
let (original, token) = if dry_run {
@@ -37,6 +38,7 @@ pub(super) async fn handle_forward(
};
let subject = build_forward_subject(&original.subject);
let refs = build_references_chain(&original);
let envelope = ForwardEnvelope {
to: &config.to,
cc: config.cc.as_deref(),
@@ -45,14 +47,19 @@ pub(super) async fn handle_forward(
subject: &subject,
body: config.body.as_deref(),
html: config.html,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &config.attachments)?;
super::send_raw_email(
doc,
matches,
&raw,
Some(&original.thread_id),
original.thread_id.as_deref(),
token.as_deref(),
)
.await
@@ -62,22 +69,24 @@ pub(super) async fn handle_forward(
pub(super) struct ForwardConfig {
pub message_id: String,
pub to: String,
pub from: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub to: Vec<Mailbox>,
pub from: Option<Vec<Mailbox>>,
pub cc: Option<Vec<Mailbox>>,
pub bcc: Option<Vec<Mailbox>>,
pub body: Option<String>,
pub html: bool,
pub attachments: Vec<Attachment>,
}
struct ForwardEnvelope<'a> {
to: &'a str,
cc: Option<&'a str>,
bcc: Option<&'a str>,
from: Option<&'a str>,
to: &'a [Mailbox],
cc: Option<&'a [Mailbox]>,
bcc: Option<&'a [Mailbox]>,
from: Option<&'a [Mailbox]>,
subject: &'a str,
body: Option<&'a str>, // Optional user note above forwarded block
html: bool,
html: bool, // When true, body and forwarded block are treated as HTML
threading: ThreadingHeaders<'a>,
}
// --- Message construction ---
@@ -90,20 +99,17 @@ fn build_forward_subject(original_subject: &str) -> String {
}
}
fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMessage) -> String {
let references = build_references(&original.references, &original.message_id_header);
let builder = MessageBuilder {
to: envelope.to,
subject: envelope.subject,
from: envelope.from,
cc: envelope.cc,
bcc: envelope.bcc,
threading: Some(ThreadingHeaders {
in_reply_to: &original.message_id_header,
references: &references,
}),
html: envelope.html,
};
fn create_forward_raw_message(
envelope: &ForwardEnvelope,
original: &OriginalMessage,
attachments: &[Attachment],
) -> Result<String, GwsError> {
let mb = mail_builder::MessageBuilder::new()
.to(to_mb_address_list(envelope.to))
.subject(envelope.subject);
let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc);
let mb = set_threading_headers(mb, &envelope.threading);
let (forwarded_block, separator) = if envelope.html {
(format_forwarded_message_html(original), "<br>\r\n")
@@ -115,40 +121,54 @@ fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMes
None => forwarded_block,
};
builder.build(&body)
finalize_message(mb, body, envelope.html, attachments)
}
/// Join mailboxes into a comma-separated Display string.
fn join_mailboxes(mailboxes: &[Mailbox]) -> String {
mailboxes
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn format_forwarded_message(original: &OriginalMessage) -> String {
let to_str = join_mailboxes(&original.to);
let date_line = original
.date
.as_deref()
.map(|d| format!("Date: {}\r\n", d))
.unwrap_or_default();
let cc_line = original
.cc
.as_ref()
.map(|cc| format!("Cc: {}\r\n", join_mailboxes(cc)))
.unwrap_or_default();
format!(
"---------- Forwarded message ---------\r\n\
From: {}\r\n\
Date: {}\r\n\
{}\
Subject: {}\r\n\
To: {}\r\n\
{}\r\n\
{}",
original.from,
original.date,
original.subject,
original.to,
if original.cc.is_empty() {
String::new()
} else {
format!("Cc: {}\r\n", original.cc)
},
original.body_text
original.from, date_line, original.subject, to_str, cc_line, original.body_text
)
}
fn format_forwarded_message_html(original: &OriginalMessage) -> String {
let cc_line = if original.cc.is_empty() {
String::new()
} else {
format!("Cc: {}<br>", format_address_list_with_links(&original.cc))
let cc_line = match &original.cc {
Some(cc) => format!("Cc: {}<br>", format_address_list_with_links(cc)),
None => String::new(),
};
let body = resolve_html_body(original);
let date = format_date_for_attribution(&original.date);
let date_line = match &original.date {
Some(d) => format!("Date: {}<br>", format_date_for_attribution(d)),
None => String::new(),
};
let from = format_forward_from(&original.from);
let to = format_address_list_with_links(&original.to);
@@ -157,7 +177,7 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String {
<div dir=\"ltr\" class=\"gmail_attr\">\
---------- Forwarded message ---------<br>\
From: {}<br>\
Date: {}<br>\
{}\
Subject: {}<br>\
To: {}<br>\
{}\
@@ -166,7 +186,7 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String {
{}\
</div>",
from,
date,
date_line,
html_escape(&original.subject),
to,
cc_line,
@@ -176,22 +196,96 @@ fn format_forwarded_message_html(original: &OriginalMessage) -> String {
// --- Argument parsing ---
fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
ForwardConfig {
fn parse_forward_args(matches: &ArgMatches) -> Result<ForwardConfig, GwsError> {
let to = Mailbox::parse_list(matches.get_one::<String>("to").unwrap());
if to.is_empty() {
return Err(GwsError::Validation(
"--to must specify at least one recipient".to_string(),
));
}
Ok(ForwardConfig {
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
to: matches.get_one::<String>("to").unwrap().to_string(),
from: parse_optional_trimmed(matches, "from"),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
to,
from: parse_optional_mailboxes(matches, "from"),
cc: parse_optional_mailboxes(matches, "cc"),
bcc: parse_optional_mailboxes(matches, "bcc"),
body: parse_optional_trimmed(matches, "body"),
html: matches.get_flag("html"),
}
attachments: parse_attachments(matches)?,
})
}
#[cfg(test)]
mod tests {
use super::super::tests::{extract_header, strip_qp_soft_breaks};
use super::*;
// --- format_forwarded_message (plain text) ---
#[test]
fn test_format_forwarded_message() {
let original = OriginalMessage {
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: Some("Mon, 1 Jan 2026".to_string()),
body_text: "Original content".to_string(),
..Default::default()
};
let msg = format_forwarded_message(&original);
assert!(msg.contains("---------- Forwarded message ---------"));
assert!(msg.contains("From: alice@example.com"));
assert!(msg.contains("Date: Mon, 1 Jan 2026"));
assert!(msg.contains("Subject: Hello"));
assert!(msg.contains("To: bob@example.com"));
assert!(msg.contains("Original content"));
}
#[test]
fn test_format_forwarded_message_missing_date() {
let original = OriginalMessage {
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
body_text: "Content".to_string(),
..Default::default()
};
let msg = format_forwarded_message(&original);
// Date line should be omitted entirely when absent
assert!(!msg.contains("Date:"));
// Other lines should still be present
assert!(msg.contains("From: alice@example.com"));
assert!(msg.contains("Subject: Hello"));
}
#[test]
fn test_format_forwarded_message_with_cc() {
let original = OriginalMessage {
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
cc: Some(vec![
Mailbox::parse("carol@example.com"),
Mailbox::parse("dave@example.com"),
]),
subject: "Hello".to_string(),
date: Some("Mon, 1 Jan 2026".to_string()),
body_text: "Content".to_string(),
..Default::default()
};
let msg = format_forwarded_message(&original);
assert!(msg.contains("Cc: carol@example.com, dave@example.com"));
// Without CC, no Cc line
let no_cc = OriginalMessage {
cc: None,
..original
};
let msg = format_forwarded_message(&no_cc);
assert!(!msg.contains("Cc:"));
}
// --- forward subject ---
#[test]
fn test_build_forward_subject_without_prefix() {
assert_eq!(build_forward_subject("Hello"), "Fwd: Hello");
@@ -210,107 +304,140 @@ mod tests {
#[test]
fn test_create_forward_raw_message_without_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
body_html: None,
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: None,
html: false,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
assert!(raw.contains("To: dave@example.com"));
assert!(raw.contains("Subject: Fwd: Hello"));
assert!(raw.contains("In-Reply-To: <abc@example.com>"));
assert!(raw.contains("References: <abc@example.com>"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("dave@example.com"));
assert!(extract_header(&raw, "Subject")
.unwrap()
.contains("Fwd: Hello"));
assert!(extract_header(&raw, "In-Reply-To")
.unwrap()
.contains("abc@example.com"));
assert!(raw.contains("---------- Forwarded message ---------"));
assert!(raw.contains("From: alice@example.com"));
// Blank line separates metadata block from body
assert!(raw.contains("To: bob@example.com\r\n\r\nOriginal content"));
// No closing ---------- delimiter
assert!(!raw.ends_with("----------"));
assert!(raw.contains("Original content"));
}
#[test]
fn test_create_forward_raw_message_with_all_optional_headers() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "carol@example.com".to_string(),
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
cc: Some(vec![Mailbox::parse("carol@example.com")]),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
body_html: None,
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let cc = Mailbox::parse_list("eve@example.com");
let bcc = Mailbox::parse_list("secret@example.com");
let from = Mailbox::parse_list("alias@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
cc: Some("eve@example.com"),
bcc: Some("secret@example.com"),
from: Some("alias@example.com"),
to: &to,
cc: Some(&cc),
bcc: Some(&bcc),
from: Some(&from),
subject: "Fwd: Hello",
body: Some("FYI see below"),
html: false,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
assert!(raw.contains("Cc: eve@example.com"));
assert!(raw.contains("Bcc: secret@example.com"));
assert!(raw.contains("From: alias@example.com"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("dave@example.com"));
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("eve@example.com"));
assert!(extract_header(&raw, "Bcc")
.unwrap()
.contains("secret@example.com"));
assert!(extract_header(&raw, "From")
.unwrap()
.contains("alias@example.com"));
assert!(raw.contains("FYI see below"));
assert!(raw.contains("Cc: carol@example.com"));
assert!(raw.contains("carol@example.com")); // in forwarded block
}
#[test]
fn test_create_forward_raw_message_references_chain() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<msg-2@example.com>".to_string(),
references: "<msg-0@example.com> <msg-1@example.com>".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
thread_id: Some("t1".to_string()),
message_id: "msg-2@example.com".to_string(),
references: vec![
"msg-0@example.com".to_string(),
"msg-1@example.com".to_string(),
],
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
body_html: None,
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: None,
html: false,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
assert!(raw.contains("In-Reply-To: <msg-2@example.com>"));
assert!(
raw.contains("References: <msg-0@example.com> <msg-1@example.com> <msg-2@example.com>")
);
// All three message IDs should appear in the References header
let refs_header = extract_header(&raw, "References").unwrap();
assert!(refs_header.contains("msg-0@example.com"));
assert!(refs_header.contains("msg-1@example.com"));
assert!(refs_header.contains("msg-2@example.com"));
// In-Reply-To should have only the direct parent
assert!(extract_header(&raw, "In-Reply-To")
.unwrap()
.contains("msg-2@example.com"));
}
fn make_forward_matches(args: &[&str]) -> ArgMatches {
@@ -322,6 +449,12 @@ mod tests {
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("attach")
.short('a')
.long("attach")
.action(ArgAction::Append),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -334,9 +467,9 @@ mod tests {
fn test_parse_forward_args() {
let matches =
make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]);
let config = parse_forward_args(&matches);
let config = parse_forward_args(&matches).unwrap();
assert_eq!(config.message_id, "abc123");
assert_eq!(config.to, "dave@example.com");
assert_eq!(config.to[0].email, "dave@example.com");
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
assert!(config.body.is_none());
@@ -350,6 +483,8 @@ mod tests {
"abc123",
"--to",
"dave@example.com",
"--from",
"alias@example.com",
"--cc",
"eve@example.com",
"--bcc",
@@ -357,9 +492,10 @@ mod tests {
"--body",
"FYI",
]);
let config = parse_forward_args(&matches);
assert_eq!(config.cc.unwrap(), "eve@example.com");
assert_eq!(config.bcc.unwrap(), "secret@example.com");
let config = parse_forward_args(&matches).unwrap();
assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com");
assert_eq!(config.cc.as_ref().unwrap()[0].email, "eve@example.com");
assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
assert_eq!(config.body.unwrap(), "FYI");
// Whitespace-only values become None
@@ -374,7 +510,7 @@ mod tests {
"--bcc",
" ",
]);
let config = parse_forward_args(&matches);
let config = parse_forward_args(&matches).unwrap();
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
@@ -389,32 +525,38 @@ mod tests {
"dave@example.com",
"--html",
]);
let config = parse_forward_args(&matches);
let config = parse_forward_args(&matches).unwrap();
assert!(config.html);
// Default is false
let matches =
make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]);
let config = parse_forward_args(&matches);
let config = parse_forward_args(&matches).unwrap();
assert!(!config.html);
}
#[test]
fn test_parse_forward_args_empty_to_returns_error() {
let matches = make_forward_matches(&["test", "--message-id", "abc123", "--to", ""]);
let err = parse_forward_args(&matches).err().unwrap();
assert!(
err.to_string().contains("--to"),
"error should mention --to"
);
}
// --- HTML mode tests ---
#[test]
fn test_format_forwarded_message_html_with_html_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
date: Some("Mon, 1 Jan 2026".to_string()),
body_text: "plain fallback".to_string(),
body_html: Some("<p>Rich <b>content</b></p>".to_string()),
..Default::default()
};
let html = format_forwarded_message_html(&original);
assert!(html.contains("gmail_quote"));
@@ -428,17 +570,12 @@ mod tests {
#[test]
fn test_format_forwarded_message_html_fallback_plain_text() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
date: Some("Mon, 1 Jan 2026".to_string()),
body_text: "Line one & <stuff>\nLine two".to_string(),
body_html: None,
..Default::default()
};
let html = format_forwarded_message_html(&original);
assert!(html.contains("Line one &amp; &lt;stuff&gt;<br>"));
@@ -448,24 +585,19 @@ mod tests {
#[test]
fn test_format_forwarded_message_html_escapes_metadata() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "Tom & Jerry <tj@example.com>".to_string(),
reply_to: "".to_string(),
to: "<alice@example.com>".to_string(),
cc: "".to_string(),
from: Mailbox::parse("Tom & Jerry <tj@example.com>"),
to: vec![Mailbox::parse("<alice@example.com>")],
subject: "A < B & C".to_string(),
date: "Jan 1 <2026>".to_string(),
date: Some("Jan 1 <2026>".to_string()),
body_text: "text".to_string(),
body_html: None,
..Default::default()
};
let html = format_forwarded_message_html(&original);
// From line: display name in <strong>, email in mailto link
assert!(html.contains("Tom &amp; Jerry"));
assert!(html.contains("<a href=\"mailto:tj@example.com\">tj@example.com</a>"));
assert!(html.contains("<a href=\"mailto:tj%40example%2Ecom\">tj@example.com</a>"));
// To line: email wrapped in mailto link
assert!(html.contains("<a href=\"mailto:alice@example.com\">"));
assert!(html.contains("<a href=\"mailto:alice%40example%2Ecom\">"));
assert!(html.contains("A &lt; B &amp; C"));
// Non-RFC-2822 date falls back to html-escaped raw string
assert!(html.contains("Jan 1 &lt;2026&gt;"));
@@ -474,23 +606,19 @@ mod tests {
#[test]
fn test_format_forwarded_message_html_conditional_cc() {
let with_cc = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "carol@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
cc: Some(vec![Mailbox::parse("carol@example.com")]),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
date: Some("Mon, 1 Jan 2026".to_string()),
body_text: "text".to_string(),
body_html: None,
..Default::default()
};
let html = format_forwarded_message_html(&with_cc);
assert!(html.contains("Cc: <a href=\"mailto:carol@example.com\">carol@example.com</a>"));
assert!(html.contains("Cc: <a href=\"mailto:carol%40example%2Ecom\">carol@example.com</a>"));
let without_cc = OriginalMessage {
cc: "".to_string(),
cc: None,
..with_cc
};
let html = format_forwarded_message_html(&without_cc);
@@ -500,103 +628,157 @@ mod tests {
#[test]
fn test_create_forward_raw_message_html_without_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
body_html: Some("<p>Original</p>".to_string()),
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: None,
html: true,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
let decoded = strip_qp_soft_breaks(&raw);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("gmail_quote"));
assert!(raw.contains("Forwarded message"));
assert!(raw.contains("<p>Original</p>"));
// No user note — forwarded block is the entire body
assert!(!raw.contains("<p>FYI</p>"));
assert!(decoded.contains("text/html"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("dave@example.com"));
assert!(decoded.contains("gmail_quote"));
assert!(decoded.contains("Forwarded message"));
assert!(decoded.contains("<p>Original</p>"));
}
#[test]
fn test_create_forward_raw_message_html_plain_text_fallback() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Plain & simple".to_string(),
body_html: None,
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: Some("<p>FYI</p>"),
html: true,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("<p>FYI</p><br>\r\n<div class=\"gmail_quote gmail_quote_container\">"));
let decoded = strip_qp_soft_breaks(&raw);
assert!(decoded.contains("text/html"));
assert!(decoded.contains("<p>FYI</p>"));
// Plain text body is HTML-escaped in the fallback
assert!(raw.contains("Plain &amp; simple"));
assert!(decoded.contains("Plain &amp; simple"));
}
#[test]
fn test_create_forward_raw_message_html() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
body_html: Some("<p>Original</p>".to_string()),
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: "dave@example.com",
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: Some("<p>FYI</p>"),
html: true,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let raw = create_forward_raw_message(&envelope, &original);
let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap();
let decoded = strip_qp_soft_breaks(&raw);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("<p>FYI</p>"));
assert!(raw.contains("gmail_quote"));
assert!(decoded.contains("text/html"));
assert!(decoded.contains("<p>FYI</p>"));
assert!(decoded.contains("gmail_quote"));
assert!(decoded.contains("Forwarded message"));
assert!(decoded.contains("<p>Original</p>"));
}
#[test]
fn test_create_forward_raw_message_with_attachment() {
let original = OriginalMessage {
thread_id: Some("t1".to_string()),
message_id: "abc@example.com".to_string(),
from: Mailbox::parse("alice@example.com"),
to: vec![Mailbox::parse("bob@example.com")],
subject: "Hello".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "Original content".to_string(),
..Default::default()
};
let refs = build_references_chain(&original);
let to = Mailbox::parse_list("dave@example.com");
let envelope = ForwardEnvelope {
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: Some("FYI, see attached"),
html: false,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
let attachments = vec![Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
}];
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("report.pdf"));
assert!(raw.contains("FYI, see attached"));
assert!(raw.contains("Forwarded message"));
assert!(raw.contains("<p>Original</p>"));
// HTML separator: <br> between note and forwarded block (not \r\n\r\n)
assert!(raw.contains("<p>FYI</p><br>\r\n<div class=\"gmail_quote gmail_quote_container\">"));
}
}
+1490 -1014
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use std::io::{self, Write};
/// Handle the `+read` subcommand.
pub(super) async fn handle_read(
_doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let message_id = matches.get_one::<String>("id").unwrap();
let dry_run = matches.get_flag("dry-run");
let original = if dry_run {
OriginalMessage::dry_run_placeholder(message_id)
} else {
let t = auth::get_token(&[GMAIL_READONLY_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
fetch_message_metadata(&client, &t, message_id).await?
};
let format = matches.get_one::<String>("format").unwrap();
let show_headers = matches.get_flag("headers");
let use_html = matches.get_flag("html");
let mut stdout = io::stdout().lock();
if format == "json" {
let json_output = serde_json::to_string_pretty(&original)
.context("Failed to serialize message to JSON")?;
writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?;
return Ok(());
}
if show_headers {
// Format structured fields into display strings for header output.
let from_str = original.from.to_string();
let to_str = format_mailbox_list(&original.to);
let cc_str = original
.cc
.as_ref()
.map(|cc| format_mailbox_list(cc))
.unwrap_or_default();
let headers_to_show: [(&str, &str); 5] = [
("From", &from_str),
("To", &to_str),
("Cc", &cc_str),
("Subject", &original.subject),
("Date", original.date.as_deref().unwrap_or_default()),
];
for (name, value) in headers_to_show {
if value.is_empty() {
continue;
}
// Replace newlines to prevent header spoofing in the output, then sanitize.
let sanitized_value = sanitize_terminal_output(&value.replace(['\r', '\n'], " "));
writeln!(stdout, "{}: {}", name, sanitized_value)
.with_context(|| format!("Failed to write '{name}' header"))?;
}
writeln!(stdout, "---").context("Failed to write header separator")?;
}
let body = if use_html {
original
.body_html
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(&original.body_text)
} else {
&original.body_text
};
writeln!(stdout, "{}", sanitize_terminal_output(body))
.context("Failed to write message body")?;
Ok(())
}
/// Format a slice of Mailbox as a displayable comma-separated string.
fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
mailboxes
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ")
}
/// Re-export the crate-wide terminal sanitizer for use in this module.
use crate::error::sanitize_for_terminal as sanitize_terminal_output;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_terminal_output() {
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
let sanitized = sanitize_terminal_output(malicious);
// ANSI escape sequences (control chars) should be removed
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
// CR is also stripped (can be abused for terminal overwrite attacks)
assert!(!sanitized.contains('\r'));
// Newline and tab should be preserved
assert!(sanitized.contains("Hello"));
assert!(sanitized.contains('\n'));
assert!(sanitized.contains('\t'));
}
#[test]
fn test_format_mailbox_list_empty() {
assert_eq!(format_mailbox_list(&[]), "");
}
#[test]
fn test_format_mailbox_list_single() {
let mailboxes = Mailbox::parse_list("alice@example.com");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
}
#[test]
fn test_format_mailbox_list_multiple() {
let mailboxes = Mailbox::parse_list("alice@example.com, Bob <bob@example.com>");
let result = format_mailbox_list(&mailboxes);
assert!(result.contains("alice@example.com"));
assert!(result.contains("bob@example.com"));
}
}
+631 -732
View File
File diff suppressed because it is too large Load Diff
+318 -38
View File
@@ -1,47 +1,79 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
/// Handle the `+send` subcommand.
pub(super) async fn handle_send(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_send_args(matches);
let config = parse_send_args(matches)?;
let raw = MessageBuilder {
to: &config.to,
subject: &config.subject,
from: None,
cc: config.cc.as_deref(),
bcc: config.bcc.as_deref(),
threading: None,
html: config.html,
}
.build(&config.body);
let raw = create_send_raw_message(&config)?;
super::send_raw_email(doc, matches, &raw, None, None).await
}
pub(super) struct SendConfig {
pub to: String,
pub to: Vec<Mailbox>,
pub subject: String,
pub body: String,
pub cc: Option<String>,
pub bcc: Option<String>,
pub from: Option<Vec<Mailbox>>,
pub cc: Option<Vec<Mailbox>>,
pub bcc: Option<Vec<Mailbox>>,
pub html: bool,
pub attachments: Vec<Attachment>,
}
fn parse_send_args(matches: &ArgMatches) -> SendConfig {
SendConfig {
to: matches.get_one::<String>("to").unwrap().to_string(),
fn create_send_raw_message(config: &SendConfig) -> Result<String, GwsError> {
let mb = mail_builder::MessageBuilder::new()
.to(to_mb_address_list(&config.to))
.subject(&config.subject);
let mb = apply_optional_headers(
mb,
config.from.as_deref(),
config.cc.as_deref(),
config.bcc.as_deref(),
);
finalize_message(mb, &config.body, config.html, &config.attachments)
}
fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
let to = Mailbox::parse_list(matches.get_one::<String>("to").unwrap());
if to.is_empty() {
return Err(GwsError::Validation(
"--to must specify at least one recipient".to_string(),
));
}
Ok(SendConfig {
to,
subject: matches.get_one::<String>("subject").unwrap().to_string(),
body: matches.get_one::<String>("body").unwrap().to_string(),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
from: parse_optional_mailboxes(matches, "from"),
cc: parse_optional_mailboxes(matches, "cc"),
bcc: parse_optional_mailboxes(matches, "bcc"),
html: matches.get_flag("html"),
}
attachments: parse_attachments(matches)?,
})
}
#[cfg(test)]
mod tests {
use super::super::tests::{extract_header, strip_qp_soft_breaks};
use super::*;
fn make_matches_send(args: &[&str]) -> ArgMatches {
@@ -49,9 +81,16 @@ mod tests {
.arg(Arg::new("to").long("to"))
.arg(Arg::new("subject").long("subject"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("from").long("from"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue));
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("attach")
.long("attach")
.short('a')
.action(ArgAction::Append),
);
cmd.try_get_matches_from(args).unwrap()
}
@@ -66,14 +105,48 @@ mod tests {
"--body",
"Body",
]);
let config = parse_send_args(&matches);
assert_eq!(config.to, "me@example.com");
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.to.len(), 1);
assert_eq!(config.to[0].email, "me@example.com");
assert_eq!(config.subject, "Hi");
assert_eq!(config.body, "Body");
assert!(config.from.is_none());
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_with_from() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
"alias@example.com",
]);
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com");
// Whitespace-only --from becomes None
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Body",
"--from",
" ",
]);
let config = parse_send_args(&matches).unwrap();
assert!(config.from.is_none());
}
#[test]
fn test_parse_send_args_with_cc_and_bcc() {
let matches = make_matches_send(&[
@@ -89,9 +162,9 @@ mod tests {
"--bcc",
"secret@example.com",
]);
let config = parse_send_args(&matches);
assert_eq!(config.cc.unwrap(), "carol@example.com");
assert_eq!(config.bcc.unwrap(), "secret@example.com");
let config = parse_send_args(&matches).unwrap();
assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com");
assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
// Whitespace-only values become None
let matches = make_matches_send(&[
@@ -107,7 +180,7 @@ mod tests {
"--bcc",
"",
]);
let config = parse_send_args(&matches);
let config = parse_send_args(&matches).unwrap();
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
@@ -124,7 +197,7 @@ mod tests {
"<b>Bold</b>",
"--html",
]);
let config = parse_send_args(&matches);
let config = parse_send_args(&matches).unwrap();
assert!(config.html);
// Default is false
@@ -137,25 +210,232 @@ mod tests {
"--body",
"Plain",
]);
let config = parse_send_args(&matches);
let config = parse_send_args(&matches).unwrap();
assert!(!config.html);
}
#[test]
fn test_parse_send_args_empty_to_returns_error() {
let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]);
let err = parse_send_args(&matches).err().unwrap();
assert!(
err.to_string().contains("--to"),
"error should mention --to"
);
}
#[test]
fn test_send_html_raw_message() {
let raw = MessageBuilder {
to: "bob@example.com",
subject: "HTML test",
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "HTML test".to_string(),
body: "<p>Hello <b>world</b></p>".to_string(),
from: None,
cc: None,
bcc: None,
threading: None,
html: true,
}
.build("<p>Hello <b>world</b></p>");
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let decoded = strip_qp_soft_breaks(&raw);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("To: bob@example.com"));
assert!(raw.contains("<p>Hello <b>world</b></p>"));
assert!(decoded.contains("text/html"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject")
.unwrap()
.contains("HTML test"));
assert!(decoded.contains("<p>Hello <b>world</b></p>"));
assert!(extract_header(&raw, "Cc").is_none());
}
#[test]
fn test_send_plain_text_raw_message() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Hello".to_string(),
body: "World".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
assert!(extract_header(&raw, "Subject").unwrap().contains("Hello"));
assert!(raw.contains("text/plain"));
assert!(raw.contains("World"));
}
#[test]
fn test_send_with_cc_and_bcc() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com")),
bcc: Some(Mailbox::parse_list("secret@example.com")),
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
assert!(extract_header(&raw, "Bcc")
.unwrap()
.contains("secret@example.com"));
// Verify no leakage between headers
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("carol@example.com"));
assert!(!extract_header(&raw, "To")
.unwrap()
.contains("secret@example.com"));
}
#[test]
fn test_send_with_from() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list("alias@example.com")),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From")
.unwrap()
.contains("alias@example.com"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("bob@example.com"));
}
#[test]
fn test_send_without_from_has_no_from_header() {
let config = SendConfig {
to: Mailbox::parse_list("bob@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(extract_header(&raw, "From").is_none());
}
#[test]
fn test_send_multiple_to_recipients() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com, bob@example.com"),
subject: "Group".to_string(),
body: "Hi all".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
let to_header = extract_header(&raw, "To").unwrap();
assert!(to_header.contains("alice@example.com"));
assert!(to_header.contains("bob@example.com"));
}
#[test]
fn test_send_crlf_injection_in_from_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: Some(Mailbox::parse_list(
"sender@example.com\r\nBcc: evil@attacker.com",
)),
cc: None,
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// The CRLF injection should not create a Bcc header
assert!(
extract_header(&raw, "Bcc").is_none(),
"CRLF injection via --from should not create Bcc header"
);
// The From header should contain the sanitized email
assert!(extract_header(&raw, "From")
.unwrap()
.contains("sender@example.com"));
}
#[test]
fn test_send_crlf_injection_in_cc_does_not_create_header() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Test".to_string(),
body: "Body".to_string(),
from: None,
cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")),
bcc: None,
html: false,
attachments: vec![],
};
let raw = create_send_raw_message(&config).unwrap();
// CRLF stripped → "X-Injected: yes" is concatenated into the email,
// not emitted as a separate header line
assert!(
extract_header(&raw, "X-Injected").is_none(),
"CRLF injection via --cc should not create X-Injected header"
);
assert!(extract_header(&raw, "Cc")
.unwrap()
.contains("carol@example.com"));
}
#[test]
fn test_send_with_attachment_produces_multipart() {
let config = SendConfig {
to: Mailbox::parse_list("alice@example.com"),
subject: "Report".to_string(),
body: "See attached".to_string(),
from: None,
cc: None,
bcc: None,
html: false,
attachments: vec![Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
}],
};
let raw = create_send_raw_message(&config).unwrap();
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("report.pdf"));
assert!(raw.contains("See attached"));
assert!(extract_header(&raw, "To")
.unwrap()
.contains("alice@example.com"));
}
}
-1
View File
@@ -123,7 +123,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
-2
View File
@@ -137,7 +137,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&pagination,
None,
@@ -181,7 +180,6 @@ TIPS:
auth_method,
None,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
+14 -8
View File
@@ -215,17 +215,12 @@ async fn run() -> Result<(), GwsError> {
.ok()
.flatten()
.map(|s| s.as_str());
let output_path = matched_args.get_one::<String>("output").map(|s| s.as_str());
let upload_path = matched_args
.try_get_one::<String>("upload")
.ok()
.flatten()
.map(|s| s.as_str());
let upload_content_type = matched_args
.try_get_one::<String>("upload-content-type")
.ok()
.flatten()
.map(|s| s.as_str());
let output_path = matched_args.get_one::<String>("output").map(|s| s.as_str());
// Validate file paths against traversal before any I/O.
// Use the returned canonical paths so the validated path is the one
@@ -243,6 +238,18 @@ async fn run() -> Result<(), GwsError> {
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
let upload = {
let upload_content_type = matched_args
.try_get_one::<String>("upload-content-type")
.ok()
.flatten()
.map(|s| s.as_str());
upload_path.map(|path| executor::UploadSource::File {
path,
content_type: upload_content_type,
})
};
let dry_run = matched_args.get_flag("dry-run");
// Build pagination config from flags
@@ -279,8 +286,7 @@ async fn run() -> Result<(), GwsError> {
token.as_deref(),
auth_method,
output_path,
upload_path,
upload_content_type,
upload,
dry_run,
&pagination,
sanitize_config.template.as_deref(),
+1 -1
View File
@@ -213,7 +213,7 @@ fn normalize_dotdot(path: &Path) -> PathBuf {
/// Rejects strings containing null bytes, ASCII control characters
/// (including DEL, 0x7F), or dangerous Unicode characters such as
/// zero-width chars, bidi overrides, and Unicode line/paragraph separators.
fn reject_control_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
pub(crate) fn reject_control_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
for c in value.chars() {
if (c as u32) < 0x20 || c as u32 == 0x7F {
return Err(GwsError::Validation(format!(