Skip to content

feat(gmail): add +reply, +reply-all, and +forward helpers#105

Open
HeroSizy wants to merge 16 commits intogoogleworkspace:mainfrom
HeroSizy:feat/gmail-reply-forward
Open

feat(gmail): add +reply, +reply-all, and +forward helpers#105
HeroSizy wants to merge 16 commits intogoogleworkspace:mainfrom
HeroSizy:feat/gmail-reply-forward

Conversation

@HeroSizy
Copy link

@HeroSizy HeroSizy commented Mar 5, 2026

Description

Closes #88 — adds first-class reply and forward support to the Gmail CLI helpers.

  • +reply — Reply to a message by ID. Automatically fetches the original message, sets In-Reply-To, References, and threadId headers, quotes the original, and sends via users.messages.send.
  • +reply-all — Same as +reply but addresses all original To/CC recipients. Supports --remove to drop recipients and --cc to add new ones.
  • +forward — Forward a message to new recipients with a standard forwarded-message block (From, Date, Subject, To, Cc). Supports optional --body for a note above the forwarded content.

All three commands support --dry-run for safe previewing (works without auth credentials).

Address handling details:

  • Prefers Reply-To over From when selecting reply recipients (mailing lists, support systems)
  • Parses multi-address Reply-To headers (e.g., list@example.com, owner@example.com) and deduplicates CC against the full set
  • Uses RFC 5322-aware mailbox list parsing — commas inside quoted display names (e.g., "Doe, John" <john@example.com>) are handled correctly
  • Uses exact normalized email extraction (not substring matching) for --remove filtering and sender exclusion — e.g., removing ann@example.com does not affect joann@example.com
  • Case-insensitive email comparison throughout

