Skip to content

Commit e782dd7

Browse files
authored
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)
1 parent 477f5d9 commit e782dd7

File tree

8 files changed

+1228
-79
lines changed

8 files changed

+1228
-79
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Forward original attachments by default and preserve inline images in HTML mode.
6+
7+
`+forward` now includes the original message's attachments and inline images by default,
8+
matching Gmail web behavior. Use `--no-original-attachments` to opt out.
9+
`+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via
10+
`multipart/related`. In plain-text mode, inline images are not included (matching Gmail web).

skills/gws-gmail-forward/SKILL.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
3131
| `--to` ||| Recipient email address(es), comma-separated |
3232
| `--from` ||| Sender address (for send-as/alias; omit to use account default) |
3333
| `--body` ||| Optional note to include above the forwarded message (plain text, or HTML with --html) |
34+
| `--no-original-attachments` ||| Do not include file attachments from the original message (inline images in --html mode are preserved) |
3435
| `--attach` ||| Attach a file (can be specified multiple times) |
3536
| `--cc` ||| CC email address(es), comma-separated |
3637
| `--bcc` ||| BCC email address(es), comma-separated |
@@ -45,14 +46,19 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI se
4546
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
4647
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
4748
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf
49+
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments
4850
```
4951

5052
## Tips
5153

5254
- Includes the original message with sender, date, subject, and recipients.
53-
- Use -a/--attach to add file attachments. Can be specified multiple times.
55+
- Original attachments are included by default (matching Gmail web behavior).
56+
- With --html, inline images are also preserved via cid: references.
57+
- In plain-text mode, inline images are not included (matching Gmail web).
58+
- Use --no-original-attachments to forward without the original message's files.
59+
- Use -a/--attach to add extra file attachments. Can be specified multiple times.
60+
- Combined size of original and user attachments is limited to 25MB.
5461
- 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.
55-
- With --html, inline images in the forwarded message (cid: references) will appear broken. Externally hosted images are unaffected.
5662

5763
## See Also
5864

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.p
5858
- The command fails if no To recipient remains after exclusions and --to additions.
5959
- Use -a/--attach to add file attachments. Can be specified multiple times.
6060
- 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.
61-
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
61+
- With --html, inline images in the quoted message are preserved via cid: references.
6262

6363
## See Also
6464

skills/gws-gmail-reply/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.do
5454
- --to adds extra recipients to the To field.
5555
- Use -a/--attach to add file attachments. Can be specified multiple times.
5656
- 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.
57-
- With --html, inline images in the quoted message (cid: references) will appear broken. Externally hosted images are unaffected.
57+
- With --html, inline images in the quoted message are preserved via cid: references.
5858
- For reply-all, use +reply-all instead.
5959

6060
## See Also

src/helpers/gmail/forward.rs

