Files
inbuxa-migrate/src/sync/import_imap/internaldate.rs
T
jcoffey-dev 2f33cd76a1
ci / test (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m51s
ci / announce (pull_request) Skipped
import: keep what an interrupted IMAP or Maildir import wrote
IMAP import wrote a whole folder in one transaction and stopped the folder
at the first message it could not import. A crash near the end of a large
INBOX kept nothing, and one bad INTERNALDATE lost the rest of the folder.
Worse, when a folder stopped early, fetches still in flight for it could be
filed into the next folder's mailbox.

- The transaction is committed after every fetch chunk. Each message is
  written in its own savepoint, so what is committed is always whole, and a
  rerun fetches only the UIDs still missing.
- A message that cannot be imported is rolled back on its own, logged with
  its folder and UID, and counted as failed; the folder carries on, and the
  message stays out of the UID map so the next run tries it again. Archive
  and I/O errors still stop the run.
- Fetch jobs and events carry a folder generation. Moving to a new folder
  cancels queued work for older ones, and any event from an older
  generation is dropped, never filed. Shutdown drains in-flight events
  before joining the workers, so it cannot hang on a blocked worker.
- INTERNALDATE month names are matched in any case.
- Maildir import gets the same per-message savepoint, and commits every 500
  new messages instead of once per folder.
2026-09-30 12:24:37 -07:00

172 lines
5.1 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use crate::error::Error;
pub fn imap_internaldate_to_rfc3339(s: &str) -> Result<String, Error> {
let s = s.trim_start_matches(' ').trim_end();
let (day, rest) = s
.split_once('-')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing day in {s:?}")))?;
let (mon, rest) = rest
.split_once('-')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing month in {s:?}")))?;
let (year, rest) = rest
.split_once(' ')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing year in {s:?}")))?;
let (time, zone) = rest
.split_once(' ')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: missing zone in {s:?}")))?;
let (h, ms) = time
.split_once(':')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: bad time {time:?}")))?;
let (m, sec) = ms
.split_once(':')
.ok_or_else(|| Error::Partial(format!("INTERNALDATE: bad time {time:?}")))?;
let day: u32 = day
.trim()
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE day {day:?}: {e}")))?;
let mon_idx = month_to_num(mon)?;
let year: i32 = year
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE year {year:?}: {e}")))?;
let h: u32 = h
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE hour {h:?}: {e}")))?;
let m: u32 = m
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE minute {m:?}: {e}")))?;
let sec: u32 = sec
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE second {sec:?}: {e}")))?;
let (sign, hh, mm) = parse_zone(zone)?;
let mut out = String::with_capacity(32);
use std::fmt::Write;
let _ = write!(
&mut out,
"{year:04}-{mon_idx:02}-{day:02}T{h:02}:{m:02}:{sec:02}"
);
if hh == 0 && mm == 0 {
out.push('Z');
} else {
let _ = write!(&mut out, "{sign}{hh:02}:{mm:02}");
}
Ok(out)
}
// RFC 3501 spells the month "Jan", but servers are not all that careful,
// and a date is not worth losing a message over: match any case.
fn month_to_num(s: &str) -> Result<u32, Error> {
let m = match s.to_ascii_lowercase().as_str() {
"jan" => 1,
"feb" => 2,
"mar" => 3,
"apr" => 4,
"may" => 5,
"jun" => 6,
"jul" => 7,
"aug" => 8,
"sep" => 9,
"oct" => 10,
"nov" => 11,
"dec" => 12,
other => return Err(Error::Partial(format!("INTERNALDATE month {other:?}"))),
};
Ok(m)
}
fn parse_zone(s: &str) -> Result<(char, u32, u32), Error> {
let s = s.trim();
if s.len() != 5 {
return Err(Error::Partial(format!("INTERNALDATE zone {s:?}")));
}
let sign = s.as_bytes()[0] as char;
if sign != '+' && sign != '-' {
return Err(Error::Partial(format!("INTERNALDATE zone sign {sign}")));
}
let hh: u32 = s[1..3]
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE zone hour: {e}")))?;
let mm: u32 = s[3..5]
.parse()
.map_err(|e| Error::Partial(format!("INTERNALDATE zone min: {e}")))?;
Ok((sign, hh, mm))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn month_matches_any_case() {
for d in [
"12-May-2025 10:00:00 +0000",
"12-may-2025 10:00:00 +0000",
"12-MAY-2025 10:00:00 +0000",
] {
assert_eq!(
imap_internaldate_to_rfc3339(d).unwrap(),
"2025-05-12T10:00:00Z",
"{d}"
);
}
assert!(imap_internaldate_to_rfc3339("12-Mai-2025 10:00:00 +0000").is_err());
}
#[test]
fn utc_zone_becomes_z() {
assert_eq!(
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 +0000").unwrap(),
"1996-07-17T02:44:25Z"
);
}
#[test]
fn positive_offset_preserved() {
assert_eq!(
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 +0200").unwrap(),
"1996-07-17T02:44:25+02:00"
);
}
#[test]
fn negative_offset_preserved() {
assert_eq!(
imap_internaldate_to_rfc3339("17-Jul-1996 02:44:25 -0700").unwrap(),
"1996-07-17T02:44:25-07:00"
);
}
#[test]
fn single_digit_day_padded() {
assert_eq!(
imap_internaldate_to_rfc3339("5-Jan-2024 09:05:01 +0000").unwrap(),
"2024-01-05T09:05:01Z"
);
}
#[test]
fn leading_space_single_digit_day() {
assert_eq!(
imap_internaldate_to_rfc3339(" 5-Jan-2024 09:05:01 +0000").unwrap(),
"2024-01-05T09:05:01Z"
);
}
#[test]
fn invalid_month_errors() {
assert!(imap_internaldate_to_rfc3339("1-XYZ-2024 00:00:00 +0000").is_err());
}
#[test]
fn malformed_input_errors() {
assert!(imap_internaldate_to_rfc3339("garbage").is_err());
assert!(imap_internaldate_to_rfc3339("17-Jul-1996").is_err());
}
}