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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
Add `--html` flag to `+send`, `+reply`, `+reply-all`, and `+forward` for HTML email composition.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & <stuff><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 & 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 < B & C"));
|
||||
// Non-RFC-2822 date falls back to html-escaped raw string
|
||||
assert!(html.contains("Jan 1 <2026>"));
|
||||
}
|
||||
|
||||
#[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 & 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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 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 <<a href="mailto:alice@example.com">alice@example.com</a>>`
|
||||
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!(
|
||||
"{} <{}>",
|
||||
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\"><{}></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 & Jerry <tj@example.com>"
|
||||
);
|
||||
assert_eq!(
|
||||
html_escape("He said \"hello\""),
|
||||
"He said "hello""
|
||||
);
|
||||
assert_eq!(html_escape("it's"), "it'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 & b < c > d "e" f'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 & <tag><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 <<a href=\"mailto:alice@example.com\">alice@example.com</a>>"
|
||||
);
|
||||
// Special chars in name
|
||||
assert_eq!(
|
||||
format_sender_for_attribution("O'Brien & Co <ob@example.com>"),
|
||||
"O'Brien & Co <<a href=\"mailto:ob@example.com\">ob@example.com</a>>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 <<a href=\"mailto:john@example.com\">john@example.com</a>>, \
|
||||
<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 <2026>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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\"><<a href=\"mailto:alice@example.com\">alice@example.com</a>></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\"><<a href=\"mailto:alice@example.com\">alice@example.com</a>></span>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+223
-150
@@ -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 & <stuff><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'Brien & Associates"));
|
||||
// Sender now has mailto link, email in angle brackets
|
||||
assert!(html.contains("<<a href=\"mailto:ob@example.com\">ob@example.com</a>>"));
|
||||
// Non-RFC-2822 date falls back to html-escaped raw string
|
||||
assert!(html.contains("Jan 1 <2026>"));
|
||||
}
|
||||
|
||||
#[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\">"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user