Lines changed: 221 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,49 @@ pub(super) async fn handle_forward(
2323

2424
let dry_run = matches.get_flag("dry-run");
2525

26-
let (original, token) = if dry_run {
26+
let (original, token, client) = if dry_run {
2727
(
2828
OriginalMessage::dry_run_placeholder(&config.message_id),
2929
None,
30+
None,
3031
)
3132
} else {
3233
let t = auth::get_token(&[GMAIL_SCOPE])
3334
.await
3435
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
35-
let client = crate::client::build_client()?;
36-
let orig = fetch_message_metadata(&client, &t, &config.message_id).await?;
37-
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
38-
(orig, Some(t))
36+
let c = crate::client::build_client()?;
37+
let orig = fetch_message_metadata(&c, &t, &config.message_id).await?;
38+
config.from = resolve_sender(&c, &t, config.from.as_deref()).await?;
39+
(orig, Some(t), Some(c))
3940
};
4041

42+
// Select which original parts to include:
43+
// - --no-original-attachments: skip regular file attachments, but still
44+
// include inline images in HTML mode (they're part of the body, not
45+
// "attachments" in the UI sense)
46+
// - Plain-text mode: drop inline images entirely (matching Gmail web)
47+
// - HTML mode: include inline images (rendered via cid: in multipart/related)
48+
let mut all_attachments = config.attachments;
49+
if let (Some(client), Some(token)) = (&client, &token) {
50+
let selected: Vec<_> = original
51+
.parts
52+
.iter()
53+
.filter(|p| include_original_part(p, config.html, config.no_original_attachments))
54+
.cloned()
55+
.collect();
56+
57+
fetch_and_merge_original_parts(
58+
client,
59+
token,
60+
&config.message_id,
61+
&selected,
62+
&mut all_attachments,
63+
)
64+
.await?;
65+
} else {
66+
eprintln!("Note: original attachments not included in dry-run preview");
67+
}
68+
4169
let subject = build_forward_subject(&original.subject);
4270
let refs = build_references_chain(&original);
4371
let envelope = ForwardEnvelope {
@@ -54,7 +82,7 @@ pub(super) async fn handle_forward(
5482
},
5583
};
5684

57-
let raw = create_forward_raw_message(&envelope, &original, &config.attachments)?;
85+
let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?;
5886

5987
super::send_raw_email(
6088
doc,
@@ -66,6 +94,21 @@ pub(super) async fn handle_forward(
6694
.await
6795
}
6896

97+
/// Whether an original MIME part should be included when forwarding.
98+
///
99+
/// - Regular attachments are included unless `--no-original-attachments` is set.
100+
/// - Inline images are included only in HTML mode (matching Gmail web, which
101+
/// strips them from plain-text forwards).
102+
fn include_original_part(part: &OriginalPart, html: bool, no_original_attachments: bool) -> bool {
103+
if no_original_attachments && !part.is_inline() {
104+
return false; // skip regular attachments when flag is set
105+
}
106+
if !html && part.is_inline() {
107+
return false; // skip inline images in plain-text mode
108+
}
109+
true
110+
}
111+
69112
// --- Data structures ---
70113

71114
pub(super) struct ForwardConfig {
@@ -77,6 +120,7 @@ pub(super) struct ForwardConfig {
77120
pub body: Option<String>,
78121
pub html: bool,
79122
pub attachments: Vec<Attachment>,
123+
pub no_original_attachments: bool,
80124
}
81125

82126
struct ForwardEnvelope<'a> {
@@ -213,6 +257,7 @@ fn parse_forward_args(matches: &ArgMatches) -> Result<ForwardConfig, GwsError> {
213257
body: parse_optional_trimmed(matches, "body"),
214258
html: matches.get_flag("html"),
215259
attachments: parse_attachments(matches)?,
260+
no_original_attachments: matches.get_flag("no-original-attachments"),
216261
})
217262
}
218263

@@ -460,6 +505,11 @@ mod tests {
460505
Arg::new("dry-run")
461506
.long("dry-run")
462507
.action(ArgAction::SetTrue),
508+
)
509+
.arg(
510+
Arg::new("no-original-attachments")
511+
.long("no-original-attachments")
512+
.action(ArgAction::SetTrue),
463513
);
464514
cmd.try_get_matches_from(args).unwrap()
465515
}
@@ -474,6 +524,21 @@ mod tests {
474524
assert!(config.cc.is_none());
475525
assert!(config.bcc.is_none());
476526
assert!(config.body.is_none());
527+
assert!(!config.no_original_attachments);
528+
}
529+
530+
#[test]
531+
fn test_parse_forward_args_no_original_attachments() {
532+
let matches = make_forward_matches(&[
533+
"test",
534+
"--message-id",
535+
"abc123",
536+
"--to",
537+
"dave@example.com",
538+
"--no-original-attachments",
539+
]);
540+
let config = parse_forward_args(&matches).unwrap();
541+
assert!(config.no_original_attachments);
477542
}
478543

479544
#[test]
@@ -774,6 +839,7 @@ mod tests {
774839
filename: "report.pdf".to_string(),
775840
content_type: "application/pdf".to_string(),
776841
data: b"fake pdf".to_vec(),
842+
content_id: None,
777843
}];
778844
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
779845

