Skip to content

Commit 6e4daaf

Browse files
jpoehneltmalobrichanderson00jpoehnelt-botgoogleworkspace-bot
authored
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>
1 parent 9c26e3c commit 6e4daaf

File tree

24 files changed

+3222
-2075
lines changed

24 files changed

+3222
-2075
lines changed

.changeset/gmail-helpers-rollup.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Gmail helpers rollup: mail-builder migration, --attach flag (upload endpoint), +read helper
6+
7+
- Migrate `+send`, `+reply`, `+reply-all`, and `+forward` to the `mail-builder` crate for RFC-compliant MIME construction
8+
- Add `--from` flag to `+send` for send-as alias support
9+
- 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)
10+
- Add `+read` helper to extract message body and headers (text, HTML, or JSON output)
11+
- Make `OriginalMessage.thread_id` optional (`Option<String>`) for draft compatibility
12+
- RFC 2822 display name quoting is handled natively by `mail-builder`
13+
- Introduce `UploadSource` enum in executor for type-safe upload strategies

Cargo.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ crossterm = "0.29.0"
5757
chrono = "0.4.44"
5858
chrono-tz = "0.10"
5959
iana-time-zone = "0.1"
60+
mail-builder = "0.4"
6061
async-trait = "0.1.89"
6162
serde_yaml = "0.9.34"
6263
percent-encoding = "2.3.2"
@@ -65,6 +66,7 @@ tracing = "0.1"
6566
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
6667
tracing-appender = "0.2"
6768
uuid = { version = "1.22.0", features = ["v4", "v5"] }
69+
mime_guess2 = "2.3.1"
6870

6971
[target.'cfg(target_os = "macos")'.dependencies]
7072
keyring = { version = "3.6.3", features = ["apple-native"] }

