daaki-smtp 0.2.0

An async SMTP client library
Documentation
//! FUTURERELEASE parameter validation helpers.
//!
//! RFC 4865 Section 5 defines the `HOLDFOR` and `HOLDUNTIL` MAIL FROM
//! parameters. `HOLDFOR` is a positive decimal interval limited to
//! `1*9DIGIT`, and `HOLDUNTIL` uses an RFC 3339 date-time value.

use crate::error::Error;

/// RFC 4865 Section 5: `hold-for-seconds = 1*9DIGIT`.
pub(crate) const MAX_HOLDFOR_SECONDS: u64 = 999_999_999;

/// RFC 4865 Section 5: validate the `HOLDFOR` interval syntax.
pub(crate) fn validate_hold_for_seconds(hold_for: u64) -> Result<(), Error> {
    if hold_for == 0 {
        return Err(Error::Protocol(
            "HOLDFOR must be a positive decimal interval \
             (RFC 4865 Section 5: hold-for-seconds = 1*9DIGIT)"
                .into(),
        ));
    }
    if hold_for > MAX_HOLDFOR_SECONDS {
        return Err(Error::Protocol(format!(
            "HOLDFOR exceeds the RFC 4865 Section 5 9-digit limit: {hold_for}"
        )));
    }
    Ok(())
}

/// RFC 4865 Section 5 / RFC 3339 Section 5.6: validate the `HOLDUNTIL`
/// date-time syntax.
pub(crate) fn validate_hold_until_datetime(hold_until: &str) -> Result<(), Error> {
    if parse_rfc3339_to_utc_key(hold_until).is_none() {
        return Err(Error::Protocol(format!(
            "HOLDUNTIL must be a valid RFC 3339 date-time: {hold_until} \
             (RFC 4865 Section 5 / RFC 3339 Section 5.6)"
        )));
    }
    Ok(())
}

/// RFC 4865 Section 5 / RFC 3339 Section 5.6: parse an RFC 3339 date-time
/// into a UTC ordering key.
///
/// Accepted format: `YYYY-MM-DDTHH:MM:SS[.frac]` followed by `Z` or
/// `+HH:MM` / `-HH:MM`. Returns `(utc_seconds, fractional_nanos)`.
/// Returns `None` if the input is malformed.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
pub(crate) fn parse_rfc3339_to_utc_key(s: &str) -> Option<(i64, u32)> {
    // Minimum valid: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars
    if s.len() < 20 {
        return None;
    }
    let b = s.as_bytes();

    // Parse date components: YYYY-MM-DD
    let year: i32 = s.get(..4)?.parse().ok()?;
    if b.get(4)? != &b'-' {
        return None;
    }
    let month: u32 = s.get(5..7)?.parse().ok()?;
    if !(1..=12).contains(&month) || b.get(7)? != &b'-' {
        return None;
    }
    let day: u32 = s.get(8..10)?.parse().ok()?;
    if day == 0 || day > days_in_month(year, month) {
        return None;
    }

    // RFC 3339 Section 5.6 note: applications MAY use lower-case
    // "t" and "z" separators in place of "T" and "Z".
    if !matches!(b.get(10), Some(b'T' | b't')) {
        return None;
    }

    // Parse time components: HH:MM:SS
    let hour: i64 = s.get(11..13)?.parse().ok()?;
    if !(0..=23).contains(&hour) || b.get(13)? != &b':' {
        return None;
    }
    let minute: i64 = s.get(14..16)?.parse().ok()?;
    if !(0..=59).contains(&minute) || b.get(16)? != &b':' {
        return None;
    }
    let second: i64 = s.get(17..19)?.parse().ok()?;
    if !(0..=60).contains(&second) {
        return None;
    }

    // RFC 3339 Section 5.6: allow an optional fractional-second part.
    let mut pos = 19;
    let fractional_nanos = if b.get(pos) == Some(&b'.') {
        pos += 1;
        let frac_start = pos;
        while matches!(b.get(pos), Some(d) if d.is_ascii_digit()) {
            pos += 1;
        }
        if pos == frac_start {
            return None;
        }
        // Preserve up to nanosecond precision for chronological comparison.
        // Extra digits are truncated after the first 9 significant digits.
        let frac = &b[frac_start..pos];
        let mut nanos_digits = [b'0'; 9];
        for (idx, digit) in frac.iter().take(9).enumerate() {
            nanos_digits[idx] = *digit;
        }
        std::str::from_utf8(&nanos_digits).ok()?.parse().ok()?
    } else {
        0
    };

    // Parse timezone: Z or +HH:MM / -HH:MM
    let tz_rest = s.get(pos..)?;
    let tz_offset_secs: i64 = if tz_rest == "Z" || tz_rest == "z" {
        0
    } else if tz_rest.len() == 6 {
        let sign = match tz_rest.as_bytes().first()? {
            b'+' => 1i64,
            b'-' => -1i64,
            _ => return None,
        };
        let tz_h: i64 = tz_rest.get(1..3)?.parse().ok()?;
        if !(0..=23).contains(&tz_h) || tz_rest.as_bytes().get(3)? != &b':' {
            return None;
        }
        let tz_m: i64 = tz_rest.get(4..6)?.parse().ok()?;
        if !(0..=59).contains(&tz_m) {
            return None;
        }
        sign * (tz_h * 3600 + tz_m * 60)
    } else {
        return None;
    };

    // Convert (year, month, day) to days since epoch using
    // Howard Hinnant's civil_to_days algorithm.
    let y = if month <= 2 {
        i64::from(year) - 1
    } else {
        i64::from(year)
    };
    let m = if month <= 2 { month + 9 } else { month - 3 };
    let era = (if y >= 0 { y } else { y - 399 }) / 400;
    let yoe = (y - era * 400) as u64;
    let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe as i64 - 719_468;

    let utc_secs = days * 86400 + hour * 3600 + minute * 60 + second;
    // Subtract timezone offset to normalize to UTC.
    Some((utc_secs - tz_offset_secs, fractional_nanos))
}

/// RFC 3339 Section 5.7 / Appendix C: month-day validation must honor leap
/// years and per-month day counts.
fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

/// RFC 3339 Appendix C: Gregorian leap-year rule.
fn is_leap_year(year: i32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}