mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
fix(imap): encode ENVELOPE strings as IMAP literals for CR/LF and 8-bit
A mail subject or sender name containing a raw newline (or 8-bit bytes)
was emitted inside a quoted string, which IMAP forbids. The resulting
malformed FETCH response broke the client's parse of the message list,
leaving the mailbox empty in Thunderbird.
Add imap_string(): a quoted string for safe 7-bit text, a server-side
literal ({N}CRLF<octets>) when the value holds CR, LF or 8-bit bytes.
Applied to subject, message-id and envelope address fields.
Adds 6 unit tests (imap_string + a newline-subject regression); the old
imap_quote tests are migrated. Full lib suite green (259 tests).
This commit is contained in:
@@ -951,7 +951,7 @@ fn build_fetch_response(seq: usize, cached: &CachedMail, items: &str, uid_mode:
|
|||||||
fn build_envelope(cached: &CachedMail) -> String {
|
fn build_envelope(cached: &CachedMail) -> String {
|
||||||
let mail = &cached.mail;
|
let mail = &cached.mail;
|
||||||
let date = format_internal_date(mail.receivedDate.as_millis());
|
let date = format_internal_date(mail.receivedDate.as_millis());
|
||||||
let subject = imap_quote(&mail.subject);
|
let subject = imap_string(&mail.subject);
|
||||||
|
|
||||||
let from = format_envelope_addr(&mail.sender);
|
let from = format_envelope_addr(&mail.sender);
|
||||||
let to = cached
|
let to = cached
|
||||||
@@ -973,12 +973,26 @@ fn build_envelope(cached: &CachedMail) -> String {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|id| format!("<{}.{}@tutabridge.local>", id.list_id, id.element_id))
|
.map(|id| format!("<{}.{}@tutabridge.local>", id.list_id, id.element_id))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let msg_id = imap_string(&msg_id);
|
||||||
|
|
||||||
format!("(\"{date}\" \"{subject}\" ({from}) ({from}) ({from}) ({to}) NIL NIL NIL \"{msg_id}\")")
|
format!("(\"{date}\" {subject} ({from}) ({from}) ({from}) ({to}) NIL NIL NIL {msg_id})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn imap_quote(s: &str) -> String {
|
/// Encode a string as an IMAP nstring. Safe 7-bit text becomes a quoted string;
|
||||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
/// a value holding CR, LF, or 8-bit bytes becomes a server-side literal
|
||||||
|
/// ({N}CRLF<octets>), since a quoted string may not contain any of those. An
|
||||||
|
/// empty value becomes "". Without this, a mail subject or sender name with an
|
||||||
|
/// embedded newline produces a malformed FETCH response and the client cannot
|
||||||
|
/// parse the message list (an empty mailbox in Thunderbird).
|
||||||
|
fn imap_string(s: &str) -> String {
|
||||||
|
if s.is_empty() {
|
||||||
|
return "\"\"".to_string();
|
||||||
|
}
|
||||||
|
if s.bytes().any(|b| b == b'\r' || b == b'\n' || b >= 0x80) {
|
||||||
|
format!("{{{}}}\r\n{}", s.len(), s)
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_envelope_addr(addr: &tutasdk::entities::generated::tutanota::MailAddress) -> String {
|
fn format_envelope_addr(addr: &tutasdk::entities::generated::tutanota::MailAddress) -> String {
|
||||||
@@ -986,9 +1000,14 @@ fn format_envelope_addr(addr: &tutasdk::entities::generated::tutanota::MailAddre
|
|||||||
let name = if addr.name.is_empty() {
|
let name = if addr.name.is_empty() {
|
||||||
"NIL".to_string()
|
"NIL".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("\"{}\"", addr.name.replace('"', "'"))
|
imap_string(&addr.name)
|
||||||
};
|
};
|
||||||
format!("({} NIL \"{}\" \"{}\")", name, user, domain)
|
format!(
|
||||||
|
"({} NIL {} {})",
|
||||||
|
name,
|
||||||
|
imap_string(user),
|
||||||
|
imap_string(domain)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_seq_num(s: &str, max: u32) -> u32 {
|
fn parse_seq_num(s: &str, max: u32) -> u32 {
|
||||||
@@ -1199,6 +1218,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- imap_string / ENVELOPE encoding ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imap_string_quotes_plain_ascii() {
|
||||||
|
assert_eq!(imap_string("Hello world"), "\"Hello world\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imap_string_escapes_quote_and_backslash() {
|
||||||
|
assert_eq!(imap_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imap_string_empty_is_empty_quotes() {
|
||||||
|
assert_eq!(imap_string(""), "\"\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imap_string_uses_literal_for_newline() {
|
||||||
|
// A quoted string may not contain CR/LF, so it must become a literal.
|
||||||
|
let s = "La\nselection";
|
||||||
|
assert_eq!(imap_string(s), format!("{{{}}}\r\n{}", s.len(), s));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imap_string_uses_literal_for_8bit() {
|
||||||
|
let s = "sélection"; // UTF-8 'é' is 8-bit
|
||||||
|
assert_eq!(imap_string(s), format!("{{{}}}\r\n{}", s.len(), s));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_envelope_newline_subject_uses_literal() {
|
||||||
|
// Regression: a newline in the subject used to be emitted as a quoted
|
||||||
|
// string, producing a malformed FETCH response that broke the client's
|
||||||
|
// parse of the message list (an empty mailbox in Thunderbird).
|
||||||
|
let subj = "La\nselection Courtois - Le bien du mois";
|
||||||
|
let env = build_envelope(&make_test_mail(1, subj, false));
|
||||||
|
assert!(
|
||||||
|
env.contains(&format!("{{{}}}\r\n{}", subj.len(), subj)),
|
||||||
|
"subject must be a literal, got: {env}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!env.contains("\"La"),
|
||||||
|
"subject must not be a quoted string holding the newline: {env}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- parse_command ---
|
// --- parse_command ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1420,8 +1486,11 @@ mod tests {
|
|||||||
_errors: Default::default(),
|
_errors: Default::default(),
|
||||||
};
|
};
|
||||||
let result = format_envelope_addr(&addr);
|
let result = format_envelope_addr(&addr);
|
||||||
// Inner quotes replaced with single quotes, wrapped in IMAP string delimiters
|
// Inner quotes are backslash-escaped inside the IMAP quoted string.
|
||||||
assert_eq!(result, "(\"John 'JD' Doe\" NIL \"john\" \"example.com\")");
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"(\"John \\\"JD\\\" Doe\" NIL \"john\" \"example.com\")"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- extract_headers (via rfc2822 module) ---
|
// --- extract_headers (via rfc2822 module) ---
|
||||||
@@ -1488,28 +1557,6 @@ mod tests {
|
|||||||
assert_eq!(parse_seq_num("-1", 100), 0);
|
assert_eq!(parse_seq_num("-1", 100), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- imap_quote ---
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_imap_quote_plain() {
|
|
||||||
assert_eq!(imap_quote("Hello World"), "Hello World");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_imap_quote_with_quotes() {
|
|
||||||
assert_eq!(imap_quote("He said \"hello\""), "He said \\\"hello\\\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_imap_quote_with_backslash() {
|
|
||||||
assert_eq!(imap_quote("path\\to\\file"), "path\\\\to\\\\file");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_imap_quote_both() {
|
|
||||||
assert_eq!(imap_quote("a\"b\\c"), "a\\\"b\\\\c");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- build_envelope: subject with quotes ---
|
// --- build_envelope: subject with quotes ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user