Known limitation: +forward currently forwards the message text/snippet only. Full MIME forwarding with attachments will be addressed in a follow-up PR (ref #88).

New files

File Purpose
src/helpers/gmail/reply.rs +reply and +reply-all logic, message metadata fetching, RFC 2822 header construction
src/helpers/gmail/forward.rs +forward logic, forwarded message formatting

Modified files

File Change
src/helpers/gmail/mod.rs Register new modules, inject +reply/+reply-all/+forward subcommands, add handler dispatch

Dry Run Output:

+reply:

$ gws gmail +reply --message-id 18f1a2b3c4d --body "Thanks, got it!" --dry-run
{
  "body": {
    "raw": "VG86IHNlbmRlckBleGFtcGxlLmNvbQ0KU3ViamVjdDogUmU6IE9yaWdpbmFsIHN1YmplY3QNCkluLVJlcGx5LVRvOiA8MThmMWEyYjNjNGRAZXhhbXBsZS5jb20-DQpSZWZlcmVuY2VzOiA8MThmMWEyYjNjNGRAZXhhbXBsZS5jb20-DQoNClRoYW5rcywgZ290IGl0IQ0KDQpPbiBUaHUsIDEgSmFuIDIwMjYgMDA6MDA6MDAgKzAwMDAsIHNlbmRlckBleGFtcGxlLmNvbSB3cm90ZToKPiBPcmlnaW5hbCBtZXNzYWdlIGJvZHk=",
    "threadId": "thread-18f1a2b3c4d"
  },
  "dry_run": true,
  "is_multipart_upload": false,
  "method": "POST",
  "query_params": {},
  "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
}

+reply-all:

$ gws gmail +reply-all --message-id 18f1a2b3c4d --body "Sounds good!" --cc eve@example.com --remove bob@example.com --dry-run
{
  "body": {
    "raw": "VG86IHNlbmRlckBleGFtcGxlLmNvbQ0KU3ViamVjdDogUmU6IE9yaWdpbmFsIHN1YmplY3QNCkluLVJlcGx5LVRvOiA8MThmMWEyYjNjNGRAZXhhbXBsZS5jb20-DQpSZWZlcmVuY2VzOiA8MThmMWEyYjNjNGRAZXhhbXBsZS5jb20-DQpDYzogeW91QGV4YW1wbGUuY29tLCBldmVAZXhhbXBsZS5jb20NCg0KU291bmRzIGdvb2QhDQoNCk9uIFRodSwgMSBKYW4gMjAyNiAwMDowMDowMCArMDAwMCwgc2VuZGVyQGV4YW1wbGUuY29tIHdyb3RlOgo-IE9yaWdpbmFsIG1lc3NhZ2UgYm9keQ==",
    "threadId": "thread-18f1a2b3c4d"
  },
  "dry_run": true,
  "is_multipart_upload": false,
  "method": "POST",
  "query_params": {},
  "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
}

+forward:

$ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body "FYI see below" --cc carol@example.com --dry-run
{
  "body": {
    "raw": "VG86IGRhdmVAZXhhbXBsZS5jb20NClN1YmplY3Q6IEZ3ZDogT3JpZ2luYWwgc3ViamVjdA0KQ2M6IGNhcm9sQGV4YW1wbGUuY29tDQoNCkZZSSBzZWUgYmVsb3cNCg0KLS0tLS0tLS0tLSBGb3J3YXJkZWQgbWVzc2FnZSAtLS0tLS0tLS0KRnJvbTogc2VuZGVyQGV4YW1wbGUuY29tCkRhdGU6IFRodSwgMSBKYW4gMjAyNiAwMDowMDowMCArMDAwMApTdWJqZWN0OiBPcmlnaW5hbCBzdWJqZWN0ClRvOiB5b3VAZXhhbXBsZS5jb20KT3JpZ2luYWwgbWVzc2FnZSBib2R5Ci0tLS0tLS0tLS0=",
    "threadId": "thread-18f1a2b3c4d"
  },
  "dry_run": true,
  "is_multipart_upload": false,
  "method": "POST",
  "query_params": {},
  "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
}

Checklist:

  • My code follows the AGENTS.md guidelines (no generated google-* crates).
  • I have run cargo fmt --all to format the code perfectly.
  • I have run cargo clippy -- -D warnings and resolved all warnings.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have provided a Changeset file (e.g. via pnpx changeset) to document my changes.

Note: This PR was developed with AI assistance (Claude). All code has been reviewed by the author.

@gemini-code-assist
Copy link
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@changeset-bot
Copy link

changeset-bot bot commented Mar 5, 2026

🦋 Changeset detected

Latest commit: 3ec8332

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@google-cla
Copy link

google-cla bot commented Mar 5, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@jpoehnelt
Copy link
Member

How does this compare to the request in #88?

Copy link
Member

@jpoehnelt jpoehnelt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code duplication between reply.rs and forward.rs
Missing encode_path_segment() on the message-id in URL construction

HeroSizy added 14 commits March 5, 2026 17:56
Add first-class reply and forward support to the Gmail helpers,
addressing the gap described in googleworkspace#88. These commands handle the
complex RFC 2822 threading mechanics (In-Reply-To, References,
threadId) that agents and CLI users struggle with today.

New commands:
- +reply: reply to a message with automatic threading
- +reply-all: reply to all recipients with --remove/--cc support
- +forward: forward a message with quoted original content
- Use crate::validate::encode_path_segment() on message_id in
  fetch_message_metadata URL construction per AGENTS.md rules
- Update auth::get_token calls to pass None for the new account
  parameter added on main
- Add send_raw_email() to mod.rs: shared encode→json→auth→execute
  pattern for sending raw RFC 2822 messages via users.messages.send
- Simplify handle_reply: delegate send logic to send_raw_email
- Simplify handle_forward: delegate send logic to send_raw_email

Addresses code duplication feedback from PR review.
The handlers read matches.get_flag("dry-run") but the flag was missing
from the clap command definitions, so it always returned false. Now
dry-run works for +reply, +reply-all, and +forward.
Same class of bug fixed for +reply/+reply-all/+forward — the handler
reads matches.get_flag("dry-run") but the arg was not registered.
- Prefer Reply-To over From when selecting reply recipients, fixing
  incorrect routing for mailing lists and support systems
- Use exact email address comparison instead of substring matching
  for --remove filtering and sender deduplication, preventing
  unintended recipient removal (e.g. ann@ no longer drops joann@)
- extract_email: malformed input (no closing bracket), empty string,
  whitespace-only
- build_reply_all_recipients: display-name sender exclusion,
  --remove with display name, extra --cc, CC becomes None when all
  filtered, case-insensitive sender exclusion
Corrects how `build_reply_all_recipients` handles multi-address `Reply-To` headers.
Previously, only the first address from `Reply-To` was used for deduplication, leading to potential redundancy by including those addresses in the `Cc` field.
The updated logic now parses all addresses in `Reply-To`, ensuring they are fully moved to the `To` field and properly excluded from `Cc`.
reply.rs and forward.rs were missing the copyright header that all
other source files in the repo include.
parse_reply_args used get_one("remove") which panics when called
from +reply (which does not register --remove). Switch to
try_get_one to safely return None for unregistered args.
Skip auth and message fetch when --dry-run is set by using placeholder
OriginalMessage data. This lets users preview the request structure
without needing credentials.
Replace naive comma-split with split_mailbox_list that respects
quoted strings, so display names containing commas like
"Doe, John" <john@example.com> are handled correctly in reply-all
recipient parsing, deduplication, and --remove filtering.
@HeroSizy HeroSizy force-pushed the feat/gmail-reply-forward branch 2 times, most recently from a883182 to e6f388e Compare March 5, 2026 10:15
@HeroSizy
Copy link
Author

HeroSizy commented Mar 5, 2026

How does this compare to the request in #88?

This PR is a direct implementation of the feature request in #88. It adds +reply, +reply-all, and +forward as first-class helpers, covering most of the asks from that issue:

  • Reply to a message by ID with automatic threading (In-Reply-To, References, threadId)
  • Reply-all with proper recipient dedup
  • Forward with the original message quoted
  • --cc and --remove flags for adding/dropping recipients without reconstructing the list

One thing not yet covered: attachment forwarding. The current +forward quotes the original message body but doesn't carry over attachments. That could be a follow-up if this lands — happy to open a separate issue to track it.

split_mailbox_list toggled quote state on every `"` without accounting
for backslash-escaped quotes (`\"`), causing display names like
`"Doe \"JD, Sr\""` to split incorrectly at interior commas.

Track `prev_backslash` so `\"` inside quoted strings is treated as a
literal quote character rather than a delimiter toggle. Double
backslashes (`\\`) are handled correctly as well.
@HeroSizy HeroSizy force-pushed the feat/gmail-reply-forward branch from 84ebbfe to 3ec8332 Compare March 5, 2026 11:39
@HeroSizy
Copy link
Author

HeroSizy commented Mar 5, 2026

Code duplication between reply.rs and forward.rs Missing encode_path_segment() on the message-id in URL construction

@jpoehnelt

Thanks for the review! Both points are fixed now — forward.rs reuses OriginalMessage, fetch_message_metadata(), and send_raw_email() from reply.rs, and the message ID goes through encode_path_segment() in the URL construction.

Sorry about the initial state — I had an AI agent push before I'd properly reviewed. Everything's been cleaned up since.

A couple of questions:

  1. forward.rs currently reaches into reply.rs via super::reply:: for shared types. Would you prefer those extracted into a common.rs within the gmail module?

  2. We've been patching the hand-rolled mailbox parser for edge cases (escaped quotes, commas in display names, etc.) and it feels like a losing battle. Would you be open to bringing in an RFC 5322-compliant address parsing crate so we can drop the custom code entirely?

@HeroSizy HeroSizy requested a review from jpoehnelt March 5, 2026 14:33
@jpoehnelt jpoehnelt added area: skills cla: no This human has *not* signed the Contributor License Agreement. complexity: high Large or complex change, careful review needed labels Mar 5, 2026
Copy link
Member

@jpoehnelt jpoehnelt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

@jpoehnelt
Copy link
Member

Nice work addressing the prior feedback — dedup and encode_path_segment() look good. A few things to address:

  • Query params in URL stringfetch_message_metadata manually interpolates ?format=metadata&metadataHeaders=... into the URL. Per AGENTS.md, use reqwest .query() instead
  • No From header — replies/forwards omit From:. Gmail infers it, but this breaks with aliases/send-as. Consider a --from flag or document the limitation
  • Missing MIME headers — raw messages lack Content-Type: text/plain; charset=utf-8 and MIME-Version: 1.0, which can cause encoding issues with non-ASCII text
  • VisibilityReplyConfig and ForwardConfig are pub but only used within helpers::gmail; should be pub(super)
  • Mailbox parser — agree with your suggestion to replace the hand-rolled split_mailbox_list with an RFC 5322 crate (mailparse etc.) in a follow-up
  • CLAcla: no label is present, needs signing before merge

@codecov
Copy link

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 77.83559% with 213 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.73%. Comparing base (f6d74b0) to head (3ec8332).
⚠️ Report is 36 commits behind head on main.

Files with missing lines Patch % Lines
src/helpers/gmail/reply.rs 79.42% 137 Missing ⚠️
src/helpers/gmail/mod.rs 64.00% 54 Missing ⚠️
src/helpers/gmail/forward.rs 84.82% 22 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #105      +/-   ##
==========================================
+ Coverage   55.19%   56.73%   +1.54%     
==========================================
  Files          38       40       +2     
  Lines       13166    14127     +961     
==========================================
+ Hits         7267     8015     +748     
- Misses       5899     6112     +213     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: skills cla: no This human has *not* signed the Contributor License Agreement. complexity: high Large or complex change, careful review needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: first-class reply and forward support for Gmail

2 participants