feat(gmail): forward original attachments and preserve inline images (#589)

Include original message attachments on +forward by default, matching
Gmail web behavior. Add --no-original-attachments flag to opt out
(skips file attachments but preserves inline images in HTML mode).

Preserve cid: inline images in HTML mode for both +forward and
+reply/+reply-all by building the correct multipart/related MIME
structure via mail-builder's MimePart API. Gmail's API rewrites
Content-Disposition: inline to attachment in multipart/mixed, so
explicit multipart/related is required.

In plain-text mode, inline images are not included for both forward
and reply, matching Gmail web behavior.

Key implementation details:
- Single-pass MIME payload walker replaces separate text/html extractors
- OriginalPart metadata type with lazy attachment data fetching
- Part classification uses Content-Disposition to distinguish regular
  attachments from inline images (some clients set Content-ID on both)
- Content-ID and content_type sanitized against CRLF header injection
- Size preflight before downloading original attachments
- Remote filename sanitization (not rejection) for sender-controlled names
- Walker does not recurse into hydratable parts (e.g., message/rfc822)
This commit is contained in:
Malo Bourgon
2026-03-24 10:32:22 -07:00
committed by GitHub
parent 477f5d90db
commit e782dd70b5
8 changed files with 1233 additions and 84 deletions
@@ -0,0 +1,10 @@
---
"@googleworkspace/cli": minor
---
Forward original attachments by default and preserve inline images in HTML mode.
`+forward` now includes the original message's attachments and inline images by default,
matching Gmail web behavior. Use `--no-original-attachments` to opt out.
`+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via
`multipart/related`. In plain-text mode, inline images are not included (matching Gmail web).
+8 -2
View File
@@ -31,6 +31,7 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
| `--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) |
| `--no-original-attachments` | — | — | Do not include file attachments from the original message (inline images in --html mode are preserved) |
| `--attach` | — | — | Attach a file (can be specified multiple times) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
@@ -45,14 +46,19 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI se
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@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
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
```
## Tips
- Includes the original message with sender, date, subject, and recipients.
- Use -a/--attach to add file attachments. Can be specified multiple times.
- Original attachments are included by default (matching Gmail web behavior).
- With --html, inline images are also preserved via cid: references.
- In plain-text mode, inline images are not included (matching Gmail web).
- Use --no-original-attachments to forward without the original message's files.
- Use -a/--attach to add extra file attachments. Can be specified multiple times.
- Combined size of original and user attachments is limited to 25MB.
- 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
+1 -1
View File
@@ -58,7 +58,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p
- 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.
- With --html, inline images in the quoted message are preserved via cid: references.
## See Also
+1 -1
View File
@@ -54,7 +54,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.do
- --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.
- With --html, inline images in the quoted message are preserved via cid: references.
- For reply-all, use +reply-all instead.
## See Also
+221 -6
View File
@@ -23,21 +23,49 @@ pub(super) async fn handle_forward(
let dry_run = matches.get_flag("dry-run");
let (original, token) = if dry_run {
let (original, token, client) = if dry_run {
(
OriginalMessage::dry_run_placeholder(&config.message_id),
None,
None,
)
} else {
let t = auth::get_token(&[GMAIL_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
(orig, Some(t))
let c = crate::client::build_client()?;
let orig = fetch_message_metadata(&c, &t, &config.message_id).await?;
config.from = resolve_sender(&c, &t, config.from.as_deref()).await?;
(orig, Some(t), Some(c))
};
// Select which original parts to include:
// - --no-original-attachments: skip regular file attachments, but still
// include inline images in HTML mode (they're part of the body, not
// "attachments" in the UI sense)
// - Plain-text mode: drop inline images entirely (matching Gmail web)
// - HTML mode: include inline images (rendered via cid: in multipart/related)
let mut all_attachments = config.attachments;
if let (Some(client), Some(token)) = (&client, &token) {
let selected: Vec<_> = original
.parts
.iter()
.filter(|p| include_original_part(p, config.html, config.no_original_attachments))
.cloned()
.collect();
fetch_and_merge_original_parts(
client,
token,
&config.message_id,
&selected,
&mut all_attachments,
)
.await?;
} else {
eprintln!("Note: original attachments not included in dry-run preview");
}
let subject = build_forward_subject(&original.subject);
let refs = build_references_chain(&original);
let envelope = ForwardEnvelope {
@@ -54,7 +82,7 @@ pub(super) async fn handle_forward(
},
};
let raw = create_forward_raw_message(&envelope, &original, &config.attachments)?;
let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?;
super::send_raw_email(
doc,
@@ -66,6 +94,21 @@ pub(super) async fn handle_forward(
.await
}
/// Whether an original MIME part should be included when forwarding.
///
/// - Regular attachments are included unless `--no-original-attachments` is set.
/// - Inline images are included only in HTML mode (matching Gmail web, which
/// strips them from plain-text forwards).
fn include_original_part(part: &OriginalPart, html: bool, no_original_attachments: bool) -> bool {
if no_original_attachments && !part.is_inline() {
return false; // skip regular attachments when flag is set
}
if !html && part.is_inline() {
return false; // skip inline images in plain-text mode
}
true
}
// --- Data structures ---
pub(super) struct ForwardConfig {
@@ -77,6 +120,7 @@ pub(super) struct ForwardConfig {
pub body: Option<String>,
pub html: bool,
pub attachments: Vec<Attachment>,
pub no_original_attachments: bool,
}
struct ForwardEnvelope<'a> {
@@ -213,6 +257,7 @@ fn parse_forward_args(matches: &ArgMatches) -> Result<ForwardConfig, GwsError> {
body: parse_optional_trimmed(matches, "body"),
html: matches.get_flag("html"),
attachments: parse_attachments(matches)?,
no_original_attachments: matches.get_flag("no-original-attachments"),
})
}
@@ -460,6 +505,11 @@ mod tests {
Arg::new("dry-run")
.long("dry-run")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-original-attachments")
.long("no-original-attachments")
.action(ArgAction::SetTrue),
);
cmd.try_get_matches_from(args).unwrap()
}
@@ -474,6 +524,21 @@ mod tests {
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
assert!(config.body.is_none());
assert!(!config.no_original_attachments);
}
#[test]
fn test_parse_forward_args_no_original_attachments() {
let matches = make_forward_matches(&[
"test",
"--message-id",
"abc123",
"--to",
"dave@example.com",
"--no-original-attachments",
]);
let config = parse_forward_args(&matches).unwrap();
assert!(config.no_original_attachments);
}
#[test]
@@ -774,6 +839,7 @@ mod tests {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
}];
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
@@ -782,4 +848,153 @@ mod tests {
assert!(raw.contains("FYI, see attached"));
assert!(raw.contains("Forwarded message"));
}
#[test]
fn test_create_forward_raw_message_html_with_inline_image() {
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: "Photo".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "See photo".to_string(),
body_html: Some("<p>See <img src=\"cid:baby@example.com\"></p>".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: Photo",
body: None,
html: true,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
// Simulate original inline image + regular attachment
let attachments = vec![
Attachment {
filename: "baby.jpg".to_string(),
content_type: "image/jpeg".to_string(),
data: b"fake jpeg".to_vec(),
content_id: Some("baby@example.com".to_string()),
},
Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
},
];
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
// Should have multipart/mixed > multipart/related + attachment
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("multipart/related"));
assert!(raw.contains("Content-ID: <baby@example.com>"));
assert!(raw.contains("report.pdf"));
}
#[test]
fn test_create_forward_raw_message_plain_text_no_inline_images() {
// In plain-text mode, inline images are filtered out upstream by the
// handler (matching Gmail web, which strips them entirely). Only regular
// attachments reach create_forward_raw_message.
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: "Photo".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "See photo".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: Photo",
body: None,
html: false,
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
};
// Only regular attachment — inline images are filtered out by the handler
let attachments = vec![Attachment {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
}];
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
assert!(!raw.contains("multipart/related"));
assert!(raw.contains("multipart/mixed"));
assert!(raw.contains("report.pdf"));
// No inline images in plain-text forward
assert!(!raw.contains("Content-ID"));
}
// --- include_original_part filter matrix ---
fn make_part(inline: bool) -> OriginalPart {
OriginalPart {
filename: "test".to_string(),
content_type: "image/png".to_string(),
size: 100,
attachment_id: "ATT1".to_string(),
content_id: if inline {
Some("cid@example.com".to_string())
} else {
None
},
}
}
#[test]
fn test_include_original_part_default_html_includes_all() {
let regular = make_part(false);
let inline = make_part(true);
assert!(include_original_part(&regular, true, false));
assert!(include_original_part(&inline, true, false));
}
#[test]
fn test_include_original_part_default_plain_drops_inline() {
let regular = make_part(false);
let inline = make_part(true);
assert!(include_original_part(&regular, false, false));
assert!(!include_original_part(&inline, false, false));
}
#[test]
fn test_include_original_part_no_attachments_html_keeps_inline() {
let regular = make_part(false);
let inline = make_part(true);
// Key behavior: --no-original-attachments skips files but keeps inline images
assert!(!include_original_part(&regular, true, true));
assert!(include_original_part(&inline, true, true));
}
#[test]
fn test_include_original_part_no_attachments_plain_drops_everything() {
let regular = make_part(false);
let inline = make_part(true);
assert!(!include_original_part(&regular, false, true));
assert!(!include_original_part(&inline, false, true));
}
}
+917 -67
View File
File diff suppressed because it is too large Load Diff
+74 -7
View File
@@ -23,30 +23,31 @@ pub(super) async fn handle_reply(
let mut config = parse_reply_args(matches)?;
let dry_run = matches.get_flag("dry-run");
let (original, token, self_email) = if dry_run {
let (original, token, self_email, client) = if dry_run {
(
OriginalMessage::dry_run_placeholder(&config.message_id),
None,
None,
None,
)
} else {
let t = auth::get_token(&[GMAIL_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
let client = crate::client::build_client()?;
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
let c = crate::client::build_client()?;
let orig = fetch_message_metadata(&c, &t, &config.message_id).await?;
config.from = resolve_sender(&c, &t, config.from.as_deref()).await?;
// For reply-all, always fetch the primary email for self-dedup and
// self-reply detection. The resolved sender may be an alias that differs from the primary
// address — both must be excluded from recipients. from_alias_email
// (extracted from config.from below) handles the alias; self_email
// handles the primary.
let self_addr = if reply_all {
Some(fetch_user_email(&client, &t).await?)
Some(fetch_user_email(&c, &t).await?)
} else {
None
};
(orig, Some(t), self_addr)
(orig, Some(t), self_addr, Some(c))
};
let self_email = self_email.as_deref();
@@ -105,7 +106,29 @@ pub(super) async fn handle_reply(
html: config.html,
};
let raw = create_reply_raw_message(&envelope, &original, &config.attachments)?;
// Fetch inline images for HTML replies only. In plain-text mode, inline
// images are dropped entirely — matching Gmail web, which strips them from
// both plain-text replies and plain-text forwards.
let mut all_attachments = config.attachments;
if let (true, Some(client), Some(token)) = (config.html, &client, &token) {
let inline_parts: Vec<_> = original
.parts
.iter()
.filter(|p| p.is_inline())
.cloned()
.collect();
fetch_and_merge_original_parts(
client,
token,
&config.message_id,
&inline_parts,
&mut all_attachments,
)
.await?;
}
let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?;
super::send_raw_email(
doc,
@@ -1499,6 +1522,7 @@ mod tests {
filename: "notes.txt".to_string(),
content_type: "text/plain".to_string(),
data: b"some notes".to_vec(),
content_id: None,
}];
let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap();
@@ -1507,4 +1531,47 @@ mod tests {
assert!(raw.contains("See attached notes"));
assert!(raw.contains("> Original body"));
}
#[test]
fn test_create_reply_raw_message_html_with_inline_image() {
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: "Photo".to_string(),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: "See photo".to_string(),
body_html: Some("<p>See <img src=\"cid:photo@example.com\"></p>".to_string()),
..Default::default()
};
let refs = build_references_chain(&original);
let to = vec![Mailbox::parse("alice@example.com")];
let envelope = ReplyEnvelope {
to: &to,
cc: None,
bcc: None,
from: None,
subject: "Re: Photo",
threading: ThreadingHeaders {
in_reply_to: &original.message_id,
references: &refs,
},
body: "Nice photo!",
html: true,
};
let attachments = vec![Attachment {
filename: "photo.png".to_string(),
content_type: "image/png".to_string(),
data: vec![0x89, 0x50],
content_id: Some("photo@example.com".to_string()),
}];
let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap();
// Should produce multipart/related for inline image in HTML reply
assert!(raw.contains("multipart/related"));
assert!(raw.contains("Content-ID: <photo@example.com>"));
assert!(!raw.contains("multipart/mixed"));
}
}
+1
View File
@@ -445,6 +445,7 @@ mod tests {
filename: "report.pdf".to_string(),
content_type: "application/pdf".to_string(),
data: b"fake pdf".to_vec(),
content_id: None,
}],
};
let raw = create_send_raw_message(&config).unwrap();