Files
ElyOffice/crates/ely-mail-engine/src/store.rs
T
2026-10-19 21:21:27 -04:00

229 lines
7.0 KiB
Rust

//! The local index, the originals and the search index.
use std::{
fs, io,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
use crate::{Error, Result};
mod accounts;
mod aside;
mod folders;
mod html;
mod messages;
mod schema;
mod search;
#[cfg(test)]
mod tests;
pub use accounts::Cached;
pub use folders::{Held, Synced};
pub use messages::{Fetched, Key, Stored};
const DB: &str = "mail.db";
const CONTENT: &str = "content";
const BACKUPS: &str = "backups";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AccountId(pub i64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MailboxId(pub i64);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MessageId(pub i64);
/// Bytes on disk per kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sizes {
pub index: u64,
pub search: u64,
pub originals: u64,
}
pub struct Store {
conn: Connection,
dir: PathBuf,
/// Why writes are refused, when they are.
read_only: Option<String>,
}
impl Store {
pub fn open(dir: &Path) -> Result<Self> {
open(dir, schema::STEPS)
}
pub fn read_only(&self) -> Option<&str> {
self.read_only.as_deref()
}
fn writable(&self) -> Result<()> {
match &self.read_only {
Some(reason) => Err(Error::ReadOnly(reason.clone())),
None => Ok(()),
}
}
/// The mailbox by name, made on first use.
pub fn mailbox(&self, account: AccountId, name: &str) -> Result<MailboxId> {
let name = mailbox_name(name);
let find = "SELECT id FROM mailboxes WHERE account = ?1 AND name = ?2";
if let Some(id) = self
.conn
.query_row(find, params![account.0, name], |row| row.get(0))
.optional()?
{
return Ok(MailboxId(id));
}
self.writable()?;
self.conn.execute(
"INSERT INTO mailboxes (account, name) VALUES (?1, ?2)",
params![account.0, name],
)?;
Ok(MailboxId(self.conn.last_insert_rowid()))
}
/// The index counts the WAL and shared-memory files.
pub fn sizes(&self) -> Result<Sizes> {
let mut files = 0;
for name in [DB.to_owned(), format!("{DB}-wal"), format!("{DB}-shm")] {
files += match fs::metadata(self.dir.join(&name)) {
Ok(meta) => meta.len(),
Err(err) if err.kind() == io::ErrorKind::NotFound => 0,
Err(err) => return Err(disk("measuring the index", err)),
};
}
let search: i64 = self.conn.query_row(
"SELECT coalesce(sum(pgsize), 0) FROM dbstat
WHERE name = 'search' OR name GLOB 'search_*'",
[],
|row| row.get(0),
)?;
let search = u64::try_from(search)
.map_err(|_| Error::Store(format!("a negative size: {search}")))?;
let index = files.checked_sub(search).ok_or_else(|| {
Error::Store(format!("search pages {search} exceed the files {files}"))
})?;
Ok(Sizes {
index,
search,
originals: folder_size(&self.dir.join(CONTENT))?,
})
}
}
fn open(dir: &Path, steps: &[&str]) -> Result<Store> {
let path = dir.join(DB);
fs::create_dir_all(dir.join(CONTENT)).map_err(|err| disk("making the store", err))?;
let mut conn = Connection::open(&path)?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
let version = usize::try_from(version)
.map_err(|_| Error::Store(format!("schema version {version} is negative")))?;
let target = steps.len();
let refused = if version > target {
Some(format!(
"written by a newer ElyMail (schema v{version}, this one reads v{target})"
))
} else if version < target {
upgrade(&mut conn, dir, version, steps).err()
} else {
None
};
if let Some(reason) = refused {
log::warn!("mail: store opened read-only: {reason}");
drop(conn);
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
let conn = Connection::open_with_flags(&path, flags)?;
return Ok(Store {
conn,
dir: dir.to_owned(),
read_only: Some(reason),
});
}
conn.pragma_update(None, "foreign_keys", true)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
aside::recover(&conn, dir)?;
log::info!("mail: store opened at schema v{target}");
Ok(Store {
conn,
dir: dir.to_owned(),
read_only: None,
})
}
/// A backup comes first; a failure keeps the old data.
fn upgrade(
conn: &mut Connection,
dir: &Path,
version: usize,
steps: &[&str],
) -> std::result::Result<(), String> {
if version > 0 {
backup(conn, dir, version).map_err(|err| format!("no backup before upgrade: {err}"))?;
}
migrate(conn, version, steps).map_err(|err| {
format!(
"upgrade from schema v{version} to v{} failed: {err}",
steps.len()
)
})?;
log::info!("mail: store schema v{version} upgraded to v{}", steps.len());
Ok(())
}
fn migrate(conn: &mut Connection, version: usize, steps: &[&str]) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
for step in &steps[version..] {
tx.execute_batch(step)?;
}
tx.pragma_update(None, "user_version", steps.len() as i64)?;
tx.commit()
}
fn backup(conn: &Connection, dir: &Path, version: usize) -> Result<()> {
let folder = dir.join(BACKUPS);
fs::create_dir_all(&folder).map_err(|err| disk("making the backup folder", err))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| Error::Store(format!("the clock is before 1970: {err}")))?
.as_secs();
let target = folder.join(format!("mail-v{version}-{now}.db"));
let target = target
.to_str()
.ok_or_else(|| Error::Store("the backup path is not UTF-8".into()))?;
conn.execute("VACUUM INTO ?1", [target])?;
log::info!("mail: store schema v{version} backed up before upgrade");
Ok(())
}
/// Two levels: `content/ab/abcd…`.
fn folder_size(folder: &Path) -> Result<u64> {
let mut total = 0;
for shard in fs::read_dir(folder).map_err(|err| disk("reading the originals", err))? {
let shard = shard.map_err(|err| disk("reading the originals", err))?;
for file in fs::read_dir(shard.path()).map_err(|err| disk("reading the originals", err))? {
let meta = file
.and_then(|file| file.metadata())
.map_err(|err| disk("reading the originals", err))?;
total += meta.len();
}
}
Ok(total)
}
fn disk(step: &str, err: io::Error) -> Error {
Error::Store(format!("{step}: {err}"))
}
/// RFC 3501 §5.1: INBOX in any case is INBOX.
fn mailbox_name(name: &str) -> &str {
match name.eq_ignore_ascii_case("INBOX") {
true => "INBOX",
false => name,
}
}