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:
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(®ular, 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(®ular, 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(®ular, 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(®ular, false, true));
|
||||
assert!(!include_original_part(&inline, false, true));
|
||||
}
|
||||
}
|
||||
|
||||
+917
-67
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user