chore: sync mailcore v0.1.0-beta.1

This commit is contained in:
Eugen Kaparulin
2026-04-17 09:05:47 +03:00
parent 2a4f740cbd
commit 90c8571d26
19 changed files with 4191 additions and 0 deletions

1210
mailcore/src/imap/client.rs Normal file

File diff suppressed because it is too large Load Diff

3
mailcore/src/imap/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod client;
pub mod utf7;
pub use client::ImapClient;

215
mailcore/src/imap/utf7.rs Normal file
View File

@@ -0,0 +1,215 @@
/// RFC 3501 modified UTF-7 decoder for IMAP mailbox names
///
/// Modified UTF-7 (RFC 3501) encoding rules:
/// - Printable US-ASCII characters (0x20-0x7e, except "&") are represented by themselves
/// - "&" is represented by "&-"
/// - All other characters are represented as "&" + modified-BASE64(UTF-16BE) + "-"
/// - Modified BASE64 uses "A-Za-z0-9,+" instead of "A-Za-z0-9+/"
pub fn decode_modified_utf7(encoded: &str) -> String {
let bytes = encoded.as_bytes();
let mut result = String::new();
let mut i = 0;
while i < bytes.len() {
let byte = bytes[i];
// Printable ASCII (except '&') - pass through
if byte != b'&' && byte >= 0x20 && byte <= 0x7e {
result.push(byte as char);
i += 1;
continue;
}
// "&" starts a sequence
if byte == b'&' {
if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
// "&-" represents literal "&"
result.push('&');
i += 2;
continue;
}
// Find the closing "-"
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'-' {
j += 1;
}
if j >= bytes.len() {
// Malformed: no closing "-", treat as literal
result.push('&');
i += 1;
continue;
}
// Extract the modified-BASE64 sequence
let b64_str = std::str::from_utf8(&bytes[i + 1..j]).unwrap_or("");
// Decode modified-BASE64 (replace "," with "/")
let b64_std = b64_str.replace(',', "/");
// Decode from standard BASE64
if let Ok(decoded_bytes) = base64_decode(&b64_std) {
// Convert UTF-16BE bytes to UTF-16 chars, then to UTF-8
if let Ok(text) = decode_utf16be(&decoded_bytes) {
result.push_str(&text);
} else {
// Invalid UTF-16, use replacement character
result.push('\u{FFFD}');
}
} else {
// Invalid BASE64, use replacement character
result.push('\u{FFFD}');
}
i = j + 1;
continue;
}
// Other bytes (shouldn't happen in valid UTF-7)
result.push('\u{FFFD}');
i += 1;
}
result
}
/// Decode standard BASE64 (RFC 4648)
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = Vec::new();
let input = input.as_bytes();
let mut i = 0;
while i < input.len() {
let mut buf = [0u8; 4];
let mut len = 0;
// Read up to 4 base64 characters
while len < 4 && i < input.len() {
let b = input[i];
if b == b'=' {
break;
}
if let Some(pos) = TABLE.iter().position(|&x| x == b) {
buf[len] = pos as u8;
len += 1;
} else if !b.is_ascii_whitespace() {
return Err("Invalid base64 character".to_string());
}
i += 1;
}
// Decode the 4-character group
match len {
0 => break,
1 => return Err("Invalid base64 length".to_string()),
2 => {
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
}
3 => {
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8);
}
4 => {
result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8);
result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8);
result.push((((buf[2] & 0x03) << 6) | buf[3]) as u8);
}
_ => return Err("Invalid base64 length".to_string()),
}
// Skip padding
while i < input.len() && input[i] == b'=' {
i += 1;
}
}
Ok(result)
}
/// Decode UTF-16BE bytes to UTF-8 string
fn decode_utf16be(bytes: &[u8]) -> Result<String, String> {
if bytes.len() % 2 != 0 {
return Err("Odd number of bytes in UTF-16BE sequence".to_string());
}
let mut chars = Vec::new();
let mut i = 0;
while i < bytes.len() {
let high = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
i += 2;
// Check for surrogate pair
if (high & 0xFC00) == 0xD800 {
// High surrogate, expect low surrogate
if i + 2 > bytes.len() {
chars.push('\u{FFFD}');
break;
}
let low = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
i += 2;
if (low & 0xFC00) != 0xDC00 {
chars.push('\u{FFFD}');
continue;
}
// Decode surrogate pair
let high_bits = (high & 0x3FF) as u32;
let low_bits = (low & 0x3FF) as u32;
let codepoint = 0x10000 + (high_bits << 10) + low_bits;
if let Some(ch) = char::from_u32(codepoint) {
chars.push(ch);
} else {
chars.push('\u{FFFD}');
}
} else if (high & 0xFC00) == 0xDC00 {
// Unexpected low surrogate
chars.push('\u{FFFD}');
} else {
// Regular BMP character
if let Some(ch) = char::from_u32(high as u32) {
chars.push(ch);
} else {
chars.push('\u{FFFD}');
}
}
}
Ok(chars.iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ascii_passthrough() {
assert_eq!(decode_modified_utf7("INBOX"), "INBOX");
assert_eq!(decode_modified_utf7("Sent Mail"), "Sent Mail");
assert_eq!(decode_modified_utf7("Drafts"), "Drafts");
}
#[test]
fn test_literal_ampersand() {
assert_eq!(decode_modified_utf7("A&-B"), "A&B");
assert_eq!(decode_modified_utf7("&-"), "&");
}
#[test]
fn test_mixed_ascii_and_encoded() {
// Test that ASCII parts pass through while encoded parts are decoded
// This is a basic test to ensure the structure works
assert!(decode_modified_utf7("Folder&-Name").contains("&"));
}
#[test]
fn test_decode_empty() {
assert_eq!(decode_modified_utf7(""), "");
}
}