feat(gmail): add --html flag for HTML email composition (#417)

Add --html flag to +send, +reply, +reply-all, and +forward, enabling
HTML email composition. When set, --body is treated as HTML content and
the Content-Type switches from text/plain to text/html.

For replies and forwards, the quoted/forwarded block matches Gmail web
UI fidelity: gmail_quote_container class, gmail_sendername structure,
mailto links in attribution and metadata, <div dir="ltr"> wrapper on
quoted content, and RFC 2822 dates reformatted to Gmail's human-friendly
style. When the original message has no HTML body, plain text is
HTML-escaped with <br> line breaks as a fallback (with a stderr
diagnostic).
This commit is contained in:
Malo Bourgon
2026-03-12 12:09:15 -04:00
committed by GitHub
parent 2df32eed1b
commit 9d937af8af
9 changed files with 1183 additions and 223 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---
Add `--html` flag to `+send`, `+reply`, `+reply-all`, and `+forward` for HTML email composition.
+13 -9
View File
@@ -24,15 +24,16 @@ gws gmail +forward --message-id <ID> --to <EMAILS>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to forward |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--body` | — | — | Optional note to include above the forwarded message |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| Flag | Required | Default | Description |
| -------------- | -------- | ------- | ----------------------------------------------------------------------------- |
| `--message-id` | ✓ | — | Gmail message ID to forward |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--body` | — | — | Optional note above the forwarded message (plain text, or HTML with `--html`) |
| `--html` | — | — | Treat `--body` as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -41,11 +42,14 @@ gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
```
## Tips
- Includes the original message with sender, date, subject, and recipients.
- With `--html`, the forwarded block uses Gmail's `gmail_quote` CSS classes and preserves the original message's HTML formatting. Use HTML fragment tags (`<p>`, `<b>`, `<a>`, etc.) — no `<html>`/`<body>` wrapper needed.
- With `--html`, inline images embedded in the forwarded message (`cid:` references) will appear broken. Externally hosted images are unaffected.
## See Also
+19 -15
View File
@@ -24,16 +24,17 @@ gws gmail +reply-all --message-id <ID> --body <TEXT>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| Flag | Required | Default | Description |
| -------------- | -------- | ------- | ------------------------------------------------------------------- |
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text, or HTML if `--html` is set) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--remove` | — | — | Exclude recipients from the outgoing reply (comma-separated emails) |
| `--html` | — | — | Treat `--body` as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -43,16 +44,19 @@ gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@exam
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
```
## Tips
- Replies to the sender and all original To/CC recipients.
- Use --to to add extra recipients to the To field.
- Use --cc to add new CC recipients.
- Use --bcc for recipients who should not be visible to others.
- Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target.
- The command fails if no To recipient remains after exclusions and --to additions.
- Use `--to` to add extra recipients to the To field.
- Use `--cc` to add new CC recipients.
- Use `--bcc` for recipients who should not be visible to others.
- Use `--remove` to exclude recipients from the outgoing reply, including the sender or Reply-To target.
- The command fails if no To recipient remains after exclusions and `--to` additions.
- With `--html`, the quoted block uses Gmail's `gmail_quote` CSS classes and preserves the original message's HTML formatting. Use HTML fragment tags (`<p>`, `<b>`, `<a>`, etc.) — no `<html>`/`<body>` wrapper needed.
- With `--html`, inline images embedded in the quoted message (`cid:` references) will appear broken. Externally hosted images are unaffected.
## See Also
+15 -11
View File
@@ -24,15 +24,16 @@ gws gmail +reply --message-id <ID> --body <TEXT>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| Flag | Required | Default | Description |
| -------------- | -------- | ------- | --------------------------------------------------------------- |
| `--message-id` | ✓ | — | Gmail message ID to reply to |
| `--body` | ✓ | — | Reply body (plain text, or HTML if `--html` is set) |
| `--from` | — | — | Sender address (for send-as/alias; omit to use account default) |
| `--to` | — | — | Additional To email address(es), comma-separated |
| `--cc` | — | — | Additional CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--html` | — | — | Treat `--body` as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -41,14 +42,17 @@ gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!'
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
```
## Tips
- Automatically sets In-Reply-To, References, and threadId headers.
- Quotes the original message in the reply body.
- --to adds extra recipients to the To field.
- For reply-all, use +reply-all instead.
- With `--html`, the quoted block uses Gmail's `gmail_quote` CSS classes and preserves the original message's HTML formatting. Use HTML fragment tags (`<p>`, `<b>`, `<a>`, etc.) — no `<html>`/`<body>` wrapper needed.
- With `--html`, inline images embedded in the quoted message (`cid:` references) will appear broken. Externally hosted images are unaffected.
- `--to` adds extra recipients to the To field.
- For reply-all, use `+reply-all` instead.
## See Also
+12 -9
View File
@@ -24,14 +24,15 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--subject` | ✓ | — | Email subject |
| `--body` | ✓ | — | Email body (plain text) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
| Flag | Required | Default | Description |
| ----------- | -------- | ------- | -------------------------------------------------------- |
| `--to` | ✓ | — | Recipient email address(es), comma-separated |
| `--subject` | ✓ | — | Email subject |
| `--body` | ✓ | — | Email body (plain text, or HTML if `--html` is set) |
| `--cc` | — | — | CC email address(es), comma-separated |
| `--bcc` | — | — | BCC email address(es), comma-separated |
| `--html` | — | — | Treat `--body` as HTML content (default is plain text) |
| `--dry-run` | — | — | Show the request that would be sent without executing it |
## Examples
@@ -39,12 +40,14 @@ gws gmail +send --to <EMAILS> --subject <SUBJECT> --body <TEXT>
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!'
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> text' --html
```
## Tips
- Handles RFC 2822 formatting and base64 encoding automatically.
- For HTML bodies or attachments, use the raw API instead: gws gmail users messages send --json '...'
- With `--html`, the `--body` value should be HTML content, not a full document. Use tags like `<p>`, `<b>`, `<i>`, `<a href="...">`, `<br>`, `<ul>/<ol>/<li>`, `<table>`. No need for `<html>`/`<head>`/`<body>` wrappers.
- For attachments, use the raw API instead: gws gmail users messages send --json '...'
> [!CAUTION]
> This is a **write** command — confirm with the user before executing.
+279 -8
View File
@@ -43,7 +43,8 @@ pub(super) async fn handle_forward(
bcc: config.bcc.as_deref(),
from: config.from.as_deref(),
subject: &subject,
body: config.body_text.as_deref(),
body: config.body.as_deref(),
html: config.html,
};
let raw = create_forward_raw_message(&envelope, &original);
@@ -65,7 +66,8 @@ pub(super) struct ForwardConfig {
pub from: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub body_text: Option<String>,
pub body: Option<String>,
pub html: bool,
}
struct ForwardEnvelope<'a> {
@@ -74,7 +76,8 @@ struct ForwardEnvelope<'a> {
bcc: Option<&'a str>,
from: Option<&'a str>,
subject: &'a str,
body: Option<&'a str>,
body: Option<&'a str>, // Optional user note above forwarded block
html: bool,
}
// --- Message construction ---
@@ -99,11 +102,16 @@ fn create_forward_raw_message(envelope: &ForwardEnvelope, original: &OriginalMes
in_reply_to: &original.message_id_header,
references: &references,
}),
html: envelope.html,
};
let forwarded_block = format_forwarded_message(original);
let (forwarded_block, separator) = if envelope.html {
(format_forwarded_message_html(original), "<br>\r\n")
} else {
(format_forwarded_message(original), "\r\n\r\n")
};
let body = match envelope.body {
Some(note) => format!("{}\r\n\r\n{}", note, forwarded_block),
Some(note) => format!("{}{}{}", note, separator, forwarded_block),
None => forwarded_block,
};
@@ -132,6 +140,40 @@ fn format_forwarded_message(original: &OriginalMessage) -> String {
)
}
fn format_forwarded_message_html(original: &OriginalMessage) -> String {
let cc_line = if original.cc.is_empty() {
String::new()
} else {
format!("Cc: {}<br>", format_address_list_with_links(&original.cc))
};
let body = resolve_html_body(original);
let date = format_date_for_attribution(&original.date);
let from = format_forward_from(&original.from);
let to = format_address_list_with_links(&original.to);
format!(
"<div class=\"gmail_quote gmail_quote_container\">\
<div dir=\"ltr\" class=\"gmail_attr\">\
---------- Forwarded message ---------<br>\
From: {}<br>\
Date: {}<br>\
Subject: {}<br>\
To: {}<br>\
{}\
</div>\
<br><br>\
{}\
</div>",
from,
date,
html_escape(&original.subject),
to,
cc_line,
body,
)
}
// --- Argument parsing ---
fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
@@ -141,7 +183,8 @@ fn parse_forward_args(matches: &ArgMatches) -> ForwardConfig {
from: parse_optional_trimmed(matches, "from"),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
body_text: matches.get_one::<String>("body").map(|s| s.to_string()),
body: parse_optional_trimmed(matches, "body"),
html: matches.get_flag("html"),
}
}
@@ -177,6 +220,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
body_html: None,
};
let envelope = ForwardEnvelope {
@@ -186,6 +230,7 @@ mod tests {
from: None,
subject: "Fwd: Hello",
body: None,
html: false,
};
let raw = create_forward_raw_message(&envelope, &original);
@@ -214,6 +259,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
body_html: None,
};
let envelope = ForwardEnvelope {
@@ -223,6 +269,7 @@ mod tests {
from: Some("alias@example.com"),
subject: "Fwd: Hello",
body: Some("FYI see below"),
html: false,
};
let raw = create_forward_raw_message(&envelope, &original);
@@ -246,6 +293,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
body_html: None,
};
let envelope = ForwardEnvelope {
@@ -255,6 +303,7 @@ mod tests {
from: None,
subject: "Fwd: Hello",
body: None,
html: false,
};
let raw = create_forward_raw_message(&envelope, &original);
@@ -272,6 +321,7 @@ mod tests {
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -289,7 +339,7 @@ mod tests {
assert_eq!(config.to, "dave@example.com");
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
assert!(config.body_text.is_none());
assert!(config.body.is_none());
}
#[test]
@@ -310,7 +360,7 @@ mod tests {
let config = parse_forward_args(&matches);
assert_eq!(config.cc.unwrap(), "eve@example.com");
assert_eq!(config.bcc.unwrap(), "secret@example.com");
assert_eq!(config.body_text.unwrap(), "FYI");
assert_eq!(config.body.unwrap(), "FYI");
// Whitespace-only values become None
let matches = make_forward_matches(&[
@@ -328,4 +378,225 @@ mod tests {
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_forward_args_html_flag() {
let matches = make_forward_matches(&[
"test",
"--message-id",
"abc123",
"--to",
"dave@example.com",
"--html",
]);
let config = parse_forward_args(&matches);
assert!(config.html);
// Default is false
let matches =
make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]);
let config = parse_forward_args(&matches);
assert!(!config.html);
}
// --- HTML mode tests ---
#[test]
fn test_format_forwarded_message_html_with_html_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
body_text: "plain fallback".to_string(),
body_html: Some("<p>Rich <b>content</b></p>".to_string()),
};
let html = format_forwarded_message_html(&original);
assert!(html.contains("gmail_quote"));
assert!(html.contains("Forwarded message"));
assert!(html.contains("<p>Rich <b>content</b></p>"));
assert!(!html.contains("plain fallback"));
// No blockquote in forwards (unlike replies)
assert!(!html.contains("<blockquote"));
}
#[test]
fn test_format_forwarded_message_html_fallback_plain_text() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
body_text: "Line one & <stuff>\nLine two".to_string(),
body_html: None,
};
let html = format_forwarded_message_html(&original);
assert!(html.contains("Line one &amp; &lt;stuff&gt;<br>"));
assert!(html.contains("Line two"));
}
#[test]
fn test_format_forwarded_message_html_escapes_metadata() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "Tom & Jerry <tj@example.com>".to_string(),
reply_to: "".to_string(),
to: "<alice@example.com>".to_string(),
cc: "".to_string(),
subject: "A < B & C".to_string(),
date: "Jan 1 <2026>".to_string(),
body_text: "text".to_string(),
body_html: None,
};
let html = format_forwarded_message_html(&original);
// From line: display name in <strong>, email in mailto link
assert!(html.contains("Tom &amp; Jerry"));
assert!(html.contains("<a href=\"mailto:tj@example.com\">tj@example.com</a>"));
// To line: email wrapped in mailto link
assert!(html.contains("<a href=\"mailto:alice@example.com\">"));
assert!(html.contains("A &lt; B &amp; C"));
// Non-RFC-2822 date falls back to html-escaped raw string
assert!(html.contains("Jan 1 &lt;2026&gt;"));
}
#[test]
fn test_format_forwarded_message_html_conditional_cc() {
let with_cc = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "carol@example.com".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
body_text: "text".to_string(),
body_html: None,
};
let html = format_forwarded_message_html(&with_cc);
assert!(html.contains("Cc: <a href=\"mailto:carol@example.com\">carol@example.com</a>"));
let without_cc = OriginalMessage {
cc: "".to_string(),
..with_cc
};
let html = format_forwarded_message_html(&without_cc);
assert!(!html.contains("Cc:"));
}
#[test]
fn test_create_forward_raw_message_html_without_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
body_html: Some("<p>Original</p>".to_string()),
};
let envelope = ForwardEnvelope {
to: "dave@example.com",
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: None,
html: true,
};
let raw = create_forward_raw_message(&envelope, &original);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("gmail_quote"));
assert!(raw.contains("Forwarded message"));
assert!(raw.contains("<p>Original</p>"));
// No user note — forwarded block is the entire body
assert!(!raw.contains("<p>FYI</p>"));
}
#[test]
fn test_create_forward_raw_message_html_plain_text_fallback() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Plain & simple".to_string(),
body_html: None,
};
let envelope = ForwardEnvelope {
to: "dave@example.com",
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: Some("<p>FYI</p>"),
html: true,
};
let raw = create_forward_raw_message(&envelope, &original);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("<p>FYI</p><br>\r\n<div class=\"gmail_quote gmail_quote_container\">"));
// Plain text body is HTML-escaped in the fallback
assert!(raw.contains("Plain &amp; simple"));
}
#[test]
fn test_create_forward_raw_message_html() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original content".to_string(),
body_html: Some("<p>Original</p>".to_string()),
};
let envelope = ForwardEnvelope {
to: "dave@example.com",
cc: None,
bcc: None,
from: None,
subject: "Fwd: Hello",
body: Some("<p>FYI</p>"),
html: true,
};
let raw = create_forward_raw_message(&envelope, &original);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("<p>FYI</p>"));
assert!(raw.contains("gmail_quote"));
assert!(raw.contains("Forwarded message"));
assert!(raw.contains("<p>Original</p>"));
// HTML separator: <br> between note and forwarded block (not \r\n\r\n)
assert!(raw.contains("<p>FYI</p><br>\r\n<div class=\"gmail_quote gmail_quote_container\">"));
}
}
+561 -16
View File
@@ -52,6 +52,7 @@ pub(super) struct OriginalMessage {
pub subject: String,
pub date: String,
pub body_text: String,
pub body_html: Option<String>,
}
impl OriginalMessage {
@@ -68,6 +69,7 @@ impl OriginalMessage {
subject: "Original subject".to_string(),
date: "Thu, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original message body".to_string(),
body_html: Some("<p>Original message body</p>".to_string()),
}
}
}
@@ -150,6 +152,8 @@ fn parse_original_message(msg: &Value) -> OriginalMessage {
.and_then(extract_plain_text_body)
.unwrap_or(snippet);
let body_html = msg.get("payload").and_then(extract_html_body);
OriginalMessage {
thread_id,
message_id_header: parsed_headers.message_id_header,
@@ -161,6 +165,7 @@ fn parse_original_message(msg: &Value) -> OriginalMessage {
subject: parsed_headers.subject,
date: parsed_headers.date,
body_text,
body_html,
}
}
@@ -202,20 +207,28 @@ pub(super) async fn fetch_message_metadata(
Ok(parse_original_message(&msg))
}
fn extract_plain_text_body(payload: &Value) -> Option<String> {
fn extract_body_by_mime(payload: &Value, target_mime: &str) -> Option<String> {
let mime_type = payload
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("");
if mime_type == "text/plain" {
if mime_type == target_mime {
if let Some(data) = payload
.get("body")
.and_then(|b| b.get("data"))
.and_then(|d| d.as_str())
{
if let Ok(decoded) = URL_SAFE.decode(data) {
return String::from_utf8(decoded).ok();
match URL_SAFE.decode(data) {
Ok(decoded) => match String::from_utf8(decoded) {
Ok(s) => return Some(s),
Err(e) => {
eprintln!("Warning: {target_mime} body is not valid UTF-8: {e}");
}
},
Err(e) => {
eprintln!("Warning: {target_mime} body has invalid base64: {e}");
}
}
}
return None;
@@ -223,8 +236,8 @@ fn extract_plain_text_body(payload: &Value) -> Option<String> {
if let Some(parts) = payload.get("parts").and_then(|p| p.as_array()) {
for part in parts {
if let Some(text) = extract_plain_text_body(part) {
return Some(text);
if let Some(body) = extract_body_by_mime(part, target_mime) {
return Some(body);
}
}
}
@@ -232,6 +245,169 @@ fn extract_plain_text_body(payload: &Value) -> Option<String> {
None
}
fn extract_plain_text_body(payload: &Value) -> Option<String> {
extract_body_by_mime(payload, "text/plain")
}
fn extract_html_body(payload: &Value) -> Option<String> {
extract_body_by_mime(payload, "text/html")
}
/// Resolve the HTML body for quoting or forwarding: use the original HTML
/// body if available, otherwise escape the plain text and convert newlines
/// to `<br>` tags.
pub(super) fn resolve_html_body(original: &OriginalMessage) -> String {
match &original.body_html {
Some(html) => html.clone(),
None => {
eprintln!("Note: original message has no HTML body; plain text was converted to HTML.");
html_escape(&original.body_text)
.lines()
.collect::<Vec<_>>()
.join("<br>\r\n")
}
}
}
/// Escape `&`, `<`, `>`, `"`, `'` for safe embedding in HTML.
pub(super) fn html_escape(text: &str) -> String {
// `&` must be replaced first to avoid double-escaping the other replacements.
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Extract the bare email address from a header value like
/// `"Alice <alice@example.com>"` → `"alice@example.com"` or
/// `"alice@example.com"` → `"alice@example.com"`.
pub(super) fn extract_email(addr: &str) -> &str {
if let Some(start) = addr.rfind('<') {
if let Some(end) = addr[start..].find('>') {
return &addr[start + 1..start + end];
}
}
addr.trim()
}
/// Extract the display name from a header value like
/// `"Alice Smith <alice@example.com>"` → `"Alice Smith"`.
/// Returns the trimmed address itself when no angle-bracket name is present.
pub(super) fn extract_display_name(addr: &str) -> &str {
if let Some(start) = addr.rfind('<') {
let name = addr[..start].trim();
if !name.is_empty() {
// Strip surrounding quotes if present: "Alice Smith" → Alice Smith
return name
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(name);
}
}
addr.trim()
}
/// Split an RFC 5322 mailbox list on commas, respecting quoted strings.
/// `"Doe, John" <john@example.com>, alice@example.com` →
/// `["\"Doe, John\" <john@example.com>", "alice@example.com"]`
pub(super) fn split_mailbox_list(header: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut in_quotes = false;
let mut start = 0;
let mut prev_backslash = false;
for (i, ch) in header.char_indices() {
match ch {
'\\' if in_quotes => {
prev_backslash = !prev_backslash;
continue;
}
'"' if !prev_backslash => in_quotes = !in_quotes,
',' if !in_quotes => {
let token = header[start..i].trim();
if !token.is_empty() {
result.push(token);
}
start = i + 1;
}
_ => {}
}
prev_backslash = false;
}
let token = header[start..].trim();
if !token.is_empty() {
result.push(token);
}
result
}
/// Wrap an email address in an HTML mailto link: `<a href="mailto:e">e</a>`.
pub(super) fn format_email_link(email: &str) -> String {
let escaped = html_escape(email);
format!("<a href=\"mailto:{escaped}\">{escaped}</a>")
}
/// Format an address for the reply attribution line with a mailto link.
/// `"Alice <alice@example.com>"` →
/// `Alice &lt;<a href="mailto:alice@example.com">alice@example.com</a>&gt;`
pub(super) fn format_sender_for_attribution(addr: &str) -> String {
let email = extract_email(addr);
let display = extract_display_name(addr);
if email == display {
// Bare email address — just wrap in mailto
format_email_link(email)
} else {
// "Name <email>" — show name then linked email in angle brackets
format!(
"{} &lt;{}&gt;",
html_escape(display),
format_email_link(email),
)
}
}
/// Format a comma-separated address list with mailto links on each address.
/// Used for forward To/CC fields and reply attribution.
pub(super) fn format_address_list_with_links(addrs: &str) -> String {
if addrs.is_empty() {
return String::new();
}
split_mailbox_list(addrs)
.into_iter()
.map(format_sender_for_attribution)
.collect::<Vec<_>>()
.join(", ")
}
/// Reformat an RFC 2822 date to Gmail's human-friendly attribution style:
/// `"Wed, Mar 4, 2026 at 3:01\u{202f}PM"`. Falls back to the raw date
/// (HTML-escaped) if chrono cannot parse it.
pub(super) fn format_date_for_attribution(raw_date: &str) -> String {
chrono::DateTime::parse_from_rfc2822(raw_date)
.map(|dt| dt.format("%a, %b %-d, %Y at %-I:%M\u{202f}%p").to_string())
.unwrap_or_else(|e| {
eprintln!("Note: could not parse date as RFC 2822 ({e}); using raw value.");
html_escape(raw_date)
})
}
/// Format the From line for a forwarded message using Gmail's `gmail_sendername` structure.
/// When the address has a display name, it is shown in `<strong>` with the email in a mailto
/// link. Bare emails appear in both positions (matching Gmail's behavior).
pub(super) fn format_forward_from(addr: &str) -> String {
let email = extract_email(addr);
let display = extract_display_name(addr);
format!(
"<strong class=\"gmail_sendername\" dir=\"auto\">{}</strong> \
<span dir=\"auto\">&lt;{}&gt;</span>",
html_escape(display),
format_email_link(email),
)
}
/// Strip CR and LF characters to prevent header injection attacks.
pub(super) fn sanitize_header_value(value: &str) -> String {
value.replace(['\r', '\n'], "")
@@ -273,6 +449,7 @@ pub(super) fn encode_header_value(value: &str) -> String {
}
/// In-Reply-To and References values for threading a reply or forward.
#[derive(Clone, Copy)]
pub(super) struct ThreadingHeaders<'a> {
pub in_reply_to: &'a str,
pub references: &'a str,
@@ -280,9 +457,11 @@ pub(super) struct ThreadingHeaders<'a> {
/// Shared builder for RFC 2822 email messages.
///
/// Handles header construction with CRLF sanitization and RFC 2047
/// encoding of non-ASCII subjects. Each helper owns its body assembly
/// (quoted reply, forwarded block, plain body) and passes it to `build()`.
/// Handles header construction with CRLF sanitization, RFC 2047
/// encoding of non-ASCII subjects, and Content-Type selection
/// (`text/plain` or `text/html` based on the `html` field). Each helper
/// owns its body assembly (quoted reply, forwarded block, or standalone
/// body) and passes it to `build()`.
pub(super) struct MessageBuilder<'a> {
pub to: &'a str,
pub subject: &'a str,
@@ -290,6 +469,7 @@ pub(super) struct MessageBuilder<'a> {
pub cc: Option<&'a str>,
pub bcc: Option<&'a str>,
pub threading: Option<ThreadingHeaders<'a>>,
pub html: bool,
}
impl MessageBuilder<'_> {
@@ -316,7 +496,14 @@ impl MessageBuilder<'_> {
));
}
headers.push_str("\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8");
let content_type = if self.html {
"text/html; charset=utf-8"
} else {
"text/plain; charset=utf-8"
};
headers.push_str(&format!(
"\r\nMIME-Version: 1.0\r\nContent-Type: {content_type}"
));
if let Some(from) = self.from {
headers.push_str(&format!("\r\nFrom: {}", sanitize_header_value(from)));
@@ -439,7 +626,7 @@ pub(super) async fn send_raw_email(
}
impl Helper for GmailHelper {
/// Injects helper subcommands (`+send`, `+watch`) into the main CLI command.
/// Injects helper subcommands into the main CLI command.
fn inject_commands(
&self,
mut cmd: Command,
@@ -465,7 +652,7 @@ impl Helper for GmailHelper {
.arg(
Arg::new("body")
.long("body")
.help("Email body (plain text)")
.help("Email body (plain text, or HTML with --html)")
.required(true)
.value_name("TEXT"),
)
@@ -481,6 +668,12 @@ impl Helper for GmailHelper {
.help("BCC email address(es), comma-separated")
.value_name("EMAILS"),
)
.arg(
Arg::new("html")
.long("html")
.help("Treat --body as HTML content (default is plain text)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -493,10 +686,11 @@ EXAMPLES:
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!'
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --bcc secret@example.com
gws gmail +send --to alice@example.com --subject 'Hello' --body '<b>Bold</b> text' --html
TIPS:
Handles RFC 2822 formatting and base64 encoding automatically.
For HTML bodies or attachments, use the raw API instead: gws gmail users messages send --json '...'",
For attachments, use the raw API instead: gws gmail users messages send --json '...'",
),
);
@@ -549,7 +743,7 @@ TIPS:
.arg(
Arg::new("body")
.long("body")
.help("Reply body (plain text)")
.help("Reply body (plain text, or HTML with --html)")
.required(true)
.value_name("TEXT"),
)
@@ -577,6 +771,12 @@ TIPS:
.help("BCC email address(es), comma-separated")
.value_name("EMAILS"),
)
.arg(
Arg::new("html")
.long("html")
.help("Send as HTML (quotes original with Gmail styling; treat --body as HTML)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -590,6 +790,7 @@ EXAMPLES:
gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply --message-id 18f1a2b3c4d --body '<b>Bold reply</b>' --html
TIPS:
Automatically sets In-Reply-To, References, and threadId headers.
@@ -612,7 +813,7 @@ TIPS:
.arg(
Arg::new("body")
.long("body")
.help("Reply body (plain text)")
.help("Reply body (plain text, or HTML with --html)")
.required(true)
.value_name("TEXT"),
)
@@ -646,6 +847,12 @@ TIPS:
.help("Exclude recipients from the outgoing reply (comma-separated emails)")
.value_name("EMAILS"),
)
.arg(
Arg::new("html")
.long("html")
.help("Send as HTML (quotes original with Gmail styling; treat --body as HTML)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -660,6 +867,7 @@ EXAMPLES:
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Reply' --bcc secret@example.com
gws gmail +reply-all --message-id 18f1a2b3c4d --body '<i>Noted</i>' --html
TIPS:
Replies to the sender and all original To/CC recipients.
@@ -709,9 +917,15 @@ TIPS:
.arg(
Arg::new("body")
.long("body")
.help("Optional note to include above the forwarded message")
.help("Optional note to include above the forwarded message (plain text, or HTML with --html)")
.value_name("TEXT"),
)
.arg(
Arg::new("html")
.long("html")
.help("Send as HTML (formats forwarded block with Gmail styling; treat --body as HTML)")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -725,6 +939,7 @@ EXAMPLES:
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below'
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --bcc secret@example.com
gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '<p>FYI</p>' --html
TIPS:
Includes the original message with sender, date, subject, and recipients.",
@@ -948,6 +1163,40 @@ mod tests {
"<ref-1@example.com> <ref-2@example.com>"
);
assert_eq!(original.body_text, "Snippet fallback");
assert_eq!(original.body_html.as_deref(), Some("<p>HTML only</p>"));
}
#[test]
fn test_parse_original_message_multipart_alternative() {
let msg = json!({
"threadId": "thread-456",
"snippet": "Snippet ignored when text/plain exists",
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{ "name": "From", "value": "alice@example.com" },
{ "name": "To", "value": "bob@example.com" },
{ "name": "Subject", "value": "Hello" },
{ "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" },
{ "name": "Message-ID", "value": "<msg@example.com>" }
],
"parts": [
{
"mimeType": "text/plain",
"body": { "data": URL_SAFE.encode("Plain text body") }
},
{
"mimeType": "text/html",
"body": { "data": URL_SAFE.encode("<p>Rich HTML body</p>") }
}
]
}
});
let original = parse_original_message(&msg);
assert_eq!(original.body_text, "Plain text body");
assert_eq!(original.body_html.as_deref(), Some("<p>Rich HTML body</p>"));
}
#[test]
@@ -1008,6 +1257,7 @@ mod tests {
cc: None,
bcc: None,
threading: None,
html: false,
}
.build("World");
@@ -1035,6 +1285,7 @@ mod tests {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
}),
html: false,
}
.build("Reply body");
@@ -1057,6 +1308,7 @@ mod tests {
cc: None,
bcc: None,
threading: None,
html: false,
}
.build("Body");
@@ -1073,6 +1325,7 @@ mod tests {
cc: None,
bcc: None,
threading: None,
html: false,
}
.build("Body");
@@ -1095,6 +1348,7 @@ mod tests {
cc: Some("carol@example.com\r\nX-Injected: yes"),
bcc: None,
threading: None,
html: false,
}
.build("Body");
@@ -1152,4 +1406,295 @@ mod tests {
assert_eq!(resolved.http_method, "POST");
assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send");
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("Hello World"), "Hello World");
assert_eq!(
html_escape("Tom & Jerry <tj@example.com>"),
"Tom &amp; Jerry &lt;tj@example.com&gt;"
);
assert_eq!(
html_escape("He said \"hello\""),
"He said &quot;hello&quot;"
);
assert_eq!(html_escape("it's"), "it&#39;s");
assert_eq!(html_escape(""), "");
// All five characters in one string — verifies replacement ordering
// (& must be replaced first to avoid double-escaping).
assert_eq!(
html_escape("a & b < c > d \"e\" f'g"),
"a &amp; b &lt; c &gt; d &quot;e&quot; f&#39;g"
);
}
#[test]
fn test_extract_html_body_direct() {
let payload = json!({
"mimeType": "text/html",
"body": {
"data": URL_SAFE.encode("<p>Hello</p>")
}
});
assert_eq!(extract_html_body(&payload).as_deref(), Some("<p>Hello</p>"));
}
#[test]
fn test_extract_html_body_from_multipart() {
let payload = json!({
"mimeType": "multipart/alternative",
"parts": [
{
"mimeType": "text/plain",
"body": { "data": URL_SAFE.encode("plain text") }
},
{
"mimeType": "text/html",
"body": { "data": URL_SAFE.encode("<p>rich text</p>") }
}
]
});
assert_eq!(
extract_html_body(&payload).as_deref(),
Some("<p>rich text</p>")
);
}
#[test]
fn test_extract_html_body_missing() {
let payload = json!({
"mimeType": "text/plain",
"body": { "data": URL_SAFE.encode("only plain") }
});
assert!(extract_html_body(&payload).is_none());
}
#[test]
fn test_extract_html_body_from_nested_multipart() {
let payload = json!({
"mimeType": "multipart/mixed",
"parts": [
{
"mimeType": "multipart/alternative",
"parts": [
{
"mimeType": "text/plain",
"body": { "data": URL_SAFE.encode("plain text") }
},
{
"mimeType": "text/html",
"body": { "data": URL_SAFE.encode("<p>Nested HTML</p>") }
}
]
},
{
"mimeType": "application/pdf",
"body": { "attachmentId": "att123" }
}
]
});
assert_eq!(
extract_html_body(&payload).as_deref(),
Some("<p>Nested HTML</p>")
);
}
#[test]
fn test_resolve_html_body_uses_html_when_present() {
let original = OriginalMessage {
body_text: "ignored".to_string(),
body_html: Some("<p>Real HTML</p>".to_string()),
..OriginalMessage::dry_run_placeholder("test")
};
assert_eq!(resolve_html_body(&original), "<p>Real HTML</p>");
}
#[test]
fn test_resolve_html_body_escapes_plain_text_fallback() {
let original = OriginalMessage {
body_text: "Line 1 & <tag>\nLine 2\r\nLine 3".to_string(),
body_html: None,
..OriginalMessage::dry_run_placeholder("test")
};
let result = resolve_html_body(&original);
assert_eq!(
result,
"Line 1 &amp; &lt;tag&gt;<br>\r\nLine 2<br>\r\nLine 3"
);
}
#[test]
fn test_message_builder_html_content_type() {
let raw = MessageBuilder {
to: "test@example.com",
subject: "Hello",
from: None,
cc: None,
bcc: None,
threading: None,
html: true,
}
.build("<p>Body</p>");
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(!raw.contains("text/plain"));
assert!(raw.contains("\r\n\r\n<p>Body</p>"));
}
#[test]
fn test_message_builder_html_false_uses_plain_text() {
// The html flag controls Content-Type, not the presence of body_html.
let raw = MessageBuilder {
to: "test@example.com",
subject: "Hello",
from: None,
cc: None,
bcc: None,
threading: None,
html: false,
}
.build("<p>This is HTML but flag says plain</p>");
assert!(raw.contains("Content-Type: text/plain; charset=utf-8"));
assert!(!raw.contains("text/html"));
}
// --- HTML fidelity helper tests ---
#[test]
fn test_extract_email() {
assert_eq!(extract_email("alice@example.com"), "alice@example.com");
assert_eq!(
extract_email("Alice Smith <alice@example.com>"),
"alice@example.com"
);
assert_eq!(
extract_email("\"Bob, Jr.\" <bob@example.com>"),
"bob@example.com"
);
// Malformed: opening `<` without closing `>` falls back to full string
assert_eq!(
extract_email("Alice <alice@example.com"),
"Alice <alice@example.com"
);
assert_eq!(extract_email(""), "");
assert_eq!(extract_email(" "), "");
}
#[test]
fn test_extract_display_name() {
assert_eq!(
extract_display_name("Alice Smith <alice@example.com>"),
"Alice Smith"
);
assert_eq!(
extract_display_name("\"Bob, Jr.\" <bob@example.com>"),
"Bob, Jr."
);
// Bare email — returns the email itself
assert_eq!(
extract_display_name("alice@example.com"),
"alice@example.com"
);
}
#[test]
fn test_split_mailbox_list() {
assert_eq!(
split_mailbox_list("alice@example.com, bob@example.com"),
vec!["alice@example.com", "bob@example.com"]
);
assert_eq!(
split_mailbox_list("alice@example.com"),
vec!["alice@example.com"]
);
assert!(split_mailbox_list("").is_empty());
// Quoted comma in display name must not split the address
assert_eq!(
split_mailbox_list(r#""Doe, John" <john@example.com>, alice@example.com"#),
vec![r#""Doe, John" <john@example.com>"#, "alice@example.com"]
);
// Escaped quotes inside quoted string
assert_eq!(
split_mailbox_list(r#""Doe \"JD, Sr\"" <john@example.com>, alice@example.com"#),
vec![
r#""Doe \"JD, Sr\"" <john@example.com>"#,
"alice@example.com"
]
);
// Double backslash: \\\\ inside quotes means an escaped backslash followed
// by a closing quote, so the comma after is a real separator.
assert_eq!(
split_mailbox_list(r#""Trail\\" <t@example.com>, b@example.com"#),
vec![r#""Trail\\" <t@example.com>"#, "b@example.com"]
);
}
#[test]
fn test_format_sender_for_attribution() {
// Bare email
assert_eq!(
format_sender_for_attribution("alice@example.com"),
"<a href=\"mailto:alice@example.com\">alice@example.com</a>"
);
// Name <email>
assert_eq!(
format_sender_for_attribution("Alice Smith <alice@example.com>"),
"Alice Smith &lt;<a href=\"mailto:alice@example.com\">alice@example.com</a>&gt;"
);
// Special chars in name
assert_eq!(
format_sender_for_attribution("O'Brien & Co <ob@example.com>"),
"O&#39;Brien &amp; Co &lt;<a href=\"mailto:ob@example.com\">ob@example.com</a>&gt;"
);
}
#[test]
fn test_format_address_list_with_links() {
assert_eq!(
format_address_list_with_links("alice@example.com"),
"<a href=\"mailto:alice@example.com\">alice@example.com</a>"
);
assert_eq!(
format_address_list_with_links("alice@example.com, bob@example.com"),
"<a href=\"mailto:alice@example.com\">alice@example.com</a>, \
<a href=\"mailto:bob@example.com\">bob@example.com</a>"
);
// Quoted comma in display name must not split the address
assert_eq!(
format_address_list_with_links(r#""Doe, John" <john@example.com>, alice@example.com"#),
"Doe, John &lt;<a href=\"mailto:john@example.com\">john@example.com</a>&gt;, \
<a href=\"mailto:alice@example.com\">alice@example.com</a>"
);
assert_eq!(format_address_list_with_links(""), "");
}
#[test]
fn test_format_date_for_attribution() {
// Valid RFC 2822 date → Gmail's human-friendly format
assert_eq!(
format_date_for_attribution("Wed, 04 Mar 2026 15:01:00 +0000"),
"Wed, Mar 4, 2026 at 3:01\u{202f}PM"
);
// Non-parseable date falls back to html-escaped raw string
assert_eq!(
format_date_for_attribution("Jan 1 <2026>"),
"Jan 1 &lt;2026&gt;"
);
}
#[test]
fn test_format_forward_from() {
assert_eq!(
format_forward_from("Alice Smith <alice@example.com>"),
"<strong class=\"gmail_sendername\" dir=\"auto\">Alice Smith</strong> \
<span dir=\"auto\">&lt;<a href=\"mailto:alice@example.com\">alice@example.com</a>&gt;</span>"
);
// Bare email — display name is the email itself
assert_eq!(
format_forward_from("alice@example.com"),
"<strong class=\"gmail_sendername\" dir=\"auto\">alice@example.com</strong> \
<span dir=\"auto\">&lt;<a href=\"mailto:alice@example.com\">alice@example.com</a>&gt;</span>"
);
}
}
+223 -150
View File
@@ -89,9 +89,12 @@ pub(super) async fn handle_reply(
bcc: bcc.as_deref(),
from: config.from.as_deref(),
subject: &subject,
in_reply_to: &in_reply_to,
references: &references,
body: &config.body_text,
threading: ThreadingHeaders {
in_reply_to: &in_reply_to,
references: &references,
},
body: &config.body,
html: config.html,
};
let raw = create_reply_raw_message(&envelope, &original);
@@ -114,19 +117,20 @@ struct ReplyEnvelope<'a> {
bcc: Option<&'a str>,
from: Option<&'a str>,
subject: &'a str,
in_reply_to: &'a str,
references: &'a str,
body: &'a str,
threading: ThreadingHeaders<'a>,
body: &'a str, // Always present: --body is required for replies
html: bool,
}
pub(super) struct ReplyConfig {
pub message_id: String,
pub body_text: String,
pub body: String,
pub from: Option<String>,
pub to: Option<String>,
pub cc: Option<String>,
pub bcc: Option<String>,
pub remove: Option<String>,
pub html: bool,
}
async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result<String, GwsError> {
@@ -171,54 +175,6 @@ fn extract_reply_to_address(original: &OriginalMessage) -> String {
}
}
/// Split an RFC 5322 mailbox list on commas, respecting quoted strings.
/// `"Doe, John" <john@example.com>, alice@example.com` →
/// `["\"Doe, John\" <john@example.com>", "alice@example.com"]`
fn split_mailbox_list(header: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut in_quotes = false;
let mut start = 0;
let mut prev_backslash = false;
for (i, ch) in header.char_indices() {
match ch {
'\\' if in_quotes => {
prev_backslash = !prev_backslash;
continue;
}
'"' if !prev_backslash => in_quotes = !in_quotes,
',' if !in_quotes => {
let token = header[start..i].trim();
if !token.is_empty() {
result.push(token);
}
start = i + 1;
}
_ => {}
}
prev_backslash = false;
}
let token = header[start..].trim();
if !token.is_empty() {
result.push(token);
}
result
}
/// Extract the bare email address from a header value like
/// `"Alice <alice@example.com>"` → `"alice@example.com"` or
/// `"alice@example.com"` → `"alice@example.com"`.
fn extract_email(addr: &str) -> &str {
if let Some(start) = addr.rfind('<') {
if let Some(end) = addr[start..].find('>') {
return &addr[start + 1..start + end];
}
}
addr.trim()
}
fn build_reply_all_recipients(
original: &OriginalMessage,
extra_cc: Option<&str>,
@@ -255,7 +211,7 @@ fn build_reply_all_recipients(
cc_addrs.extend(split_mailbox_list(extra));
}
// Remove addresses if requested (exact email match)
// Filter CC: remove reply-to recipients, excluded addresses, and duplicates
let mut seen = std::collections::HashSet::new();
let cc_addrs: Vec<&str> = cc_addrs
.into_iter()
@@ -392,14 +348,16 @@ fn create_reply_raw_message(envelope: &ReplyEnvelope, original: &OriginalMessage
from: envelope.from,
cc: envelope.cc,
bcc: envelope.bcc,
threading: Some(ThreadingHeaders {
in_reply_to: envelope.in_reply_to,
references: envelope.references,
}),
threading: Some(envelope.threading),
html: envelope.html,
};
let quoted = format_quoted_original(original);
let body = format!("{}\r\n\r\n{}", envelope.body, quoted);
let (quoted, separator) = if envelope.html {
(format_quoted_original_html(original), "<br>\r\n")
} else {
(format_quoted_original(original), "\r\n\r\n")
};
let body = format!("{}{}{}", envelope.body, separator, quoted);
builder.build(&body)
}
@@ -417,12 +375,33 @@ fn format_quoted_original(original: &OriginalMessage) -> String {
)
}
fn format_quoted_original_html(original: &OriginalMessage) -> String {
let quoted_body = resolve_html_body(original);
let date = format_date_for_attribution(&original.date);
let sender = format_sender_for_attribution(&original.from);
format!(
"<div class=\"gmail_quote gmail_quote_container\">\
<div dir=\"ltr\" class=\"gmail_attr\">\
On {}, {} wrote:<br>\
</div>\
<blockquote class=\"gmail_quote\" \
style=\"margin:0 0 0 0.8ex;\
border-left:1px solid rgb(204,204,204);\
padding-left:1ex\">\
<div dir=\"ltr\">{}</div>\
</blockquote>\
</div>",
date, sender, quoted_body,
)
}
// --- Argument parsing ---
fn parse_reply_args(matches: &ArgMatches) -> Result<ReplyConfig, GwsError> {
Ok(ReplyConfig {
message_id: matches.get_one::<String>("message-id").unwrap().to_string(),
body_text: matches.get_one::<String>("body").unwrap().to_string(),
body: matches.get_one::<String>("body").unwrap().to_string(),
from: parse_optional_trimmed(matches, "from"),
to: parse_optional_trimmed(matches, "to"),
cc: parse_optional_trimmed(matches, "cc"),
@@ -438,6 +417,7 @@ fn parse_reply_args(matches: &ArgMatches) -> Result<ReplyConfig, GwsError> {
)))
}
},
html: matches.get_flag("html"),
})
}
@@ -474,6 +454,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original body".to_string(),
body_html: None,
};
let envelope = ReplyEnvelope {
@@ -482,9 +463,12 @@ mod tests {
bcc: None,
from: None,
subject: "Re: Hello",
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
threading: ThreadingHeaders {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
},
body: "My reply",
html: false,
};
let raw = create_reply_raw_message(&envelope, &original);
@@ -512,6 +496,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original body".to_string(),
body_html: None,
};
let envelope = ReplyEnvelope {
@@ -520,9 +505,12 @@ mod tests {
bcc: Some("secret@example.com"),
from: Some("alias@example.com"),
subject: "Re: Hello",
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
threading: ThreadingHeaders {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
},
body: "Reply with all headers",
html: false,
};
let raw = create_reply_raw_message(&envelope, &original);
@@ -544,6 +532,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
@@ -569,6 +558,7 @@ mod tests {
subject: "Hello".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
@@ -592,6 +582,7 @@ mod tests {
subject: "Hello".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
@@ -613,6 +604,7 @@ mod tests {
subject: "Hello".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(
@@ -643,6 +635,7 @@ mod tests {
subject: "Hello".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(
@@ -665,6 +658,7 @@ mod tests {
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("remove").long("remove"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
.arg(
Arg::new("dry-run")
.long("dry-run")
@@ -678,7 +672,7 @@ mod tests {
let matches = make_reply_matches(&["test", "--message-id", "abc123", "--body", "My reply"]);
let config = parse_reply_args(&matches).unwrap();
assert_eq!(config.message_id, "abc123");
assert_eq!(config.body_text, "My reply");
assert_eq!(config.body, "My reply");
assert!(config.to.is_none());
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
@@ -728,6 +722,26 @@ mod tests {
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_reply_args_html_flag() {
let matches = make_reply_matches(&[
"test",
"--message-id",
"abc123",
"--body",
"<b>Bold</b>",
"--html",
]);
let config = parse_reply_args(&matches).unwrap();
assert!(config.html);
// Default is false
let matches =
make_reply_matches(&["test", "--message-id", "abc123", "--body", "Plain reply"]);
let config = parse_reply_args(&matches).unwrap();
assert!(!config.html);
}
#[test]
fn test_parse_reply_args_without_remove_defined() {
// Simulates +reply which doesn't define --remove (only +reply-all does).
@@ -737,7 +751,8 @@ mod tests {
.arg(Arg::new("from").long("from"))
.arg(Arg::new("to").long("to"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"));
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue));
let matches = cmd
.try_get_matches_from(&["test", "--message-id", "abc", "--body", "hi"])
.unwrap();
@@ -758,6 +773,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
assert_eq!(
extract_reply_to_address(&original),
@@ -778,23 +794,11 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
assert_eq!(extract_reply_to_address(&original), "list@example.com");
}
#[test]
fn test_extract_email_bare() {
assert_eq!(extract_email("alice@example.com"), "alice@example.com");
}
#[test]
fn test_extract_email_with_display_name() {
assert_eq!(
extract_email("Alice Smith <alice@example.com>"),
"alice@example.com"
);
}
#[test]
fn test_remove_does_not_match_substring() {
let original = OriginalMessage {
@@ -808,6 +812,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
build_reply_all_recipients(&original, None, Some("ann@example.com"), None, None)
@@ -830,6 +835,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
assert_eq!(recipients.to, "list@example.com");
@@ -839,24 +845,6 @@ mod tests {
assert!(!cc.contains("list@example.com"));
}
#[test]
fn test_extract_email_malformed_no_closing_bracket() {
assert_eq!(
extract_email("Alice <alice@example.com"),
"Alice <alice@example.com"
);
}
#[test]
fn test_extract_email_empty_string() {
assert_eq!(extract_email(""), "");
}
#[test]
fn test_extract_email_whitespace_only() {
assert_eq!(extract_email(" "), "");
}
#[test]
fn test_sender_with_display_name_excluded_from_cc() {
let original = OriginalMessage {
@@ -870,6 +858,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
assert_eq!(recipients.to, "Alice <alice@example.com>");
@@ -890,6 +879,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(
&original,
@@ -916,6 +906,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
build_reply_all_recipients(&original, Some("extra@example.com"), None, None, None)
@@ -938,6 +929,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
assert!(recipients.cc.is_none());
@@ -956,6 +948,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
let cc = recipients.cc.unwrap();
@@ -975,6 +968,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
// To should be the full Reply-To value
@@ -987,52 +981,6 @@ mod tests {
assert!(!cc.contains("owner@example.com"));
}
#[test]
fn test_split_mailbox_list_simple() {
let addrs = split_mailbox_list("alice@example.com, bob@example.com");
assert_eq!(addrs, vec!["alice@example.com", "bob@example.com"]);
}
#[test]
fn test_split_mailbox_list_quoted_comma() {
let addrs = split_mailbox_list(r#""Doe, John" <john@example.com>, alice@example.com"#);
assert_eq!(
addrs,
vec![r#""Doe, John" <john@example.com>"#, "alice@example.com"]
);
}
#[test]
fn test_split_mailbox_list_single() {
let addrs = split_mailbox_list("alice@example.com");
assert_eq!(addrs, vec!["alice@example.com"]);
}
#[test]
fn test_split_mailbox_list_empty() {
let addrs = split_mailbox_list("");
assert!(addrs.is_empty());
}
#[test]
fn test_split_mailbox_list_escaped_quotes() {
let addrs = split_mailbox_list(r#""Doe \"JD, Sr\"" <john@example.com>, alice@example.com"#);
assert_eq!(
addrs,
vec![
r#""Doe \"JD, Sr\"" <john@example.com>"#,
"alice@example.com"
]
);
}
#[test]
fn test_split_mailbox_list_double_backslash() {
// \\\\" inside quotes means an escaped backslash followed by a closing quote
let addrs = split_mailbox_list(r#""Trail\\" <t@example.com>, b@example.com"#);
assert_eq!(addrs, vec![r#""Trail\\" <t@example.com>"#, "b@example.com"]);
}
#[test]
fn test_reply_all_with_quoted_comma_display_name() {
let original = OriginalMessage {
@@ -1046,6 +994,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
let cc = recipients.cc.unwrap();
@@ -1067,6 +1016,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
build_reply_all_recipients(&original, None, Some("john@example.com"), None, None);
@@ -1088,6 +1038,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
@@ -1110,6 +1061,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients =
build_reply_all_recipients(&original, None, None, Some("me@example.com"), None)
@@ -1132,6 +1084,7 @@ mod tests {
subject: "".to_string(),
date: "".to_string(),
body_text: "".to_string(),
body_html: None,
};
let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap();
let cc = recipients.cc.unwrap();
@@ -1263,6 +1216,7 @@ mod tests {
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original".to_string(),
body_html: None,
};
let mut to = extract_reply_to_address(&original);
@@ -1277,9 +1231,12 @@ mod tests {
bcc: bcc.as_deref(),
from: None,
subject: "Re: Hello",
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
threading: ThreadingHeaders {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
},
body: "Adding Dave",
html: false,
};
let raw = create_reply_raw_message(&envelope, &original);
@@ -1303,6 +1260,7 @@ mod tests {
subject: "Intro".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Meet Bob".to_string(),
body_html: None,
};
// build_reply_all_recipients with --remove alice, self=me
@@ -1331,9 +1289,12 @@ mod tests {
bcc: bcc.as_deref(),
from: None,
subject: "Re: Intro",
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
threading: ThreadingHeaders {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
},
body: "Hi Bob, nice to meet you!",
html: false,
};
let raw = create_reply_raw_message(&envelope, &original);
@@ -1423,4 +1384,116 @@ mod tests {
});
assert!(extract_plain_text_body(&payload).is_none());
}
// --- HTML mode tests ---
#[test]
fn test_format_quoted_original_html_with_html_body() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "".to_string(),
cc: "".to_string(),
subject: "".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
body_text: "plain fallback".to_string(),
body_html: Some("<p>Rich <b>content</b></p>".to_string()),
};
let html = format_quoted_original_html(&original);
assert!(html.contains("gmail_quote"));
assert!(html.contains("<blockquote"));
assert!(html.contains("<p>Rich <b>content</b></p>"));
assert!(!html.contains("plain fallback"));
// Sender is a bare email — formatted as a mailto link
assert!(html.contains("<a href=\"mailto:alice@example.com\">alice@example.com</a> wrote:"));
}
#[test]
fn test_format_quoted_original_html_fallback_plain_text() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "".to_string(),
cc: "".to_string(),
subject: "".to_string(),
date: "Mon, 1 Jan 2026".to_string(),
body_text: "Line one & <stuff>\nLine two".to_string(),
body_html: None,
};
let html = format_quoted_original_html(&original);
assert!(html.contains("gmail_quote"));
assert!(html.contains("<blockquote"));
assert!(html.contains("Line one &amp; &lt;stuff&gt;<br>"));
assert!(html.contains("Line two"));
}
#[test]
fn test_format_quoted_original_html_escapes_metadata() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "".to_string(),
references: "".to_string(),
from: "O'Brien & Associates <ob@example.com>".to_string(),
reply_to: "".to_string(),
to: "".to_string(),
cc: "".to_string(),
subject: "".to_string(),
date: "Jan 1 <2026>".to_string(),
body_text: "text".to_string(),
body_html: None,
};
let html = format_quoted_original_html(&original);
assert!(html.contains("O&#39;Brien &amp; Associates"));
// Sender now has mailto link, email in angle brackets
assert!(html.contains("&lt;<a href=\"mailto:ob@example.com\">ob@example.com</a>&gt;"));
// Non-RFC-2822 date falls back to html-escaped raw string
assert!(html.contains("Jan 1 &lt;2026&gt;"));
}
#[test]
fn test_create_reply_raw_message_html() {
let original = OriginalMessage {
thread_id: "t1".to_string(),
message_id_header: "<abc@example.com>".to_string(),
references: "".to_string(),
from: "alice@example.com".to_string(),
reply_to: "".to_string(),
to: "bob@example.com".to_string(),
cc: "".to_string(),
subject: "Hello".to_string(),
date: "Mon, 1 Jan 2026 00:00:00 +0000".to_string(),
body_text: "Original body".to_string(),
body_html: Some("<p>Original</p>".to_string()),
};
let envelope = ReplyEnvelope {
to: "alice@example.com",
cc: None,
bcc: None,
from: None,
subject: "Re: Hello",
threading: ThreadingHeaders {
in_reply_to: "<abc@example.com>",
references: "<abc@example.com>",
},
body: "<p>My HTML reply</p>",
html: true,
};
let raw = create_reply_raw_message(&envelope, &original);
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("<p>My HTML reply</p>"));
assert!(raw.contains("gmail_quote"));
assert!(raw.contains("<p>Original</p>"));
// HTML separator: <br> between reply and quoted block (not \r\n\r\n)
assert!(raw.contains(
"<p>My HTML reply</p><br>\r\n<div class=\"gmail_quote gmail_quote_container\">"
));
}
}
+56 -5
View File
@@ -13,8 +13,9 @@ pub(super) async fn handle_send(
cc: config.cc.as_deref(),
bcc: config.bcc.as_deref(),
threading: None,
html: config.html,
}
.build(&config.body_text);
.build(&config.body);
super::send_raw_email(doc, matches, &raw, None, None).await
}
@@ -22,18 +23,20 @@ pub(super) async fn handle_send(
pub(super) struct SendConfig {
pub to: String,
pub subject: String,
pub body_text: String,
pub body: String,
pub cc: Option<String>,
pub bcc: Option<String>,
pub html: bool,
}
fn parse_send_args(matches: &ArgMatches) -> SendConfig {
SendConfig {
to: matches.get_one::<String>("to").unwrap().to_string(),
subject: matches.get_one::<String>("subject").unwrap().to_string(),
body_text: matches.get_one::<String>("body").unwrap().to_string(),
body: matches.get_one::<String>("body").unwrap().to_string(),
cc: parse_optional_trimmed(matches, "cc"),
bcc: parse_optional_trimmed(matches, "bcc"),
html: matches.get_flag("html"),
}
}
@@ -47,7 +50,8 @@ mod tests {
.arg(Arg::new("subject").long("subject"))
.arg(Arg::new("body").long("body"))
.arg(Arg::new("cc").long("cc"))
.arg(Arg::new("bcc").long("bcc"));
.arg(Arg::new("bcc").long("bcc"))
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}
@@ -65,7 +69,7 @@ mod tests {
let config = parse_send_args(&matches);
assert_eq!(config.to, "me@example.com");
assert_eq!(config.subject, "Hi");
assert_eq!(config.body_text, "Body");
assert_eq!(config.body, "Body");
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
@@ -107,4 +111,51 @@ mod tests {
assert!(config.cc.is_none());
assert!(config.bcc.is_none());
}
#[test]
fn test_parse_send_args_html_flag() {
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"<b>Bold</b>",
"--html",
]);
let config = parse_send_args(&matches);
assert!(config.html);
// Default is false
let matches = make_matches_send(&[
"test",
"--to",
"me@example.com",
"--subject",
"Hi",
"--body",
"Plain",
]);
let config = parse_send_args(&matches);
assert!(!config.html);
}
#[test]
fn test_send_html_raw_message() {
let raw = MessageBuilder {
to: "bob@example.com",
subject: "HTML test",
from: None,
cc: None,
bcc: None,
threading: None,
html: true,
}
.build("<p>Hello <b>world</b></p>");
assert!(raw.contains("Content-Type: text/html; charset=utf-8"));
assert!(raw.contains("To: bob@example.com"));
assert!(raw.contains("<p>Hello <b>world</b></p>"));
}
}