From be5bdc561e19f1a9ddaba08e78ce5c554d73c429 Mon Sep 17 00:00:00 2001 From: Jona Meijers Date: Mon, 9 Mar 2026 10:45:08 +0000 Subject: [PATCH 1/5] fix: RFC 2047 encode non-ASCII email subjects in +send helper --- src/helpers/gmail/send.rs | 42 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 20a2605c..92ee7f88 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -59,9 +59,23 @@ pub(super) async fn handle_send( Ok(()) } +/// RFC 2047 encode a header value if it contains non-ASCII characters. +fn encode_header_value(value: &str) -> String { + if value.is_ascii() { + value.to_string() + } else { + format!("=?UTF-8?B?{}?=", URL_SAFE.encode(value.as_bytes())) + } +} + /// Helper to create a raw MIME email string. fn create_raw_message(to: &str, subject: &str, body: &str) -> String { - format!("To: {}\r\nSubject: {}\r\n\r\n{}", to, subject, body) + format!( + "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: {}\r\nSubject: {}\r\n\r\n{}", + to, + encode_header_value(subject), + body + ) } /// Creates a JSON body for sending an email. @@ -91,9 +105,31 @@ mod tests { use super::*; #[test] - fn test_create_raw_message() { + fn test_create_raw_message_ascii() { let msg = create_raw_message("test@example.com", "Hello", "World"); - assert_eq!(msg, "To: test@example.com\r\nSubject: Hello\r\n\r\nWorld"); + assert_eq!( + msg, + "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nTo: test@example.com\r\nSubject: Hello\r\n\r\nWorld" + ); + } + + #[test] + fn test_create_raw_message_non_ascii_subject() { + let msg = create_raw_message("test@example.com", "Solar — Quote Request", "Body"); + assert!(msg.contains("=?UTF-8?B?")); + assert!(!msg.contains("Solar — Quote Request")); + } + + #[test] + fn test_encode_header_value_ascii() { + assert_eq!(encode_header_value("Hello World"), "Hello World"); + } + + #[test] + fn test_encode_header_value_non_ascii() { + let encoded = encode_header_value("Solar — Quote"); + assert!(encoded.starts_with("=?UTF-8?B?")); + assert!(encoded.ends_with("?=")); } #[test] From 799aa0b02baa652ac910677ef5d6f657897064a0 Mon Sep 17 00:00:00 2001 From: Jona Meijers Date: Mon, 9 Mar 2026 11:16:51 +0000 Subject: [PATCH 2/5] chore: add changeset for RFC 2047 fix --- .changeset/fix-rfc2047-subject.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-rfc2047-subject.md diff --git a/.changeset/fix-rfc2047-subject.md b/.changeset/fix-rfc2047-subject.md new file mode 100644 index 00000000..8f16db7c --- /dev/null +++ b/.changeset/fix-rfc2047-subject.md @@ -0,0 +1,5 @@ +--- +"gws": patch +--- + +Fix garbled non-ASCII email subjects in `gmail +send` by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers. From 8f63a0ac31d34a51aa0da9111c5ccd0a7d7d1a5b Mon Sep 17 00:00:00 2001 From: Jona Meijers Date: Mon, 9 Mar 2026 11:26:36 +0000 Subject: [PATCH 3/5] fix: use standard Base64 and fold long encoded-words per RFC 2047 --- src/helpers/gmail/send.rs | 41 +++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 92ee7f88..64591963 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -60,12 +60,26 @@ pub(super) async fn handle_send( } /// RFC 2047 encode a header value if it contains non-ASCII characters. +/// Uses standard Base64 (RFC 2045) and folds at 75-char encoded-word limit. fn encode_header_value(value: &str) -> String { if value.is_ascii() { - value.to_string() - } else { - format!("=?UTF-8?B?{}?=", URL_SAFE.encode(value.as_bytes())) + return value.to_string(); } + + use base64::engine::general_purpose::STANDARD; + + // RFC 2047 specifies a 75-character limit for encoded-words. + // Max raw length of 45 bytes -> 60 encoded chars. 60 + len("=?UTF-8?B??=") = 72, < 75. + const MAX_RAW_LEN: usize = 45; + + let encoded_words: Vec = value + .as_bytes() + .chunks(MAX_RAW_LEN) + .map(|chunk| format!("=?UTF-8?B?{}?=", STANDARD.encode(chunk))) + .collect(); + + // Join with CRLF and a space for folding. + encoded_words.join("\r\n ") } /// Helper to create a raw MIME email string. @@ -126,10 +140,25 @@ mod tests { } #[test] - fn test_encode_header_value_non_ascii() { + fn test_encode_header_value_non_ascii_short() { let encoded = encode_header_value("Solar — Quote"); - assert!(encoded.starts_with("=?UTF-8?B?")); - assert!(encoded.ends_with("?=")); + // Single encoded-word, no folding needed + assert_eq!(encoded, "=?UTF-8?B?U29sYXIg4oCUIFF1b3Rl?="); + } + + #[test] + fn test_encode_header_value_non_ascii_long_folds() { + let long_subject = "This is a very long subject line that contains non-ASCII characters like — and it must be folded to respect the 75-character line limit of RFC 2047."; + let encoded = encode_header_value(long_subject); + + assert!(encoded.contains("\r\n "), "Encoded string should be folded"); + let parts: Vec<&str> = encoded.split("\r\n ").collect(); + assert!(parts.len() > 1, "Should be multiple parts"); + for part in &parts { + assert!(part.starts_with("=?UTF-8?B?")); + assert!(part.ends_with("?=")); + assert!(part.len() <= 75, "Part too long: {} chars", part.len()); + } } #[test] From db88c1a04a8ee9ef2527d83f7a1e55fbb83c8fd7 Mon Sep 17 00:00:00 2001 From: Jona Meijers Date: Mon, 9 Mar 2026 11:34:36 +0000 Subject: [PATCH 4/5] fix: chunk at char boundaries to avoid splitting multi-byte UTF-8 --- src/helpers/gmail/send.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs index 64591963..637f09b7 100644 --- a/src/helpers/gmail/send.rs +++ b/src/helpers/gmail/send.rs @@ -72,10 +72,22 @@ fn encode_header_value(value: &str) -> String { // Max raw length of 45 bytes -> 60 encoded chars. 60 + len("=?UTF-8?B??=") = 72, < 75. const MAX_RAW_LEN: usize = 45; - let encoded_words: Vec = value - .as_bytes() - .chunks(MAX_RAW_LEN) - .map(|chunk| format!("=?UTF-8?B?{}?=", STANDARD.encode(chunk))) + // Chunk at character boundaries to avoid splitting multi-byte UTF-8 sequences. + let mut chunks: Vec<&str> = Vec::new(); + let mut start = 0; + for (i, ch) in value.char_indices() { + if i + ch.len_utf8() - start > MAX_RAW_LEN && i > start { + chunks.push(&value[start..i]); + start = i; + } + } + if start < value.len() { + chunks.push(&value[start..]); + } + + let encoded_words: Vec = chunks + .iter() + .map(|chunk| format!("=?UTF-8?B?{}?=", STANDARD.encode(chunk.as_bytes()))) .collect(); // Join with CRLF and a space for folding. @@ -161,6 +173,20 @@ mod tests { } } + #[test] + fn test_encode_header_value_multibyte_boundary() { + // Build a subject where a multi-byte char (€ = 3 bytes) falls near the chunk boundary. + // Each chunk must decode to valid UTF-8 — no split multi-byte sequences. + use base64::engine::general_purpose::STANDARD; + let subject = format!("{}€€€", "A".repeat(43)); // 43 ASCII + 9 bytes of €s = 52 bytes + let encoded = encode_header_value(&subject); + for part in encoded.split("\r\n ") { + let b64 = part.trim_start_matches("=?UTF-8?B?").trim_end_matches("?="); + let decoded = STANDARD.decode(b64).expect("valid base64"); + String::from_utf8(decoded).expect("each chunk must be valid UTF-8"); + } + } + #[test] fn test_create_send_body() { let raw = "To: a@b.com\r\nSubject: hi\r\n\r\nbody"; From fb5b67543e0f61d7f6c7c82edd28ce4ef5ebfe82 Mon Sep 17 00:00:00 2001 From: Justin Poehnelt Date: Mon, 9 Mar 2026 11:21:47 -0600 Subject: [PATCH 5/5] chore: fix changeset package Fix garbled non-ASCII email subjects in gmail +send by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers. --- .changeset/fix-rfc2047-subject.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-rfc2047-subject.md b/.changeset/fix-rfc2047-subject.md index 8f16db7c..d11bf6a2 100644 --- a/.changeset/fix-rfc2047-subject.md +++ b/.changeset/fix-rfc2047-subject.md @@ -1,5 +1,5 @@ --- -"gws": patch +"@googleworkspace/cli": patch --- Fix garbled non-ASCII email subjects in `gmail +send` by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers.