= LazyLock::new(|| Regex::new(r#"]*>"#
static DIV_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap());
-/// Compiled word-boundary regexes for each named CSS color, ensuring exact
-/// matching so that e.g. "white" does not match inside "whitesmoke".
static NAMED_COLOR_REGEXES: LazyLock > = LazyLock::new(|| {
NAMED_COLORS
.iter()
@@ -65,7 +63,6 @@ static NAMED_COLOR_REGEXES: LazyLock> = LazyLock::new
.collect()
});
-/// Named CSS colors mapped to RGB values.
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
static NAMED_COLORS: LazyLock = LazyLock::new(|| {
vec![
@@ -132,15 +129,6 @@ impl ToSql for EmailTheme {
}
}
-/// Detect whether an email's HTML is light, dark, transparent, or adaptive.
-///
-/// Detection priority:
-/// 1. If the email has `@media(prefers-color-scheme:dark)` rules with background
-/// overrides, classify as Adaptive (it handles dark mode natively).
-/// 2. Extract background colors from body, table, td, div elements (inline styles,
-/// style tags, bgcolor attrs). Group by luminance (light vs dark). If one group
-/// dominates (>= 50% of total weight), return that group's classification.
-/// 3. If no backgrounds found, return Transparent.
pub fn detect_email_theme(html: &str) -> Option {
if html.trim().is_empty() {
return None;
@@ -148,9 +136,6 @@ pub fn detect_email_theme(html: &str) -> Option {
let decoded = decode_quoted_printable(html);
- // Check for @media(prefers-color-scheme:dark) with background overrides.
- // These emails handle their own dark adaptation — classify as Adaptive
- // so the frontend sets color-scheme and lets the email CSS handle it.
if has_dark_mode_media_query(&decoded) {
return Some(EmailTheme::Adaptive);
}
@@ -164,8 +149,6 @@ pub fn detect_email_theme(html: &str) -> Option {
classify_by_luminance_groups(&backgrounds)
}
-/// Check if the HTML has a @media(prefers-color-scheme:dark) block that sets
-/// background colors, indicating the email handles dark mode natively.
fn has_dark_mode_media_query(html: &str) -> bool {
let lower = html.to_lowercase();
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
@@ -177,8 +160,6 @@ fn has_dark_mode_media_query(html: &str) -> bool {
false
}
-/// Group all extracted colors into "light" and "dark" buckets by their
-/// luminance, then check which bucket dominates.
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option {
let mut light_weight: f32 = 0.0;
let mut dark_weight: f32 = 0.0;
@@ -205,20 +186,12 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option= 0.5 {
- // If the only signal is from a wrapper table/td with white background
- // and there's no body-level background, treat as transparent.
- // A lone "white" on a wrapper element is usually just a reset, not
- // intentional theming.
if !has_body_background && is_only_white_wrapper(colors) {
return Some(EmailTheme::Transparent);
}
Some(EmailTheme::Light)
} else if dark_coverage >= 0.5 {
- // Guard against tiny accent elements being the only signal:
- // if total weight is very low (only 1-2 low-weight elements like td),
- // that's likely an accent, not a real background.
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
Some(EmailTheme::Transparent)
} else {
@@ -229,19 +202,15 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option bool {
if colors.is_empty() {
return false;
}
for bg in colors {
- // If there's a body-level background, it's intentional
if bg.element_weight >= 1.0 {
return false;
}
- // Only consider pure white as a wrapper reset
+
let c = bg.color.trim().to_lowercase();
if c != "#ffffff" && c != "white" {
return false;
@@ -273,21 +242,16 @@ fn extract_background_colors(html: &str) -> Vec {
extract_style_tag_colors(html, &mut colors);
extract_bgcolor_attrs(html, &mut colors);
- // Deduplicate: if the same color appears on the same element from both
- // bgcolor and style attributes, keep only the one with higher weight.
deduplicate_colors(&mut colors);
colors
}
-/// Remove duplicate color entries that likely come from the same element
-/// having both bgcolor="X" and style="background-color:X".
fn deduplicate_colors(colors: &mut Vec) {
if colors.len() <= 1 {
return;
}
- // Track seen (normalized_color, weight) pairs and remove exact duplicates
let mut seen: HashSet = HashSet::new();
colors.retain(|bg| {
let key = format!("{}:{:.1}", bg.color, bg.element_weight);
@@ -334,7 +298,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) {
continue;
}
- // Extract from body{...} rules
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
let body_styles = &body_cap[1];
if body_styles.to_lowercase().contains("background") {
@@ -342,7 +305,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) {
}
}
- // Extract from class rules that mention background
for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
let class_styles = &class_cap[1];
if class_styles.to_lowercase().contains("background") {
@@ -350,7 +312,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) {
}
}
- // Extract from element-level rules (td{...}, table{...}, etc.)
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
let tag = elem_cap[1].to_lowercase();
let elem_styles = &elem_cap[2];
@@ -368,12 +329,9 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec) {
}
}
-/// Extract color values only from background/background-color properties in a CSS rule body.
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec) {
let lower = css.to_lowercase();
- // Only extract colors from background/background-color property values,
- // not from the entire CSS rule (which may contain color, border-color, etc.)
for bg_cap in BG_PROP_RE.captures_iter(&lower) {
let bg_value = bg_cap[1].trim();
@@ -425,8 +383,6 @@ fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec) {
}
}
-/// A color extracted from an inline style, with its alpha value.
struct ExtractedColor {
color: String,
alpha: f32,
@@ -478,8 +433,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec {
.map(|m| m.as_str().to_lowercase())
.unwrap_or_default();
- // Only extract colors from background/background-color property values,
- // not from the entire style string (which may contain border-color, color, etc.)
for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
let bg_value = bg_cap[1].trim();
@@ -512,8 +465,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec {
});
}
- // Check for named colors using word-boundary matching to avoid
- // false positives (e.g. "white" matching inside "whitesmoke").
for (name, re) in NAMED_COLOR_REGEXES.iter() {
if re.is_match(bg_value) {
colors.push(ExtractedColor {
@@ -529,8 +480,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec {
colors
}
-/// Parse the alpha channel from an 8-digit hex color (e.g. `#rrggbbaa`).
-/// Returns 1.0 for 3/6-digit hex or if parsing fails.
fn parse_hex_alpha(color: &str) -> f32 {
let c = color.trim().to_lowercase();
if let Some(hex) = c.strip_prefix('#') {
@@ -545,7 +494,6 @@ fn parse_hex_alpha(color: &str) -> f32 {
1.0
}
-/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`).
fn parse_alpha_str(s: &str) -> f32 {
if let Some(pct) = s.strip_suffix('%') {
pct.parse::().unwrap_or(100.0) / 100.0
@@ -577,7 +525,6 @@ fn normalize_color(color: &str) -> String {
}
6 => c,
8 => {
- // 8-digit hex: strip alpha channel
format!("#{}", &hex[0..6])
}
_ => c,
@@ -595,7 +542,6 @@ fn calculate_luminance(color: &str) -> f64 {
fn parse_color(color: &str) -> (u8, u8, u8) {
let c = color.trim().to_lowercase();
- // Check named colors first
for (name, rgb) in NAMED_COLORS.iter() {
if c == *name {
return *rgb;
@@ -629,7 +575,6 @@ fn parse_color(color: &str) -> (u8, u8, u8) {
);
}
- // Unknown color - default to white (high luminance = light, safe fallback)
(255, 255, 255)
}
@@ -686,7 +631,6 @@ mod tests {
#[test]
fn test_luminance_grouping_mixed_light_colors() {
- // Multiple light colors should still classify as Light
let html = r#"
@@ -700,29 +644,24 @@ mod tests {
#[test]
fn test_accent_td_not_treated_as_background() {
- // A single td with a dark accent color shouldn't classify the email as dark
let html = r##""##;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
}
#[test]
fn test_deduplicate_bgcolor_and_style() {
- // Same color from both bgcolor and style on same td should be deduplicated
let html = r##""##;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
}
#[test]
fn test_lone_white_wrapper_table_is_transparent() {
- // A wrapper table with background-color:white but no body background
- // should be treated as transparent (boilerplate reset)
let html = r#""#;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
}
#[test]
fn test_named_color_white() {
- // Body with white background is intentional
let html = r#"Content"#;
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
}
@@ -760,8 +699,6 @@ mod tests {
#[test]
fn test_white_does_not_match_whitesmoke() {
- // "whitesmoke" is a distinct named color (very light gray, RGB 245,245,245).
- // It must NOT be misidentified as "white".
let html = r#"Content"#;
let colors = extract_background_colors(html);
assert_eq!(colors.len(), 1);
@@ -810,7 +747,6 @@ mod tests {
#[test]
fn test_named_color_in_style_tag_exact_match() {
- // Ensure style-tag extraction also uses exact matching
let html = r#"Content"#;
let colors = extract_background_colors(html);
assert!(
@@ -826,8 +762,6 @@ mod tests {
#[test]
fn test_rgba_half_alpha_reduced_weight() {
- // rgba(0,0,0,0.5) on body should have weight 1.0 * 0.5 = 0.5
- // compared to rgb(0,0,0) which would have weight 1.0
let html_rgba =
r#"Content"#;
let html_rgb = r#"Content"#;
@@ -851,7 +785,6 @@ mod tests {
#[test]
fn test_rgba_fully_transparent_ignored() {
- // rgba(0,0,0,0) should be skipped entirely (alpha < 0.1)
let html = r#"Content"#;
let colors = extract_background_colors(html);
assert!(
@@ -863,7 +796,6 @@ mod tests {
#[test]
fn test_rgba_full_alpha_same_as_rgb() {
- // rgba(255,255,255,1.0) should behave identically to rgb(255,255,255)
let html_rgba =
r#"Content"#;
let html_rgb =
@@ -885,7 +817,6 @@ mod tests {
#[test]
fn test_rgba_in_style_tag() {
- // RGBA in a Content"#;
let colors = extract_background_colors(html);
assert_eq!(colors.len(), 1);
diff --git a/src/error.rs b/src/error.rs
index 700c186..37bfb14 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -2,13 +2,11 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
-/// Structured JSON error envelope returned to clients.
#[derive(Debug, Serialize)]
struct ErrorEnvelope {
error: ErrorBody,
}
-/// Body of the error envelope.
#[derive(Debug, Serialize)]
struct ErrorBody {
code: String,
@@ -16,29 +14,25 @@ struct ErrorBody {
status: u16,
}
-/// Application-level error type that converts into an Axum response
-/// with a structured JSON error envelope.
#[derive(Debug)]
#[allow(dead_code)]
pub enum AppError {
- /// Internal server error (500).
InternalError(String),
- /// Resource not found (404).
+
NotFound(String),
- /// Unauthorized access (401).
+
Unauthorized(String),
- /// Bad request (400).
+
BadRequest(String),
- /// Authenticated but forbidden by policy (403).
+
Forbidden(String),
- /// Service unavailable (503).
+
ServiceUnavailable(String),
- /// Request payload is larger than the configured mail limit (413).
+
PayloadTooLarge(String),
}
impl AppError {
- /// Returns the HTTP status code for this error variant.
fn status_code(&self) -> StatusCode {
match self {
AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
@@ -51,7 +45,6 @@ impl AppError {
}
}
- /// Returns the error code string for this error variant.
fn error_code(&self) -> &'static str {
match self {
AppError::InternalError(_) => "INTERNAL_ERROR",
@@ -64,7 +57,6 @@ impl AppError {
}
}
- /// Returns the human-readable message for this error variant.
fn message(&self) -> &str {
match self {
AppError::InternalError(msg)
diff --git a/src/imap/client.rs b/src/imap/client.rs
index 961e37f..731d86a 100644
--- a/src/imap/client.rs
+++ b/src/imap/client.rs
@@ -19,38 +19,24 @@ pub(crate) use super::connection::connect;
#[path = "mock.rs"]
pub mod mock;
-/// Abstraction over IMAP operations.
-///
-/// Every method receives explicit connection parameters so that the trait
-/// keeps its API independent of connection management.
-///
-/// The `Send + Sync` bounds allow implementations to be shared across
-/// Tokio tasks and stored in `Arc`.
#[allow(dead_code)]
#[allow(clippy::double_must_use)]
#[async_trait]
pub trait ImapClient: Send + Sync {
- /// Return the capabilities advertised by the authenticated IMAP server.
async fn capabilities(&self, creds: &ImapCredentials) -> Result, ImapError>;
- /// Drop reusable request-path connections for this account. The default
- /// implementation is a no-op for test/alternate clients.
async fn reset_connections(&self, _creds: &ImapCredentials) -> Result<(), ImapError> {
Ok(())
}
- /// List all folders (mailboxes) on the server.
async fn list_folders(&self, creds: &ImapCredentials) -> Result, ImapError>;
- /// Perform a lightweight `SELECT` on a folder to get its status
- /// (UIDVALIDITY, EXISTS count, UIDNEXT) without fetching any messages.
async fn folder_status(
&self,
creds: &ImapCredentials,
folder: &str,
) -> Result;
- /// Fetch message headers (envelopes) for a range of UIDs in a folder.
async fn fetch_headers(
&self,
creds: &ImapCredentials,
@@ -58,7 +44,6 @@ pub trait ImapClient: Send + Sync {
uid_range: &str,
) -> Result, ImapError>;
- /// Fetch the full body of a single message by UID.
async fn fetch_body(
&self,
creds: &ImapCredentials,
@@ -66,7 +51,6 @@ pub trait ImapClient: Send + Sync {
uid: u32,
) -> Result;
- /// Fetch the complete RFC822 message bytes for source/download operations.
async fn fetch_raw_message(
&self,
_creds: &ImapCredentials,
@@ -79,7 +63,6 @@ pub trait ImapClient: Send + Sync {
})
}
- /// Add flags to a message (IMAP +FLAGS).
async fn add_flags(
&self,
creds: &ImapCredentials,
@@ -88,7 +71,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str],
) -> Result<(), ImapError>;
- /// Remove flags from a message (IMAP -FLAGS).
async fn remove_flags(
&self,
creds: &ImapCredentials,
@@ -97,7 +79,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str],
) -> Result<(), ImapError>;
- /// Set (replace) the flags on a message.
async fn set_flags(
&self,
creds: &ImapCredentials,
@@ -106,7 +87,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str],
) -> Result<(), ImapError>;
- /// Move a message from one folder to another.
async fn move_message(
&self,
creds: &ImapCredentials,
@@ -115,7 +95,6 @@ pub trait ImapClient: Send + Sync {
to_folder: &str,
) -> Result<(), ImapError>;
- /// Copy a message to another folder without removing the source.
async fn copy_message(
&self,
creds: &ImapCredentials,
@@ -124,13 +103,10 @@ pub trait ImapClient: Send + Sync {
to_folder: &str,
) -> Result<(), ImapError>;
- /// Mark all messages in a folder as read.
async fn mark_all_read(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
- /// Permanently remove every message from a folder.
async fn empty_folder(&self, creds: &ImapCredentials, folder: &str) -> Result<(), ImapError>;
- /// Permanently remove a message that has the `\Deleted` flag.
async fn expunge_message(
&self,
creds: &ImapCredentials,
@@ -138,7 +114,6 @@ pub trait ImapClient: Send + Sync {
uid: u32,
) -> Result<(), ImapError>;
- /// Append a raw RFC822 message to a folder (e.g. saving sent mail to "Sent").
async fn append_message(
&self,
creds: &ImapCredentials,
@@ -147,11 +122,6 @@ pub trait ImapClient: Send + Sync {
flags: &[&str],
) -> Result<(), ImapError>;
- /// Append a message and resolve the server-assigned UID without guessing.
- ///
- /// `identity_header` and `identity_value` must uniquely identify this
- /// application-owned snapshot. Implementations only accept a UID at or
- /// above the mailbox UIDNEXT observed before APPEND.
async fn append_message_resolved_uid(
&self,
creds: &ImapCredentials,
@@ -162,14 +132,12 @@ pub trait ImapClient: Send + Sync {
identity_value: &str,
) -> Result<(u32, u32), ImapError>;
- /// Create a new folder (mailbox) and subscribe to it.
async fn create_folder(
&self,
creds: &ImapCredentials,
folder_name: &str,
) -> Result<(), ImapError>;
- /// Rename an existing folder.
async fn rename_folder(
&self,
creds: &ImapCredentials,
@@ -177,14 +145,12 @@ pub trait ImapClient: Send + Sync {
to: &str,
) -> Result<(), ImapError>;
- /// Permanently delete a folder (mailbox).
async fn delete_folder(
&self,
creds: &ImapCredentials,
folder_name: &str,
) -> Result<(), ImapError>;
- /// Subscribe to or unsubscribe from a folder.
async fn subscribe_folder(
&self,
creds: &ImapCredentials,
@@ -192,24 +158,18 @@ pub trait ImapClient: Send + Sync {
subscribe: bool,
) -> Result<(), ImapError>;
- /// Fetch only UIDs and FLAGS for all messages in a folder.
- /// Used for periodic reconciliation to detect flag changes and deletions.
async fn fetch_uids_and_flags(
&self,
creds: &ImapCredentials,
folder: &str,
) -> Result)>, ImapError>;
- /// Lightweight STATUS command to get folder metadata without SELECT.
- /// Returns UIDVALIDITY, EXISTS, UIDNEXT, UNSEEN, and HIGHESTMODSEQ.
async fn folder_status_extended(
&self,
creds: &ImapCredentials,
folder: &str,
) -> Result;
- /// Fetch only messages whose flags changed since `since_modseq` using CONDSTORE.
- /// Returns (vec of (uid, flags), new_highest_modseq).
async fn fetch_changed_flags(
&self,
creds: &ImapCredentials,
@@ -217,19 +177,14 @@ pub trait ImapClient: Send + Sync {
since_modseq: u64,
) -> Result<(Vec<(u32, Vec)>, u64), ImapError>;
- /// Fetch mailbox quota via IMAP GETQUOTAROOT.
- /// Returns `None` if the server doesn't support quotas.
async fn get_quota(&self, creds: &ImapCredentials) -> Result |