Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-769-reply-qp-soft-wrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

fix(gmail): pre-wrap quoted lines to prevent QP soft-wrap corruption in +reply
73 changes: 73 additions & 0 deletions crates/google-workspace-cli/src/helpers/gmail/reply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,10 +371,35 @@ fn create_reply_raw_message(
finalize_message(mb, body, envelope.html, attachments)
}

fn wrap_line(line: &str, max_len: usize) -> Vec<String> {
if line.len() <= max_len {
return vec![line.to_string()];
}
// word-wrap at max_len, breaking on spaces
let mut result = Vec::new();
let mut current = String::new();
for word in line.split(' ') {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= max_len {
current.push(' ');
current.push_str(word);
} else {
result.push(current.clone());
current = word.to_string();
}
}
if !current.is_empty() {
result.push(current);
}
result
}
Comment on lines +374 to +396
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of wrap_line does not wrap individual words that are longer than max_len. If an email contains a long unbroken string (such as a URL, a base64 block, or a long path), line.split(' ') will produce a single word exceeding max_len. Since the function does not split words longer than max_len, the line will remain unwrapped and will still exceed the SMTP/MIME 76-character limit once prefixed with > . This will trigger the exact Quoted-Printable soft-wrap corruption this PR aims to prevent.

To fix this, we should hard-wrap words longer than max_len at safe UTF-8 character boundaries.

fn wrap_line(line: &str, max_len: usize) -> Vec<String> {
    if line.len() <= max_len {
        return vec![line.to_string()];
    }
    let mut result = Vec::new();
    let mut current = String::new();
    for word in line.split(' ') {
        if word.len() > max_len {
            if !current.is_empty() {
                result.push(current.clone());
                current.clear();
            }
            let mut start = 0;
            while start < word.len() {
                let mut end = start + max_len;
                if end >= word.len() {
                    current = word[start..].to_string();
                    break;
                }
                while !word.is_char_boundary(end) {
                    end -= 1;
                }
                if end == start {
                    end = start + 1;
                    while end < word.len() && !word.is_char_boundary(end) {
                        end += 1;
                    }
                }
                result.push(word[start..end].to_string());
                start = end;
            }
        } else {
            if current.is_empty() {
                current.push_str(word);
            } else if current.len() + 1 + word.len() <= max_len {
                current.push(' ');
                current.push_str(word);
            } else {
                result.push(current.clone());
                current = word.to_string();
            }
        }
    }
    if !current.is_empty() {
        result.push(current);
    }
    result
}
References
  1. Avoid introducing changes that are outside the primary goal of a pull request to prevent scope creep.


fn format_quoted_original(original: &OriginalMessage) -> String {
let quoted_body: String = original
.body_text
.lines()
.flat_map(|line| wrap_line(line, 73))
.map(|line| format!("> {}", line))
.collect::<Vec<_>>()
.join("\r\n");
Expand Down Expand Up @@ -1300,6 +1325,54 @@ mod tests {
assert!(quoted.contains("> Hello"));
}

#[test]
fn test_format_quoted_original_long_lines_do_not_exceed_75_chars() {
// Lines near 70–75 chars would exceed 76 chars after "> " prefix without pre-wrapping.
let long_line = "This is a fairly long line of text that sits right around seventy-four characters.";
assert!(long_line.len() > 73);
let original = OriginalMessage {
from: Mailbox::parse("alice@example.com"),
date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()),
body_text: long_line.to_string(),
..Default::default()
};
let quoted = format_quoted_original(&original);
for line in quoted.lines() {
assert!(
line.len() <= 76,
"Line exceeds 76 chars ({} chars): {:?}",
line.len(),
line
);
}
}

#[test]
fn test_word_wrap_preserves_short_lines() {
let short = "Hello, world!";
let wrapped = wrap_line(short, 73);
assert_eq!(wrapped, vec!["Hello, world!"]);
}

#[test]
fn test_word_wrap_breaks_long_line() {
// Build a line that is >73 chars
let line = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13";
assert!(line.len() > 73);
let wrapped = wrap_line(line, 73);
assert!(wrapped.len() > 1, "Long line should be split into multiple chunks");
for chunk in &wrapped {
assert!(
chunk.len() <= 73,
"Chunk exceeds 73 chars ({} chars): {:?}",
chunk.len(),
chunk
);
}
// Reassembling should recover the original
assert_eq!(wrapped.join(" "), line);
}

// --- end-to-end --to behavioral tests ---

#[test]
Expand Down
Loading