docs/skills.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Shortcut commands for common operations.
4141
| [gws-gmail-reply](../skills/gws-gmail-reply/SKILL.md) | Gmail: Reply to a message (handles threading automatically). |
4242
| [gws-gmail-reply-all](../skills/gws-gmail-reply-all/SKILL.md) | Gmail: Reply-all to a message (handles threading automatically). |
4343
| [gws-gmail-forward](../skills/gws-gmail-forward/SKILL.md) | Gmail: Forward a message to new recipients. |
44+
| [gws-gmail-read](../skills/gws-gmail-read/SKILL.md) | Gmail: Read a message and extract its body or headers. |
4445
| [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. |
4546
| [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. |
4647
| [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. |

skills/gws-gmail-forward/SKILL.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
2929
| `--message-id` ||| Gmail message ID to forward |
3030
| `--to` ||| Recipient email address(es), comma-separated |
3131
| `--from` ||| Sender address (for send-as/alias; omit to use account default) |
32+
| `--body` ||| Optional note to include above the forwarded message (plain text, or HTML with --html) |
33+
| `--attach` ||| Attach a file (can be specified multiple times) |
3234
| `--cc` ||| CC email address(es), comma-separated |
3335
| `--bcc` ||| BCC email address(es), comma-separated |
34-
| `--body` ||| Optional note to include above the forwarded message (plain text, or HTML with --html) |
35-
| `--html` ||| Send as HTML (formats forwarded block with Gmail styling; treat --body as HTML) |
36+
| `--html` ||| Treat --body as HTML content (default is plain text) |
3637
| `--dry-run` ||| Show the request that would be sent without executing it |
3738

3839
## Examples
@@ -41,13 +42,16 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
4142
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
4243
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
4344
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
44-
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com
4545
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
46+
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
4647
```
4748

4849
## Tips
4950

5051
- Includes the original message with sender, date, subject, and recipients.
52+
- Use -a/--attach to add file attachments. Can be specified multiple times.
53+
- 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.
54+
- With --html, inline images in the forwarded message (cid: references) will appear broken. Externally hosted images are unaffected.
5155

5256
## See Also
5357

skills/gws-gmail-read/SKILL.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
name: gws-gmail-read
3+
version: 1.0.0
4+
description: "Gmail: Read a message and extract its body or headers."
5+
metadata:
6+
openclaw:
7+
category: "productivity"
8+
requires:
9+
bins: ["gws"]
10+
cliHelp: "gws gmail +read --help"
11+
---
12+
13+
# gmail +read
14+
15+
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
16+
17+
Read a message and extract its body or headers
18+
19+
## Usage
20+
21+
```bash
22+
gws gmail +read --id <ID>
23+
```
24+
25+
## Flags
26+
27+
| Flag | Required | Default | Description |
28+
|------|----------|---------|-------------|
29+
| `--id` ||| The Gmail message ID to read |
30+
| `--headers` ||| Include headers (From, To, Subject, Date) in the output |
31+
| `--format` || text | Output format (text, json) |
32+
| `--html` ||| Return HTML body instead of plain text |
33+
| `--dry-run` ||| Show the request that would be sent without executing it |
34+
35+
## Examples
36+
37+
```bash
38+
gws gmail +read --id 18f1a2b3c4d
39+
gws gmail +read --id 18f1a2b3c4d --headers
40+
gws gmail +read --id 18f1a2b3c4d --format json | jq '.body'
41+
```
42+
43+
## Tips
44+
45+
- Converts HTML-only messages to plain text automatically.
46+
- Handles multipart/alternative and base64 decoding.
47+
48+
## See Also
49+
50+
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
51+
- [gws-gmail](../gws-gmail/SKILL.md) — All send, read, and manage email commands

skills/gws-gmail-reply-all/SKILL.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,21 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
3030
| `--body` ||| Reply body (plain text, or HTML with --html) |
3131
| `--from` ||| Sender address (for send-as/alias; omit to use account default) |
3232
| `--to` ||| Additional To email address(es), comma-separated |
33-
| `--cc` ||| Additional CC email address(es), comma-separated |
33+
| `--attach` ||| Attach a file (can be specified multiple times) |
34+
| `--cc` ||| CC email address(es), comma-separated |
3435
| `--bcc` ||| BCC email address(es), comma-separated |
35-
| `--remove` ||| Exclude recipients from the outgoing reply (comma-separated emails) |
36-
| `--html` ||| Send as HTML (quotes original with Gmail styling; treat --body as HTML) |
36+
| `--html` ||| Treat --body as HTML content (default is plain text) |
3737
| `--dry-run` ||| Show the request that would be sent without executing it |
38+
| `--remove` ||| Exclude recipients from the outgoing reply (comma-separated emails) |
3839

3940
## Examples
4041

4142
```bash
4243
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!'
4344
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com
4445
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
45-
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
46-
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
4746
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
47+
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf
4848
```
4949

5050
## Tips
@@ -55,6 +55,9 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
5555
- Use --bcc for recipients who should not be visible to others.
5656
- Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
5757
- The command fails if no To recipient remains after exclusions and --to additions.
58+
- Use -a/--attach to add file attachments. Can be specified multiple times.
59+
- 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.
60+
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
5861

5962
## See Also
6063

skills/gws-gmail-reply/SKILL.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ gws gmail +reply --message-id <ID> --body <TEXT>
3030
| `--body` ||| Reply body (plain text, or HTML with --html) |
3131
| `--from` ||| Sender address (for send-as/alias; omit to use account default) |
3232
| `--to` ||| Additional To email address(es), comma-separated |
33-
| `--cc` ||| Additional CC email address(es), comma-separated |
33+
| `--attach` ||| Attach a file (can be specified multiple times) |
34+
| `--cc` ||| CC email address(es), comma-separated |
3435
| `--bcc` ||| BCC email address(es), comma-separated |
35-
| `--html` ||| Send as HTML (quotes original with Gmail styling; treat --body as HTML) |
36+
| `--html` ||| Treat --body as HTML content (default is plain text) |
3637
| `--dry-run` ||| Show the request that would be sent without executing it |
3738

3839
## Examples
@@ -41,15 +42,18 @@ gws gmail +reply --message-id <ID> --body <TEXT>
4142
gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
4243
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
4344
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
44-
gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
4545
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
46+
gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx
4647
```
4748

4849
## Tips
4950

5051
- Automatically sets In-Reply-To, References, and threadId headers.
5152
- Quotes the original message in the reply body.
5253
- --to adds extra recipients to the To field.
54+
- Use -a/--attach to add file attachments. Can be specified multiple times.
55+
- 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.
56+
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
5357
- For reply-all, use +reply-all instead.
5458

5559
## See Also

skills/gws-gmail-send/SKILL.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
2929
| `--to` ||| Recipient email address(es), comma-separated |
3030
| `--subject` ||| Email subject |
3131
| `--body` ||| Email body (plain text, or HTML with --html) |
32+
| `--from` ||| Sender address (for send-as/alias; omit to use account default) |
33+
| `--attachment` ||| Attach a file (can be repeated for multiple files) |
34+
| `--attach` ||| Attach a file (can be specified multiple times) |
3235
| `--cc` ||| CC email address(es), comma-separated |
3336
| `--bcc` ||| BCC email address(es), comma-separated |
3437
| `--html` ||| Treat --body as HTML content (default is plain text) |
@@ -39,14 +42,18 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
3942
```bash
4043
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!'
4144
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com
42-
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com
4345
gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> text' --html
46+
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com
47+
gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf
48+
gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv
4449
```
4550

4651
## Tips
4752

48-
- Handles RFC 2822 formatting and base64 encoding automatically.
49-
- For attachments, use the raw API instead: gws gmail users messages send --json '...'
53+
- Handles RFC 5322 formatting, MIME encoding, and base64 automatically.
54+
- Use --from to send from a configured send-as alias instead of your primary address.
55+
- Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB.
56+
- With --html, use fragment tags (<p>, <b>, <a>, <br>, etc.) — no <html>/<body> wrapper needed.
5057

5158
> [!CAUTION]
5259
> This is a **write** command — confirm with the user before executing.

skills/gws-gmail/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ gws gmail <resource> <method> [flags]
2727
| [`+reply`](../gws-gmail-reply/SKILL.md) | Reply to a message (handles threading automatically) |
2828
| [`+reply-all`](../gws-gmail-reply-all/SKILL.md) | Reply-all to a message (handles threading automatically) |
2929
| [`+forward`](../gws-gmail-forward/SKILL.md) | Forward a message to new recipients |
30+
| [`+read`](../gws-gmail-read/SKILL.md) | Read a message and extract its body or headers |
3031
| [`+watch`](../gws-gmail-watch/SKILL.md) | Watch for new emails and stream them as NDJSON |
3132

3233
## API Resources

0 commit comments

Comments
 (0)