use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub(crate) fn ledger_path(archive_dir: &Path) -> PathBuf {
archive_dir.join("insert-ledger.jsonl")
}
fn ledger_lock_path(archive_dir: &Path) -> PathBuf {
archive_dir.join("insert-ledger.lock")
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum InsertOrigin {
Inserted,
RemoteProbe,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct InsertLedgerRecord {
pub(crate) destination: String,
pub(crate) key: String,
pub(crate) source_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) inserted_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) inserted_thread_id: Option<String>,
pub(crate) inserted_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) label_ids: Vec<String>,
pub(crate) origin: InsertOrigin,
}
#[derive(Debug, Default)]
pub(crate) struct InsertLedger(BTreeMap<(String, String), InsertLedgerRecord>);
impl InsertLedger {
pub(crate) fn load(path: &Path) -> Result<Self> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(e)
.with_context(|| format!("Failed to read insert ledger at {}", path.display()))
}
};
let mut map = BTreeMap::new();
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let record: InsertLedgerRecord = serde_json::from_str(line).with_context(|| {
format!(
"Failed to parse insert ledger line {} at {} — this ledger is the sole \
record of what `gmail insert` has already inserted into a live mailbox; a \
silently-discarded ledger means the next run duplicates every message it \
covers. Fix the line or restore the ledger from a backup before re-running \
`gmail insert` (recovery from a lost ledger is `--verify-remote`)",
i + 1,
path.display()
)
})?;
map.insert((record.destination.clone(), record.key.clone()), record);
}
Ok(Self(map))
}
pub(crate) fn contains(&self, destination: &str, key: &str) -> bool {
self.0
.contains_key(&(destination.to_string(), key.to_string()))
}
pub(crate) fn record(&mut self, record: InsertLedgerRecord) {
self.0
.insert((record.destination.clone(), record.key.clone()), record);
}
pub(crate) fn save(&self, path: &Path) -> Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let dir = dir.unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)
.with_context(|| format!("Failed to create a temp file in {}", dir.display()))?;
for record in self.0.values() {
serde_json::to_writer(&mut tmp, record)
.context("Failed to serialise an insert ledger record")?;
tmp.write_all(b"\n")
.context("Failed to write an insert ledger record")?;
}
tmp.flush().context("Failed to flush the insert ledger")?;
tmp.persist(path)
.map_err(|e| e.error)
.with_context(|| format!("Failed to publish insert ledger to {}", path.display()))?;
Ok(())
}
}
pub(crate) fn dedupe_key(rfc822_msgid: Option<&str>, source_id: &str) -> String {
match rfc822_msgid.map(str::trim).filter(|s| !s.is_empty()) {
Some(msgid) => msgid
.trim_start_matches('<')
.trim_end_matches('>')
.to_string(),
None => format!("archive-id:{source_id}"),
}
}
#[derive(Debug)]
pub(crate) struct LedgerLock {
#[allow(dead_code)] inner: crate::daemon::paths::FileLock,
}
impl LedgerLock {
pub(crate) fn acquire(archive_dir: &Path) -> Result<Self> {
let path = ledger_lock_path(archive_dir);
match crate::daemon::paths::try_lock_or_busy(&path, "the insert-ledger lock file")? {
Some(inner) => Ok(Self { inner }),
None => anyhow::bail!(
"another `gmail insert` run appears to already be in progress against this \
archive dir ({} is locked) — concurrent runs would clobber each other's ledger",
path.display()
),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_record(destination: &str, key: &str) -> InsertLedgerRecord {
InsertLedgerRecord {
destination: destination.to_string(),
key: key.to_string(),
source_id: "src1".to_string(),
inserted_id: Some("new1".to_string()),
inserted_thread_id: Some("thread1".to_string()),
inserted_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
label_ids: vec!["INBOX".to_string()],
origin: InsertOrigin::Inserted,
}
}
#[test]
fn dedupe_key_strips_angle_brackets_and_whitespace() {
assert_eq!(
dedupe_key(Some(" <abc123@example.com> "), "src1"),
"abc123@example.com"
);
}
#[test]
fn dedupe_key_preserves_case() {
assert_eq!(
dedupe_key(Some("<AbC@Example.com>"), "src1"),
"AbC@Example.com"
);
}
#[test]
fn dedupe_key_falls_back_to_archive_id_sentinel_when_absent() {
assert_eq!(dedupe_key(None, "src1"), "archive-id:src1");
}
#[test]
fn dedupe_key_falls_back_when_msgid_is_blank() {
assert_eq!(dedupe_key(Some(" "), "src1"), "archive-id:src1");
}
#[test]
fn load_absent_file_is_an_empty_ledger() {
let dir = tempfile::tempdir().unwrap();
let ledger = InsertLedger::load(&dir.path().join("insert-ledger.jsonl")).unwrap();
assert!(!ledger.contains("a@example.com", "k1"));
}
#[test]
fn load_rejects_a_corrupt_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
std::fs::write(&path, "not json at all\n").unwrap();
let err = InsertLedger::load(&path).unwrap_err();
assert!(err.to_string().contains("Failed to parse insert ledger"));
}
#[test]
fn load_skips_blank_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
let record = sample_record("a@example.com", "k1");
std::fs::write(
&path,
format!("\n{}\n\n", serde_json::to_string(&record).unwrap()),
)
.unwrap();
let ledger = InsertLedger::load(&path).unwrap();
assert!(ledger.contains("a@example.com", "k1"));
}
#[test]
fn load_surfaces_a_non_not_found_read_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
std::fs::create_dir(&path).unwrap();
let err = InsertLedger::load(&path).unwrap_err();
assert!(err.to_string().contains("Failed to read insert ledger"));
}
#[test]
fn save_then_load_round_trips_a_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
let mut ledger = InsertLedger::default();
ledger.record(sample_record("a@example.com", "k1"));
ledger.save(&path).unwrap();
let loaded = InsertLedger::load(&path).unwrap();
assert!(loaded.contains("a@example.com", "k1"));
}
#[test]
fn save_is_atomic_and_leaves_no_temp_file_behind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
let mut ledger = InsertLedger::default();
ledger.record(sample_record("a@example.com", "k1"));
ledger.save(&path).unwrap();
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.path() != path)
.collect();
assert!(leftover.is_empty(), "expected no leftover temp files");
}
#[test]
fn contains_is_scoped_to_the_destination_address() {
let mut ledger = InsertLedger::default();
ledger.record(sample_record("a@example.com", "k1"));
assert!(ledger.contains("a@example.com", "k1"));
assert!(!ledger.contains("b@example.com", "k1"));
}
#[test]
fn record_replaces_an_existing_entry_for_the_same_key() {
let mut ledger = InsertLedger::default();
ledger.record(sample_record("a@example.com", "k1"));
let mut updated = sample_record("a@example.com", "k1");
updated.inserted_id = Some("different-id".to_string());
ledger.record(updated);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert-ledger.jsonl");
ledger.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 1, "must not duplicate the entry");
assert!(text.contains("different-id"));
}
#[cfg(unix)]
#[test]
fn ledger_lock_file_persists_after_drop_and_is_re_lockable() {
let dir = tempfile::tempdir().unwrap();
let lock_path = ledger_lock_path(dir.path());
let lock = LedgerLock::acquire(dir.path()).unwrap();
assert!(lock_path.exists());
drop(lock);
assert!(
lock_path.exists(),
"the lock file must survive its own drop"
);
LedgerLock::acquire(dir.path()).unwrap();
}
#[test]
fn ledger_lock_acquire_fails_while_another_is_held() {
let dir = tempfile::tempdir().unwrap();
let _lock = LedgerLock::acquire(dir.path()).unwrap();
let err = LedgerLock::acquire(dir.path()).unwrap_err();
assert!(err.to_string().contains("already be in progress"));
}
#[cfg(unix)]
#[test]
fn ledger_lock_acquire_reports_a_non_collision_failure_distinctly() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let err = LedgerLock::acquire(dir.path()).unwrap_err();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(err.to_string().contains("failed to lock"), "{err:?}");
assert!(
!err.to_string().contains("already be in progress"),
"{err:?}"
);
}
}