@@ -782,4 +848,153 @@ mod tests {
782848
assert!(raw.contains("FYI, see attached"));
783849
assert!(raw.contains("Forwarded message"));
784850
}
851+
852+
#[test]
853+
fn test_create_forward_raw_message_html_with_inline_image() {
854+
let original = OriginalMessage {
855+
thread_id: Some("t1".to_string()),
856+
message_id: "abc@example.com".to_string(),
857+
from: Mailbox::parse("alice@example.com"),
858+
to: vec![Mailbox::parse("bob@example.com")],
859+
subject: "Photo".to_string(),
860+
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
861+
body_text: "See photo".to_string(),
862+
body_html: Some("<p>See <img src=\"cid:baby@example.com\"></p>".to_string()),
863+
..Default::default()
864+
};
865+
866+
let refs = build_references_chain(&original);
867+
let to = Mailbox::parse_list("dave@example.com");
868+
let envelope = ForwardEnvelope {
869+
to: &to,
870+
cc: None,
871+
bcc: None,
872+
from: None,
873+
subject: "Fwd: Photo",
874+
body: None,
875+
html: true,
876+
threading: ThreadingHeaders {
877+
in_reply_to: &original.message_id,
878+
references: &refs,
879+
},
880+
};
881+
// Simulate original inline image + regular attachment
882+
let attachments = vec![
883+
Attachment {
884+
filename: "baby.jpg".to_string(),
885+
content_type: "image/jpeg".to_string(),
886+
data: b"fake jpeg".to_vec(),
887+
content_id: Some("baby@example.com".to_string()),
888+
},
889+
Attachment {
890+
filename: "report.pdf".to_string(),
891+
content_type: "application/pdf".to_string(),
892+
data: b"fake pdf".to_vec(),
893+
content_id: None,
894+
},
895+
];
896+
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
897+
898+
// Should have multipart/mixed > multipart/related + attachment
899+
assert!(raw.contains("multipart/mixed"));
900+
assert!(raw.contains("multipart/related"));
901+
assert!(raw.contains("Content-ID: <baby@example.com>"));
902+
assert!(raw.contains("report.pdf"));
903+
}
904+
905+
#[test]
906+
fn test_create_forward_raw_message_plain_text_no_inline_images() {
907+
// In plain-text mode, inline images are filtered out upstream by the
908+
// handler (matching Gmail web, which strips them entirely). Only regular
909+
// attachments reach create_forward_raw_message.
910+
let original = OriginalMessage {
911+
thread_id: Some("t1".to_string()),
912+
message_id: "abc@example.com".to_string(),
913+
from: Mailbox::parse("alice@example.com"),
914+
to: vec![Mailbox::parse("bob@example.com")],
915+
subject: "Photo".to_string(),
916+
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
917+
body_text: "See photo".to_string(),
918+
..Default::default()
919+
};
920+
921+
let refs = build_references_chain(&original);
922+
let to = Mailbox::parse_list("dave@example.com");
923+
let envelope = ForwardEnvelope {
924+
to: &to,
925+
cc: None,
926+
bcc: None,
927+
from: None,
928+
subject: "Fwd: Photo",
929+
body: None,
930+
html: false,
931+
threading: ThreadingHeaders {
932+
in_reply_to: &original.message_id,
933+
references: &refs,
934+
},
935+
};
936+
// Only regular attachment — inline images are filtered out by the handler
937+
let attachments = vec![Attachment {
938+
filename: "report.pdf".to_string(),
939+
content_type: "application/pdf".to_string(),
940+
data: b"fake pdf".to_vec(),
941+
content_id: None,
942+
}];
943+
let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap();
944+
945+
assert!(!raw.contains("multipart/related"));
946+
assert!(raw.contains("multipart/mixed"));
947+
assert!(raw.contains("report.pdf"));
948+
// No inline images in plain-text forward
949+
assert!(!raw.contains("Content-ID"));
950+
}
951+
952+
// --- include_original_part filter matrix ---
953+
954+
fn make_part(inline: bool) -> OriginalPart {
955+
OriginalPart {
956+
filename: "test".to_string(),
957+
content_type: "image/png".to_string(),
958+
size: 100,
959+
attachment_id: "ATT1".to_string(),
960+
content_id: if inline {
961+
Some("cid@example.com".to_string())
962+
} else {
963+
None
964+
},
965+
}
966+
}
967+
968+
#[test]
969+
fn test_include_original_part_default_html_includes_all() {
970+
let regular = make_part(false);
971+
let inline = make_part(true);
972+
assert!(include_original_part(&regular, true, false));
973+
assert!(include_original_part(&inline, true, false));
974+
}
975+
976+
#[test]
977+
fn test_include_original_part_default_plain_drops_inline() {
978+
let regular = make_part(false);
979+
let inline = make_part(true);
980+
assert!(include_original_part(&regular, false, false));
981+
assert!(!include_original_part(&inline, false, false));
982+
}
983+
984+
#[test]
985+
fn test_include_original_part_no_attachments_html_keeps_inline() {
986+
let regular = make_part(false);
987+
let inline = make_part(true);
988+
// Key behavior: --no-original-attachments skips files but keeps inline images
989+
assert!(!include_original_part(&regular, true, true));
990+
assert!(include_original_part(&inline, true, true));
991+
}
992+
993+
#[test]
994+
fn test_include_original_part_no_attachments_plain_drops_everything() {
995+
let regular = make_part(false);
996+
let inline = make_part(true);
997+
assert!(!include_original_part(&regular, false, true));
998+
assert!(!include_original_part(&inline, false, true));
999+
}
7851000
}

0 commit comments

Comments
 (0)