Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::time::Duration;
use rusqlite::params;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, Layout};
use super::{Account, Endpoint};
const BASE_IMAGE: &str = "ckulka/baikal:0.10.1-nginx";
const HTTP_PORT: u16 = 80;
const REALM: &str = "BaikalDAV";
const ADMIN_PASSWORD_HASH: &str = "004b53ae84286ba6003b34e8ef404826";
const DIGEST_USER1: &str = "b7a47e657a4a966d0d844b1211416024";
const DIGEST_USER2: &str = "9fc67834c5a099f0a2eccea3f6918169";
const DIGEST_USER3: &str = "e1b30b108dad01b323a999b0bb1252ab";
const SCHEMA: &str = include_str!("baikal_schema.sql");
const BAIKAL_YAML: &str = "system:
configured_version: '0.10.1'
timezone: 'UTC'
card_enabled: true
cal_enabled: true
invite_from: '[email protected]'
dav_auth_type: 'Basic'
admin_passwordhash: '004b53ae84286ba6003b34e8ef404826'
failed_access_message: 'user %u authentication failure for Baikal'
auth_realm: 'BaikalDAV'
base_uri: ''
database:
backend: 'sqlite'
sqlite_file: '/var/www/baikal/Specific/db/db.sqlite'
";
const DOCKERFILE: &str = r#"FROM ckulka/baikal:0.10.1-nginx
COPY baikal.yaml /var/www/baikal/config/baikal.yaml
COPY db.sqlite /var/www/baikal/Specific/db/db.sqlite
RUN touch /var/www/baikal/Specific/INSTALL_DISABLED && \
chown -R www-data:www-data /var/www/baikal/config /var/www/baikal/Specific && \
chmod 0640 /var/www/baikal/config/baikal.yaml /var/www/baikal/Specific/db/db.sqlite
"#;
pub struct Baikal {
container: Container<GenericImage>,
pub http: Endpoint,
pub accounts: Vec<Account>,
}
impl Baikal {
pub fn start() -> ContainerResult<Self> {
let db_bytes = build_sqlite_db()?;
let image: GenericImage = GenericBuildableImage::new("vandelay-baikal", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(BAIKAL_YAML.as_bytes().to_vec(), "baikal.yaml")
.with_data(db_bytes, "db.sqlite")
.build_image()
.map_err(|e| ContainerError::Seed(format!("baikal build: {e}")))?;
let _ = BASE_IMAGE;
let request = image
.with_exposed_port(HTTP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("start worker process"))
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let host = container.get_host()?.to_string();
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
http,
accounts,
})
}
pub fn base_url(&self) -> String {
self.http.http_base()
}
pub fn dav_root(&self) -> String {
format!("{}/dav.php", self.base_url())
}
pub fn calendar_home(&self, account: &Account) -> String {
format!("{}/calendars/{}/", self.dav_root(), account.username)
}
pub fn addressbook_home(&self, account: &Account) -> String {
format!("{}/addressbooks/{}/", self.dav_root(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let icals = load_icals()?;
let vcards = load_vcards()?;
let mut out = Vec::new();
for acct in &self.accounts {
let seed = self.seed_account(acct, &icals, &vcards)?;
out.push(seed);
}
Ok(out)
}
pub fn seed_account(
&self,
account: &Account,
icals: &[RawFixture],
vcards: &[RawFixture],
) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let layout = &account.layout;
let mut seed = AccountSeed::new(account.username.clone());
for (idx, cal) in layout.calendars.iter().enumerate() {
let segment = collection_segment("cal", idx, cal);
let path = format!("/calendars/{}/{}/", account.username, segment);
let mkcal = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop>
<d:displayname>{cal}</d:displayname>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/>
<c:comp name="VTODO"/>
</c:supported-calendar-component-set>
</d:prop></d:set>
</c:mkcalendar>"#
);
client.mkcalendar(&path, Some(&mkcal))?;
let mut plan = CollectionPlan::new((*cal).to_owned(), path.clone());
let count = events_per_calendar(layout, idx);
let offset = events_offset(layout, idx);
for (i, fx) in icals.iter().cycle().skip(offset).take(count).enumerate() {
let item = format!("{path}{}-{}.ics", fx.name, i);
let suffix = format!("{segment}-{i}");
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("ical fixture {} has no UID", fx.name))
})?;
client.put(&item, "text/calendar; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: item,
source: bytes,
});
}
seed.calendars.push(plan);
}
for (idx, ab) in layout.address_books.iter().enumerate() {
let segment = collection_segment("ab", idx, ab);
let path = format!("/addressbooks/{}/{}/", account.username, segment);
let mkbook = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<d:set><d:prop>
<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>
<d:displayname>{ab}</d:displayname>
</d:prop></d:set>
</d:mkcol>"#
);
client.mkcol(&path, Some(&mkbook))?;
let mut plan = CollectionPlan::new((*ab).to_owned(), path.clone());
let count = contacts_per_book(layout, idx);
let offset = contacts_offset(layout, idx);
for (i, fx) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
let item = format!("{path}{}-{}.vcf", fx.name, i);
let suffix = format!("{segment}-{i}");
let (bytes, uid) = rewrite_uid(&fx.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("vcard fixture {} has no UID", fx.name))
})?;
client.put(&item, "text/vcard; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: item,
source: bytes,
});
}
seed.address_books.push(plan);
}
Ok(seed)
}
pub fn seed_malformed_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
) -> ContainerResult<String> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let segment = collection_segment(
"cal",
calendar_segment_idx,
account.layout.calendars[calendar_segment_idx],
);
let path = format!(
"/calendars/{}/{}/broken-{tag}.ics",
account.username, segment
);
let bad = malformed_ical(tag);
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
Ok(path)
}
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
client.delete(path)?;
Ok(())
}
pub fn add_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
icals: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let display = account.layout.calendars[calendar_segment_idx];
let segment = collection_segment("cal", calendar_segment_idx, display);
let fixture = icals.first().ok_or_else(|| {
ContainerError::Seed("no ical fixture available for add_event".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
let path = format!("/calendars/{}/{segment}/added-{tag}.ics", account.username);
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn add_contact(
&self,
account: &Account,
book_segment_idx: usize,
tag: &str,
vcards: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.dav_root(), &account.username, &account.password);
let display = account.layout.address_books[book_segment_idx];
let segment = collection_segment("ab", book_segment_idx, display);
let fixture = vcards.first().ok_or_else(|| {
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
let path = format!(
"/addressbooks/{}/{segment}/added-{tag}.vcf",
account.username
);
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let client = DavSeed::new(self.dav_root(), &acct.username, &acct.password);
let body = client.propfind(&format!("/calendars/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"baikal propfind for {} returned no multistatus",
acct.username
)));
}
let _ = seed;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn build_sqlite_db() -> ContainerResult<Vec<u8>> {
let tmp = tempfile::NamedTempFile::new()
.map_err(|e| ContainerError::Seed(format!("baikal tempfile: {e}")))?;
let path = tmp.path().to_path_buf();
drop(tmp);
{
let conn = rusqlite::Connection::open(&path)
.map_err(|e| ContainerError::Seed(format!("baikal sqlite open: {e}")))?;
conn.execute_batch(SCHEMA)
.map_err(|e| ContainerError::Seed(format!("baikal schema: {e}")))?;
let users: [(&str, &str); 3] = [
(layouts::ACCOUNT1, DIGEST_USER1),
(layouts::ACCOUNT2, DIGEST_USER2),
(layouts::ACCOUNT3, DIGEST_USER3),
];
for (name, digest) in users {
conn.execute(
"INSERT INTO users (username, digesta1) VALUES (?1, ?2)",
params![name, digest],
)
.map_err(|e| ContainerError::Seed(format!("baikal user {name}: {e}")))?;
let uri = format!("principals/{name}");
let email = format!("{name}@vandelay.test");
let display = format!("Vandelay {name}");
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, ?2, ?3)",
params![uri, email, display],
)
.map_err(|e| ContainerError::Seed(format!("baikal principal {name}: {e}")))?;
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
params![format!("{uri}/calendar-proxy-read")],
)
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-read {name}: {e}")))?;
conn.execute(
"INSERT INTO principals (uri, email, displayname) VALUES (?1, NULL, NULL)",
params![format!("{uri}/calendar-proxy-write")],
)
.map_err(|e| ContainerError::Seed(format!("baikal cal-proxy-write {name}: {e}")))?;
}
let _ = REALM;
let _ = ADMIN_PASSWORD_HASH;
}
let bytes =
std::fs::read(&path).map_err(|e| ContainerError::Seed(format!("baikal read db: {e}")))?;
let _ = std::fs::remove_file(&path);
Ok(bytes)
}
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
let mut out = format!("{prefix}-{:02}-", idx);
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_end_matches('-').to_owned()
}
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
if layout.calendars.is_empty() {
return 0;
}
let base = layout.event_count / layout.calendars.len().max(1);
let extra = if idx == 0 {
layout.event_count % layout.calendars.len()
} else {
0
};
base + extra
}
fn events_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
}
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
if layout.address_books.is_empty() {
return 0;
}
let base = layout.contact_count / layout.address_books.len().max(1);
let extra = if idx == 0 {
layout.contact_count % layout.address_books.len()
} else {
0
};
base + extra
}
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
}
#[derive(Debug, Clone)]
pub struct SeededItem {
pub uid: String,
pub href: String,
pub source: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CollectionPlan {
pub display_name: String,
pub base_href: String,
pub items: Vec<SeededItem>,
}
impl CollectionPlan {
fn new(display_name: String, base_href: String) -> Self {
Self {
display_name,
base_href,
items: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub calendars: Vec<CollectionPlan>,
pub address_books: Vec<CollectionPlan>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
calendars: Vec::new(),
address_books: Vec::new(),
}
}
pub fn total_events(&self) -> usize {
self.calendars.iter().map(|c| c.items.len()).sum()
}
pub fn total_contacts(&self) -> usize {
self.address_books.iter().map(|c| c.items.len()).sum()
}
pub fn event_uids(&self) -> HashSet<String> {
self.calendars
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
pub fn contact_uids(&self) -> HashSet<String> {
self.address_books
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
}
+147
View File
@@ -0,0 +1,147 @@
CREATE TABLE addressbooks (
id integer primary key asc NOT NULL,
principaluri text NOT NULL,
displayname text,
uri text NOT NULL,
description text,
synctoken integer DEFAULT 1 NOT NULL
);
CREATE TABLE cards (
id integer primary key asc NOT NULL,
addressbookid integer NOT NULL,
carddata blob,
uri text NOT NULL,
lastmodified integer,
etag text,
size integer
);
CREATE TABLE addressbookchanges (
id integer primary key asc NOT NULL,
uri text,
synctoken integer NOT NULL,
addressbookid integer NOT NULL,
operation integer NOT NULL
);
CREATE INDEX addressbookid_synctoken ON addressbookchanges (addressbookid, synctoken);
CREATE TABLE calendarobjects (
id integer primary key asc NOT NULL,
calendardata blob NOT NULL,
uri text NOT NULL,
calendarid integer NOT NULL,
lastmodified integer NOT NULL,
etag text NOT NULL,
size integer NOT NULL,
componenttype text,
firstoccurence integer,
lastoccurence integer,
uid text
);
CREATE TABLE calendars (
id integer primary key asc NOT NULL,
synctoken integer DEFAULT 1 NOT NULL,
components text NOT NULL
);
CREATE TABLE calendarinstances (
id integer primary key asc NOT NULL,
calendarid integer,
principaluri text,
access integer,
displayname text,
uri text NOT NULL,
description text,
calendarorder integer,
calendarcolor text,
timezone text,
transparent bool,
share_href text,
share_displayname text,
share_invitestatus integer DEFAULT '2',
UNIQUE (principaluri, uri),
UNIQUE (calendarid, principaluri),
UNIQUE (calendarid, share_href)
);
CREATE TABLE calendarchanges (
id integer primary key asc NOT NULL,
uri text,
synctoken integer NOT NULL,
calendarid integer NOT NULL,
operation integer NOT NULL
);
CREATE INDEX calendarid_synctoken ON calendarchanges (calendarid, synctoken);
CREATE TABLE calendarsubscriptions (
id integer primary key asc NOT NULL,
uri text NOT NULL,
principaluri text NOT NULL,
source text NOT NULL,
displayname text,
refreshrate text,
calendarorder integer,
calendarcolor text,
striptodos bool,
stripalarms bool,
stripattachments bool,
lastmodified int
);
CREATE TABLE schedulingobjects (
id integer primary key asc NOT NULL,
principaluri text NOT NULL,
calendardata blob,
uri text NOT NULL,
lastmodified integer,
etag text NOT NULL,
size integer NOT NULL
);
CREATE INDEX principaluri_uri ON calendarsubscriptions (principaluri, uri);
BEGIN TRANSACTION;
CREATE TABLE locks (
id integer primary key asc NOT NULL,
owner text,
timeout integer,
created integer,
token text,
scope integer,
depth integer,
uri text
);
COMMIT;
CREATE TABLE principals (
id INTEGER PRIMARY KEY ASC NOT NULL,
uri TEXT NOT NULL,
email TEXT,
displayname TEXT,
UNIQUE(uri)
);
CREATE TABLE groupmembers (
id INTEGER PRIMARY KEY ASC NOT NULL,
principal_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
UNIQUE(principal_id, member_id)
);
CREATE TABLE propertystorage (
id integer primary key asc NOT NULL,
path text NOT NULL,
name text NOT NULL,
valuetype integer NOT NULL,
value string
);
CREATE UNIQUE INDEX path_property ON propertystorage (path, name);
CREATE TABLE users (
id integer primary key asc NOT NULL,
username TEXT NOT NULL,
digesta1 TEXT NOT NULL,
UNIQUE(username)
);
+393
View File
@@ -0,0 +1,393 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{MboxMessage, load_mbox};
use super::error::{ContainerError, ContainerResult};
use super::imap_client::{ImapSeed, full_path};
use super::layouts::{self};
use super::{Account, Endpoint};
const IMAP_PORT: u16 = 143;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
cyrus-imapd cyrus-admin cyrus-clients sasl2-bin \
libsasl2-modules libsasl2-modules-db \
ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus && \
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
COPY imapd.conf /etc/imapd.conf
COPY cyrus.conf /etc/cyrus.conf
COPY sasldb.txt /tmp/sasldb.txt
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 143
CMD ["/entrypoint.sh"]
"#;
const IMAPD_CONF: &str = r#"configdirectory: /var/lib/cyrus
partition-default: /var/spool/cyrus
admins: cyrus
allowanonymouslogin: no
allowplaintext: yes
sasl_mech_list: PLAIN LOGIN
sasl_pwcheck_method: auxprop
sasl_auxprop_plugin: sasldb
sasl_sasldb_path: /etc/sasldb2
unixhierarchysep: yes
altnamespace: yes
sasl_minimum_layer: 0
"#;
const CYRUS_CONF: &str = r#"START {
recover cmd="ctl_cyrusdb -r"
}
SERVICES {
imap cmd="imapd" listen="143" prefork=0
}
EVENTS {
checkpoint cmd="ctl_cyrusdb -c" period=30
delprune cmd="cyr_expire -E 3" at=0400
}
"#;
fn sasldb_entries() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push(' ');
out.push_str(layouts::PASSWORD);
out.push('\n');
}
out.push_str("cyrus admin-secret\n");
out
}
const ENTRYPOINT: &str = r#"#!/bin/bash
set -e
while IFS=' ' read -r user pw; do
[ -z "$user" ] && continue
echo "$pw" | saslpasswd2 -p -c -f /etc/sasldb2 "$user"
done < /tmp/sasldb.txt
chown cyrus:mail /etc/sasldb2
chmod 0640 /etc/sasldb2
mkdir -p /var/lib/cyrus /var/spool/cyrus /run/cyrus
chown -R cyrus:mail /var/lib/cyrus /var/spool/cyrus /run/cyrus
su cyrus -s /bin/bash -c '/usr/sbin/cyrus makedirs' || true
/usr/sbin/cyrmaster -C /etc/imapd.conf -M /etc/cyrus.conf &
master_pid=$!
for _ in $(seq 1 120); do
if (exec 3<>/dev/tcp/127.0.0.1/143) 2>/dev/null; then
exec 3<&- 3>&-
break
fi
sleep 0.5
done
provision() {
exec 3<>/dev/tcp/127.0.0.1/143
read -r _greeting <&3
printf 'a1 LOGIN cyrus admin-secret\r\n' >&3
read -r _ <&3
local tag=1
for u in "$@"; do
tag=$((tag + 1))
printf 'a%d CREATE user/%s\r\n' "$tag" "$u" >&3
read -r _ <&3
done
tag=$((tag + 1))
printf 'a%d LOGOUT\r\n' "$tag" >&3
read -r _ <&3
exec 3<&- 3>&-
}
users=()
while IFS=' ' read -r user _pw; do
[ -z "$user" ] && continue
[ "$user" = "cyrus" ] && continue
users+=("$user")
done < /tmp/sasldb.txt
provision "${users[@]}"
echo "cyrus provisioned"
wait "$master_pid"
"#;
pub struct Cyrus {
container: Container<GenericImage>,
pub imap: Endpoint,
pub accounts: Vec<Account>,
}
impl Cyrus {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-cyrus", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(IMAPD_CONF.as_bytes().to_vec(), "imapd.conf")
.with_data(CYRUS_CONF.as_bytes().to_vec(), "cyrus.conf")
.with_data(sasldb_entries().into_bytes(), "sasldb.txt")
.with_data(ENTRYPOINT.as_bytes().to_vec(), "entrypoint.sh")
.build_image()
.map_err(|e| ContainerError::Seed(format!("cyrus build: {e}")))?;
let request = image
.with_exposed_port(IMAP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stdout("cyrus provisioned"))
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let host = container.get_host()?.to_string();
let imap = Endpoint::new(host, container.get_host_port_ipv4(IMAP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
imap,
accounts,
})
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let limit = self
.accounts
.iter()
.map(|a| a.layout.email_count)
.max()
.unwrap_or(0)
.max(1);
let messages = load_mbox(limit)?;
let mut out = Vec::new();
for acct in &self.accounts {
let m = self.seed_account(acct, &messages)?;
out.push(m);
}
Ok(out)
}
fn seed_account(
&self,
account: &Account,
messages: &[MboxMessage],
) -> ContainerResult<AccountSeed> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let sep = client.discover_separator().unwrap_or('/');
let mut paths = Vec::new();
for spec in account.layout.mailboxes {
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
})?;
client.create(&path)?;
client.subscribe(&path)?;
paths.push(path);
}
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
targets.extend(paths.iter().cloned());
let mut histogram: HashMap<String, usize> =
targets.iter().map(|t| (t.clone(), 0)).collect();
let mut appends: Vec<SeededAppend> = Vec::new();
let total = account.layout.email_count.min(messages.len());
if total < account.layout.email_count {
return Err(ContainerError::Seed(format!(
"{} only has {} messages but layout requires {}",
account.username,
messages.len(),
account.layout.email_count
)));
}
for (i, msg) in messages.iter().take(total).enumerate() {
let target = targets[i % targets.len()].clone();
client.append_with_flags(&target, &[], &msg.raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: msg.raw.clone(),
target,
flags: Vec::new(),
tag: SeedTag::Bulk(i),
});
}
let mut dedup_target: Option<String> = None;
if targets.len() > 1 && !messages.is_empty() {
let target = targets[1].clone();
client.append_with_flags(&target, &[], &messages[0].raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: messages[0].raw.clone(),
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::Dedup,
});
dedup_target = Some(target);
}
let mut flagged_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = flag_probe_message();
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
tag: SeedTag::FlagProbe,
});
flagged_target = Some(target);
}
client.logout()?;
let total_appends = histogram.values().sum();
Ok(AccountSeed {
username: account.username.clone(),
paths,
histogram,
total_appends,
dedup_target,
flagged_target,
appends,
})
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.delete_and_expunge_first("INBOX")?;
client.logout()?;
Ok(())
}
pub fn append_new_message(
&self,
account: &Account,
mailbox: &str,
tag: &str,
) -> ContainerResult<(Vec<u8>, String)> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let message_id = format!("<cyrus-added-{tag}-{}@vandelay.test>", account.username);
let body = format!(
"From: cyrus-added-{tag}@vandelay.test\r\n\
To: {}@vandelay.test\r\n\
Subject: Cyrus added probe {tag}\r\n\
Message-ID: {message_id}\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Cyrus added probe body {tag}.\r\n",
account.username
);
let raw = body.into_bytes();
client.append_with_flags(mailbox, &[], &raw)?;
client.logout()?;
Ok((raw, message_id))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&acct.username, &acct.password)?;
let names = client.list_all()?;
let expected = acct.layout.mailboxes.len() + 1;
if names.len() < expected {
return Err(ContainerError::Seed(format!(
"cyrus {}: expected >= {expected} mailboxes, got {}",
acct.username,
names.len()
)));
}
let inbox_n = client.select("INBOX")?;
if acct.layout.email_count > 0 && inbox_n == 0 {
return Err(ContainerError::Seed(format!(
"cyrus {}: INBOX EXISTS = 0 after seed",
acct.username
)));
}
for path in &seed.paths {
let n = client.select(path)?;
let want = seed.histogram.get(path).copied().unwrap_or(0);
if n < want {
return Err(ContainerError::Seed(format!(
"cyrus {}: mailbox {path} EXISTS={n} but {want} were appended",
acct.username
)));
}
}
client.logout()?;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
pub fn flag_probe_message() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Cyrus flag probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Cyrus flag probe body.\r\n";
body.as_bytes().to_vec()
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub paths: Vec<String>,
pub histogram: HashMap<String, usize>,
pub total_appends: usize,
pub dedup_target: Option<String>,
pub flagged_target: Option<String>,
pub appends: Vec<SeededAppend>,
}
#[derive(Debug, Clone)]
pub struct SeededAppend {
pub raw: Vec<u8>,
pub target: String,
pub flags: Vec<String>,
pub tag: SeedTag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedTag {
Bulk(usize),
Dedup,
FlagProbe,
}
+475
View File
@@ -0,0 +1,475 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use flate2::read::GzDecoder;
use mail_parser::mailbox::mbox::MessageIterator;
use super::error::{ContainerError, ContainerResult};
fn resources_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
}
#[derive(Debug, Clone)]
pub struct MboxMessage {
pub raw: Vec<u8>,
pub received_at: i64,
}
pub fn load_mbox(limit: usize) -> ContainerResult<Vec<MboxMessage>> {
let path = resources_dir().join("mailbox.gz");
if path.exists() {
let file = std::fs::File::open(&path)?;
let mut decoder = GzDecoder::new(file);
let mut bytes = Vec::new();
decoder.read_to_end(&mut bytes)?;
let reader = BufReader::new(std::io::Cursor::new(bytes));
let mut out = Vec::new();
for item in MessageIterator::new(reader) {
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
let received_at = message.internal_date() as i64;
out.push(MboxMessage {
raw: message.unwrap_contents(),
received_at,
});
if out.len() >= limit {
break;
}
}
if !out.is_empty() {
return Ok(out);
}
}
if let Ok(takeout) = load_takeout_mbox("all_mail_including_spam_and_trash.mbox")
&& !takeout.is_empty()
{
return Ok(takeout.into_iter().take(limit).collect());
}
Ok(synth_messages(limit))
}
#[derive(Debug, Clone)]
pub struct RawFixture {
pub name: String,
pub bytes: Vec<u8>,
}
pub fn load_vcards() -> ContainerResult<Vec<RawFixture>> {
Ok(synth_vcards(48))
}
pub fn load_icals() -> ContainerResult<Vec<RawFixture>> {
Ok(synth_icals(48))
}
pub fn load_takeout_mbox(name: &str) -> ContainerResult<Vec<MboxMessage>> {
let path = resources_dir().join("takeout").join(name);
let bytes = std::fs::read(&path)
.map_err(|e| ContainerError::Resource(format!("open {}: {e}", path.display())))?;
let reader = BufReader::new(std::io::Cursor::new(bytes));
let mut out = Vec::new();
for item in MessageIterator::new(reader) {
let message = item.map_err(|e| ContainerError::Resource(format!("mbox parse: {e}")))?;
let received_at = message.internal_date() as i64;
out.push(MboxMessage {
raw: message.unwrap_contents(),
received_at,
});
}
Ok(out)
}
fn synth_messages(n: usize) -> Vec<MboxMessage> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = format!(
"From: sender{i}@vandelay.test\r\n\
To: [email protected]\r\n\
Subject: Synthetic test message {i}\r\n\
Message-ID: <synth-{i}@vandelay.test>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
This is synthetic seed message #{i} for vandelay container tests.\r\n",
);
out.push(MboxMessage {
raw: body.into_bytes(),
received_at: 0,
});
}
out
}
const VCARD_VARIANTS: &[fn(usize) -> String] = &[
vcard_v3_basic,
vcard_v3_full,
vcard_v3_multi_email,
vcard_v3_org_and_title,
vcard_v4_basic,
vcard_v4_with_address,
vcard_v4_nickname,
vcard_v4_birthday,
];
fn synth_vcards(n: usize) -> Vec<RawFixture> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = VCARD_VARIANTS[i % VCARD_VARIANTS.len()](i);
out.push(RawFixture {
name: format!("synth-{i:03}"),
bytes: body.into_bytes(),
});
}
out
}
fn vcard_v3_basic(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-basic-{i}\r\n\
FN:Basic Contact {i}\r\n\
N:Contact{i};Basic;;;\r\n\
EMAIL;TYPE=INTERNET:basic-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_full(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-full-{i}\r\n\
FN:Full Contact {i}\r\n\
N:Contact{i};Full;Middle;;\r\n\
EMAIL;TYPE=INTERNET;TYPE=WORK:full-{i}@vandelay.test\r\n\
TEL;TYPE=CELL:+15550010{i:03}\r\n\
ORG:Vandelay Test\r\n\
TITLE:Senior Test Engineer\r\n\
URL:https://vandelay.test/{i}\r\n\
NOTE:Synthetic full v3 contact #{i}\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_multi_email(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-multi-{i}\r\n\
FN:Multi Email {i}\r\n\
N:Contact{i};MultiEmail;;;\r\n\
EMAIL;TYPE=INTERNET;TYPE=HOME:multi-{i}[email protected]\r\n\
EMAIL;TYPE=INTERNET;TYPE=WORK:multi-{i}[email protected]\r\n\
END:VCARD\r\n"
)
}
fn vcard_v3_org_and_title(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:vandelay-card-org-{i}\r\n\
FN:Org Contact {i}\r\n\
N:Contact{i};Org;;Dr.;PhD\r\n\
ORG:Vandelay Industries;Engineering;Test Group\r\n\
TITLE:Principal Researcher\r\n\
EMAIL;TYPE=INTERNET:org-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_basic(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-a000-0000{i:08x}\r\n\
FN:V4 Basic {i}\r\n\
N:Contact{i};V4Basic;;;\r\n\
EMAIL:v4-basic-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_with_address(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-b000-0000{i:08x}\r\n\
FN:V4 Address {i}\r\n\
N:Contact{i};V4Address;;;\r\n\
EMAIL:v4-address-{i}@vandelay.test\r\n\
ADR;TYPE=home:;;{i} Test Street;Springfield;OR;97477;US\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_nickname(i: usize) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-c000-0000{i:08x}\r\n\
FN:V4 Nick {i}\r\n\
N:Contact{i};V4Nick;;;\r\n\
NICKNAME:Nicky{i}\r\n\
EMAIL:v4-nick-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
fn vcard_v4_birthday(i: usize) -> String {
let year = 1970 + (i % 30);
let month = (i % 12) + 1;
let day = (i % 28) + 1;
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:urn:uuid:00000000-0000-4000-d000-0000{i:08x}\r\n\
FN:V4 Birthday {i}\r\n\
N:Contact{i};V4Birthday;;;\r\n\
BDAY:{year:04}{month:02}{day:02}\r\n\
EMAIL:v4-bday-{i}@vandelay.test\r\n\
END:VCARD\r\n"
)
}
const ICAL_VARIANTS: &[fn(usize) -> String] = &[
ical_simple_dt,
ical_all_day,
ical_multi_day_dt,
ical_with_organizer_attendees,
ical_with_categories_location,
ical_recurring_daily,
ical_recurring_weekly_until,
ical_with_alarm,
];
fn synth_icals(n: usize) -> Vec<RawFixture> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let body = ICAL_VARIANTS[i % ICAL_VARIANTS.len()](i);
out.push(RawFixture {
name: format!("synth-{i:03}"),
bytes: body.into_bytes(),
});
}
out
}
fn ical_simple_dt(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let hour = (i % 24) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-simple-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T{hour:02}0000Z\r\n\
DTEND:2025{month:02}{day:02}T{hour:02}3000Z\r\n\
SUMMARY:Simple datetime event {i}\r\n\
DESCRIPTION:Simple synthetic event #{i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_all_day(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let next_day = day + 1;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-allday-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART;VALUE=DATE:2025{month:02}{day:02}\r\n\
DTEND;VALUE=DATE:2025{month:02}{next_day:02}\r\n\
SUMMARY:All-day event {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_multi_day_dt(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 27) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-multiday-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T090000Z\r\n\
DTEND:2025{month:02}{day:02}T170000Z\r\n\
SUMMARY:Workshop session {i}\r\n\
DESCRIPTION:Multi-hour workshop with description.\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_organizer_attendees(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-meeting-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T140000Z\r\n\
DTEND:2025{month:02}{day:02}T150000Z\r\n\
SUMMARY:Sync meeting {i}\r\n\
ORGANIZER;CN=Organiser:mailto:organiser-{i}@vandelay.test\r\n\
ATTENDEE;CN=Alice;PARTSTAT=ACCEPTED:mailto:alice-{i}@vandelay.test\r\n\
ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION:mailto:bob-{i}@vandelay.test\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_categories_location(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-cat-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T100000Z\r\n\
DTEND:2025{month:02}{day:02}T110000Z\r\n\
SUMMARY:Categorised event {i}\r\n\
LOCATION:Test Lab\\, Room {i}\r\n\
CATEGORIES:VANDELAY,TEST,FIXTURE\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_recurring_daily(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-daily-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T070000Z\r\n\
DTEND:2025{month:02}{day:02}T073000Z\r\n\
RRULE:FREQ=DAILY;COUNT=10\r\n\
SUMMARY:Daily standup {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_recurring_weekly_until(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
let until_month = (month % 12) + 1;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-weekly-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T160000Z\r\n\
DTEND:2025{month:02}{day:02}T170000Z\r\n\
RRULE:FREQ=WEEKLY;UNTIL=2025{until_month:02}{day:02}T160000Z;BYDAY=MO\r\n\
SUMMARY:Weekly retro {i}\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
fn ical_with_alarm(i: usize) -> String {
let month = ((i % 12) + 1) as u32;
let day = ((i % 28) + 1) as u32;
format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//container-test//EN\r\n\
BEGIN:VEVENT\r\n\
UID:vandelay-alarm-{i}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:2025{month:02}{day:02}T080000Z\r\n\
DTEND:2025{month:02}{day:02}T083000Z\r\n\
SUMMARY:Event with alarm {i}\r\n\
BEGIN:VALARM\r\n\
ACTION:DISPLAY\r\n\
DESCRIPTION:Reminder {i}\r\n\
TRIGGER:-PT15M\r\n\
END:VALARM\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n"
)
}
pub fn malformed_ical(name: &str) -> RawFixture {
let body = format!(
"BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
PRODID:-//vandelay//broken//EN\r\n\
BEGIN:VEVENT\r\n\
UID:broken-{name}@vandelay.test\r\n\
DTSTAMP:20250101T120000Z\r\n\
DTSTART:NOT-A-DATE\r\n\
RRULE:FREQ=BOGUS;INTERVAL=oops\r\n\
END:NOT-A-VEVENT\r\n",
);
RawFixture {
name: format!("broken-{name}"),
bytes: body.into_bytes(),
}
}
pub fn rewrite_uid(bytes: &[u8], suffix: &str) -> Option<(Vec<u8>, String)> {
let text = std::str::from_utf8(bytes).ok()?;
let mut out = String::with_capacity(text.len() + 32);
let mut new_uid: Option<String> = None;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("UID:") {
let combined = format!("{rest}-{suffix}");
if new_uid.is_none() {
new_uid = Some(combined.clone());
}
out.push_str("UID:");
out.push_str(&combined);
} else {
out.push_str(line);
}
out.push_str("\r\n");
}
let uid = new_uid?;
Some((out.into_bytes(), uid))
}
pub fn extract_uid(bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(bytes).ok()?;
for line in text.lines() {
let l = line.trim_end_matches('\r');
if let Some(rest) = l.strip_prefix("UID:") {
return Some(rest.to_owned());
}
}
None
}
+167
View File
@@ -0,0 +1,167 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use ureq::Agent;
use ureq::http::{Method, Request};
use super::error::{ContainerError, ContainerResult};
pub struct DavSeed {
agent: Agent,
base: String,
auth: String,
}
impl DavSeed {
pub fn new(base: impl Into<String>, user: &str, password: &str) -> Self {
let agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.allow_non_standard_methods(true)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.new_agent();
let credentials = B64.encode(format!("{user}:{password}"));
Self {
agent,
base: base.into(),
auth: format!("Basic {credentials}"),
}
}
fn url(&self, path: &str) -> String {
if path.starts_with("http://") || path.starts_with("https://") {
path.to_owned()
} else if path.starts_with('/') {
format!("{}{path}", self.base.trim_end_matches('/'))
} else {
format!("{}/{}", self.base.trim_end_matches('/'), path)
}
}
fn run(
&self,
method: &str,
url: &str,
depth: Option<u8>,
content_type: Option<&str>,
body: Option<&[u8]>,
) -> ContainerResult<(u16, Vec<u8>)> {
let parsed = Method::from_bytes(method.as_bytes())
.map_err(|e| ContainerError::Protocol(format!("bad method {method}: {e}")))?;
let mut builder = Request::builder()
.method(parsed)
.uri(url)
.header("Authorization", &self.auth);
if let Some(d) = depth {
builder = builder.header("Depth", d.to_string());
}
if let Some(ct) = content_type {
builder = builder.header("Content-Type", ct);
}
let payload: Vec<u8> = body.map(|b| b.to_vec()).unwrap_or_default();
let request = builder
.body(payload)
.map_err(|e| ContainerError::Protocol(format!("build request: {e}")))?;
let mut response = self.agent.run(request)?;
let status = response.status().as_u16();
let bytes = response.body_mut().read_to_vec()?;
Ok((status, bytes))
}
pub fn mkcol(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
self.mkcol_with_method("MKCOL", path, body)
}
pub fn mkcalendar(&self, path: &str, body: Option<&str>) -> ContainerResult<u16> {
self.mkcol_with_method("MKCALENDAR", path, body)
}
fn mkcol_with_method(
&self,
method: &str,
path: &str,
body: Option<&str>,
) -> ContainerResult<u16> {
let url = self.url(path);
let bytes = body.map(|b| b.as_bytes());
let (status, response_body) = self.run(
method,
&url,
None,
Some("application/xml; charset=utf-8"),
bytes,
)?;
if !is_collection_created(status) {
return Err(ContainerError::Protocol(format!(
"{method} {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn put(&self, path: &str, content_type: &str, body: &[u8]) -> ContainerResult<u16> {
let url = self.url(path);
let (status, response_body) =
self.run("PUT", &url, None, Some(content_type), Some(body))?;
if !(200..300).contains(&status) {
return Err(ContainerError::Protocol(format!(
"PUT {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn delete(&self, path: &str) -> ContainerResult<u16> {
let url = self.url(path);
let (status, response_body) = self.run("DELETE", &url, None, None, None)?;
if !(200..300).contains(&status) && status != 404 {
return Err(ContainerError::Protocol(format!(
"DELETE {url} -> {status}: {}",
truncate(&response_body)
)));
}
Ok(status)
}
pub fn propfind(&self, path: &str, depth: u8) -> ContainerResult<String> {
let url = self.url(path);
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><resourcetype/><displayname/></prop></propfind>"#;
let (status, bytes) = self.run(
"PROPFIND",
&url,
Some(depth),
Some("application/xml; charset=utf-8"),
Some(body.as_bytes()),
)?;
if !(200..400).contains(&status) {
return Err(ContainerError::Protocol(format!(
"PROPFIND {url} -> {status}: {}",
truncate(&bytes)
)));
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
}
fn is_collection_created(status: u16) -> bool {
matches!(status, 200 | 201 | 204)
}
fn truncate(body: &[u8]) -> String {
let s = String::from_utf8_lossy(body);
if s.len() > 2000 {
format!("{}…", &s[..2000])
} else {
s.into_owned()
}
}
+503
View File
@@ -0,0 +1,503 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::data::{MboxMessage, load_mbox};
use super::error::{ContainerError, ContainerResult};
use super::imap_client::{ImapSeed, full_path};
use super::layouts::{self};
use super::sieve_client::SieveSeed as SieveClient;
use super::{Account, Endpoint};
const IMAP_PORT: u16 = 143;
const SIEVE_PORT: u16 = 4190;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
dovecot-imapd dovecot-managesieved dovecot-pop3d dovecot-sieve \
openssl ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
addgroup --gid 5000 vmail && \
adduser --system --no-create-home --uid 5000 --gid 5000 vmail && \
mkdir -p /var/vmail && chown -R vmail:vmail /var/vmail && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/dovecot/dovecot.key \
-out /etc/dovecot/dovecot.crt \
-subj /CN=localhost && \
chmod 600 /etc/dovecot/dovecot.key
COPY dovecot.conf /etc/dovecot/dovecot.conf
COPY users /etc/dovecot/users
EXPOSE 143 4190
CMD ["dovecot", "-F"]
"#;
const DOVECOT_CONF: &str = r#"protocols = imap sieve
listen = *
mail_location = maildir:/var/vmail/%u/Maildir
mail_uid = 5000
mail_gid = 5000
mail_privileged_group = vmail
namespace inbox {
inbox = yes
separator = /
prefix =
}
auth_mechanisms = plain login
disable_plaintext_auth = no
passdb {
driver = passwd-file
args = /etc/dovecot/users
}
userdb {
driver = static
args = uid=5000 gid=5000 home=/var/vmail/%u
}
service imap-login {
inet_listener imap {
port = 143
}
}
service managesieve-login {
inet_listener sieve {
port = 4190
}
}
protocol sieve {
managesieve_max_line_length = 1M
managesieve_max_compile_errors = 5
}
ssl = yes
ssl_cert = </etc/dovecot/dovecot.crt
ssl_key = </etc/dovecot/dovecot.key
log_path = /dev/stderr
info_log_path = /dev/stderr
plugin {
sieve = file:~/sieve;active=~/.dovecot.sieve
sieve_max_script_size = 1M
}
"#;
fn users_file() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push_str(":{plain}");
out.push_str(layouts::PASSWORD);
out.push_str(":5000:5000::/var/vmail/");
out.push_str(u);
out.push_str("::\n");
}
out
}
pub struct Dovecot {
container: Container<GenericImage>,
pub imap: Endpoint,
pub sieve: Endpoint,
pub accounts: Vec<Account>,
}
impl Dovecot {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-dovecot", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(DOVECOT_CONF.as_bytes().to_vec(), "dovecot.conf")
.with_data(users_file().into_bytes(), "users")
.build_image()
.map_err(|e| ContainerError::Seed(format!("dovecot build: {e}")))?;
let request = image
.with_exposed_port(IMAP_PORT.tcp())
.with_exposed_port(SIEVE_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("starting up"))
.with_startup_timeout(Duration::from_secs(120));
let container = request.start()?;
let host = container.get_host()?.to_string();
let imap = Endpoint::new(host.clone(), container.get_host_port_ipv4(IMAP_PORT.tcp())?);
let sieve = Endpoint::new(host, container.get_host_port_ipv4(SIEVE_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
imap,
sieve,
accounts,
})
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let limit = self
.accounts
.iter()
.map(|a| a.layout.email_count)
.max()
.unwrap_or(0)
.max(1);
let messages = load_mbox(limit)?;
let mut out = Vec::new();
for acct in &self.accounts {
let mailbox = self.seed_imap(acct, &messages)?;
let sieve = self.seed_sieve(acct)?;
out.push(AccountSeed {
username: acct.username.clone(),
mailbox,
sieve,
});
}
Ok(out)
}
fn seed_imap(
&self,
account: &Account,
messages: &[MboxMessage],
) -> ContainerResult<MailboxSeed> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let sep = client.discover_separator().unwrap_or('/');
let mut paths = Vec::new();
for spec in account.layout.mailboxes {
let path = full_path(account.layout.mailboxes, spec.key, sep).ok_or_else(|| {
ContainerError::Seed(format!("missing mailbox key: {}", spec.key))
})?;
client.create(&path)?;
client.subscribe(&path)?;
paths.push(path);
}
let mut targets: Vec<String> = vec!["INBOX".to_owned()];
targets.extend(paths.iter().cloned());
let mut histogram: HashMap<String, usize> =
targets.iter().map(|t| (t.clone(), 0)).collect();
let mut appends: Vec<SeededAppend> = Vec::new();
let total = account.layout.email_count.min(messages.len());
if total < account.layout.email_count {
return Err(ContainerError::Seed(format!(
"{} only has {} messages but layout requires {}",
account.username,
messages.len(),
account.layout.email_count
)));
}
for (i, msg) in messages.iter().take(total).enumerate() {
let target = targets[i % targets.len()].clone();
client.append_with_flags(&target, &[], &msg.raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: msg.raw.clone(),
target,
flags: Vec::new(),
tag: SeedTag::Bulk(i),
});
}
let mut dedup_target: Option<String> = None;
if targets.len() > 1 && !messages.is_empty() {
let target = targets[1].clone();
client.append_with_flags(&target, &[], &messages[0].raw)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: messages[0].raw.clone(),
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::Dedup,
});
dedup_target = Some(target);
}
let mut flagged_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = flag_probe_message();
client.append_with_flags(&target, &["\\Seen", "\\Flagged"], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: vec!["$seen".to_owned(), "$flagged".to_owned()],
tag: SeedTag::FlagProbe,
});
flagged_target = Some(target);
}
let mut nomid_target: Option<String> = None;
if !messages.is_empty() {
let target = "INBOX".to_owned();
let probe = no_message_id_probe();
client.append_with_flags(&target, &[], &probe)?;
*histogram.entry(target.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: probe,
target: target.clone(),
flags: Vec::new(),
tag: SeedTag::NoMessageId,
});
nomid_target = Some(target);
}
let mut mid_dedup_targets: Option<(String, String)> = None;
if targets.len() >= 3 {
let body = shared_message_id_probe();
let a = targets[1].clone();
let b = targets[2].clone();
client.append_with_flags(&a, &[], &body)?;
client.append_with_flags(&b, &[], &body)?;
*histogram.entry(a.clone()).or_insert(0) += 1;
*histogram.entry(b.clone()).or_insert(0) += 1;
appends.push(SeededAppend {
raw: body.clone(),
target: a.clone(),
flags: Vec::new(),
tag: SeedTag::SharedMid,
});
appends.push(SeededAppend {
raw: body,
target: b.clone(),
flags: Vec::new(),
tag: SeedTag::SharedMid,
});
mid_dedup_targets = Some((a, b));
}
client.logout()?;
let extras = ExtraAppends {
dedup_target,
flagged_target,
nomid_target,
mid_dedup_targets,
};
let total_appends = histogram_total(&histogram);
Ok(MailboxSeed {
paths,
histogram,
total_appends,
extras,
appends,
})
}
fn seed_sieve(&self, account: &Account) -> ContainerResult<SieveSeed> {
if account.layout.sieve_scripts.is_empty() {
return Ok(SieveSeed::default());
}
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
client.authenticate(&account.username, &account.password)?;
let mut active_name: Option<&'static str> = None;
let mut names = Vec::new();
for script in account.layout.sieve_scripts {
client.putscript(script.name, script.body)?;
names.push(script.name.to_owned());
if script.active {
active_name = Some(script.name);
}
}
if let Some(name) = active_name {
client.setactive(name)?;
}
client.logout()?;
Ok(SieveSeed {
names,
active: active_name.map(str::to_owned),
})
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.delete_and_expunge_first("INBOX")?;
client.logout()?;
Ok(())
}
pub fn append_new_message(
&self,
account: &Account,
mailbox: &str,
tag: &str,
) -> ContainerResult<(Vec<u8>, String)> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
let message_id = format!("<added-{tag}-{}@vandelay.test>", account.username);
let body = format!(
"From: added-{tag}@vandelay.test\r\n\
To: {}@vandelay.test\r\n\
Subject: Added probe {tag}\r\n\
Message-ID: {message_id}\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Added probe body {tag}.\r\n",
account.username
);
let raw = body.into_bytes();
client.append_with_flags(mailbox, &[], &raw)?;
client.logout()?;
Ok((raw, message_id))
}
pub fn install_broken_sieve(&self, account: &Account) -> ContainerResult<String> {
let mut client = SieveClient::connect_seed(&self.sieve.host, self.sieve.port)?;
client.authenticate(&account.username, &account.password)?;
let name = "broken-script";
client.putscript_raw(name, "INVALID:::not_a_sieve_program;;;")?;
client.logout()?;
Ok(name.to_owned())
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&acct.username, &acct.password)?;
let names = client.list_all()?;
let expected = acct.layout.mailboxes.len() + 1;
if names.len() < expected {
return Err(ContainerError::Seed(format!(
"{}: expected >= {expected} mailboxes, got {}",
acct.username,
names.len()
)));
}
let inbox_n = client.select("INBOX")?;
if acct.layout.email_count > 0 && inbox_n == 0 {
return Err(ContainerError::Seed(format!(
"{}: INBOX EXISTS = 0 after seed",
acct.username
)));
}
for path in &seed.mailbox.paths {
let n = client.select(path)?;
let want = seed.mailbox.histogram.get(path).copied().unwrap_or(0);
if n < want {
return Err(ContainerError::Seed(format!(
"{}: mailbox {path} EXISTS={n} but {want} were appended",
acct.username
)));
}
}
client.logout()?;
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn histogram_total(h: &HashMap<String, usize>) -> usize {
h.values().sum()
}
pub fn flag_probe_message() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Flag probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Flag probe body.\r\n";
body.as_bytes().to_vec()
}
pub fn no_message_id_probe() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: No Message-ID probe\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
No Message-ID body.\r\n";
body.as_bytes().to_vec()
}
pub fn shared_message_id_probe() -> Vec<u8> {
let body = "From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Shared MID probe\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Shared MID body.\r\n";
body.as_bytes().to_vec()
}
#[derive(Debug, Clone)]
pub struct MailboxSeed {
pub paths: Vec<String>,
pub histogram: HashMap<String, usize>,
pub total_appends: usize,
pub extras: ExtraAppends,
pub appends: Vec<SeededAppend>,
}
#[derive(Debug, Clone)]
pub struct SeededAppend {
pub raw: Vec<u8>,
pub target: String,
pub flags: Vec<String>,
pub tag: SeedTag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedTag {
Bulk(usize),
Dedup,
FlagProbe,
NoMessageId,
SharedMid,
}
#[derive(Debug, Clone)]
pub struct ExtraAppends {
pub dedup_target: Option<String>,
pub flagged_target: Option<String>,
pub nomid_target: Option<String>,
pub mid_dedup_targets: Option<(String, String)>,
}
#[derive(Debug, Default, Clone)]
pub struct SieveSeed {
pub names: Vec<String>,
pub active: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub mailbox: MailboxSeed,
pub sieve: SieveSeed,
}
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io;
use thiserror::Error;
pub type ContainerResult<T> = Result<T, ContainerError>;
#[derive(Debug, Error)]
pub enum ContainerError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("testcontainers: {0}")]
Testcontainers(#[from] testcontainers::TestcontainersError),
#[error("ureq: {0}")]
Ureq(#[from] Box<ureq::Error>),
#[error("utf8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("env: {0}")]
Env(String),
#[error("protocol: {0}")]
Protocol(String),
#[error("seed: {0}")]
Seed(String),
#[error("resource: {0}")]
Resource(String),
}
impl From<ureq::Error> for ContainerError {
fn from(e: ureq::Error) -> Self {
ContainerError::Ureq(Box::new(e))
}
}
+283
View File
@@ -0,0 +1,283 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use super::error::{ContainerError, ContainerResult};
use super::layouts::MailboxSpec;
pub struct ImapSeed {
reader: BufReader<TcpStream>,
writer: TcpStream,
tag_seq: u32,
pub separator: char,
}
impl ImapSeed {
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let stream = loop {
match TcpStream::connect((host, port)) {
Ok(s) => break s,
Err(e) => {
if std::time::Instant::now() >= deadline {
return Err(e.into());
}
std::thread::sleep(Duration::from_millis(250));
}
}
};
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
let writer = stream.try_clone()?;
let mut me = Self {
reader: BufReader::new(stream),
writer,
tag_seq: 0,
separator: '/',
};
let greeting = me.read_line()?;
if !greeting.starts_with("* OK") && !greeting.starts_with("* PREAUTH") {
return Err(ContainerError::Protocol(format!(
"imap greeting unexpected: {greeting}"
)));
}
Ok(me)
}
fn next_tag(&mut self) -> String {
self.tag_seq += 1;
format!("a{:04}", self.tag_seq)
}
fn read_line(&mut self) -> ContainerResult<String> {
let mut line = String::new();
let n = self.reader.read_line(&mut line)?;
if n == 0 {
return Err(ContainerError::Protocol("imap eof".to_owned()));
}
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
Ok(line)
}
fn read_tagged(&mut self, tag: &str) -> ContainerResult<Vec<String>> {
let mut untagged = Vec::new();
loop {
let line = self.read_line()?;
if let Some(rest) = line.strip_prefix(&format!("{tag} ")) {
if rest.starts_with("OK") {
return Ok(untagged);
}
return Err(ContainerError::Protocol(format!(
"imap tagged failure: {line}"
)));
}
untagged.push(line);
}
}
fn send(&mut self, line: &str) -> ContainerResult<()> {
self.writer.write_all(line.as_bytes())?;
self.writer.write_all(b"\r\n")?;
self.writer.flush()?;
Ok(())
}
pub fn login(&mut self, user: &str, password: &str) -> ContainerResult<()> {
let tag = self.next_tag();
let escaped_user = quote(user);
let escaped_pw = quote(password);
self.send(&format!("{tag} LOGIN {escaped_user} {escaped_pw}"))?;
self.read_tagged(&tag)?;
Ok(())
}
pub fn discover_separator(&mut self) -> ContainerResult<char> {
let tag = self.next_tag();
self.send(&format!(r#"{tag} LIST "" """#))?;
let untagged = self.read_tagged(&tag)?;
for line in &untagged {
if let Some(rest) = line.strip_prefix("* LIST ")
&& let Some(open) = rest.find('"')
&& let Some(close) = rest[open + 1..].find('"')
{
let sep = &rest[open + 1..open + 1 + close];
if let Some(c) = sep.chars().next() {
self.separator = c;
return Ok(c);
}
}
}
Ok('/')
}
pub fn create(&mut self, full_path: &str) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} CREATE {}", quote(full_path)))?;
match self.read_tagged(&tag) {
Ok(_) => Ok(()),
Err(ContainerError::Protocol(msg)) if msg.contains("already exists") => Ok(()),
Err(e) => Err(e),
}
}
pub fn subscribe(&mut self, full_path: &str) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} SUBSCRIBE {}", quote(full_path)))?;
let _ = self.read_tagged(&tag);
Ok(())
}
pub fn append(&mut self, mailbox: &str, message: &[u8]) -> ContainerResult<()> {
self.append_with_flags(mailbox, &[], message)
}
pub fn append_with_flags(
&mut self,
mailbox: &str,
flags: &[&str],
message: &[u8],
) -> ContainerResult<()> {
let tag = self.next_tag();
let flag_clause = if flags.is_empty() {
String::new()
} else {
format!(" ({})", flags.join(" "))
};
let cmd = format!(
"{tag} APPEND {}{flag_clause} {{{}}}\r\n",
quote(mailbox),
message.len(),
);
self.writer.write_all(cmd.as_bytes())?;
self.writer.flush()?;
let cont = self.read_line()?;
if !cont.starts_with('+') {
return Err(ContainerError::Protocol(format!(
"append expected continuation, got: {cont}"
)));
}
self.writer.write_all(message)?;
self.writer.write_all(b"\r\n")?;
self.writer.flush()?;
self.read_tagged(&tag)?;
Ok(())
}
pub fn list_all(&mut self) -> ContainerResult<Vec<String>> {
let tag = self.next_tag();
self.send(&format!(r#"{tag} LIST "" "*""#))?;
let untagged = self.read_tagged(&tag)?;
let mut names = Vec::new();
for line in untagged {
if let Some(rest) = line.strip_prefix("* LIST ")
&& let Some(name) = parse_list_name(rest)
{
names.push(name);
}
}
Ok(names)
}
pub fn select(&mut self, mailbox: &str) -> ContainerResult<usize> {
let tag = self.next_tag();
self.send(&format!("{tag} SELECT {}", quote(mailbox)))?;
let untagged = self.read_tagged(&tag)?;
for line in untagged {
if let Some(rest) = line.strip_prefix("* ")
&& let Some(num) = rest.strip_suffix(" EXISTS")
&& let Ok(n) = num.parse::<usize>()
{
return Ok(n);
}
}
Ok(0)
}
pub fn delete_and_expunge_first(&mut self, mailbox: &str) -> ContainerResult<()> {
self.select(mailbox)?;
let store_tag = self.next_tag();
self.send(&format!("{store_tag} STORE 1 +FLAGS (\\Deleted)"))?;
self.read_tagged(&store_tag)?;
let expunge_tag = self.next_tag();
self.send(&format!("{expunge_tag} EXPUNGE"))?;
self.read_tagged(&expunge_tag)?;
Ok(())
}
pub fn logout(&mut self) -> ContainerResult<()> {
let tag = self.next_tag();
self.send(&format!("{tag} LOGOUT"))?;
let mut buf = String::new();
let _ = self.reader.read_to_string(&mut buf);
Ok(())
}
}
pub fn full_path(specs: &[MailboxSpec], key: &str, separator: char) -> Option<String> {
let mut chain: Vec<&str> = Vec::new();
let mut cur = key;
loop {
let spec = specs.iter().find(|s| s.key == cur)?;
chain.push(spec.name);
match spec.parent {
Some(p) => cur = p,
None => break,
}
}
chain.reverse();
Some(chain.join(&separator.to_string()))
}
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
if c == '\\' || c == '"' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
fn parse_list_name(line: &str) -> Option<String> {
let mut chars = line.chars();
let _ = chars.next()?;
let mut depth = 1;
let mut idx = 1;
for (i, c) in line[1..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
idx = i + 2;
break;
}
}
_ => {}
}
}
let rest = line[idx..].trim_start();
let mut parts = rest.splitn(2, ' ');
let _sep_token = parts.next()?;
let name_token = parts.next()?.trim();
if let Some(stripped) = name_token.strip_prefix('"') {
let end = stripped.find('"')?;
Some(stripped[..end].to_owned())
} else {
Some(name_token.to_owned())
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#[derive(Debug, Clone, Copy)]
pub struct MailboxSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
}
#[derive(Debug, Clone, Copy)]
pub struct FileSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
pub directory: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct SieveScript {
pub name: &'static str,
pub body: &'static str,
pub active: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Layout {
pub mailboxes: &'static [MailboxSpec],
pub files: &'static [FileSpec],
pub calendars: &'static [&'static str],
pub address_books: &'static [&'static str],
pub sieve_scripts: &'static [SieveScript],
pub email_count: usize,
pub contact_count: usize,
pub event_count: usize,
}
macro_rules! mailbox {
($k:expr, $n:expr, $p:expr) => {
MailboxSpec {
key: $k,
name: $n,
parent: $p,
}
};
}
macro_rules! node {
($k:expr, $n:expr, $p:expr, $d:expr) => {
FileSpec {
key: $k,
name: $n,
parent: $p,
directory: $d,
}
};
}
pub const ACCOUNT1: &str = "user1";
pub const ACCOUNT2: &str = "user2";
pub const ACCOUNT3: &str = "user3";
pub const PASSWORD: &str = "VandelayUser#2026";
pub static MAILBOXES_DEEP: &[MailboxSpec] = &[
mailbox!("proj", "Projects", None),
mailbox!("y2024", "2024", Some("proj")),
mailbox!("q1", "Q1", Some("y2024")),
mailbox!("acme", "ClientAcme", Some("q1")),
mailbox!("acme_in", "Internal", Some("acme")),
mailbox!("q2", "Q2", Some("y2024")),
mailbox!("globex", "ClientGlobex", Some("q2")),
mailbox!("y2025", "2025", Some("proj")),
mailbox!("alpha", "Alpha", Some("y2025")),
mailbox!("beta", "Beta", Some("y2025")),
mailbox!("travel", "Travel", None),
mailbox!("asia", "Asia", Some("travel")),
mailbox!("jp", "Japan", Some("asia")),
mailbox!("tokyo", "Tokyo", Some("jp")),
mailbox!("eu", "Europe", Some("travel")),
mailbox!("it", "Italy", Some("eu")),
mailbox!("rome", "Rome", Some("it")),
mailbox!("arch", "Archive", None),
mailbox!("arch23", "2023", Some("arch")),
mailbox!("arch24", "2024", Some("arch")),
];
pub static MAILBOXES_MEDIUM: &[MailboxSpec] = &[
mailbox!("work", "Work", None),
mailbox!("clients", "Clients", Some("work")),
mailbox!("acme", "AcmeCorp", Some("clients")),
mailbox!("inv", "Invoices", Some("acme")),
mailbox!("globex", "Globex", Some("clients")),
mailbox!("internal", "Internal", Some("work")),
mailbox!("pers", "Personal", None),
mailbox!("rcpt", "Receipts", Some("pers")),
];
pub static MAILBOXES_FLAT: &[MailboxSpec] = &[
mailbox!("lists", "Lists", None),
mailbox!("news", "Newsletters", Some("lists")),
mailbox!("misc", "Misc", None),
];
pub static FILES_DEEP: &[FileSpec] = &[
node!("docs", "Documents", None, true),
node!("rep", "Reports", Some("docs"), true),
node!("d24", "2024", Some("rep"), true),
node!("q1f", "q1-results.bin", Some("d24"), false),
node!("q2f", "q2-results.bin", Some("d24"), false),
node!("d25", "2025", Some("rep"), true),
node!("q1f25", "q1-results.bin", Some("d25"), false),
node!("spec", "Specs", Some("docs"), true),
node!("ovr", "overview.bin", Some("spec"), false),
node!("notes", "notes.bin", Some("docs"), false),
node!("photos", "Photos", None, true),
node!("vac", "Vacations", Some("photos"), true),
node!("vit", "Italy", Some("vac"), true),
node!("vrome", "rome.bin", Some("vit"), false),
node!("vjp", "Japan", Some("vac"), true),
node!("vtokyo", "tokyo.bin", Some("vjp"), false),
node!("readme", "README.bin", None, false),
];
pub static FILES_MEDIUM: &[FileSpec] = &[
node!("media", "Media", None, true),
node!("audio", "Audio", Some("media"), true),
node!("song", "song.bin", Some("audio"), false),
node!("video", "Video", Some("media"), true),
node!("clip", "clip.bin", Some("video"), false),
node!("proj", "Projects", None, true),
node!("src", "src", Some("proj"), true),
node!("main", "main.bin", Some("src"), false),
];
pub static FILES_FLAT: &[FileSpec] = &[
node!("bak", "Backup", None, true),
node!("snap", "snapshot.bin", Some("bak"), false),
node!("scratch", "scratch.bin", None, false),
];
pub static CALENDARS_DEEP: &[&str] = &["Personal", "Work", "Birthdays", "Travel"];
pub static CALENDARS_MEDIUM: &[&str] = &["Personal", "Work"];
pub static CALENDARS_FLAT: &[&str] = &["Personal", "Holidays"];
pub static ADDRESS_BOOKS_DEEP: &[&str] = &["Personal", "Work", "Friends"];
pub static ADDRESS_BOOKS_MEDIUM: &[&str] = &["Personal", "Work"];
pub static ADDRESS_BOOKS_FLAT: &[&str] = &["Personal", "Family"];
pub static SIEVE_DEEP: &[SieveScript] = &[
SieveScript {
name: "spam",
body: "require [\"fileinto\"];\nif header :contains \"X-Spam\" \"YES\" {\n fileinto \"Junk\";\n stop;\n}\n",
active: true,
},
SieveScript {
name: "vacation",
body: "require [\"vacation\"];\nvacation :days 7 :subject \"Out of office\" \"I am away, will reply later.\";\n",
active: false,
},
SieveScript {
name: "organize",
body: "require [\"fileinto\",\"mailbox\"];\nif address :is \"from\" \"[email protected]\" {\n fileinto :create \"Projects/2025/Beta\";\n}\n",
active: false,
},
];
pub static SIEVE_MEDIUM: &[SieveScript] = &[
SieveScript {
name: "filter",
body: "require [\"fileinto\"];\nif address :is \"to\" \"[email protected]\" {\n fileinto \"Work\";\n}\n",
active: true,
},
SieveScript {
name: "draft",
body: "require [\"reject\"];\nif address :is \"from\" \"[email protected]\" {\n reject \"go away\";\n}\n",
active: false,
},
];
pub static SIEVE_FLAT: &[SieveScript] = &[];
pub fn layout_for(username: &str) -> Layout {
match username {
s if s == ACCOUNT1 => Layout {
mailboxes: MAILBOXES_DEEP,
files: FILES_DEEP,
calendars: CALENDARS_DEEP,
address_books: ADDRESS_BOOKS_DEEP,
sieve_scripts: SIEVE_DEEP,
email_count: 200,
contact_count: 40,
event_count: 40,
},
s if s == ACCOUNT2 => Layout {
mailboxes: MAILBOXES_MEDIUM,
files: FILES_MEDIUM,
calendars: CALENDARS_MEDIUM,
address_books: ADDRESS_BOOKS_MEDIUM,
sieve_scripts: SIEVE_MEDIUM,
email_count: 80,
contact_count: 15,
event_count: 15,
},
_ => Layout {
mailboxes: MAILBOXES_FLAT,
files: FILES_FLAT,
calendars: CALENDARS_FLAT,
address_books: ADDRESS_BOOKS_FLAT,
sieve_scripts: SIEVE_FLAT,
email_count: 20,
contact_count: 10,
event_count: 10,
},
}
}
pub fn accounts() -> [&'static str; 3] {
[ACCOUNT1, ACCOUNT2, ACCOUNT3]
}
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#![allow(dead_code, unused_imports)]
pub mod baikal;
pub mod cyrus;
pub mod data;
pub mod dav_client;
pub mod dovecot;
pub mod error;
pub mod imap_client;
pub mod layouts;
pub mod radicale;
pub mod sieve_client;
pub mod stalwart;
pub mod validate;
pub mod webdav;
pub use error::{ContainerError, ContainerResult};
#[derive(Debug, Clone)]
pub struct Endpoint {
pub host: String,
pub port: u16,
}
impl Endpoint {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
}
}
pub fn http_base(&self) -> String {
format!("http://{}:{}", self.host, self.port)
}
}
#[derive(Debug, Clone)]
pub struct Account {
pub username: String,
pub password: String,
pub layout: layouts::Layout,
}
+385
View File
@@ -0,0 +1,385 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::time::Duration;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::SyncRunner;
use testcontainers::{Container, GenericImage, ImageExt};
use super::data::{RawFixture, load_icals, load_vcards, malformed_ical, rewrite_uid};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, Layout};
use super::{Account, Endpoint};
const IMAGE_NAME: &str = "tomsquest/docker-radicale";
const IMAGE_TAG: &str = "3.7.3.0";
const RADICALE_PORT: u16 = 5232;
const CONFIG: &str = "[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = plain
[storage]
filesystem_folder = /data/collections
";
pub struct Radicale {
container: Container<GenericImage>,
pub endpoint: Endpoint,
pub accounts: Vec<Account>,
}
impl Radicale {
pub fn start() -> ContainerResult<Self> {
let mut users = String::new();
for u in layouts::accounts() {
users.push_str(u);
users.push(':');
users.push_str(layouts::PASSWORD);
users.push('\n');
}
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
.with_exposed_port(RADICALE_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("Radicale server ready"));
let request = image
.with_copy_to("/config/config", CONFIG.as_bytes().to_vec())
.with_copy_to("/config/users", users.into_bytes())
.with_startup_timeout(Duration::from_secs(90));
let container = request.start()?;
let host = container.get_host()?.to_string();
let port = container.get_host_port_ipv4(RADICALE_PORT.tcp())?;
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
endpoint: Endpoint::new(host, port),
accounts,
})
}
pub fn base_url(&self) -> String {
self.endpoint.http_base()
}
pub fn account_url(&self, account: &Account) -> String {
format!("{}/{}/", self.base_url(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let icals = load_icals()?;
let vcards = load_vcards()?;
let mut out = Vec::new();
for acct in &self.accounts {
let seed = self.seed_account(acct, &icals, &vcards)?;
out.push(seed);
}
Ok(out)
}
pub fn seed_account(
&self,
account: &Account,
icals: &[RawFixture],
vcards: &[RawFixture],
) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let layout = &account.layout;
let mut seed = AccountSeed::new(account.username.clone());
for (idx, cal) in layout.calendars.iter().enumerate() {
let collection = collection_segment("cal", idx, cal);
let base = format!("/{}/{}/", account.username, collection);
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<set><prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>{cal}</displayname>
</prop></set>
</mkcol>"#
);
client.mkcol(&base, Some(&body))?;
let mut plan = CollectionPlan::new((*cal).to_owned(), base.clone());
let count = events_per_calendar(layout, idx);
let offset = events_offset(layout, idx);
for (i, fixture) in icals.iter().cycle().skip(offset).take(count).enumerate() {
let path = format!("{base}{}-{}.ics", fixture.name, i);
let suffix = format!("{collection}-{i}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("ical fixture {} has no UID", fixture.name))
})?;
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: path,
source: bytes,
});
}
seed.calendars.push(plan);
}
for (idx, ab) in layout.address_books.iter().enumerate() {
let collection = collection_segment("ab", idx, ab);
let base = format!("/{}/{}/", account.username, collection);
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<set><prop>
<resourcetype><collection/><card:addressbook/></resourcetype>
<displayname>{ab}</displayname>
</prop></set>
</mkcol>"#
);
client.mkcol(&base, Some(&body))?;
let mut plan = CollectionPlan::new((*ab).to_owned(), base.clone());
let count = contacts_per_book(layout, idx);
let offset = contacts_offset(layout, idx);
for (i, fixture) in vcards.iter().cycle().skip(offset).take(count).enumerate() {
let path = format!("{base}{}-{}.vcf", fixture.name, i);
let suffix = format!("{collection}-{i}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix).ok_or_else(|| {
ContainerError::Seed(format!("vcard fixture {} has no UID", fixture.name))
})?;
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
plan.items.push(SeededItem {
uid,
href: path,
source: bytes,
});
}
seed.address_books.push(plan);
}
Ok(seed)
}
pub fn seed_malformed_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
) -> ContainerResult<String> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let collection = collection_segment(
"cal",
calendar_segment_idx,
account.layout.calendars[calendar_segment_idx],
);
let path = format!("/{}/{}/broken-{tag}.ics", account.username, collection);
let bad = malformed_ical(tag);
client.put(&path, "text/calendar; charset=utf-8", &bad.bytes)?;
Ok(path)
}
pub fn delete_item(&self, account: &Account, path: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
client.delete(path)?;
Ok(())
}
pub fn add_event(
&self,
account: &Account,
calendar_segment_idx: usize,
tag: &str,
icals: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let display = account.layout.calendars[calendar_segment_idx];
let collection = collection_segment("cal", calendar_segment_idx, display);
let fixture = icals.first().ok_or_else(|| {
ContainerError::Seed("no ical fixture available for add_event".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("ical fixture has no UID".to_owned()))?;
let path = format!("/{}/{collection}/added-{tag}.ics", account.username);
client.put(&path, "text/calendar; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn add_contact(
&self,
account: &Account,
book_segment_idx: usize,
tag: &str,
vcards: &[RawFixture],
) -> ContainerResult<(String, String)> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let display = account.layout.address_books[book_segment_idx];
let collection = collection_segment("ab", book_segment_idx, display);
let fixture = vcards.first().ok_or_else(|| {
ContainerError::Seed("no vcard fixture available for add_contact".to_owned())
})?;
let suffix = format!("added-{tag}");
let (bytes, uid) = rewrite_uid(&fixture.bytes, &suffix)
.ok_or_else(|| ContainerError::Seed("vcard fixture has no UID".to_owned()))?;
let path = format!("/{}/{collection}/added-{tag}.vcf", account.username);
client.put(&path, "text/vcard; charset=utf-8", &bytes)?;
Ok((path, uid))
}
pub fn verify_seed(&self, seeds: &[AccountSeed]) -> ContainerResult<()> {
for (acct, seed) in self.accounts.iter().zip(seeds) {
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"radicale propfind for {} returned no multistatus",
acct.username
)));
}
let expected_cal_names: HashSet<&str> = seed
.calendars
.iter()
.map(|c| c.display_name.as_str())
.collect();
let expected_ab_names: HashSet<&str> = seed
.address_books
.iter()
.map(|c| c.display_name.as_str())
.collect();
if expected_cal_names.is_empty() && expected_ab_names.is_empty() {
continue;
}
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SeededItem {
pub uid: String,
pub href: String,
pub source: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct CollectionPlan {
pub display_name: String,
pub base_href: String,
pub items: Vec<SeededItem>,
}
impl CollectionPlan {
fn new(display_name: String, base_href: String) -> Self {
Self {
display_name,
base_href,
items: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub calendars: Vec<CollectionPlan>,
pub address_books: Vec<CollectionPlan>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
calendars: Vec::new(),
address_books: Vec::new(),
}
}
pub fn total_events(&self) -> usize {
self.calendars.iter().map(|c| c.items.len()).sum()
}
pub fn total_contacts(&self) -> usize {
self.address_books.iter().map(|c| c.items.len()).sum()
}
pub fn event_uids(&self) -> HashSet<String> {
self.calendars
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
pub fn contact_uids(&self) -> HashSet<String> {
self.address_books
.iter()
.flat_map(|c| c.items.iter().map(|i| i.uid.clone()))
.collect()
}
}
fn collection_segment(prefix: &str, idx: usize, name: &str) -> String {
let mut out = format!("{prefix}-{:02}-", idx);
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_end_matches('-').to_owned()
}
fn events_per_calendar(layout: &Layout, idx: usize) -> usize {
if layout.calendars.is_empty() {
return 0;
}
let base = layout.event_count / layout.calendars.len().max(1);
let extra = if idx == 0 {
layout.event_count % layout.calendars.len()
} else {
0
};
base + extra
}
fn events_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| events_per_calendar(layout, i)).sum()
}
fn contacts_per_book(layout: &Layout, idx: usize) -> usize {
if layout.address_books.is_empty() {
return 0;
}
let base = layout.contact_count / layout.address_books.len().max(1);
let extra = if idx == 0 {
layout.contact_count % layout.address_books.len()
} else {
0
};
base + extra
}
fn contacts_offset(layout: &Layout, idx: usize) -> usize {
(0..idx).map(|i| contacts_per_book(layout, i)).sum()
}
+150
View File
@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use super::error::{ContainerError, ContainerResult};
pub struct SieveSeed {
reader: BufReader<TcpStream>,
writer: TcpStream,
}
impl SieveSeed {
pub fn connect_seed(host: &str, port: u16) -> ContainerResult<Self> {
Self::connect(host, port)
}
pub fn connect(host: &str, port: u16) -> ContainerResult<Self> {
let stream = TcpStream::connect((host, port))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
let writer = stream.try_clone()?;
let mut me = Self {
reader: BufReader::new(stream),
writer,
};
me.read_until_ok()?;
Ok(me)
}
fn read_line(&mut self) -> ContainerResult<String> {
let mut line = String::new();
let n = self.reader.read_line(&mut line)?;
if n == 0 {
return Err(ContainerError::Protocol("sieve eof".to_owned()));
}
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
Ok(line)
}
fn read_until_ok(&mut self) -> ContainerResult<Vec<String>> {
let mut lines = Vec::new();
loop {
let line = self.read_line()?;
if line.starts_with("OK") {
return Ok(lines);
}
if line.starts_with("NO") || line.starts_with("BYE") {
return Err(ContainerError::Protocol(format!("sieve: {line}")));
}
lines.push(line);
}
}
fn write_all(&mut self, bytes: &[u8]) -> ContainerResult<()> {
self.writer.write_all(bytes)?;
self.writer.flush()?;
Ok(())
}
pub fn authenticate(&mut self, user: &str, password: &str) -> ContainerResult<()> {
let mut blob = Vec::new();
blob.push(0u8);
blob.extend_from_slice(user.as_bytes());
blob.push(0u8);
blob.extend_from_slice(password.as_bytes());
let encoded = B64.encode(&blob);
let cmd = format!(
"AUTHENTICATE \"PLAIN\" {{{}+}}\r\n{encoded}\r\n",
encoded.len()
);
self.write_all(cmd.as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn putscript(&mut self, name: &str, body: &str) -> ContainerResult<()> {
let cmd = format!(
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
escape(name),
body.len(),
body
);
self.write_all(cmd.as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn putscript_raw(&mut self, name: &str, body: &str) -> ContainerResult<bool> {
let cmd = format!(
"PUTSCRIPT \"{}\" {{{}+}}\r\n{}\r\n",
escape(name),
body.len(),
body
);
self.write_all(cmd.as_bytes())?;
match self.read_until_ok() {
Ok(_) => Ok(true),
Err(ContainerError::Protocol(_)) => Ok(false),
Err(e) => Err(e),
}
}
pub fn setactive(&mut self, name: &str) -> ContainerResult<()> {
self.write_all(format!("SETACTIVE \"{}\"\r\n", escape(name)).as_bytes())?;
self.read_until_ok()?;
Ok(())
}
pub fn listscripts(&mut self) -> ContainerResult<Vec<(String, bool)>> {
self.write_all(b"LISTSCRIPTS\r\n")?;
let lines = self.read_until_ok()?;
let mut scripts = Vec::new();
for line in lines {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix('"') {
let close = stripped.find('"').unwrap_or(stripped.len());
let name = stripped[..close].to_owned();
let rest = &stripped[close..];
let active = rest.contains("ACTIVE");
scripts.push((name, active));
}
}
Ok(scripts)
}
pub fn logout(&mut self) -> ContainerResult<()> {
let _ = self.write_all(b"LOGOUT\r\n");
let mut buf = String::new();
let _ = self.reader.read_to_string(&mut buf);
Ok(())
}
}
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
+165
View File
@@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::net::TcpListener;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use base64::Engine;
use serde_json::Value;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::SyncRunner;
use testcontainers::{Container, GenericImage, ImageExt};
use ureq::Agent;
use ureq::config::RedirectAuthHeaders;
use ureq::tls::{TlsConfig, TlsProvider};
use super::error::{ContainerError, ContainerResult};
const IMAGE_NAME: &str = "stalwartlabs/stalwart";
const IMAGE_TAG: &str = "latest";
const HTTPS_PORT: u16 = 443;
const IMAP_PORT: u16 = 143;
const IMAPS_PORT: u16 = 993;
const SIEVE_PORT: u16 = 4190;
const CONFIG_PATH: &str = "/etc/stalwart/config.json";
const CONFIG_JSON: &str = r#"{"@type":"RocksDb","blobSize":16834,"bufferSize":134217728,"path":"/var/lib/stalwart","poolWorkers":null}"#;
pub const ADMIN_USER: &str = "admin";
pub const ADMIN_PASSWORD: &str = "admin";
pub struct Stalwart {
_container: Container<GenericImage>,
pub host: String,
pub https_port: u16,
pub imap_port: u16,
pub imaps_port: u16,
pub sieve_port: u16,
pub public_url: String,
}
impl Stalwart {
pub fn start() -> ContainerResult<Self> {
let https_port = pick_free_port()?;
let imap_port = pick_free_port()?;
let imaps_port = pick_free_port()?;
let sieve_port = pick_free_port()?;
let host = "127.0.0.1".to_owned();
let public_url = format!("https://{host}:{https_port}");
let image = GenericImage::new(IMAGE_NAME, IMAGE_TAG)
.with_exposed_port(HTTPS_PORT.tcp())
.with_exposed_port(IMAP_PORT.tcp())
.with_exposed_port(IMAPS_PORT.tcp())
.with_exposed_port(SIEVE_PORT.tcp())
.with_wait_for(WaitFor::seconds(2));
let request = image
.with_env_var("STALWART_PUBLIC_URL", &public_url)
.with_env_var("STALWART_RECOVERY_ADMIN", "admin:admin")
.with_copy_to(CONFIG_PATH, CONFIG_JSON.as_bytes().to_vec())
.with_mapped_port(https_port, HTTPS_PORT.tcp())
.with_mapped_port(imap_port, IMAP_PORT.tcp())
.with_mapped_port(imaps_port, IMAPS_PORT.tcp())
.with_mapped_port(sieve_port, SIEVE_PORT.tcp())
.with_startup_timeout(Duration::from_secs(180));
let container = request.start()?;
let me = Self {
_container: container,
host,
https_port,
imap_port,
imaps_port,
sieve_port,
public_url,
};
me.wait_ready(Duration::from_secs(120))?;
Ok(me)
}
pub fn base_url(&self) -> &str {
&self.public_url
}
pub fn fetch_jmap_session(&self) -> ContainerResult<Value> {
let agent = build_agent();
let auth = basic(ADMIN_USER, ADMIN_PASSWORD);
let url = format!("{}/.well-known/jmap", self.public_url);
let mut resp = agent.get(&url).header("Authorization", &auth).call()?;
let status = resp.status().as_u16();
let text = resp
.body_mut()
.read_to_string()
.map_err(|e| ContainerError::Protocol(format!("body read: {e}")))?;
if status != 200 {
return Err(ContainerError::Protocol(format!(
"jmap session status {status}: {text}"
)));
}
serde_json::from_str(&text)
.map_err(|e| ContainerError::Protocol(format!("jmap session parse: {e}")))
}
fn wait_ready(&self, total: Duration) -> ContainerResult<()> {
let deadline = Instant::now() + total;
let mut last_err = String::from("no probe attempted");
while Instant::now() < deadline {
match self.fetch_jmap_session() {
Ok(v) if v.get("apiUrl").is_some() => return Ok(()),
Ok(v) => last_err = format!("session missing apiUrl: {v}"),
Err(e) => last_err = e.to_string(),
}
std::thread::sleep(Duration::from_millis(500));
}
Err(ContainerError::Protocol(format!(
"stalwart did not become ready in {total:?}: {last_err}"
)))
}
}
static SHARED: OnceLock<Stalwart> = OnceLock::new();
pub fn shared() -> &'static Stalwart {
SHARED.get_or_init(|| Stalwart::start().expect("start shared stalwart container"))
}
fn pick_free_port() -> ContainerResult<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
fn build_agent() -> Agent {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let tls = TlsConfig::builder()
.provider(TlsProvider::Rustls)
.unversioned_rustls_crypto_provider(provider)
.disable_verification(true)
.build();
Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.max_redirects(10)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.build()
.new_agent()
}
fn basic(user: &str, password: &str) -> String {
let mut raw = String::with_capacity(user.len() + password.len() + 1);
raw.push_str(user);
raw.push(':');
raw.push_str(password);
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
)
}
+610
View File
@@ -0,0 +1,610 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
pub fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-container-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&p);
p
}
pub fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 2,
dry_run: false,
max_retries: 3,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
pub fn open_archive(archive: &Path) -> Connection {
Connection::open(archive).expect("open archive")
}
pub fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap_or(0)
}
pub fn blob_bytes(conn: &Connection, blob_id: i64) -> Vec<u8> {
conn.query_row("SELECT data FROM blobs WHERE id = ?1", [blob_id], |r| {
r.get::<_, Vec<u8>>(0)
})
.expect("blob fetch")
}
pub fn mailbox_path(conn: &Connection, mailbox_id: i64, sep: char) -> String {
let mut parts: Vec<String> = Vec::new();
let mut cur = Some(mailbox_id);
while let Some(id) = cur {
let (name, parent): (String, Option<i64>) = conn
.query_row(
"SELECT name, parent_id FROM mailboxes WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("mailbox path");
parts.push(name);
cur = parent;
}
parts.reverse();
parts.join(&sep.to_string())
}
pub fn file_path(conn: &Connection, node_id: i64) -> String {
let mut parts: Vec<String> = Vec::new();
let mut cur = Some(node_id);
while let Some(id) = cur {
let (name, parent): (String, Option<i64>) = conn
.query_row(
"SELECT name, parent_id FROM file_nodes WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("file path");
parts.push(name);
cur = parent;
}
parts.reverse();
parts.join("/")
}
pub fn emails_in_mailbox(conn: &Connection, mailbox_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM emails
WHERE EXISTS (SELECT 1 FROM json_each(emails.mailbox_ids) j WHERE j.value = ?1)",
[mailbox_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn email_rows_for_blob(conn: &Connection, blob_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM emails WHERE blob_id = ?1",
[blob_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn blob_id_for_hash(conn: &Connection, hash: &[u8]) -> Option<i64> {
conn.query_row("SELECT id FROM blobs WHERE hash = ?1", [hash], |r| r.get(0))
.ok()
}
pub fn mailbox_id_by_name(conn: &Connection, name: &str) -> Option<i64> {
conn.query_row("SELECT id FROM mailboxes WHERE name = ?1", [name], |r| {
r.get(0)
})
.ok()
}
pub fn mailbox_id_by_path(conn: &Connection, path: &str, sep: char) -> Option<i64> {
let mut parent: Option<i64> = None;
for segment in path.split(sep) {
let id: i64 = match parent {
Some(p) => conn
.query_row(
"SELECT id FROM mailboxes WHERE parent_id = ?1 AND name = ?2",
rusqlite::params![p, segment],
|r| r.get(0),
)
.ok()?,
None => conn
.query_row(
"SELECT id FROM mailboxes WHERE parent_id IS NULL AND name = ?1",
[segment],
|r| r.get(0),
)
.ok()?,
};
parent = Some(id);
}
parent
}
pub fn file_node_id_by_path(conn: &Connection, segments: &[&str]) -> Option<i64> {
let mut parent: Option<i64> = None;
for segment in segments {
let id: i64 = match parent {
Some(p) => conn
.query_row(
"SELECT id FROM file_nodes WHERE parent_id = ?1 AND name = ?2",
rusqlite::params![p, segment],
|r| r.get(0),
)
.ok()?,
None => conn
.query_row(
"SELECT id FROM file_nodes WHERE parent_id IS NULL AND name = ?1",
[segment],
|r| r.get(0),
)
.ok()?,
};
parent = Some(id);
}
parent
}
pub fn collection_names(conn: &Connection, table: &str) -> HashSet<String> {
let mut stmt = conn
.prepare(&format!("SELECT name FROM {table}"))
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn collection_id_by_name(conn: &Connection, table: &str, name: &str) -> Option<i64> {
conn.query_row(
&format!("SELECT id FROM {table} WHERE name = ?1"),
[name],
|r| r.get(0),
)
.ok()
}
pub fn calendar_event_count(conn: &Connection, calendar_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM calendar_events
WHERE EXISTS (SELECT 1 FROM json_each(calendar_events.calendar_ids) j WHERE j.value = ?1)",
[calendar_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn contact_card_count(conn: &Connection, address_book_id: i64) -> i64 {
conn.query_row(
"SELECT count(*) FROM contact_cards
WHERE EXISTS (SELECT 1 FROM json_each(contact_cards.address_book_ids) j WHERE j.value = ?1)",
[address_book_id],
|r| r.get(0),
)
.unwrap_or(0)
}
pub fn all_event_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn
.prepare(
"SELECT json_extract(data, '$.uid') FROM calendar_events
WHERE json_extract(data, '$.uid') IS NOT NULL",
)
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn all_contact_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn
.prepare("SELECT uid FROM contact_cards")
.expect("prepare");
stmt.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect()
}
pub fn keywords_for_blob(conn: &Connection, blob_id: i64) -> Vec<String> {
let json: String = conn
.query_row(
"SELECT keywords FROM emails WHERE blob_id = ?1 LIMIT 1",
[blob_id],
|r| r.get(0),
)
.expect("keywords fetch");
serde_json::from_str::<Vec<String>>(&json).expect("keywords json")
}
pub fn event_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
let raw: String = conn
.query_row(
"SELECT data FROM calendar_events WHERE json_extract(data,'$.uid') = ?1",
[uid],
|r| r.get(0),
)
.ok()?;
serde_json::from_str(&raw).ok()
}
pub fn contact_data_by_uid(conn: &Connection, uid: &str) -> Option<serde_json::Value> {
let raw: String = conn
.query_row(
"SELECT data FROM contact_cards WHERE uid = ?1",
[uid],
|r| r.get(0),
)
.ok()?;
serde_json::from_str(&raw).ok()
}
pub fn unfold_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for line in text.lines() {
let l = line.trim_end_matches('\r');
if l.starts_with(' ') || l.starts_with('\t') {
out.push_str(&l[1..]);
} else {
if !out.is_empty() {
out.push('\n');
}
out.push_str(l);
}
}
out
}
pub fn extract_property(text: &str, name: &str) -> Option<String> {
component_top_level_properties(text, name)
.into_iter()
.next()
}
pub fn extract_property_all(text: &str, name: &str) -> Vec<String> {
component_top_level_properties(text, name)
}
fn component_top_level_properties(text: &str, name: &str) -> Vec<String> {
let value_colon = format!("{name}:");
let value_semi = format!("{name};");
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut item_depth: Option<i32> = None;
for line in unfold_text(text).lines() {
let l = line.trim_end_matches('\r');
if l.starts_with("BEGIN:") {
depth += 1;
if matches!(l, "BEGIN:VEVENT" | "BEGIN:VTODO" | "BEGIN:VCARD") {
item_depth = Some(depth);
}
continue;
}
if l.starts_with("END:") {
if Some(depth) == item_depth {
item_depth = None;
}
depth -= 1;
continue;
}
if item_depth.is_some_and(|d| d == depth) {
if let Some(rest) = l.strip_prefix(&value_colon) {
out.push(rest.to_owned());
} else if l.starts_with(&value_semi)
&& let Some(colon) = l.find(':')
{
out.push(l[colon + 1..].to_owned());
}
}
}
out
}
pub fn has_line_prefix(text: &str, prefix: &str) -> bool {
let unfolded = unfold_text(text);
unfolded.lines().any(|l| l.starts_with(prefix))
}
pub fn json_contains_string(value: &serde_json::Value, needle: &str) -> bool {
let serialised = value.to_string();
serialised.contains(needle)
}
pub fn assert_message_round_trip(conn: &Connection, raw: &[u8], target_mailbox: &str, label: &str) {
let hash = blake3::hash(raw);
let blob_id = blob_id_for_hash(conn, hash.as_bytes())
.unwrap_or_else(|| panic!("{label}: blob for seeded message missing in archive"));
let stored = blob_bytes(conn, blob_id);
assert_eq!(
stored, raw,
"{label}: stored blob bytes differ from seeded raw"
);
let mailbox_id = mailbox_id_by_path(conn, target_mailbox, '/')
.unwrap_or_else(|| panic!("{label}: target mailbox {target_mailbox} missing in archive"));
let count: i64 = conn
.query_row(
"SELECT count(*) FROM emails
WHERE blob_id = ?1
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j WHERE j.value = ?2)",
rusqlite::params![blob_id, mailbox_id],
|r| r.get(0),
)
.unwrap();
assert!(
count >= 1,
"{label}: blob {blob_id} not linked to mailbox {target_mailbox} (id {mailbox_id})"
);
}
pub fn assert_event_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
let text = std::str::from_utf8(source).expect("seeded ical bytes utf-8");
let data = event_data_by_uid(conn, uid)
.unwrap_or_else(|| panic!("{label}: event uid {uid} missing in archive"));
let obj = data.as_object().expect("event data is object");
let stored_uid = obj.get("uid").and_then(|v| v.as_str()).unwrap_or("");
assert_eq!(
stored_uid, uid,
"{label}: event uid mismatch in stored data"
);
let summary = extract_property(text, "SUMMARY").unwrap_or_default();
if !summary.is_empty() {
let title = obj.get("title").and_then(|v| v.as_str()).unwrap_or("");
let expected = unescape_text(&summary);
assert!(
title == expected || title.contains(&expected),
"{label}: event {uid} title mismatch: stored={title:?} expected to contain={expected:?}"
);
}
if extract_property(text, "DESCRIPTION").is_some() {
let descr = obj
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
!descr.is_empty(),
"{label}: event {uid} description was seeded but stored data has no description"
);
}
let start = obj.get("start").and_then(|v| v.as_str()).unwrap_or("");
assert!(
!start.is_empty(),
"{label}: event {uid} stored data.start is empty"
);
if has_line_prefix(text, "RRULE:") || has_line_prefix(text, "RRULE;") {
let has_rule = obj
.get("recurrenceRules")
.map(|v| !v.as_array().is_some_and(|a| a.is_empty()))
.unwrap_or(false)
|| obj
.get("recurrenceRule")
.map(|v| {
!v.as_object().is_some_and(|o| o.is_empty())
&& !v.as_array().is_some_and(|a| a.is_empty())
})
.unwrap_or(false);
assert!(
has_rule,
"{label}: event {uid} seeded RRULE but no recurrenceRule/recurrenceRules in stored data: {obj:?}"
);
}
if has_line_prefix(text, "BEGIN:VALARM") {
let alerts = obj
.get("alerts")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!alerts.is_empty(),
"{label}: event {uid} seeded VALARM but alerts empty"
);
}
if has_line_prefix(text, "ORGANIZER:")
|| has_line_prefix(text, "ORGANIZER;")
|| has_line_prefix(text, "ATTENDEE:")
|| has_line_prefix(text, "ATTENDEE;")
{
let parts = obj
.get("participants")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!parts.is_empty(),
"{label}: event {uid} seeded ORGANIZER/ATTENDEE but participants empty"
);
for addr in extract_property_all(text, "ATTENDEE") {
if let Some(idx) = addr.find("mailto:")
&& let Some(end) = addr[idx + 7..].find([',', ';'])
{
let email = &addr[idx + 7..idx + 7 + end];
assert!(
json_contains_string(&data, email),
"{label}: event {uid} attendee {email} not present in stored JSON"
);
} else if let Some(idx) = addr.find("mailto:") {
let email = &addr[idx + 7..];
assert!(
json_contains_string(&data, email),
"{label}: event {uid} attendee {email} not present in stored JSON"
);
}
}
}
if let Some(location) = extract_property(text, "LOCATION") {
let cleaned = unescape_text(&location);
let probe = cleaned.split(',').next().unwrap_or(&cleaned).trim();
if !probe.is_empty() {
assert!(
json_contains_string(&data, probe),
"{label}: event {uid} location fragment {probe:?} not present in stored JSON"
);
}
}
if let Some(cats) = extract_property(text, "CATEGORIES") {
let keywords = obj
.get("keywords")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
assert!(
!keywords.is_empty(),
"{label}: event {uid} seeded CATEGORIES={cats} but keywords empty"
);
for cat in cats.split(',') {
let cat = cat.trim();
if cat.is_empty() {
continue;
}
assert!(
keywords.contains_key(cat),
"{label}: event {uid} category {cat} not in keywords {keywords:?}"
);
}
}
}
pub fn assert_contact_round_trip(conn: &Connection, source: &[u8], uid: &str, label: &str) {
let text = std::str::from_utf8(source).expect("seeded vcard bytes utf-8");
let data = contact_data_by_uid(conn, uid)
.unwrap_or_else(|| panic!("{label}: contact uid {uid} missing in archive"));
if let Some(fn_value) = extract_property(text, "FN") {
let cleaned = unescape_text(&fn_value);
assert!(
json_contains_string(&data, &cleaned),
"{label}: contact {uid} FN {cleaned:?} not present in stored JSContact"
);
}
for email in extract_property_all(text, "EMAIL") {
assert!(
json_contains_string(&data, &email),
"{label}: contact {uid} EMAIL {email:?} not present in stored JSContact"
);
}
for tel in extract_property_all(text, "TEL") {
assert!(
json_contains_string(&data, &tel),
"{label}: contact {uid} TEL {tel:?} not present in stored JSContact"
);
}
if let Some(org) = extract_property(text, "ORG") {
let primary = org.split(';').next().unwrap_or(&org).trim();
if !primary.is_empty() {
assert!(
json_contains_string(&data, primary),
"{label}: contact {uid} ORG {primary:?} not present in stored JSContact"
);
}
}
if let Some(nick) = extract_property(text, "NICKNAME") {
assert!(
json_contains_string(&data, &nick),
"{label}: contact {uid} NICKNAME {nick:?} not present"
);
}
if let Some(bday) = extract_property(text, "BDAY") {
let iso = format_vcard_date_as_iso(&bday);
let present_raw = json_contains_string(&data, &bday);
let present_iso = iso
.as_deref()
.map(|s| json_contains_string(&data, s))
.unwrap_or(false);
let obj = data.as_object().expect("contact data is object");
let has_anniversaries = obj
.get("anniversaries")
.map(|v| !v.as_object().is_some_and(|o| o.is_empty()))
.unwrap_or(false);
assert!(
present_raw || present_iso || has_anniversaries,
"{label}: contact {uid} BDAY {bday:?} not present in stored JSContact (no anniversaries either)"
);
}
}
fn format_vcard_date_as_iso(bday: &str) -> Option<String> {
let trimmed = bday.trim();
if trimmed.len() == 8 && trimmed.bytes().all(|b| b.is_ascii_digit()) {
Some(format!(
"{}-{}-{}",
&trimmed[..4],
&trimmed[4..6],
&trimmed[6..8]
))
} else {
None
}
}
fn unescape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(&next) = chars.peek()
{
match next {
',' | ';' | '\\' => {
out.push(next);
chars.next();
continue;
}
'n' | 'N' => {
out.push('\n');
chars.next();
continue;
}
_ => {}
}
}
out.push(c);
}
out
}
pub fn cleanup(archive: &Path) {
let _ = std::fs::remove_file(archive);
let mut wal = archive.as_os_str().to_owned();
wal.push("-wal");
let _ = std::fs::remove_file(Path::new(&wal));
let mut shm = archive.as_os_str().to_owned();
shm.push("-shm");
let _ = std::fs::remove_file(Path::new(&shm));
}
+321
View File
@@ -0,0 +1,321 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::{SyncBuilder, SyncRunner};
use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt};
use super::dav_client::DavSeed;
use super::error::{ContainerError, ContainerResult};
use super::layouts::{self, FileSpec};
use super::{Account, Endpoint};
const HTTP_PORT: u16 = 80;
const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
apache2 apache2-utils ca-certificates && \
a2enmod dav dav_fs auth_basic authn_file && \
rm -rf /var/lib/apt/lists/*
COPY apache.conf /etc/apache2/sites-available/000-default.conf
COPY htpasswd /etc/apache2/htpasswd
RUN mkdir -p /var/dav && \
chown -R www-data:www-data /var/dav
EXPOSE 80
CMD ["apachectl", "-D", "FOREGROUND"]
"#;
const APACHE_CONF: &str = r#"DavLockDB /tmp/DavLock
<VirtualHost *:80>
DocumentRoot /var/dav
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
<Directory /var/dav>
Dav On
AuthType Basic
AuthName "WebDAV"
AuthUserFile /etc/apache2/htpasswd
Require valid-user
Options Indexes FollowSymLinks
</Directory>
</VirtualHost>
"#;
fn htpasswd_for_test() -> String {
let mut out = String::new();
for u in layouts::accounts() {
out.push_str(u);
out.push(':');
out.push_str("{SHA}");
out.push_str(&sha1_b64(layouts::PASSWORD.as_bytes()));
out.push('\n');
}
out
}
fn sha1_b64(bytes: &[u8]) -> String {
let digest = simple_sha1(bytes);
B64.encode(digest)
}
fn simple_sha1(bytes: &[u8]) -> [u8; 20] {
let mut h0: u32 = 0x6745_2301;
let mut h1: u32 = 0xEFCD_AB89;
let mut h2: u32 = 0x98BA_DCFE;
let mut h3: u32 = 0x1032_5476;
let mut h4: u32 = 0xC3D2_E1F0;
let bit_len = (bytes.len() as u64).wrapping_mul(8);
let mut msg = bytes.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 80];
for (i, word) in w.iter_mut().take(16).enumerate() {
*word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap());
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4);
for (i, &wi) in w.iter().enumerate() {
let (f, k): (u32, u32) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999),
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
_ => (b ^ c ^ d, 0xCA62_C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h0 = h0.wrapping_add(a);
h1 = h1.wrapping_add(b);
h2 = h2.wrapping_add(c);
h3 = h3.wrapping_add(d);
h4 = h4.wrapping_add(e);
}
let mut out = [0u8; 20];
out[..4].copy_from_slice(&h0.to_be_bytes());
out[4..8].copy_from_slice(&h1.to_be_bytes());
out[8..12].copy_from_slice(&h2.to_be_bytes());
out[12..16].copy_from_slice(&h3.to_be_bytes());
out[16..20].copy_from_slice(&h4.to_be_bytes());
out
}
pub struct WebDav {
container: Container<GenericImage>,
pub http: Endpoint,
pub accounts: Vec<Account>,
}
impl WebDav {
pub fn start() -> ContainerResult<Self> {
let image: GenericImage = GenericBuildableImage::new("vandelay-webdav", "test")
.with_dockerfile_string(DOCKERFILE.to_owned())
.with_data(APACHE_CONF.as_bytes().to_vec(), "apache.conf")
.with_data(htpasswd_for_test().into_bytes(), "htpasswd")
.build_image()
.map_err(|e| ContainerError::Seed(format!("webdav build: {e}")))?;
let request = image
.with_exposed_port(HTTP_PORT.tcp())
.with_wait_for(WaitFor::message_on_stderr("AH00558"))
.with_startup_timeout(Duration::from_secs(120));
let container = request.start()?;
let host = container.get_host()?.to_string();
let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?);
let accounts: Vec<Account> = layouts::accounts()
.iter()
.map(|name| Account {
username: (*name).to_owned(),
password: layouts::PASSWORD.to_owned(),
layout: layouts::layout_for(name),
})
.collect();
Ok(Self {
container,
http,
accounts,
})
}
pub fn base_url(&self) -> String {
self.http.http_base()
}
pub fn account_url(&self, account: &Account) -> String {
format!("{}/{}/", self.base_url(), account.username)
}
pub fn seed_all(&self) -> ContainerResult<Vec<AccountSeed>> {
let mut out = Vec::new();
for acct in &self.accounts {
let m = self.seed_account(acct)?;
out.push(m);
}
Ok(out)
}
pub fn seed_account(&self, account: &Account) -> ContainerResult<AccountSeed> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let root = format!("/{}/", account.username);
client.mkcol(&root, None)?;
let mut seed = AccountSeed::new(account.username.clone());
let specs = account.layout.files;
for spec in specs {
let path = build_path(&root, specs, spec.key)
.ok_or_else(|| ContainerError::Seed(format!("missing file key: {}", spec.key)))?;
if spec.directory {
client.mkcol(&path, None)?;
seed.directories += 1;
} else {
let payload = synth_payload(spec.name);
client.put(&path, "application/octet-stream", payload.as_bytes())?;
seed.files.push(SeededFile {
key: spec.key.to_owned(),
name: spec.name.to_owned(),
href: path,
payload: payload.into_bytes(),
});
}
}
Ok(seed)
}
pub fn delete_resource(&self, account: &Account, href: &str) -> ContainerResult<()> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
client.delete(href)?;
Ok(())
}
pub fn add_file(
&self,
account: &Account,
parent_segments: &[&str],
name: &str,
payload: &[u8],
) -> ContainerResult<String> {
let client = DavSeed::new(self.base_url(), &account.username, &account.password);
let mut href = format!("/{}/", account.username);
for seg in parent_segments {
href.push_str(seg);
href.push('/');
}
href.push_str(name);
client.put(&href, "application/octet-stream", payload)?;
Ok(href)
}
pub fn verify_seed(&self) -> ContainerResult<()> {
for acct in &self.accounts {
let client = DavSeed::new(self.base_url(), &acct.username, &acct.password);
let body = client.propfind(&format!("/{}/", acct.username), 1)?;
if !body.contains("multistatus") {
return Err(ContainerError::Protocol(format!(
"webdav propfind for {} returned no multistatus",
acct.username
)));
}
}
Ok(())
}
pub fn stop(self) -> ContainerResult<()> {
self.container.stop()?;
Ok(())
}
}
fn build_path(root: &str, specs: &[FileSpec], key: &str) -> Option<String> {
let mut chain: Vec<&str> = Vec::new();
let mut cur = key;
let mut leaf_dir = false;
loop {
let spec = specs.iter().find(|s| s.key == cur)?;
chain.push(spec.name);
if chain.len() == 1 {
leaf_dir = spec.directory;
}
match spec.parent {
Some(p) => cur = p,
None => break,
}
}
chain.reverse();
let mut path = String::from(root);
for (i, name) in chain.iter().enumerate() {
path.push_str(name);
let is_last = i + 1 == chain.len();
if !is_last || leaf_dir {
path.push('/');
}
}
Some(path)
}
pub fn synth_payload(name: &str) -> String {
let mut out = String::new();
out.push_str(name);
out.push('\n');
for i in 0..16 {
out.push_str(&format!("line {i} for {name}\n"));
}
out
}
#[derive(Debug, Clone)]
pub struct SeededFile {
pub key: String,
pub name: String,
pub href: String,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct AccountSeed {
pub username: String,
pub directories: usize,
pub files: Vec<SeededFile>,
}
impl AccountSeed {
fn new(username: String) -> Self {
Self {
username,
directories: 0,
files: Vec::new(),
}
}
}
+489
View File
@@ -0,0 +1,489 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
use std::collections::HashSet;
use integration::Account;
use integration::baikal::{AccountSeed, Baikal};
use integration::validate::{
all_contact_uids, all_event_uids, assert_contact_round_trip, assert_event_round_trip,
calendar_event_count, cleanup, collection_id_by_name, collection_names, common,
contact_card_count, count, open_archive, tmp_archive,
};
use rusqlite::Connection;
use serde_json::Value;
use vandelay::error::Error;
use vandelay::sync::import_dav;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
fn dav_config(kind: DavKindArg, account: &Account, dav_root: &str) -> DavImportConfig {
let path = match kind {
DavKindArg::Caldav => format!("{dav_root}/calendars/{}/", account.username),
DavKindArg::Carddav => format!("{dav_root}/addressbooks/{}/", account.username),
DavKindArg::Webdav => format!("{dav_root}/"),
};
DavImportConfig {
kind,
url: path,
auth: DavAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
dav_connections: 2,
multiget_batch: 25,
allow_source_change: false,
}
}
fn dav_config_via_discovery(
kind: DavKindArg,
account: &Account,
dav_root: &str,
) -> DavImportConfig {
DavImportConfig {
kind,
url: format!("{dav_root}/"),
auth: DavAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
dav_connections: 2,
multiget_batch: 25,
allow_source_change: false,
}
}
#[test]
#[ignore = "requires Docker"]
fn baikal_starts_seeds_and_imports() {
let b = Baikal::start().expect("baikal start");
let per_account = b.seed_all().expect("baikal seed");
assert_eq!(
per_account.len(),
b.accounts.len(),
"seed should return stats for every account"
);
b.verify_seed(&per_account).expect("baikal verify");
let dav_root = b.dav_root();
for seed in &per_account {
let account = b
.accounts
.iter()
.find(|a| a.username == seed.username)
.expect("account lookup");
assert!(
!seed.calendars.is_empty(),
"{}: expected calendars seeded",
seed.username
);
assert!(
!seed.address_books.is_empty(),
"{}: expected address books seeded",
seed.username
);
assert!(
seed.total_events() > 0,
"{}: expected events seeded",
seed.username
);
assert!(
seed.total_contacts() > 0,
"{}: expected contacts seeded",
seed.username
);
let cal_archive = tmp_archive(&format!("baikal-cal-{}", seed.username));
let summary = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &dav_root),
)
.expect("caldav import");
assert!(
!summary.any_failed(),
"{}: first caldav import had failures: {summary:?}",
seed.username
);
let conn = open_archive(&cal_archive);
assert_event_collection_exact(&conn, seed);
assert_event_uids_round_trip(&conn, seed);
assert_event_data_well_formed(&conn);
for plan in &seed.calendars {
for item in &plan.items {
assert_event_round_trip(
&conn,
&item.source,
&item.uid,
&format!("{}/{}", seed.username, plan.display_name),
);
}
}
let cal_count = count(&conn, "calendars") as usize;
let ev_count = count(&conn, "calendar_events") as usize;
let blob_count = count(&conn, "blobs") as usize;
drop(conn);
let summary2 = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &dav_root),
)
.expect("idempotent caldav re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent caldav re-import had failures",
seed.username
);
let conn = open_archive(&cal_archive);
assert_eq!(
count(&conn, "calendars") as usize,
cal_count,
"{}: idempotent re-import changed calendar count",
seed.username
);
assert_eq!(
count(&conn, "calendar_events") as usize,
ev_count,
"{}: idempotent re-import changed event count",
seed.username
);
assert_eq!(
count(&conn, "blobs") as usize,
blob_count,
"{}: idempotent re-import changed blob count",
seed.username
);
drop(conn);
let card_archive = tmp_archive(&format!("baikal-card-{}", seed.username));
let summary = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &dav_root),
)
.expect("carddav import");
assert!(
!summary.any_failed(),
"{}: first carddav import had failures: {summary:?}",
seed.username
);
let conn = open_archive(&card_archive);
assert_contact_collection_exact(&conn, seed);
assert_contact_uids_round_trip(&conn, seed);
assert_contact_data_well_formed(&conn);
for plan in &seed.address_books {
for item in &plan.items {
assert_contact_round_trip(
&conn,
&item.source,
&item.uid,
&format!("{}/{}", seed.username, plan.display_name),
);
}
}
let book_count = count(&conn, "address_books") as usize;
let card_count = count(&conn, "contact_cards") as usize;
drop(conn);
let summary2 = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &dav_root),
)
.expect("idempotent carddav re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent carddav re-import had failures",
seed.username
);
let conn = open_archive(&card_archive);
assert_eq!(
count(&conn, "address_books") as usize,
book_count,
"{}: idempotent re-import changed address book count",
seed.username
);
assert_eq!(
count(&conn, "contact_cards") as usize,
card_count,
"{}: idempotent re-import changed contact count",
seed.username
);
drop(conn);
let vanish_href = seed.calendars[0]
.items
.first()
.map(|i| i.href.clone())
.expect("at least one event seeded");
let vanish_uid = seed.calendars[0].items.first().unwrap().uid.clone();
b.delete_item(account, &vanish_href)
.expect("baikal delete vanished item");
let summary_after_vanish = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &dav_root),
)
.expect("caldav re-import after vanish");
assert!(
!summary_after_vanish.any_failed(),
"{}: re-import after vanish had failures",
seed.username
);
let conn = open_archive(&cal_archive);
assert_eq!(
count(&conn, "calendar_events") as usize,
ev_count - 1,
"{}: vanished event was not pruned",
seed.username
);
let uids_now = all_event_uids(&conn);
assert!(
!uids_now.contains(&vanish_uid),
"{}: vanished event uid {vanish_uid} still in archive",
seed.username
);
drop(conn);
let icals = integration::data::load_icals().expect("load icals");
let (_added_event_href, added_event_uid) = b
.add_event(account, 0, &format!("bk-{}", seed.username), &icals)
.expect("baikal add event");
let summary_after_add_event = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &dav_root),
)
.expect("caldav re-import after add");
assert!(
!summary_after_add_event.any_failed(),
"{}: re-import after add-event had failures",
seed.username
);
let conn = open_archive(&cal_archive);
let after_add_events = count(&conn, "calendar_events") as usize;
assert_eq!(
after_add_events, ev_count,
"{}: add-then-import should restore count to baseline",
seed.username
);
let uids_now = all_event_uids(&conn);
assert!(
uids_now.contains(&added_event_uid),
"{}: added event uid {added_event_uid} not found in archive",
seed.username
);
drop(conn);
let vcards = integration::data::load_vcards().expect("load vcards");
let (_added_card_href, added_card_uid) = b
.add_contact(account, 0, &format!("bk-{}", seed.username), &vcards)
.expect("baikal add contact");
let summary_after_add_contact = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &dav_root),
)
.expect("carddav re-import after add");
assert!(
!summary_after_add_contact.any_failed(),
"{}: re-import after add-contact had failures",
seed.username
);
let conn = open_archive(&card_archive);
let after_add_contacts = count(&conn, "contact_cards") as usize;
assert_eq!(
after_add_contacts,
card_count + 1,
"{}: add-then-import should grow contact count by 1",
seed.username
);
let imported_contact_uids = all_contact_uids(&conn);
assert!(
imported_contact_uids.contains(&added_card_uid),
"{}: added contact uid {added_card_uid} not found",
seed.username
);
drop(conn);
let discovery_archive = tmp_archive(&format!("baikal-cal-discovery-{}", seed.username));
let discovery_summary = import_dav::run(
common(&discovery_archive),
dav_config_via_discovery(DavKindArg::Caldav, account, &dav_root),
)
.expect("caldav import via discovery from dav root");
assert!(
!discovery_summary.any_failed(),
"{}: caldav import via discovery had failures: {discovery_summary:?}",
seed.username
);
let conn = open_archive(&discovery_archive);
let discovered_events = count(&conn, "calendar_events") as usize;
assert_eq!(
discovered_events, ev_count,
"{}: discovery-based import event count mismatch (server holds {ev_count} after vanish+add)",
seed.username
);
drop(conn);
cleanup(&discovery_archive);
cleanup(&cal_archive);
cleanup(&card_archive);
}
let primary = &b.accounts[0];
let other = &b.accounts[1];
let shared_archive = tmp_archive("baikal-source-change");
import_dav::run(
common(&shared_archive),
dav_config(DavKindArg::Caldav, primary, &dav_root),
)
.expect("seed archive with primary user");
let err = import_dav::run(
common(&shared_archive),
dav_config(DavKindArg::Caldav, other, &dav_root),
)
.expect_err("expected source-change abort");
assert!(
matches!(err, Error::SourceChange(_)),
"expected SourceChange, got {err:?}"
);
cleanup(&shared_archive);
b.stop().expect("baikal stop");
}
fn assert_event_collection_exact(conn: &Connection, seed: &AccountSeed) {
let names = collection_names(conn, "calendars");
let expected: HashSet<String> = seed
.calendars
.iter()
.map(|c| c.display_name.clone())
.collect();
assert_eq!(
names, expected,
"{}: calendar displayname set mismatch",
seed.username
);
for plan in &seed.calendars {
let cal_id = collection_id_by_name(conn, "calendars", &plan.display_name)
.unwrap_or_else(|| panic!("calendar {} missing", plan.display_name));
let got = calendar_event_count(conn, cal_id);
assert_eq!(
got as usize,
plan.items.len(),
"{}: calendar {} event count {got} != seeded {}",
seed.username,
plan.display_name,
plan.items.len()
);
}
}
fn assert_contact_collection_exact(conn: &Connection, seed: &AccountSeed) {
let names = collection_names(conn, "address_books");
let expected: HashSet<String> = seed
.address_books
.iter()
.map(|c| c.display_name.clone())
.collect();
assert_eq!(
names, expected,
"{}: address book displayname set mismatch",
seed.username
);
for plan in &seed.address_books {
let book_id = collection_id_by_name(conn, "address_books", &plan.display_name)
.unwrap_or_else(|| panic!("address book {} missing", plan.display_name));
let got = contact_card_count(conn, book_id);
assert_eq!(
got as usize,
plan.items.len(),
"{}: book {} contact count {got} != seeded {}",
seed.username,
plan.display_name,
plan.items.len()
);
}
}
fn assert_event_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
let imported = all_event_uids(conn);
let expected = seed.event_uids();
let missing: Vec<_> = expected.difference(&imported).collect();
assert!(
missing.is_empty(),
"{}: event uids missing from archive: {missing:?}",
seed.username
);
}
fn assert_contact_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
let imported = all_contact_uids(conn);
let expected = seed.contact_uids();
let missing: Vec<_> = expected.difference(&imported).collect();
assert!(
missing.is_empty(),
"{}: contact uids missing from archive: {missing:?}",
seed.username
);
}
fn assert_event_data_well_formed(conn: &Connection) {
let mut stmt = conn
.prepare("SELECT data, data_type FROM calendar_events")
.expect("prepare");
let rows: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.expect("query")
.filter_map(|r| r.ok())
.collect();
assert!(!rows.is_empty(), "no calendar_events rows");
for (raw, data_type) in rows {
assert_eq!(
data_type, "Event",
"all VEVENT seeds must land as Event, got data_type={data_type}, raw={raw}"
);
let v: Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("calendar_events.data invalid JSON: {e}; raw={raw}"));
let obj = v.as_object().expect("event data is object");
let kind = obj
.get("@type")
.or_else(|| obj.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
assert_eq!(kind, "Event", "JSCalendar @type must be Event, got {kind}");
assert!(obj.contains_key("uid"), "event JSON missing uid: {obj:?}");
assert!(
obj.contains_key("start"),
"event JSON missing start: {obj:?}"
);
}
}
fn assert_contact_data_well_formed(conn: &Connection) {
let mut stmt = conn
.prepare("SELECT uid, data FROM contact_cards")
.expect("prepare");
let rows: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.expect("query")
.filter_map(|r| r.ok())
.collect();
assert!(!rows.is_empty(), "no contact_cards rows");
for (uid, raw) in rows {
assert!(!uid.is_empty(), "contact uid empty");
let v: Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("contact_cards.data invalid JSON: {e}; raw={raw}"));
let obj = v.as_object().expect("contact data is object");
let kind = obj
.get("@type")
.or_else(|| obj.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
assert_eq!(kind, "Card", "JSContact @type must be Card, got {kind}");
}
}
+320
View File
@@ -0,0 +1,320 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
use std::collections::HashSet;
use integration::cyrus::{AccountSeed, Cyrus, flag_probe_message};
use integration::validate::{
assert_message_round_trip, blob_bytes, blob_id_for_hash, cleanup, common, count,
email_rows_for_blob, emails_in_mailbox, keywords_for_blob, mailbox_id_by_name,
mailbox_id_by_path, mailbox_path, open_archive, tmp_archive,
};
use integration::{Account, Endpoint};
use vandelay::error::Error;
use vandelay::sync::import_imap;
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
fn imap_config(account: &Account, imap: &Endpoint) -> ImapImportConfig {
ImapImportConfig {
url: format!("imap://{}:{}", imap.host, imap.port),
auth: ImapAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
compress: false,
include: Vec::new(),
exclude: Vec::new(),
exclude_special: Vec::new(),
folder: Vec::new(),
subscribed_only: false,
automap: true,
include_deleted: false,
fetch_batch: 64,
imap_connections: 2,
allow_source_change: false,
}
}
#[test]
#[ignore = "requires Docker"]
fn cyrus_starts_seeds_and_imports() {
let c = Cyrus::start().expect("cyrus start");
let seeds = c.seed_all().expect("cyrus seed");
c.verify_seed(&seeds).expect("cyrus verify");
for seed in &seeds {
let account = c
.accounts
.iter()
.find(|a| a.username == seed.username)
.expect("account");
assert!(
!seed.paths.is_empty(),
"{}: expected mailboxes seeded",
seed.username
);
assert!(
seed.total_appends > 0,
"{}: expected emails appended",
seed.username
);
let archive = tmp_archive(&format!("cyrus-{}", seed.username));
let summary =
import_imap::run(common(&archive), imap_config(account, &c.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"cyrus import failed for {}: {summary:?}",
seed.username
);
let conn = open_archive(&archive);
let mailbox_count = count(&conn, "mailboxes") as usize;
let email_count = count(&conn, "emails") as usize;
let blob_count = count(&conn, "blobs") as usize;
let expected_mailboxes = account.layout.mailboxes.len() + 1;
assert_eq!(
mailbox_count, expected_mailboxes,
"{}: imported {mailbox_count} mailboxes, expected exactly {expected_mailboxes}",
seed.username
);
assert_eq!(
email_count, seed.total_appends,
"{}: every seeded email must land in archive (appended {}, imported {email_count})",
seed.username, seed.total_appends
);
assert!(blob_count > 0, "{}: blobs interned", seed.username);
let bad_match: i64 = conn
.query_row(
"SELECT count(*) FROM emails WHERE message_match IS NULL OR message_match = ''",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
bad_match, 0,
"{}: every imported email must have a non-empty message_match",
seed.username
);
if let Some(target) = &seed.flagged_target {
let probe = flag_probe_message();
let probe_hash = blake3::hash(&probe);
let blob_id = blob_id_for_hash(&conn, probe_hash.as_bytes()).expect("flag probe blob");
let stored = blob_bytes(&conn, blob_id);
assert_eq!(
stored, probe,
"{}: flag probe blob bytes mismatch",
seed.username
);
let keywords = keywords_for_blob(&conn, blob_id);
let kw_set: HashSet<_> = keywords.iter().map(String::as_str).collect();
assert!(
kw_set.contains("$seen"),
"{}: flag probe missing $seen in keywords {keywords:?}",
seed.username
);
assert!(
kw_set.contains("$flagged"),
"{}: flag probe missing $flagged in keywords {keywords:?}",
seed.username
);
let _ = target;
}
if let Some(_target) = &seed.dedup_target
&& let Some(first) = seeds
.iter()
.find(|s| s.username == seed.username)
.and_then(|_| account.layout.mailboxes.first())
{
let _ = first;
let total_dup: i64 = conn
.query_row(
"SELECT count(*) FROM emails e
WHERE EXISTS (SELECT 1 FROM emails e2
WHERE e2.blob_id = e.blob_id AND e2.id <> e.id)",
[],
|r| r.get(0),
)
.unwrap();
assert!(
total_dup >= 2,
"{}: dedup append should produce at least two rows sharing a blob",
seed.username
);
}
for path in &seed.paths {
let id = mailbox_id_by_path(&conn, path, '/').unwrap_or_else(|| {
panic!("{}: mailbox path {path} missing in archive", seed.username)
});
assert_eq!(mailbox_path(&conn, id, '/'), *path);
let expected = seed.histogram.get(path).copied().unwrap_or(0);
let got = emails_in_mailbox(&conn, id) as usize;
assert_eq!(
got, expected,
"{}: mailbox {path} email count {got} != appended {expected}",
seed.username
);
}
for append in &seed.appends {
assert_message_round_trip(
&conn,
&append.raw,
&append.target,
&format!("{}/append/{:?}", seed.username, append.tag),
);
}
let inbox_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
inbox_role,
Some("inbox".to_owned()),
"{}: INBOX role tagged",
seed.username
);
let inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX");
let inbox_seeded = seed.histogram.get("INBOX").copied().unwrap_or(0);
assert_eq!(
emails_in_mailbox(&conn, inbox_id) as usize,
inbox_seeded,
"{}: INBOX email count mismatch",
seed.username
);
let probe_blob = blob_id_for_hash(&conn, blake3::hash(&flag_probe_message()).as_bytes());
if let Some(blob_id) = probe_blob {
let rows = email_rows_for_blob(&conn, blob_id);
assert!(rows >= 1, "{}: flag probe missing", seed.username);
}
drop(conn);
let summary2 = import_imap::run(common(&archive), imap_config(account, &c.imap))
.expect("idempotent imap re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent re-import had failures: {summary2:?}",
seed.username
);
let conn = open_archive(&archive);
let email_count2 = count(&conn, "emails") as usize;
let blob_count2 = count(&conn, "blobs") as usize;
assert_eq!(
email_count2, email_count,
"{}: idempotent re-import added emails",
seed.username
);
assert_eq!(
blob_count2, blob_count,
"{}: idempotent re-import added blobs",
seed.username
);
drop(conn);
c.delete_first_inbox_message(account)
.expect("expunge first INBOX message");
let summary_after = import_imap::run(common(&archive), imap_config(account, &c.imap))
.expect("re-import after expunge");
assert!(
!summary_after.any_failed(),
"{}: re-import after expunge had failures",
seed.username
);
let conn = open_archive(&archive);
let email_count3 = count(&conn, "emails") as usize;
assert_eq!(
email_count3,
email_count - 1,
"{}: expunged message was not pruned",
seed.username
);
drop(conn);
let (added_raw, added_mid) = c
.append_new_message(account, "INBOX", "post-import")
.expect("append new INBOX message");
let summary_after_append =
import_imap::run(common(&archive), imap_config(account, &c.imap))
.expect("re-import after append");
assert!(
!summary_after_append.any_failed(),
"{}: re-import after append had failures",
seed.username
);
let conn = open_archive(&archive);
let email_count_after_append = count(&conn, "emails") as usize;
assert_eq!(
email_count_after_append, email_count,
"{}: append-after-expunge should restore email count to baseline",
seed.username
);
let added_hash = blake3::hash(&added_raw);
let added_blob =
blob_id_for_hash(&conn, added_hash.as_bytes()).expect("added message blob present");
let mid_normalised = added_mid
.trim_matches(|c| c == '<' || c == '>')
.to_ascii_lowercase();
let mid_found: i64 = conn
.query_row(
"SELECT count(*) FROM emails
WHERE EXISTS (SELECT 1 FROM json_each(json_extract(message_match,'$.m')) j
WHERE j.value = ?1)",
[mid_normalised.as_str()],
|r| r.get(0),
)
.unwrap();
assert!(
mid_found >= 1,
"{}: added Message-ID {added_mid} not found in message_match",
seed.username
);
let added_inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX id");
let added_in_inbox: bool = conn
.query_row(
"SELECT EXISTS (SELECT 1 FROM emails
WHERE blob_id = ?1
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j
WHERE j.value = ?2))",
rusqlite::params![added_blob, added_inbox_id],
|r| r.get::<_, i64>(0).map(|n| n != 0),
)
.unwrap();
assert!(
added_in_inbox,
"{}: added message blob not linked to INBOX",
seed.username
);
drop(conn);
cleanup(&archive);
}
let primary = &c.accounts[0];
let other = &c.accounts[1];
let shared_archive = tmp_archive("cyrus-source-change");
import_imap::run(common(&shared_archive), imap_config(primary, &c.imap))
.expect("seed archive with primary user");
let err = import_imap::run(common(&shared_archive), imap_config(other, &c.imap))
.expect_err("expected source-change abort");
assert!(
matches!(err, Error::SourceChange(_)),
"expected SourceChange, got {err:?}"
);
cleanup(&shared_archive);
c.stop().expect("cyrus stop");
}
fn _unused(_: &AccountSeed) {}
+516
View File
@@ -0,0 +1,516 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
use std::collections::HashSet;
use integration::Account;
use integration::dovecot::{AccountSeed, Dovecot, flag_probe_message, shared_message_id_probe};
use integration::validate::{
assert_message_round_trip, blob_bytes, blob_id_for_hash, cleanup, common, count,
email_rows_for_blob, emails_in_mailbox, keywords_for_blob, mailbox_id_by_name,
mailbox_id_by_path, mailbox_path, open_archive, tmp_archive,
};
use rusqlite::Connection;
use vandelay::error::Error;
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
use vandelay::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
use vandelay::sync::{import_imap, import_managesieve};
fn imap_config(account: &Account, imap: &integration::Endpoint) -> ImapImportConfig {
ImapImportConfig {
url: format!("imap://{}:{}", imap.host, imap.port),
auth: ImapAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: false,
compress: false,
include: Vec::new(),
exclude: Vec::new(),
exclude_special: Vec::new(),
folder: Vec::new(),
subscribed_only: false,
automap: true,
include_deleted: false,
fetch_batch: 64,
imap_connections: 2,
allow_source_change: false,
}
}
fn sieve_config(account: &Account, sieve: &integration::Endpoint) -> ManageSieveImportConfig {
ManageSieveImportConfig {
url: format!("sieve://{}:{}", sieve.host, sieve.port),
auth: ManageSieveAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
allow_source_change: false,
}
}
#[test]
#[ignore = "requires Docker"]
fn dovecot_starts_seeds_and_imports() {
let d = Dovecot::start().expect("dovecot start");
let seeds = d.seed_all().expect("dovecot seed");
assert_eq!(
seeds.len(),
d.accounts.len(),
"seed should return stats for every account"
);
d.verify_seed(&seeds).expect("dovecot verify");
for seed in &seeds {
let account = d
.accounts
.iter()
.find(|a| a.username == seed.username)
.expect("account");
assert!(
!seed.mailbox.paths.is_empty(),
"{}: expected mailboxes seeded",
seed.username
);
assert!(
seed.mailbox.total_appends > 0,
"{}: expected emails appended",
seed.username
);
let archive = tmp_archive(&format!("dovecot-{}", seed.username));
let summary =
import_imap::run(common(&archive), imap_config(account, &d.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"imap import had failures for {}: {summary:?}",
seed.username
);
let conn = open_archive(&archive);
let mailbox_count = count(&conn, "mailboxes") as usize;
let email_count = count(&conn, "emails") as usize;
let blob_count = count(&conn, "blobs") as usize;
let expected_mailboxes = account.layout.mailboxes.len() + 1;
assert_eq!(
mailbox_count, expected_mailboxes,
"{}: imported {mailbox_count} mailboxes, expected exactly {expected_mailboxes}",
seed.username
);
assert_eq!(
email_count, seed.mailbox.total_appends,
"{}: every seeded email must land in archive (appended {}, imported {email_count})",
seed.username, seed.mailbox.total_appends
);
assert!(blob_count > 0, "{}: blobs interned", seed.username);
let bad_match: i64 = conn
.query_row(
"SELECT count(*) FROM emails WHERE message_match IS NULL OR message_match = ''",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
bad_match, 0,
"{}: every imported email must have a non-empty message_match",
seed.username
);
let nomid_target = seed
.mailbox
.extras
.nomid_target
.clone()
.expect("nomid target");
let nomid_hash = blake3::hash(&integration::dovecot::no_message_id_probe())
.as_bytes()
.to_vec();
let nomid_matches: Vec<String> = {
let mut nomid_stmt = conn
.prepare(
"SELECT message_match FROM emails
WHERE blob_id = (SELECT id FROM blobs WHERE hash = ?1)",
)
.expect("prepare nomid");
nomid_stmt
.query_map([nomid_hash.as_slice()], |r| r.get::<_, String>(0))
.expect("query nomid")
.filter_map(|r| r.ok())
.collect()
};
assert!(
!nomid_matches.is_empty(),
"{}: no-Message-ID probe missing in archive (target {nomid_target})",
seed.username
);
for m in &nomid_matches {
let parsed: serde_json::Value = serde_json::from_str(m).expect("message_match json");
let mids = parsed
.get("m")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
assert!(
mids.is_empty(),
"{}: no-Message-ID probe should have empty Message-ID set, got {m}",
seed.username
);
let fb = parsed.get("f").and_then(|v| v.as_str()).unwrap_or("");
assert_eq!(
fb.len(),
64,
"{}: no-Message-ID probe must carry a BLAKE3 fallback hex, got {m}",
seed.username
);
}
if let Some(target) = &seed.mailbox.extras.flagged_target {
let probe = flag_probe_message();
let probe_hash = blake3::hash(&probe);
let blob_id = blob_id_for_hash(&conn, probe_hash.as_bytes()).expect("flag probe blob");
let stored = blob_bytes(&conn, blob_id);
assert_eq!(
stored, probe,
"{}: flag probe blob bytes mismatch",
seed.username
);
let keywords = keywords_for_blob(&conn, blob_id);
let kw_set: HashSet<_> = keywords.iter().map(String::as_str).collect();
assert!(
kw_set.contains("$seen"),
"{}: flag probe missing $seen in keywords {keywords:?}",
seed.username
);
assert!(
kw_set.contains("$flagged"),
"{}: flag probe missing $flagged in keywords {keywords:?}",
seed.username
);
let _ = target;
}
if let Some((a, b)) = &seed.mailbox.extras.mid_dedup_targets {
let body = shared_message_id_probe();
let hash = blake3::hash(&body);
let blob_id = blob_id_for_hash(&conn, hash.as_bytes()).expect("shared-mid probe blob");
let rows_for_blob = email_rows_for_blob(&conn, blob_id);
assert_eq!(
rows_for_blob, 2,
"{}: shared-MID probe should produce 2 email rows for the dedup blob, got {rows_for_blob}",
seed.username
);
let matches: Vec<String> = {
let mut stmt = conn
.prepare("SELECT message_match FROM emails WHERE blob_id = ?1")
.expect("prepare matches");
stmt.query_map([blob_id], |r| r.get::<_, String>(0))
.expect("query matches")
.filter_map(|r| r.ok())
.collect()
};
assert_eq!(matches.len(), 2, "{}: expected 2 matches", seed.username);
assert_eq!(
matches[0], matches[1],
"{}: shared-MID emails must have identical message_match, got {matches:?}",
seed.username
);
let _ = (a, b);
}
if let Some(target) = &seed.mailbox.extras.dedup_target {
let _ = target;
}
for path in &seed.mailbox.paths {
let id = mailbox_id_by_path(&conn, path, '/').unwrap_or_else(|| {
panic!("{}: mailbox path {path} missing in archive", seed.username)
});
assert_eq!(mailbox_path(&conn, id, '/'), *path);
let expected = seed.mailbox.histogram.get(path).copied().unwrap_or(0);
let got = emails_in_mailbox(&conn, id) as usize;
assert_eq!(
got, expected,
"{}: mailbox {path} email count {got} != appended {expected}",
seed.username
);
}
for append in &seed.mailbox.appends {
assert_message_round_trip(
&conn,
&append.raw,
&append.target,
&format!("{}/append/{:?}", seed.username, append.tag),
);
}
let inbox_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
inbox_role,
Some("inbox".to_owned()),
"{}: INBOX role tagged",
seed.username
);
let inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX");
let inbox_seeded = seed.mailbox.histogram.get("INBOX").copied().unwrap_or(0);
assert_eq!(
emails_in_mailbox(&conn, inbox_id) as usize,
inbox_seeded,
"{}: INBOX email count mismatch",
seed.username
);
assert_received_at_rfc3339_ish(&conn);
drop(conn);
let summary2 = import_imap::run(common(&archive), imap_config(account, &d.imap))
.expect("idempotent imap re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent re-import had failures: {summary2:?}",
seed.username
);
let conn = open_archive(&archive);
let email_count2 = count(&conn, "emails") as usize;
let mailbox_count2 = count(&conn, "mailboxes") as usize;
let blob_count2 = count(&conn, "blobs") as usize;
assert_eq!(
email_count2, email_count,
"{}: idempotent re-import added emails",
seed.username
);
assert_eq!(
mailbox_count2, mailbox_count,
"{}: idempotent re-import added mailboxes",
seed.username
);
assert_eq!(
blob_count2, blob_count,
"{}: idempotent re-import added blobs",
seed.username
);
drop(conn);
d.delete_first_inbox_message(account)
.expect("expunge first INBOX message");
let summary_after = import_imap::run(common(&archive), imap_config(account, &d.imap))
.expect("re-import after expunge");
assert!(
!summary_after.any_failed(),
"{}: re-import after expunge had failures",
seed.username
);
let conn = open_archive(&archive);
let email_count3 = count(&conn, "emails") as usize;
assert_eq!(
email_count3,
email_count - 1,
"{}: expunged message was not pruned",
seed.username
);
drop(conn);
let (added_raw, added_mid) = d
.append_new_message(account, "INBOX", "post-import")
.expect("append new INBOX message");
let summary_after_append =
import_imap::run(common(&archive), imap_config(account, &d.imap))
.expect("re-import after append");
assert!(
!summary_after_append.any_failed(),
"{}: re-import after append had failures",
seed.username
);
let conn = open_archive(&archive);
let email_count_after_append = count(&conn, "emails") as usize;
assert_eq!(
email_count_after_append, email_count,
"{}: append-after-expunge should restore email count to baseline",
seed.username
);
let added_hash = blake3::hash(&added_raw);
let added_blob = integration::validate::blob_id_for_hash(&conn, added_hash.as_bytes())
.expect("added message blob present");
let stored = integration::validate::blob_bytes(&conn, added_blob);
assert_eq!(
stored, added_raw,
"{}: added message blob bytes mismatch",
seed.username
);
let mid_normalised = added_mid
.trim_matches(|c| c == '<' || c == '>')
.to_ascii_lowercase();
let mid_found: i64 = conn
.query_row(
"SELECT count(*) FROM emails
WHERE EXISTS (SELECT 1 FROM json_each(json_extract(message_match,'$.m')) j
WHERE j.value = ?1)",
[mid_normalised.as_str()],
|r| r.get(0),
)
.unwrap();
assert!(
mid_found >= 1,
"{}: added Message-ID {added_mid} not found in message_match",
seed.username
);
let added_inbox_id = mailbox_id_by_name(&conn, "INBOX").expect("INBOX id");
let added_in_inbox: bool = conn
.query_row(
"SELECT EXISTS (SELECT 1 FROM emails
WHERE blob_id = ?1
AND EXISTS (SELECT 1 FROM json_each(mailbox_ids) j
WHERE j.value = ?2))",
rusqlite::params![added_blob, added_inbox_id],
|r| r.get::<_, i64>(0).map(|n| n != 0),
)
.unwrap();
assert!(
added_in_inbox,
"{}: added message blob not linked to INBOX",
seed.username
);
drop(conn);
if !account.layout.sieve_scripts.is_empty() {
let sieve_archive = tmp_archive(&format!("dovecot-sieve-{}", seed.username));
let summary =
import_managesieve::run(common(&sieve_archive), sieve_config(account, &d.sieve))
.expect("sieve import");
assert!(
!summary.any_failed(),
"sieve import had failures for {}: {summary:?}",
seed.username
);
let conn = open_archive(&sieve_archive);
let script_count = count(&conn, "sieve_scripts") as usize;
assert_eq!(
script_count,
account.layout.sieve_scripts.len(),
"{}: sieve scripts count mismatch",
seed.username
);
let active_seed = account
.layout
.sieve_scripts
.iter()
.filter(|s| s.active)
.count();
let active_imported: i64 = conn
.query_row(
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
active_imported as usize, active_seed,
"{}: active sieve script count mismatch",
seed.username
);
for seeded in account.layout.sieve_scripts {
let blob_id: i64 = conn
.query_row(
"SELECT blob_id FROM sieve_scripts WHERE name = ?1",
[seeded.name],
|r| r.get(0),
)
.unwrap();
let body = blob_bytes(&conn, blob_id);
assert_eq!(
body,
seeded.body.as_bytes(),
"{}: sieve script {} body round-trip",
seed.username,
seeded.name
);
let is_active: i64 = conn
.query_row(
"SELECT is_active FROM sieve_scripts WHERE name = ?1",
[seeded.name],
|r| r.get(0),
)
.unwrap();
assert_eq!(
is_active == 1,
seeded.active,
"{}: sieve script {} active flag mismatch",
seed.username,
seeded.name
);
}
let actives: i64 = conn
.query_row(
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert!(
actives <= 1,
"{}: at most one sieve script may be active",
seed.username
);
drop(conn);
let summary2 =
import_managesieve::run(common(&sieve_archive), sieve_config(account, &d.sieve))
.expect("idempotent sieve re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent sieve re-import had failures",
seed.username
);
cleanup(&sieve_archive);
}
cleanup(&archive);
}
let primary = &d.accounts[0];
let other = &d.accounts[1];
let shared_archive = tmp_archive("dovecot-source-change");
import_imap::run(common(&shared_archive), imap_config(primary, &d.imap))
.expect("seed archive with primary user");
let err = import_imap::run(common(&shared_archive), imap_config(other, &d.imap))
.expect_err("expected source-change abort");
assert!(
matches!(err, Error::SourceChange(_)),
"expected SourceChange, got {err:?}"
);
cleanup(&shared_archive);
d.stop().expect("dovecot stop");
}
fn assert_received_at_rfc3339_ish(conn: &Connection) {
let mut stmt = conn
.prepare("SELECT received_at FROM emails LIMIT 50")
.expect("prepare");
let rows: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.expect("query")
.filter_map(|r| r.ok())
.collect();
assert!(!rows.is_empty(), "no emails to check received_at");
for r in rows {
assert!(
r.contains('T') && (r.ends_with('Z') || r.contains('+') || r.contains('-')),
"received_at does not look like RFC 3339: {r}"
);
}
}
fn _unused(_: &AccountSeed) {}
+459
View File
@@ -0,0 +1,459 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
use std::collections::HashSet;
use integration::radicale::{AccountSeed, Radicale};
use integration::validate::{
all_contact_uids, all_event_uids, assert_contact_round_trip, assert_event_round_trip,
calendar_event_count, cleanup, collection_id_by_name, collection_names, common,
contact_card_count, count, open_archive, tmp_archive,
};
use integration::{Account, Endpoint};
use rusqlite::Connection;
use serde_json::Value;
use vandelay::error::Error;
use vandelay::sync::import_dav;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
fn dav_config(kind: DavKindArg, account: &Account, http: &Endpoint) -> DavImportConfig {
DavImportConfig {
kind,
url: format!("http://{}:{}/{}/", http.host, http.port, account.username),
auth: DavAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
dav_connections: 2,
multiget_batch: 25,
allow_source_change: false,
}
}
#[test]
#[ignore = "requires Docker"]
fn radicale_starts_seeds_and_imports() {
let r = Radicale::start().expect("radicale start");
let per_account = r.seed_all().expect("radicale seed");
assert_eq!(
per_account.len(),
r.accounts.len(),
"seed should return stats for every account"
);
r.verify_seed(&per_account).expect("radicale verify");
for seed in &per_account {
let account = r
.accounts
.iter()
.find(|a| a.username == seed.username)
.expect("account lookup");
assert!(
!seed.calendars.is_empty(),
"{}: expected calendars seeded",
seed.username
);
assert!(
!seed.address_books.is_empty(),
"{}: expected address books seeded",
seed.username
);
assert!(
seed.total_events() > 0,
"{}: expected events seeded",
seed.username
);
assert!(
seed.total_contacts() > 0,
"{}: expected contacts seeded",
seed.username
);
let cal_archive = tmp_archive(&format!("radicale-cal-{}", seed.username));
let summary = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &r.endpoint),
)
.expect("caldav import");
assert!(
!summary.any_failed(),
"{}: first caldav import had failures: {summary:?}",
seed.username
);
let conn = open_archive(&cal_archive);
assert_event_collection_exact(&conn, seed);
assert_event_uids_round_trip(&conn, seed);
assert_event_data_well_formed(&conn);
for plan in &seed.calendars {
for item in &plan.items {
assert_event_round_trip(
&conn,
&item.source,
&item.uid,
&format!("{}/{}", seed.username, plan.display_name),
);
}
}
let cal_count = count(&conn, "calendars") as usize;
let ev_count = count(&conn, "calendar_events") as usize;
let blob_count = count(&conn, "blobs") as usize;
drop(conn);
let summary2 = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &r.endpoint),
)
.expect("idempotent caldav re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent caldav re-import had failures",
seed.username
);
let conn = open_archive(&cal_archive);
assert_eq!(
count(&conn, "calendars") as usize,
cal_count,
"{}: idempotent re-import changed calendar count",
seed.username
);
assert_eq!(
count(&conn, "calendar_events") as usize,
ev_count,
"{}: idempotent re-import changed event count",
seed.username
);
assert_eq!(
count(&conn, "blobs") as usize,
blob_count,
"{}: idempotent re-import changed blob count",
seed.username
);
drop(conn);
let card_archive = tmp_archive(&format!("radicale-card-{}", seed.username));
let summary = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &r.endpoint),
)
.expect("carddav import");
assert!(
!summary.any_failed(),
"{}: first carddav import had failures: {summary:?}",
seed.username
);
let conn = open_archive(&card_archive);
assert_contact_collection_exact(&conn, seed);
assert_contact_uids_round_trip(&conn, seed);
assert_contact_data_well_formed(&conn);
for plan in &seed.address_books {
for item in &plan.items {
assert_contact_round_trip(
&conn,
&item.source,
&item.uid,
&format!("{}/{}", seed.username, plan.display_name),
);
}
}
let book_count = count(&conn, "address_books") as usize;
let card_count = count(&conn, "contact_cards") as usize;
drop(conn);
let summary2 = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &r.endpoint),
)
.expect("idempotent carddav re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent carddav re-import had failures",
seed.username
);
let conn = open_archive(&card_archive);
assert_eq!(
count(&conn, "address_books") as usize,
book_count,
"{}: idempotent re-import changed address book count",
seed.username
);
assert_eq!(
count(&conn, "contact_cards") as usize,
card_count,
"{}: idempotent re-import changed contact count",
seed.username
);
drop(conn);
let vanish_target_href = seed.calendars[0]
.items
.first()
.map(|i| i.href.clone())
.expect("at least one event seeded");
let vanish_uid = seed.calendars[0]
.items
.first()
.map(|i| i.uid.clone())
.expect("uid");
r.delete_item(account, &vanish_target_href)
.expect("radicale delete vanished item");
let summary_after_vanish = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &r.endpoint),
)
.expect("caldav re-import after vanish");
assert!(
!summary_after_vanish.any_failed(),
"{}: re-import after vanish had failures",
seed.username
);
let conn = open_archive(&cal_archive);
let after_vanish_events = count(&conn, "calendar_events") as usize;
assert_eq!(
after_vanish_events,
ev_count - 1,
"{}: vanished event was not pruned from archive",
seed.username
);
let uids_now = all_event_uids(&conn);
assert!(
!uids_now.contains(&vanish_uid),
"{}: vanished event uid {vanish_uid} still in archive",
seed.username
);
drop(conn);
let icals = integration::data::load_icals().expect("load icals");
let (_added_event_href, added_event_uid) = r
.add_event(account, 0, &format!("rad-{}", seed.username), &icals)
.expect("radicale add event");
let summary_after_add_event = import_dav::run(
common(&cal_archive),
dav_config(DavKindArg::Caldav, account, &r.endpoint),
)
.expect("caldav re-import after add");
assert!(
!summary_after_add_event.any_failed(),
"{}: re-import after add-event had failures",
seed.username
);
let conn = open_archive(&cal_archive);
let after_add_events = count(&conn, "calendar_events") as usize;
assert_eq!(
after_add_events,
ev_count,
"{}: add-then-import should restore count to baseline (was {} after vanish, expected {ev_count})",
seed.username,
ev_count - 1
);
let uids_now = all_event_uids(&conn);
assert!(
uids_now.contains(&added_event_uid),
"{}: added event uid {added_event_uid} not found in archive after re-import",
seed.username
);
let added_cal_id =
collection_id_by_name(&conn, "calendars", &seed.calendars[0].display_name)
.expect("calendar id");
let new_calendar_count = calendar_event_count(&conn, added_cal_id);
assert_eq!(
new_calendar_count as usize,
seed.calendars[0].items.len(),
"{}: target calendar event count after add+vanish should match original",
seed.username
);
drop(conn);
let vcards = integration::data::load_vcards().expect("load vcards");
let (_added_card_href, added_card_uid) = r
.add_contact(account, 0, &format!("rad-{}", seed.username), &vcards)
.expect("radicale add contact");
let summary_after_add_contact = import_dav::run(
common(&card_archive),
dav_config(DavKindArg::Carddav, account, &r.endpoint),
)
.expect("carddav re-import after add");
assert!(
!summary_after_add_contact.any_failed(),
"{}: re-import after add-contact had failures",
seed.username
);
let conn = open_archive(&card_archive);
let after_add_contacts = count(&conn, "contact_cards") as usize;
assert_eq!(
after_add_contacts,
card_count + 1,
"{}: add-then-import should grow contact count by 1",
seed.username
);
let imported_contact_uids = all_contact_uids(&conn);
assert!(
imported_contact_uids.contains(&added_card_uid),
"{}: added contact uid {added_card_uid} not found",
seed.username
);
drop(conn);
cleanup(&cal_archive);
cleanup(&card_archive);
}
let primary = &r.accounts[0];
let other = &r.accounts[1];
let shared_archive = tmp_archive("radicale-source-change");
import_dav::run(
common(&shared_archive),
dav_config(DavKindArg::Caldav, primary, &r.endpoint),
)
.expect("seed archive with primary user");
let err = import_dav::run(
common(&shared_archive),
dav_config(DavKindArg::Caldav, other, &r.endpoint),
)
.expect_err("expected source-change abort when pointing at second user");
assert!(
matches!(err, Error::SourceChange(_)),
"expected SourceChange, got {err:?}"
);
cleanup(&shared_archive);
r.stop().expect("radicale stop");
}
fn assert_event_collection_exact(conn: &Connection, seed: &AccountSeed) {
let names = collection_names(conn, "calendars");
let expected: HashSet<String> = seed
.calendars
.iter()
.map(|c| c.display_name.clone())
.collect();
assert_eq!(
names, expected,
"{}: calendar displayname set mismatch",
seed.username
);
for plan in &seed.calendars {
let cal_id = collection_id_by_name(conn, "calendars", &plan.display_name)
.unwrap_or_else(|| panic!("calendar {} missing", plan.display_name));
let got = calendar_event_count(conn, cal_id);
assert_eq!(
got as usize,
plan.items.len(),
"{}: calendar {} event count {got} != seeded {}",
seed.username,
plan.display_name,
plan.items.len()
);
}
}
fn assert_contact_collection_exact(conn: &Connection, seed: &AccountSeed) {
let names = collection_names(conn, "address_books");
let expected: HashSet<String> = seed
.address_books
.iter()
.map(|c| c.display_name.clone())
.collect();
assert_eq!(
names, expected,
"{}: address book displayname set mismatch",
seed.username
);
for plan in &seed.address_books {
let book_id = collection_id_by_name(conn, "address_books", &plan.display_name)
.unwrap_or_else(|| panic!("address book {} missing", plan.display_name));
let got = contact_card_count(conn, book_id);
assert_eq!(
got as usize,
plan.items.len(),
"{}: book {} contact count {got} != seeded {}",
seed.username,
plan.display_name,
plan.items.len()
);
}
}
fn assert_event_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
let imported = all_event_uids(conn);
let expected = seed.event_uids();
let missing: Vec<_> = expected.difference(&imported).collect();
assert!(
missing.is_empty(),
"{}: event uids missing from archive: {missing:?}",
seed.username
);
}
fn assert_contact_uids_round_trip(conn: &Connection, seed: &AccountSeed) {
let imported = all_contact_uids(conn);
let expected = seed.contact_uids();
let missing: Vec<_> = expected.difference(&imported).collect();
assert!(
missing.is_empty(),
"{}: contact uids missing from archive: {missing:?}",
seed.username
);
}
fn assert_event_data_well_formed(conn: &Connection) {
let mut stmt = conn
.prepare("SELECT data, data_type FROM calendar_events")
.expect("prepare");
let rows: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.expect("query")
.filter_map(|r| r.ok())
.collect();
assert!(!rows.is_empty(), "no calendar_events rows");
for (raw, data_type) in rows {
assert_eq!(
data_type, "Event",
"all VEVENT seeds must land as Event, got data_type={data_type}, raw={raw}"
);
let v: Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("calendar_events.data invalid JSON: {e}; raw={raw}"));
let obj = v.as_object().expect("event data is object");
let kind = obj
.get("@type")
.or_else(|| obj.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
assert_eq!(kind, "Event", "JSCalendar @type must be Event, got {kind}");
assert!(obj.contains_key("uid"), "event JSON missing uid: {obj:?}");
assert!(
obj.contains_key("start"),
"event JSON missing start: {obj:?}"
);
}
}
fn assert_contact_data_well_formed(conn: &Connection) {
let mut stmt = conn
.prepare("SELECT uid, data FROM contact_cards")
.expect("prepare");
let rows: Vec<(String, String)> = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.expect("query")
.filter_map(|r| r.ok())
.collect();
assert!(!rows.is_empty(), "no contact_cards rows");
for (uid, raw) in rows {
assert!(!uid.is_empty(), "contact uid empty");
let v: Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("contact_cards.data invalid JSON: {e}; raw={raw}"));
let obj = v.as_object().expect("contact data is object");
let kind = obj
.get("@type")
.or_else(|| obj.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
assert_eq!(kind, "Card", "JSContact @type must be Card, got {kind}");
}
}
+408
View File
@@ -0,0 +1,408 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
use std::collections::HashSet;
use integration::layouts::FileSpec;
use integration::validate::{
blob_bytes, cleanup, common, count, file_node_id_by_path, file_path, open_archive, tmp_archive,
};
use integration::webdav::{AccountSeed, WebDav};
use integration::{Account, Endpoint};
use vandelay::error::Error;
use vandelay::sync::import_dav;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
fn webdav_config(account: &Account, http: &Endpoint) -> DavImportConfig {
DavImportConfig {
kind: DavKindArg::Webdav,
url: format!("http://{}:{}/{}/", http.host, http.port, account.username),
auth: DavAuth::Basic {
user: account.username.clone(),
password: account.password.clone(),
},
allow_cleartext: true,
dav_connections: 2,
multiget_batch: 25,
allow_source_change: false,
}
}
#[test]
#[ignore = "requires Docker"]
fn webdav_starts_seeds_and_imports() {
let w = WebDav::start().expect("webdav start");
let seeds = w.seed_all().expect("webdav seed");
assert_eq!(
seeds.len(),
w.accounts.len(),
"seed should return stats for every account"
);
w.verify_seed().expect("webdav verify");
for seed in &seeds {
assert!(
seed.directories > 0,
"{}: expected directories seeded",
seed.username
);
assert!(
!seed.files.is_empty(),
"{}: expected files seeded",
seed.username
);
}
for seed in &seeds {
let account = w
.accounts
.iter()
.find(|a| a.username == seed.username)
.expect("account lookup");
let archive = tmp_archive(&format!("webdav-{}", seed.username));
let summary = import_dav::run(common(&archive), webdav_config(account, &w.http))
.expect("webdav import");
assert!(
!summary.any_failed(),
"{}: webdav import had failures: {summary:?}",
seed.username
);
let conn = open_archive(&archive);
let nodes = count(&conn, "file_nodes") as usize;
let dirs: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE node_type = 'directory'",
[],
|r| r.get(0),
)
.unwrap();
let files: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
[],
|r| r.get(0),
)
.unwrap();
let expected_dirs = account.layout.files.iter().filter(|s| s.directory).count() + 1;
let expected_files = account.layout.files.len() - (expected_dirs - 1);
assert_eq!(
files as usize, expected_files,
"{}: every seeded file must land in archive (seeded {expected_files}, imported {files})",
seed.username
);
assert_eq!(
dirs as usize, expected_dirs,
"{}: directory count mismatch (seeded layout dirs + account root = {expected_dirs}, imported {dirs})",
seed.username
);
assert_eq!(
nodes,
account.layout.files.len() + 1,
"{}: total file_nodes mismatch (layout + account root)",
seed.username
);
for spec in account.layout.files {
let segments = layout_segments(account.layout.files, spec.key);
let mut probes: Vec<Vec<&str>> = Vec::new();
probes.push(segments.clone());
probes.push({
let mut s = vec![account.username.as_str()];
s.extend(segments.iter().copied());
s
});
let node_id = probes
.iter()
.find_map(|p| file_node_id_by_path(&conn, p))
.unwrap_or_else(|| {
panic!(
"{}: file_node at layout path {segments:?} missing in archive",
seed.username
)
});
let reconstructed = file_path(&conn, node_id);
let expected_relative = segments.join("/");
assert!(
reconstructed.ends_with(&expected_relative),
"{}: path '{reconstructed}' does not end with seeded layout path '{expected_relative}'",
seed.username
);
let (blob_id, node_type, media_type, created, modified): (
Option<i64>,
String,
Option<String>,
String,
Option<String>,
) = conn
.query_row(
"SELECT blob_id, node_type, media_type, created, modified
FROM file_nodes WHERE id = ?1",
[node_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
let expected_type = if spec.directory { "directory" } else { "file" };
assert_eq!(
node_type, expected_type,
"{}: {} node_type mismatch",
seed.username, spec.name
);
assert!(
!created.is_empty(),
"{}: {} missing created timestamp",
seed.username,
spec.name
);
if !spec.directory {
let blob_id = blob_id.expect("file blob_id");
let actual = blob_bytes(&conn, blob_id);
let expected = integration::webdav::synth_payload(spec.name).into_bytes();
assert_eq!(
actual, expected,
"{}: file {} content round-tripped incorrectly",
seed.username, spec.name
);
let mt = media_type.unwrap_or_default();
assert!(
!mt.is_empty(),
"{}: file {} missing media_type",
seed.username,
spec.name
);
let m = modified.unwrap_or_default();
assert!(
!m.is_empty(),
"{}: file {} missing modified timestamp",
seed.username,
spec.name
);
let (blob_size, blob_hash): (i64, Vec<u8>) = conn
.query_row(
"SELECT length(data), hash FROM blobs WHERE id = ?1",
[blob_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
blob_size as usize,
expected.len(),
"{}: file {} blob length mismatch",
seed.username,
spec.name
);
assert_eq!(
blob_hash,
blake3::hash(&expected).as_bytes().to_vec(),
"{}: file {} blob.hash mismatch",
seed.username,
spec.name
);
}
}
let payload_groups = duplicate_payload_groups(account.layout.files);
for (name, occurrences) in payload_groups {
if occurrences < 2 {
continue;
}
let payload = integration::webdav::synth_payload(&name).into_bytes();
let hash = blake3::hash(&payload);
let blob_id: i64 = conn
.query_row(
"SELECT id FROM blobs WHERE hash = ?1",
[hash.as_bytes()],
|r| r.get(0),
)
.unwrap_or_else(|e| {
panic!(
"{}: duplicate-named payload {} missing single blob: {e}",
seed.username, name
)
});
let refs: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE blob_id = ?1",
[blob_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
refs as usize, occurrences,
"{}: blob {blob_id} for duplicate payload {name} should be referenced {occurrences} times, got {refs}",
seed.username
);
}
drop(conn);
let summary2 = import_dav::run(common(&archive), webdav_config(account, &w.http))
.expect("idempotent webdav re-import");
assert!(
!summary2.any_failed(),
"{}: idempotent re-import had failures: {summary2:?}",
seed.username
);
let conn = open_archive(&archive);
let nodes2 = count(&conn, "file_nodes") as usize;
let blobs1 = count(&conn, "blobs") as usize;
assert_eq!(
nodes2, nodes,
"{}: idempotent re-import changed file_nodes count",
seed.username
);
drop(conn);
let vanish = seed.files.first().expect("at least one file seeded");
w.delete_resource(account, &vanish.href)
.expect("webdav delete file");
let summary_after = import_dav::run(common(&archive), webdav_config(account, &w.http))
.expect("re-import after delete");
assert!(
!summary_after.any_failed(),
"{}: re-import after delete had failures",
seed.username
);
let conn = open_archive(&archive);
let files_after: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
files_after as usize,
expected_files - 1,
"{}: vanished file not pruned",
seed.username
);
let blobs2 = count(&conn, "blobs") as usize;
assert!(
blobs2 <= blobs1,
"{}: blob count after delete grew unexpectedly ({blobs1} -> {blobs2})",
seed.username
);
drop(conn);
let new_payload = format!(
"added-{} content for vandelay webdav add-after-import probe\n",
seed.username
);
let new_payload_bytes = new_payload.as_bytes().to_vec();
let added_name = format!("added-{}.bin", seed.username);
let _added_href = w
.add_file(account, &[], &added_name, &new_payload_bytes)
.expect("webdav add file");
let summary_after_add = import_dav::run(common(&archive), webdav_config(account, &w.http))
.expect("re-import after add");
assert!(
!summary_after_add.any_failed(),
"{}: re-import after add had failures",
seed.username
);
let conn = open_archive(&archive);
let files_after_add: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
files_after_add as usize, expected_files,
"{}: add-after-delete should restore baseline file count",
seed.username
);
let added_hash = blake3::hash(&new_payload_bytes);
let added_blob: i64 = conn
.query_row(
"SELECT id FROM blobs WHERE hash = ?1",
[added_hash.as_bytes()],
|r| r.get(0),
)
.expect("added file blob should be present");
let refs: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE blob_id = ?1",
[added_blob],
|r| r.get(0),
)
.unwrap();
assert_eq!(
refs, 1,
"{}: added file should be referenced by exactly one file_node",
seed.username
);
let added_node_id: i64 = conn
.query_row(
"SELECT id FROM file_nodes WHERE name = ?1",
[&added_name],
|r| r.get(0),
)
.expect("added file_node by name");
let rebuilt = file_path(&conn, added_node_id);
assert!(
rebuilt.ends_with(&added_name),
"{}: reconstructed path for added node should end with {added_name}, got {rebuilt}",
seed.username
);
drop(conn);
cleanup(&archive);
}
let primary = &w.accounts[0];
let other = &w.accounts[1];
let shared_archive = tmp_archive("webdav-source-change");
import_dav::run(common(&shared_archive), webdav_config(primary, &w.http))
.expect("seed archive with primary user");
let err = import_dav::run(common(&shared_archive), webdav_config(other, &w.http))
.expect_err("expected source-change abort");
assert!(
matches!(err, Error::SourceChange(_)),
"expected SourceChange, got {err:?}"
);
cleanup(&shared_archive);
w.stop().expect("webdav stop");
}
fn layout_segments<'a>(specs: &'a [FileSpec], key: &str) -> Vec<&'a str> {
let mut parts: Vec<&'a str> = Vec::new();
let mut cur = key;
loop {
let spec = specs.iter().find(|s| s.key == cur).expect("layout key");
parts.push(spec.name);
match spec.parent {
Some(p) => cur = p,
None => break,
}
}
parts.reverse();
parts
}
fn duplicate_payload_groups(specs: &[FileSpec]) -> Vec<(String, usize)> {
let mut seen: Vec<(String, usize)> = Vec::new();
let mut names: HashSet<String> = HashSet::new();
for spec in specs.iter().filter(|s| !s.directory) {
names.insert(spec.name.to_owned());
}
for name in names {
let occurrences = specs
.iter()
.filter(|s| !s.directory && s.name == name)
.count();
seen.push((name, occurrences));
}
seen
}
fn _unused_seed_marker(_: &AccountSeed) {}
+112
View File
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use integration::stalwart::shared as shared_stalwart;
use vandelay::jmap::account::{self, AccountSelector};
use vandelay::jmap::http::{Auth, HttpClient, RetryPolicy};
use vandelay::jmap::session::Session;
fn admin_client() -> HttpClient {
HttpClient::new(
Auth::Basic {
user: seeder::ADMIN_USER.into(),
password: seeder::ADMIN_PASSWORD.into(),
},
RetryPolicy::new(5),
true,
)
}
#[test]
#[ignore = "requires Docker"]
fn session_discovery_and_admin_principal_resolution() {
let stalwart = shared_stalwart();
let fx = seeder::provision(stalwart.base_url()).expect("provision");
let client = admin_client();
let session =
Session::discover(&client, &fx.base_url).expect("session discovery via .well-known");
assert!(
session.api_url.starts_with("https://") || session.api_url.starts_with("http://"),
"apiUrl should be absolute: {}",
session.api_url
);
let limits = session.core_limits().expect("core limits present");
assert!(limits.max_objects_in_get >= 1);
assert!(limits.max_concurrent_requests >= 1);
assert!(
!session.accounts.is_empty(),
"authenticated admin session must enumerate accounts"
);
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(
!fx.domain_id.is_empty(),
"seeder should have ensured a domain id"
);
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
let target = fx.account("test1").expect("test1 seeded");
assert!(
!target.admin_role,
"test1 must be a regular user, not admin"
);
let seeded = target.seeded.as_ref().expect("test1 seed stats");
assert!(seeded.emails > 0, "test1 should be seeded with emails");
assert!(
seeded.mailboxes_created >= 7,
"test1 layout has at least 7 mailboxes"
);
assert!(
seeded.file_nodes >= 9,
"test1 layout has at least 9 file nodes"
);
assert!(
seeded.contacts > 0,
"test1 should be seeded with at least one contact"
);
assert!(
seeded.events > 0,
"test1 should be seeded with at least one event"
);
assert!(
seeded.address_books > 0,
"test1 layout requests an extra address book"
);
assert!(
seeded.calendars > 0,
"test1 layout requests an extra calendar"
);
assert!(seeded.identity, "test1 layout requests a custom identity");
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 layout activates a sieve script"
);
let resolved = account::resolve(
&AccountSelector::Name(target.email.clone()),
&session,
&client,
)
.expect("admin principal resolution");
assert_eq!(
resolved, target.account_id,
"{} must resolve to the seeded account id {}",
target.email, target.account_id
);
seeder::teardown(stalwart.base_url()).expect("teardown");
}
+1216
View File
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use mockito::Matcher;
use vandelay::exchange_ews::EwsClient;
use vandelay::exchange_ews::autodiscover::{DiscoverySource, discover};
use vandelay::exchange_ews::error::EwsError;
use vandelay::exchange_ews::parse::{
EnvelopeKind, parse_find_folder_response, parse_find_item_response,
parse_get_attachment_inline, parse_response_messages, parse_sync_folder_items_response,
read_envelope_summary,
};
use vandelay::exchange_ews::types::{FolderId, ItemId, ResponseCode};
use vandelay::exchange_ews::xml::{
FolderRef, ItemShape, Traversal, find_folder_body, find_item_body, get_attachment_body,
get_item_body, sync_folder_items_body,
};
use vandelay::jmap::http::{Auth, RetryPolicy};
const TXT_XML: &str = "text/xml; charset=utf-8";
const APP_JSON: &str = "application/json";
fn client(retries: u32) -> EwsClient {
EwsClient::new(
Auth::Bearer {
token: "t".to_owned(),
},
RetryPolicy::new(retries),
false,
)
}
const NS: &str = " xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" \
xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\" \
xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\"";
fn envelope(inner: &str) -> String {
format!(
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header><soap:Body>{inner}</soap:Body></soap:Envelope>"
)
}
#[test]
fn autodiscover_v2_returns_global_endpoint() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/autodiscover/autodiscover.json")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("Email".into(), "[email protected]".into()),
Matcher::UrlEncoded("Protocol".into(), "Ews".into()),
]))
.with_status(200)
.with_header("content-type", APP_JSON)
.with_body(r#"{"Protocol":"Ews","Url":"https://outlook.office365.com/EWS/Exchange.asmx"}"#)
.create();
let _ = server;
let url = "https://outlook.office365.com/EWS/Exchange.asmx";
assert!(vandelay::exchange_ews::autodiscover::is_fully_qualified_ews_url(url));
let r = discover(Some(url), None, None, false).unwrap();
assert_eq!(r.source, DiscoverySource::SuppliedUrl);
assert_eq!(r.ews_url, url);
}
#[test]
fn find_folder_pagination_and_classification() {
let body = format!(
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"3\" IncludesLastItemInRange=\"true\">\
<t:Folders>\
<t:Folder><t:FolderId Id=\"FMAIL\" ChangeKey=\"C\"/><t:ParentFolderId Id=\"ROOT\"/>\
<t:FolderClass>IPF.Note</t:FolderClass><t:DisplayName>Inbox</t:DisplayName></t:Folder>\
<t:CalendarFolder><t:FolderId Id=\"FCAL\"/><t:ParentFolderId Id=\"ROOT\"/>\
<t:FolderClass>IPF.Appointment</t:FolderClass><t:DisplayName>Calendar</t:DisplayName></t:CalendarFolder>\
<t:ContactsFolder><t:FolderId Id=\"FCON\"/><t:ParentFolderId Id=\"ROOT\"/>\
<t:FolderClass>IPF.Contact</t:FolderClass><t:DisplayName>Contacts</t:DisplayName></t:ContactsFolder>\
</t:Folders></m:RootFolder></m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
);
let parsed = parse_find_folder_response(envelope(&body).as_bytes()).unwrap();
assert_eq!(parsed.folders.len(), 3);
let inbox = &parsed.folders[0];
assert_eq!(inbox.folder_id.id, "FMAIL");
assert_eq!(inbox.folder_class, "IPF.Note");
}
#[test]
fn find_item_offset_loop_terminates_on_includes_last_true() {
let mut server = mockito::Server::new();
let url = format!("{}/EWS/Exchange.asmx", server.url());
let page1 = envelope(&format!(
"<m:FindItemResponse{NS}><m:ResponseMessages><m:FindItemResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"4\" IncludesLastItemInRange=\"false\">\
<t:Items><t:Message><t:ItemId Id=\"A\" ChangeKey=\"1\"/></t:Message>\
<t:Message><t:ItemId Id=\"B\" ChangeKey=\"1\"/></t:Message></t:Items></m:RootFolder>\
</m:FindItemResponseMessage></m:ResponseMessages></m:FindItemResponse>"
));
let page2 = envelope(&format!(
"<m:FindItemResponse{NS}><m:ResponseMessages><m:FindItemResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"4\" IncludesLastItemInRange=\"true\">\
<t:Items><t:Message><t:ItemId Id=\"C\" ChangeKey=\"1\"/></t:Message>\
<t:Message><t:ItemId Id=\"D\" ChangeKey=\"1\"/></t:Message></t:Items></m:RootFolder>\
</m:FindItemResponseMessage></m:ResponseMessages></m:FindItemResponse>"
));
let _m1 = server
.mock("POST", "/EWS/Exchange.asmx")
.match_body(Matcher::Regex("Offset=\"0\"".into()))
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(page1)
.create();
let _m2 = server
.mock("POST", "/EWS/Exchange.asmx")
.match_body(Matcher::Regex("Offset=\"2\"".into()))
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(page2)
.create();
let c = client(0);
let folder = FolderId::new("FID", "FCK");
let body0 = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 0, 2);
let r0 = c.call(&url, "FindItem", &body0).unwrap();
let parsed0 = parse_find_item_response(&r0.body).unwrap();
assert_eq!(parsed0.items.len(), 2);
assert!(parsed0.more);
let body1 = find_item_body(FolderRef::Concrete(&folder), Traversal::Shallow, 2, 2);
let r1 = c.call(&url, "FindItem", &body1).unwrap();
let parsed1 = parse_find_item_response(&r1.body).unwrap();
assert_eq!(parsed1.items.len(), 2);
assert!(!parsed1.more);
}
#[test]
fn get_item_mixed_success_and_per_item_error() {
let body = envelope(&format!(
"<m:GetItemResponse{NS}><m:ResponseMessages>\
<m:GetItemResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Items><t:Message><t:ItemId Id=\"OK1\" ChangeKey=\"K\"/>\
<t:Subject>S</t:Subject>\
<t:MimeContent CharacterSet=\"UTF-8\">SGVsbG8=</t:MimeContent>\
</t:Message></m:Items></m:GetItemResponseMessage>\
<m:GetItemResponseMessage ResponseClass=\"Error\">\
<m:ResponseCode>ErrorItemNotFound</m:ResponseCode>\
<m:MessageText>not found</m:MessageText>\
</m:GetItemResponseMessage>\
</m:ResponseMessages></m:GetItemResponse>"
));
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
assert_eq!(r.len(), 2);
assert!(r[0].success);
assert!(r[0].inner_xml.contains("SGVsbG8="));
assert!(!r[1].success);
assert!(matches!(r[1].response_code, ResponseCode::ItemNotFound));
}
#[test]
fn server_busy_with_back_off_triggers_retry_and_eventually_succeeds() {
let mut server = mockito::Server::new();
let url = format!("{}/EWS/Exchange.asmx", server.url());
let busy_body = format!(
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header>\
<soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>busy</faultstring><detail>\
<ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorServerBusy</ResponseCode>\
<t:MessageXml><t:Value Name=\"BackOffMilliseconds\">200</t:Value></t:MessageXml>\
</detail></soap:Fault></soap:Body></soap:Envelope>"
);
let ok_body = envelope(&format!(
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
));
let _m1 = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(busy_body)
.expect(1)
.create();
let _m2 = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(ok_body)
.expect_at_least(1)
.create();
let c = client(3);
let body = find_folder_body(
FolderRef::Distinguished(
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
),
Traversal::Deep,
);
let r = c
.call(&url, "FindFolder", &body)
.expect("call should retry past busy fault");
let parsed = parse_find_folder_response(&r.body).unwrap();
assert!(parsed.folders.is_empty());
assert!(c.retries_observed() >= 1);
}
#[test]
fn http_401_surfaces_as_auth_error() {
let mut server = mockito::Server::new();
let url = format!("{}/EWS/Exchange.asmx", server.url());
let _m = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(401)
.with_header("www-authenticate", "Bearer error=\"invalid_token\"")
.with_body("unauthorized")
.create();
let c = client(0);
let body = find_folder_body(
FolderRef::Distinguished(
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
),
Traversal::Deep,
);
let err = c.call(&url, "FindFolder", &body).unwrap_err();
assert!(matches!(err, EwsError::Auth(_)), "got {err:?}");
}
#[test]
fn mime_content_round_trips_through_base64_decode() {
let original = b"From: alice@x\r\nSubject: hi\r\n\r\nbody";
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(original);
let body = envelope(&format!(
"<m:GetItemResponse{NS}><m:ResponseMessages>\
<m:GetItemResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Items><t:Message><t:ItemId Id=\"X\" ChangeKey=\"K\"/>\
<t:Subject>hi</t:Subject>\
<t:MimeContent CharacterSet=\"UTF-8\">{encoded}</t:MimeContent>\
</t:Message></m:Items></m:GetItemResponseMessage>\
</m:ResponseMessages></m:GetItemResponse>"
));
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
let item = vandelay::exchange_ews::parse::parse_message_item(&r[0].inner_xml).unwrap();
let s = item.mime_content.unwrap();
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(cleaned.as_bytes())
.unwrap();
assert_eq!(bytes, original);
}
#[test]
fn get_attachment_inline_decodes_photo_blob() {
let body = envelope(&format!(
"<m:GetAttachmentResponse{NS}><m:ResponseMessages>\
<m:GetAttachmentResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Attachments>\
<t:FileAttachment><t:AttachmentId Id=\"A1\"/><t:Name>p.png</t:Name>\
<t:ContentType>image/png</t:ContentType>\
<t:IsContactPhoto>true</t:IsContactPhoto>\
<t:Content>iVBORw0KGgo=</t:Content></t:FileAttachment>\
</m:Attachments></m:GetAttachmentResponseMessage></m:ResponseMessages></m:GetAttachmentResponse>"
));
let items = parse_get_attachment_inline(body.as_bytes()).unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].is_contact_photo);
assert_eq!(items[0].content_base64, "iVBORw0KGgo=");
assert_eq!(items[0].content_type.as_deref(), Some("image/png"));
}
#[test]
fn calendar_item_master_inlines_modified_and_deleted_occurrences() {
let body = "<vandelay-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<t:CalendarItem>\
<t:ItemId Id=\"M1\" ChangeKey=\"K1\"/>\
<t:Subject>Daily</t:Subject>\
<t:UID>uid-1</t:UID>\
<t:Start>2025-06-15T14:00:00Z</t:Start>\
<t:End>2025-06-15T15:00:00Z</t:End>\
<t:CalendarItemType>RecurringMaster</t:CalendarItemType>\
<t:Recurrence>\
<t:DailyRecurrence><t:Interval>1</t:Interval></t:DailyRecurrence>\
<t:NumberedRecurrence><t:StartDate>2025-06-15</t:StartDate><t:NumberOfOccurrences>3</t:NumberOfOccurrences></t:NumberedRecurrence>\
</t:Recurrence>\
<t:ModifiedOccurrences>\
<t:Occurrence><t:ItemId Id=\"OCC1\"/><t:Start>2025-06-16T15:00:00Z</t:Start><t:End>2025-06-16T16:30:00Z</t:End><t:OriginalStart>2025-06-16T14:00:00Z</t:OriginalStart></t:Occurrence>\
</t:ModifiedOccurrences>\
<t:DeletedOccurrences>\
<t:DeletedOccurrence><t:Start>2025-06-17T14:00:00Z</t:Start></t:DeletedOccurrence>\
</t:DeletedOccurrences>\
</t:CalendarItem></vandelay-inner>";
let item = vandelay::exchange_ews::parse::parse_calendar_item(body).unwrap();
assert_eq!(item.id.id, "M1");
assert_eq!(item.uid.as_deref(), Some("uid-1"));
assert_eq!(item.modified_occurrences.len(), 1);
assert_eq!(
item.modified_occurrences[0].item_id.id, "OCC1",
"occurrence ItemId must populate the occurrence, not overwrite the master"
);
assert_eq!(item.deleted_occurrences.len(), 1);
}
#[test]
fn sync_folder_items_creates_updates_deletes_round_trip() {
let body = envelope(&format!(
"<m:SyncFolderItemsResponse{NS}><m:ResponseMessages><m:SyncFolderItemsResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:SyncState>STATE-1</m:SyncState>\
<m:IncludesLastItemInRange>true</m:IncludesLastItemInRange>\
<m:Changes>\
<t:Create><t:Message><t:ItemId Id=\"N1\" ChangeKey=\"A\"/></t:Message></t:Create>\
<t:Create><t:Message><t:ItemId Id=\"N2\" ChangeKey=\"A\"/></t:Message></t:Create>\
<t:Update><t:Message><t:ItemId Id=\"U1\" ChangeKey=\"B\"/></t:Message></t:Update>\
<t:Delete><t:ItemId Id=\"D1\"/></t:Delete>\
<t:ReadFlagChange><t:ItemId Id=\"R1\"/><t:IsRead>true</t:IsRead></t:ReadFlagChange>\
</m:Changes></m:SyncFolderItemsResponseMessage></m:ResponseMessages></m:SyncFolderItemsResponse>"
));
let parsed = parse_sync_folder_items_response(body.as_bytes()).unwrap();
assert_eq!(parsed.sync_state, "STATE-1");
assert!(!parsed.more);
assert_eq!(parsed.changes.len(), 5);
}
#[test]
fn invalid_sync_state_data_fault_is_surfaced() {
let body = format!(
"<soap:Envelope{NS}><soap:Header><t:ServerVersionInfo MajorVersion=\"15\" MinorVersion=\"1\"/></soap:Header>\
<soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>bad state</faultstring><detail>\
<ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorInvalidSyncStateData</ResponseCode>\
</detail></soap:Fault></soap:Body></soap:Envelope>"
);
let env = read_envelope_summary(body.as_bytes()).unwrap();
match env {
EnvelopeKind::Fault { fault, .. } => {
assert!(matches!(
fault.response_code,
ResponseCode::InvalidSyncStateData
));
}
_ => panic!("expected fault"),
}
}
#[test]
fn coordinator_diff_classifies_new_vanished_changed_unchanged() {
use vandelay::db::exchange_ews_ids::ItemRow;
use vandelay::sync::import_exchange_ews::items::{EnumeratedItem, diff};
let server = vec![
EnumeratedItem {
element: "Message".into(),
id: ItemId::new("A", "ck-1"),
},
EnumeratedItem {
element: "Message".into(),
id: ItemId::new("B", "ck-2"),
},
EnumeratedItem {
element: "Message".into(),
id: ItemId::new("C", "ck-1"),
},
];
let local = vec![
ItemRow {
item_id: "A".into(),
change_key: "ck-1".into(),
local_id: 1,
},
ItemRow {
item_id: "B".into(),
change_key: "ck-1".into(),
local_id: 2,
},
ItemRow {
item_id: "Z".into(),
change_key: "ck-9".into(),
local_id: 99,
},
];
let plan = diff(&server, &local);
assert_eq!(plan.new.len(), 1);
assert_eq!(plan.new[0].id, "C");
assert_eq!(plan.present_unchanged.len(), 1);
assert_eq!(plan.present_changed.len(), 1);
assert_eq!(plan.present_changed[0].0.id, "B");
assert_eq!(plan.vanished.len(), 1);
assert_eq!(plan.vanished[0].0, "Z");
}
#[test]
fn source_change_protection_refuses_different_account() {
use rusqlite::Connection;
use vandelay::db;
use vandelay::db::sources::SourceKey;
let tmp = tempfile::NamedTempFile::new().unwrap();
let conn = Connection::open(tmp.path()).unwrap();
db::init::apply_schema(&conn).unwrap();
let key = SourceKey {
kind: "exchange_ews".into(),
session_url: "https://outlook.office365.com/EWS/Exchange.asmx".into(),
account_id: "[email protected]".into(),
};
db::sources::upsert_source(&conn, &key, None, "[email protected]").unwrap();
let conflict = db::sources::conflicting_source(
&conn,
"exchange_ews",
"https://outlook.office365.com/EWS/Exchange.asmx",
"[email protected]",
)
.unwrap();
assert!(conflict.is_some(), "different account_id must conflict");
}
#[test]
fn mailbox_kinds_are_three_separate_sources() {
use rusqlite::Connection;
use vandelay::db;
use vandelay::db::sources::SourceKey;
let tmp = tempfile::NamedTempFile::new().unwrap();
let conn = Connection::open(tmp.path()).unwrap();
db::init::apply_schema(&conn).unwrap();
let primary = SourceKey {
kind: "exchange_ews".into(),
session_url: "https://x/EWS/Exchange.asmx".into(),
account_id: "[email protected]".into(),
};
let archive = SourceKey {
kind: "exchange_ews".into(),
session_url: "https://x/EWS/Exchange.asmx".into(),
account_id: "[email protected]#archive".into(),
};
let public = SourceKey {
kind: "exchange_ews".into(),
session_url: "https://x/EWS/Exchange.asmx".into(),
account_id: "[email protected]".into(),
};
let p = db::sources::upsert_source(&conn, &primary, None, "[email protected]").unwrap();
let a = db::sources::upsert_source(&conn, &archive, None, "[email protected]").unwrap();
let q = db::sources::upsert_source(&conn, &public, None, "[email protected]").unwrap();
assert_ne!(p, a);
assert_ne!(p, q);
assert_ne!(a, q);
}
#[test]
fn get_item_batches_chunk_the_id_list() {
let ids: Vec<ItemId> = (0..7).map(|i| ItemId::new(format!("I{i}"), "K")).collect();
let chunks: Vec<&[ItemId]> = ids.chunks(3).collect();
assert_eq!(chunks.len(), 3);
for chunk in &chunks {
let body = get_item_body(ItemShape::Message, chunk);
assert!(body.contains("<m:GetItem>"));
for id in *chunk {
assert!(body.contains(&format!("Id=\"{}\"", id.id)));
}
}
}
#[test]
fn sync_folder_items_request_body_carries_state_and_max() {
let folder = FolderId::new("FID", "FCK");
let body = sync_folder_items_body(&folder, "OPAQUE", 512);
assert!(body.contains("<m:SyncState>OPAQUE</m:SyncState>"));
assert!(body.contains("<m:MaxChangesReturned>512</m:MaxChangesReturned>"));
assert!(body.contains("<m:SyncScope>NormalItems</m:SyncScope>"));
assert!(body.contains("<m:SyncFolderId><t:FolderId Id=\"FID\" ChangeKey=\"FCK\"/>"));
}
#[test]
fn get_attachment_body_carries_inline_request() {
let body = get_attachment_body(&["A1", "A2"]);
assert!(body.contains("<t:AttachmentId Id=\"A1\"/>"));
assert!(body.contains("<t:AttachmentId Id=\"A2\"/>"));
assert!(body.contains("<t:IncludeMimeContent>true</t:IncludeMimeContent>"));
}
#[test]
fn http_500_with_server_busy_body_is_treated_as_fault_and_retried() {
let mut server = mockito::Server::new();
let url = format!("{}/EWS/Exchange.asmx", server.url());
let busy_body = format!(
"<soap:Envelope{NS}><soap:Body><soap:Fault>\
<faultcode>soap:Server</faultcode><faultstring>busy</faultstring>\
<detail><ResponseCode xmlns=\"http://schemas.microsoft.com/exchange/services/2006/types\">ErrorServerBusy</ResponseCode>\
<t:MessageXml><t:Value Name=\"BackOffMilliseconds\">100</t:Value></t:MessageXml>\
</detail></soap:Fault></soap:Body></soap:Envelope>"
);
let ok_body = envelope(&format!(
"<m:FindFolderResponse{NS}><m:ResponseMessages><m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"0\" IncludesLastItemInRange=\"true\"><t:Folders/></m:RootFolder>\
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>"
));
let _m1 = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(busy_body)
.expect(1)
.create();
let _m2 = server
.mock("POST", "/EWS/Exchange.asmx")
.with_status(200)
.with_header("content-type", TXT_XML)
.with_body(ok_body)
.create();
let c = client(3);
let body = find_folder_body(
FolderRef::Distinguished(
vandelay::exchange_ews::types::DistinguishedFolderId::MsgFolderRoot,
),
Traversal::Deep,
);
let r = c.call(&url, "FindFolder", &body).expect("should retry");
let parsed = parse_find_folder_response(&r.body).unwrap();
assert!(parsed.folders.is_empty());
}
#[test]
fn warning_response_class_is_treated_as_success_in_mock() {
let body = envelope(&format!(
"<m:GetItemResponse{NS}><m:ResponseMessages>\
<m:GetItemResponseMessage ResponseClass=\"Warning\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Items><t:Message><t:ItemId Id=\"W1\" ChangeKey=\"K\"/></t:Message></m:Items>\
</m:GetItemResponseMessage></m:ResponseMessages></m:GetItemResponse>"
));
let r = parse_response_messages(body.as_bytes(), b"GetItemResponseMessage").unwrap();
assert!(r[0].success);
}
#[test]
fn default_namespace_envelope_parses_via_resolved_names() {
let body = "<Envelope xmlns=\"http://schemas.xmlsoap.org/soap/envelope/\" \
xmlns:m=\"http://schemas.microsoft.com/exchange/services/2006/messages\" \
xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<Body>\
<m:FindFolderResponse><m:ResponseMessages>\
<m:FindFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:RootFolder TotalItemsInView=\"1\" IncludesLastItemInRange=\"true\">\
<t:Folders><t:Folder><t:FolderId Id=\"D1\"/><t:DisplayName>Inbox</t:DisplayName>\
</t:Folder></t:Folders></m:RootFolder>\
</m:FindFolderResponseMessage></m:ResponseMessages></m:FindFolderResponse>\
</Body></Envelope>";
let parsed = parse_find_folder_response(body.as_bytes()).unwrap();
assert_eq!(parsed.folders.len(), 1);
assert_eq!(parsed.folders[0].folder_id.id, "D1");
}
#[test]
fn get_folder_messages_preserve_position_when_one_errors() {
let body = envelope(&format!(
"<m:GetFolderResponse{NS}><m:ResponseMessages>\
<m:GetFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Folders><t:Folder><t:FolderId Id=\"FA\"/><t:DisplayName>Inbox</t:DisplayName></t:Folder></m:Folders>\
</m:GetFolderResponseMessage>\
<m:GetFolderResponseMessage ResponseClass=\"Error\">\
<m:ResponseCode>ErrorAccessDenied</m:ResponseCode>\
</m:GetFolderResponseMessage>\
<m:GetFolderResponseMessage ResponseClass=\"Success\">\
<m:ResponseCode>NoError</m:ResponseCode>\
<m:Folders><t:Folder><t:FolderId Id=\"FC\"/><t:DisplayName>Drafts</t:DisplayName></t:Folder></m:Folders>\
</m:GetFolderResponseMessage>\
</m:ResponseMessages></m:GetFolderResponse>"
));
let msgs = parse_response_messages(body.as_bytes(), b"GetFolderResponseMessage").unwrap();
assert_eq!(msgs.len(), 3, "all three messages must be present");
assert!(msgs[0].success);
assert!(!msgs[1].success);
assert!(msgs[2].success);
}
File diff suppressed because it is too large Load Diff
+1596
View File
File diff suppressed because it is too large Load Diff
+1042
View File
File diff suppressed because it is too large Load Diff
+758
View File
@@ -0,0 +1,758 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use tempfile::TempDir;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
use vandelay::sync::import_maildir::{MaildirImportConfig, run};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-mock-maildir-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 1,
allow_invalid_certs: false,
logger: Logger::from_flags(true, 0),
}
}
fn base_cfg(root: &Path) -> MaildirImportConfig {
MaildirImportConfig {
maildir: root.to_path_buf(),
include: Vec::new(),
exclude: Vec::new(),
folder: Vec::new(),
automap: true,
include_deleted: false,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn ensure_maildir(root: &Path) {
for sub in ["cur", "new", "tmp"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
}
fn ensure_subfolder(root: &Path, dotted: &str) -> PathBuf {
let sub = root.join(dotted);
for s in ["cur", "new", "tmp"] {
fs::create_dir_all(sub.join(s)).unwrap();
}
sub
}
fn write_message(folder: &Path, sub: &str, filename: &str, body: &[u8]) -> PathBuf {
let dir = folder.join(sub);
fs::create_dir_all(&dir).unwrap();
let p = dir.join(filename);
fs::write(&p, body).unwrap();
p
}
fn rfc5322(id: &str, subject: &str, body: &str) -> Vec<u8> {
format!(
"From: [email protected]\r\nTo: [email protected]\r\n\
Subject: {subject}\r\nMessage-ID: <{id}@example.com>\r\n\
Date: Mon, 12 May 2025 10:00:00 +0000\r\n\r\n{body}\r\n"
)
.into_bytes()
}
fn folder_mailbox_id(conn: &Connection, canonical: &str) -> Option<i64> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE type_name = 'mailbox' AND folder = ?1 AND unique_id = ''",
rusqlite::params![canonical],
|r| r.get(0),
)
.ok()
}
fn emails_for_folder(conn: &Connection, canonical: &str) -> Vec<(String, String)> {
let mailbox_id = folder_mailbox_id(conn, canonical).expect("mailbox row");
let mut stmt = conn
.prepare(
"SELECT b.hash, e.keywords FROM emails e
JOIN blobs b ON b.id = e.blob_id
WHERE EXISTS (
SELECT 1 FROM json_each(e.mailbox_ids) WHERE value = ?1
)
ORDER BY e.id",
)
.unwrap();
let rows = stmt
.query_map(rusqlite::params![mailbox_id], |r| {
let hash: Vec<u8> = r.get(0)?;
let kw: String = r.get(1)?;
Ok((hex(&hash), kw))
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
fn dovecot_fixture(root: &Path) -> HashMap<&'static str, Vec<&'static str>> {
ensure_maildir(root);
write_message(
root,
"cur",
"1739471123.M001P00123V0000I0000aaaa.host01,S=128,W=131:2,S",
&rfc5322("dovecot-inbox-1", "Welcome", "Welcome to Dovecot"),
);
write_message(
root,
"cur",
"1739471124.M002P00123V0000I0000aaab.host01,S=140,W=143:2,RS",
&rfc5322("dovecot-inbox-2", "Re: Welcome", "Reply body"),
);
write_message(
root,
"new",
"1739471200.M003P00123V0000I0000aaac.host01,S=64,W=66",
&rfc5322("dovecot-inbox-3", "Unread mail", "Body of the unread"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1739472000.M001P00123V0000I0000bbb1.host01,S=80,W=82:2,S",
&rfc5322("dovecot-sent-1", "Sent to friend", "Hi friend"),
);
let drafts = ensure_subfolder(root, ".Drafts");
write_message(
&drafts,
"cur",
"1739472100.M002P00123V0000I0000bbb2.host01,S=50,W=52:2,DS",
&rfc5322("dovecot-draft-1", "Half-written", "Almost there"),
);
let trash = ensure_subfolder(root, ".Trash");
write_message(
&trash,
"cur",
"1739472200.M003P00123V0000I0000bbb3.host01,S=30,W=32:2,ST",
&rfc5322("dovecot-trash-1", "Junk", "trashed"),
);
ensure_subfolder(root, ".Archive");
let arch25 = ensure_subfolder(root, ".Archive.2025");
write_message(
&arch25,
"cur",
"1739473000.M001P00123V0000I0000cccc.host01,S=200,W=204:2,S",
&rfc5322("dovecot-arch25-1", "Last year", "Old mail"),
);
write_message(
&arch25,
"cur",
"1739473001.M002P00123V0000I0000cccd.host01,S=200,W=204:2,SF",
&rfc5322("dovecot-arch25-2", "Important", "Flagged"),
);
fs::write(root.join("dovecot.index.log"), b"index").unwrap();
fs::write(root.join("dovecot-uidlist"), b"uidlist").unwrap();
fs::create_dir_all(root.join(".dovecot.imap")).unwrap();
fs::write(root.join("cur/.uidvalidity"), b"sidecar").unwrap();
let mut expected: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
expected.insert("INBOX", vec![]);
expected.insert("Sent", vec![]);
expected.insert("Drafts", vec![]);
expected.insert("Trash", vec![]);
expected.insert("Archive", vec![]);
expected.insert("Archive.2025", vec![]);
expected
}
#[test]
fn dovecot_layout_imports_every_folder_with_correct_roles() {
let td = TempDir::new().unwrap();
let expected = dovecot_fixture(td.path());
let archive = tmp_archive("dovecot");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "import failed: {summary:?}");
let conn = Connection::open(&archive).unwrap();
for name in expected.keys() {
assert!(
folder_mailbox_id(&conn, name).is_some(),
"missing mailbox row for {name:?}"
);
}
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 3, "3 INBOX messages expected");
let seen_count = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
assert_eq!(seen_count, 2, "two cur/ msgs are $seen");
let sent = emails_for_folder(&conn, "Sent");
assert_eq!(sent.len(), 1);
let trash = emails_for_folder(&conn, "Trash");
assert!(trash.is_empty(), "Trashed message skipped by default");
let arch_2025 = emails_for_folder(&conn, "Archive.2025");
assert_eq!(arch_2025.len(), 2);
let flagged = arch_2025.iter().any(|(_, kw)| kw.contains("$flagged"));
assert!(flagged, "one Archive.2025 message has $flagged");
let archive_parent_id = folder_mailbox_id(&conn, "Archive").unwrap();
let arch_2025_parent: Option<i64> = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE name = '2025'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(arch_2025_parent, Some(archive_parent_id));
let _ = fs::remove_file(&archive);
}
#[test]
fn dovecot_include_deleted_keeps_trash_message() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("dovecot-include-deleted");
let mut cfg = base_cfg(td.path());
cfg.include_deleted = true;
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let trash = emails_for_folder(&conn, "Trash");
assert_eq!(trash.len(), 1);
assert!(trash[0].1.contains("$deleted"));
let _ = fs::remove_file(&archive);
}
fn courier_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"cur",
"1234567890.12345_0.mail.example:2,S",
&rfc5322("courier-1", "Hi", "Hello from Courier"),
);
write_message(
root,
"new",
"1234567891.12345_1.mail.example",
&rfc5322("courier-2", "Unread", "New body"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1234567900.12350_0.mail.example:2,S",
&rfc5322("courier-sent-1", "Sent reply", "Bye"),
);
let lists = ensure_subfolder(root, ".Lists.maildir-dev");
write_message(
&lists,
"cur",
"1234568000.12400_0.mail.example:2,RS",
&rfc5322("courier-list-1", "[Maildir-dev] thread", "list body"),
);
fs::write(root.join("courierimapsubscribed"), b"INBOX.Sent\n").unwrap();
}
#[test]
fn courier_layout_with_orphan_parent_creates_ephemeral_lists_row() {
let td = TempDir::new().unwrap();
courier_fixture(td.path());
let archive = tmp_archive("courier");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let lists_id = folder_mailbox_id(&conn, "Lists").expect("ephemeral Lists row created");
let leaf_id = folder_mailbox_id(&conn, "Lists.maildir-dev").expect("Lists.maildir-dev row");
let leaf_parent: Option<i64> = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE id = ?1",
rusqlite::params![leaf_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(leaf_parent, Some(lists_id));
let role: Option<String> = conn
.query_row(
"SELECT role FROM mailboxes WHERE id = ?1",
rusqlite::params![lists_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(role, None);
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
assert_eq!(emails_for_folder(&conn, "Lists.maildir-dev").len(), 1);
let _ = fs::remove_file(&archive);
}
fn qmail_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"new",
"1700000000.12345.box01",
&rfc5322("qmail-1", "qmail one", "body 1"),
);
write_message(
root,
"new",
"1700000001.12346.box01",
&rfc5322("qmail-2", "qmail two", "body 2"),
);
write_message(
root,
"cur",
"1700000002.12347.box01:2,S",
&rfc5322("qmail-3", "Seen", "body 3"),
);
}
#[test]
fn qmail_root_only_maildir_imports_inbox_only() {
let td = TempDir::new().unwrap();
qmail_fixture(td.path());
let archive = tmp_archive("qmail");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mboxes = count(&conn, "mailboxes");
assert_eq!(mboxes, 1, "INBOX only");
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 3);
let seen = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
assert_eq!(seen, 1, "only the cur/ message has $seen");
let _ = fs::remove_file(&archive);
}
fn cyrus_export_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"cur",
"1701000000.X1.cyrus-export:2,S",
&rfc5322("cyrus-1", "Cyrus mail 1", "exported"),
);
write_message(
root,
"cur",
"1701000001.X2.cyrus-export:2,",
&rfc5322("cyrus-2", "Cyrus mail 2", "exported"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1701000100.X3.cyrus-export:2,S",
&rfc5322("cyrus-sent-1", "Sent from Cyrus", "exported sent"),
);
let arch = ensure_subfolder(root, ".Archive");
write_message(
&arch,
"cur",
"1701000200.X4.cyrus-export:2,S",
&rfc5322("cyrus-arch-1", "Archived", "old"),
);
}
#[test]
fn cyrus_style_export_imports_cleanly() {
let td = TempDir::new().unwrap();
cyrus_export_fixture(td.path());
let archive = tmp_archive("cyrus");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
assert_eq!(emails_for_folder(&conn, "Archive").len(), 1);
let sent_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(sent_role.as_deref(), Some("sent"));
let _ = fs::remove_file(&archive);
}
#[test]
fn include_filter_keeps_only_matching_folders() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-include");
let mut cfg = base_cfg(td.path());
cfg.include = vec![regex::Regex::new(r"^(INBOX|Sent)$").unwrap()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
assert!(folder_mailbox_id(&conn, "Trash").is_none());
assert!(folder_mailbox_id(&conn, "Archive.2025").is_none());
let _ = fs::remove_file(&archive);
}
#[test]
fn exclude_filter_drops_matching_folders() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-exclude");
let mut cfg = base_cfg(td.path());
cfg.exclude = vec![regex::Regex::new(r"^Trash$").unwrap()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "Trash").is_none());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let _ = fs::remove_file(&archive);
}
#[test]
fn explicit_folder_overrides_include_exclude() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-folder");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["Sent".to_owned()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mbox_count = count(&conn, "mailboxes");
assert_eq!(mbox_count, 1);
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let _ = fs::remove_file(&archive);
}
#[test]
fn noautomap_drops_roles_except_inbox() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("noautomap");
let mut cfg = base_cfg(td.path());
cfg.automap = false;
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let sent_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
r.get(0)
})
.unwrap();
assert!(sent_role.is_none(), "automap off -> Sent has no role");
let inbox_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(inbox_role.as_deref(), Some("inbox"));
let _ = fs::remove_file(&archive);
}
#[test]
fn dry_run_reports_diff_without_writing_emails() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("dry-run");
let mut common_cfg = common(&archive);
common_cfg.dry_run = true;
let summary = run(common_cfg, base_cfg(td.path())).expect("dryrun");
let (mailbox_label, mailbox_counts) = &summary.per_type[0];
let (email_label, email_counts) = &summary.per_type[1];
assert_eq!(*mailbox_label, "mailbox");
assert_eq!(*email_label, "email");
assert!(mailbox_counts.created >= 6);
assert!(email_counts.created >= 6);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0, "dry-run wrote no emails");
assert_eq!(count(&conn, "mailboxes"), 0, "dry-run wrote no mailboxes");
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_path_without_cur_subdir() {
let td = TempDir::new().unwrap();
fs::create_dir_all(td.path().join("new")).unwrap();
let archive = tmp_archive("not-a-maildir");
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
assert!(matches!(err, vandelay::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_dovecot_layout_fs_tree() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
for s in ["cur", "new", "tmp"] {
fs::create_dir_all(td.path().join("Sent").join(s)).unwrap();
}
let archive = tmp_archive("layout-fs");
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
match err {
vandelay::error::Error::Usage(msg) => {
assert!(msg.contains("Maildir++"), "msg was: {msg}");
}
other => panic!("expected Usage error, got {other:?}"),
}
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_nonexistent_path() {
let archive = tmp_archive("nonexistent");
let mut cfg = base_cfg(Path::new("/definitely/not/a/real/maildir"));
cfg.maildir = PathBuf::from("/definitely/not/a/real/maildir");
let err = run(common(&archive), cfg).expect_err("should refuse");
assert!(matches!(err, vandelay::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn source_change_protection_refuses_second_path() {
let td_a = TempDir::new().unwrap();
dovecot_fixture(td_a.path());
let td_b = TempDir::new().unwrap();
qmail_fixture(td_b.path());
let archive = tmp_archive("source-change");
run(common(&archive), base_cfg(td_a.path())).expect("first import");
let err = run(common(&archive), base_cfg(td_b.path())).expect_err("second import refused");
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn allow_source_change_unlocks_second_path() {
let td_a = TempDir::new().unwrap();
dovecot_fixture(td_a.path());
let td_b = TempDir::new().unwrap();
qmail_fixture(td_b.path());
let archive = tmp_archive("allow-source-change");
run(common(&archive), base_cfg(td_a.path())).expect("first import");
let mut second = base_cfg(td_b.path());
second.allow_source_change = true;
run(common(&archive), second).expect("second import permitted");
let _ = fs::remove_file(&archive);
}
#[test]
fn folder_and_include_are_mutually_exclusive() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let archive = tmp_archive("mutex-folder-include");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["INBOX".to_owned()];
cfg.include = vec![regex::Regex::new("^Sent$").unwrap()];
let err = run(common(&archive), cfg).expect_err("mutex");
match err {
vandelay::error::Error::Usage(msg) => {
assert!(
msg.contains("--folder") && msg.contains("--include"),
"msg was: {msg}"
);
}
other => panic!("expected Usage, got {other:?}"),
}
let _ = fs::remove_file(&archive);
}
#[test]
fn folder_and_exclude_are_mutually_exclusive() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let archive = tmp_archive("mutex-folder-exclude");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["INBOX".to_owned()];
cfg.exclude = vec![regex::Regex::new("^Trash$").unwrap()];
let err = run(common(&archive), cfg).expect_err("mutex");
assert!(matches!(err, vandelay::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn three_level_folder_delete_is_leaf_first() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
ensure_subfolder(td.path(), ".A");
ensure_subfolder(td.path(), ".A.B");
let leaf = ensure_subfolder(td.path(), ".A.B.C");
write_message(
&leaf,
"cur",
"1.M0.host:2,S",
&rfc5322("3lvl-1", "x", "body"),
);
let archive = tmp_archive("three-level-delete");
run(common(&archive), base_cfg(td.path())).expect("first import");
fs::remove_dir_all(td.path().join(".A.B.C")).unwrap();
fs::remove_dir_all(td.path().join(".A.B")).unwrap();
let summary = run(common(&archive), base_cfg(td.path())).expect("second import");
assert!(!summary.any_failed(), "{summary:?}");
let (_, mbox) = &summary.per_type[0];
let (_, email) = &summary.per_type[1];
assert_eq!(mbox.deleted, 2, "B and B.C deleted leaf-first");
assert_eq!(email.deleted, 1);
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "A").is_some());
assert!(folder_mailbox_id(&conn, "A.B").is_none());
assert!(folder_mailbox_id(&conn, "A.B.C").is_none());
let _ = fs::remove_file(&archive);
}
#[test]
fn same_unique_id_in_two_folders_produces_two_rows_one_blob() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let sent = ensure_subfolder(td.path(), ".Sent");
let body = rfc5322("dup-1", "shared", "shared body");
write_message(td.path(), "cur", "1.M0.host:2,S", &body);
write_message(&sent, "cur", "1.M0.host:2,S", &body);
let archive = tmp_archive("cross-folder-dup");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "blobs"), 1, "BLAKE3 dedup");
assert_eq!(count(&conn, "emails"), 2, "one row per folder");
let inbox = emails_for_folder(&conn, "INBOX");
let sent_rows = emails_for_folder(&conn, "Sent");
assert_eq!(inbox.len(), 1);
assert_eq!(sent_rows.len(), 1);
assert_eq!(inbox[0].0, sent_rows[0].0);
let _ = fs::remove_file(&archive);
}
#[cfg(unix)]
#[test]
fn unreadable_file_is_counted_and_warned_not_aborted() {
use std::os::unix::fs::PermissionsExt;
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
write_message(
td.path(),
"cur",
"1.M0.host:2,S",
&rfc5322("ok-1", "ok", "ok"),
);
let bad = write_message(
td.path(),
"cur",
"2.M0.host:2,S",
&rfc5322("bad-1", "denied", "denied"),
);
let mut perms = fs::metadata(&bad).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&bad, perms).unwrap();
let archive = tmp_archive("unreadable");
let summary = run(common(&archive), base_cfg(td.path())).expect("not aborted");
let mut perms = fs::metadata(&bad).unwrap().permissions();
perms.set_mode(0o600);
fs::set_permissions(&bad, perms).unwrap();
let (_, email) = &summary.per_type[1];
assert!(summary.any_failed(), "exit-5 worthy");
assert_eq!(email.failed, 1, "the bad file is counted");
assert_eq!(email.created, 1, "the readable file imports");
let _ = fs::remove_file(&archive);
}
#[test]
fn pointing_at_a_dot_subfolder_warns_but_imports() {
let parent = TempDir::new().unwrap();
ensure_maildir(parent.path());
let sub = ensure_subfolder(parent.path(), ".Sent");
write_message(&sub, "cur", "1.M0.host:2,S", &rfc5322("sub-1", "x", "x"));
let archive = tmp_archive("dot-subfolder-as-root");
let summary = run(common(&archive), base_cfg(&sub)).expect("import proceeds");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 1);
let _ = fs::remove_file(&archive);
}
#[test]
fn malformed_message_yields_zero_message_match_but_imports() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
write_message(td.path(), "cur", "1.M0.host:2,S", b"");
write_message(
td.path(),
"cur",
"2.M0.host:2,S",
b"X-Garbage: yes\r\n\r\nno headers",
);
let archive = tmp_archive("malformed");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 2);
let _ = fs::remove_file(&archive);
}
File diff suppressed because it is too large Load Diff
+1431
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use integration::stalwart::shared as shared_stalwart;
#[test]
#[ignore = "requires Docker"]
fn provision_no_teardown() -> seeder::error::SeedResult<()> {
let stalwart = shared_stalwart();
let fx = seeder::provision(stalwart.base_url())?;
println!(
"provisioned domain={} (id={}) base_url={}",
fx.domain, fx.domain_id, fx.base_url
);
println!("admin login: {} / {}", fx.admin_login.0, fx.admin_login.1);
for local in seeder::SYNC_IN {
if let Some(acc) = fx.account(local) {
let seeded = acc
.seeded
.as_ref()
.map(|s| {
format!(
"mailboxes_created={} emails={} contacts={} events={} \
address_books={} calendars={} file_nodes={} \
sieve_active={:?} identity={}",
s.mailboxes_created,
s.emails,
s.contacts,
s.events,
s.address_books,
s.calendars,
s.file_nodes,
s.sieve_active,
s.identity
)
})
.unwrap_or_else(|| "(no seed stats)".to_owned());
println!(
"{}: email={} password={} account_id={} role={:?} | {seeded}",
acc.localpart, acc.email, acc.password, acc.account_id, acc.admin_role
);
}
}
println!("(no teardown; container is torn down when the test binary exits)");
let _references_teardown: fn(&str) -> seeder::error::SeedResult<()> = seeder::teardown;
Ok(())
}
+285
View File
@@ -0,0 +1,285 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use integration::stalwart::shared as shared_stalwart;
use seeder::jmap::Jmap;
use seeder::seed::SeedStats;
use serde_json::{Value, json};
const CORE: &str = "urn:ietf:params:jmap:core";
const MAIL: &str = "urn:ietf:params:jmap:mail";
const SIEVE: &str = "urn:ietf:params:jmap:sieve";
const CONTACTS: &str = "urn:ietf:params:jmap:contacts";
const CALENDARS: &str = "urn:ietf:params:jmap:calendars";
const FILENODE: &str = "urn:ietf:params:jmap:filenode";
const PRINCIPALS: &str = "urn:ietf:params:jmap:principals";
const DEFAULT_MAILBOXES: usize = 5;
fn list_len(v: &Value) -> usize {
v.get("list")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(usize::MAX)
}
fn ids_len(v: &Value) -> usize {
v.get("ids")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(usize::MAX)
}
fn bare() -> SeedStats {
SeedStats {
mailboxes_created: 0,
emails: 0,
contacts: 0,
events: 0,
sieve_active: None,
identity: false,
file_nodes: 0,
address_books: 1,
calendars: 1,
}
}
fn verify_account(label: &str, user: &Jmap, account_id: &str, s: &SeedStats) {
let mailboxes = user
.call(
&[CORE, MAIL],
"Mailbox/get",
account_id,
json!({ "properties": ["id", "role"] }),
)
.expect("Mailbox/get");
assert_eq!(
list_len(&mailboxes),
DEFAULT_MAILBOXES + s.mailboxes_created,
"{label}: mailbox count"
);
let emails = user
.call(
&[CORE, MAIL],
"Email/query",
account_id,
json!({ "calculateTotal": true }),
)
.expect("Email/query");
let email_total = emails
.get("total")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or_else(|| ids_len(&emails));
assert_eq!(email_total, s.emails, "{label}: email count");
let scripts = user
.call(
&[CORE, SIEVE],
"SieveScript/get",
account_id,
json!({ "properties": ["id", "name", "isActive"] }),
)
.expect("SieveScript/get");
match s.sieve_active {
Some(active) => {
let list = scripts
.get("list")
.and_then(Value::as_array)
.expect("sieve list");
assert_eq!(list.len(), 2, "{label}: sieve script count");
let primary = list
.iter()
.find(|s| s.get("name").and_then(Value::as_str) == Some("vandelay-test-filter"))
.expect("primary sieve script present");
assert_eq!(
primary.get("isActive").and_then(Value::as_bool),
Some(active),
"{label}: primary sieve isActive"
);
}
None => assert_eq!(list_len(&scripts), 0, "{label}: expected no sieve scripts"),
}
let identities = user
.call(
&[CORE, MAIL],
"Identity/get",
account_id,
json!({ "properties": ["id", "name"] }),
)
.expect("Identity/get");
let custom = identities
.get("list")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|i| {
i.get("name").and_then(Value::as_str) == Some(seeder::CUSTOM_IDENTITY_NAME)
})
.count()
})
.unwrap_or(0);
assert_eq!(
custom,
usize::from(s.identity),
"{label}: custom identity presence"
);
let files = user
.call(
&[CORE, FILENODE],
"FileNode/query",
account_id,
json!({ "calculateTotal": true }),
)
.expect("FileNode/query");
let file_total = files
.get("total")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or_else(|| ids_len(&files));
assert_eq!(file_total, s.file_nodes, "{label}: file node count");
let books = user
.call(
&[CORE, CONTACTS],
"AddressBook/get",
account_id,
json!({ "properties": ["id"] }),
)
.expect("AddressBook/get");
assert_eq!(
list_len(&books),
s.address_books,
"{label}: address book count"
);
let calendars = user
.call(
&[CORE, CALENDARS],
"Calendar/get",
account_id,
json!({ "properties": ["id"] }),
)
.expect("Calendar/get");
assert_eq!(list_len(&calendars), s.calendars, "{label}: calendar count");
let cards = user
.call(
&[CORE, CONTACTS],
"ContactCard/get",
account_id,
json!({ "properties": ["id"] }),
)
.expect("ContactCard/get");
assert_eq!(list_len(&cards), s.contacts, "{label}: contact card count");
let cal_events = user
.call(
&[CORE, CALENDARS],
"CalendarEvent/query",
account_id,
json!({ "calculateTotal": true }),
)
.expect("CalendarEvent/query");
let event_total = cal_events
.get("total")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or_else(|| ids_len(&cal_events));
assert_eq!(event_total, s.events, "{label}: calendar event count");
}
#[test]
#[ignore = "requires Docker"]
fn provision_seed_verify_and_teardown() {
let stalwart = shared_stalwart();
let fixture = seeder::provision(stalwart.base_url()).expect("provision");
assert_eq!(fixture.domain, seeder::DOMAIN);
assert!(!fixture.domain_id.is_empty());
assert_eq!(
fixture.accounts.len(),
seeder::SYNC_IN.len() + seeder::SYNC_OUT.len() + 1
);
for lp in seeder::SYNC_IN {
let a = fixture.account(lp).expect("sync-in account");
let stats = a.seeded.as_ref().expect("sync-in seeded stats");
assert!(stats.emails > 0, "{lp}: expected seeded emails");
let user = Jmap::connect(&fixture.base_url, &a.email, &a.password).expect("login sync-in");
assert_eq!(user.account_id, a.account_id);
verify_account(lp, &user, &a.account_id, stats);
}
let baseline = bare();
for lp in seeder::SYNC_OUT {
let a = fixture.account(lp).expect("sync-out account");
assert!(a.seeded.is_none(), "{lp}: targets must not be seeded");
let user = Jmap::connect(&fixture.base_url, &a.email, &a.password).expect("login sync-out");
verify_account(lp, &user, &a.account_id, &baseline);
}
let admin_fx = fixture
.account(seeder::ADMIN_LOCALPART)
.expect("admin account");
assert!(admin_fx.admin_role);
let admin = Jmap::connect(&fixture.base_url, &admin_fx.email, &admin_fx.password)
.expect("login admin account");
let test1 = fixture.account("test1").expect("test1");
let resolved = admin
.request(
&[CORE, PRINCIPALS],
json!([
["Principal/query", { "filter": { "name": test1.email } }, "q"],
["Principal/get", {
"#ids": { "resultOf": "q", "name": "Principal/query", "path": "/ids" },
"properties": ["id", "name", "accounts"]
}, "g"]
]),
)
.expect("Principal discovery");
let principals = resolved
.get("methodResponses")
.and_then(Value::as_array)
.and_then(|r| r.get(1))
.and_then(|r| r.get(1))
.and_then(|g| g.get("list"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let exact: Vec<&Value> = principals
.iter()
.filter(|p| p.get("name").and_then(Value::as_str) == Some(test1.email.as_str()))
.collect();
assert_eq!(exact.len(), 1, "admin Principal exact-name resolution");
let owner = exact[0]
.get("accounts")
.and_then(Value::as_object)
.and_then(|m| {
m.iter().find_map(|(_, v)| {
v.get("urn:ietf:params:jmap:principals:owner")
.and_then(|o| o.get("accountIdForPrincipal"))
.and_then(Value::as_str)
})
})
.expect("accountIdForPrincipal");
assert_eq!(owner, test1.account_id, "resolved account id matches test1");
seeder::teardown(stalwart.base_url()).expect("teardown");
let (admin_user, admin_password) = &fixture.admin_login;
let gone = seeder::admin::Admin::connect(&fixture.base_url, admin_user, admin_password)
.expect("admin reconnect")
.domain_id(seeder::DOMAIN)
.expect("domain_id query");
assert!(gone.is_none(), "domain should be gone after teardown");
}
+208
View File
@@ -0,0 +1,208 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use serde_json::{Value, json};
use super::error::{SeedError, SeedResult};
use super::jmap::Jmap;
pub const STALWART: &[&str] = &["urn:ietf:params:jmap:core", "urn:stalwart:jmap"];
pub struct Admin {
jmap: Jmap,
account_id: String,
}
impl Admin {
pub fn connect(base: &str, user: &str, password: &str) -> SeedResult<Admin> {
let jmap = Jmap::connect(base, user, password)?;
let account_id = jmap.account_id.clone();
Ok(Admin { jmap, account_id })
}
fn created_id(response: &Value, key: &str) -> SeedResult<String> {
response
.get("created")
.and_then(|c| c.get(key))
.and_then(|o| o.get("id"))
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SeedError::Shape(format!("create {key} missing id: {response}")))
}
pub fn domain_id(&self, name: &str) -> SeedResult<Option<String>> {
let calls = json!([
["x:Domain/query", { "accountId": self.account_id, "filter": { "name": name } }, "q"],
["x:Domain/get", {
"accountId": self.account_id,
"#ids": { "resultOf": "q", "name": "x:Domain/query", "path": "/ids" },
"properties": ["id", "name"]
}, "g"]
]);
let parsed = self.jmap.request(STALWART, calls)?;
let responses = parsed
.get("methodResponses")
.and_then(Value::as_array)
.ok_or_else(|| SeedError::Shape("missing methodResponses".to_owned()))?;
let get = responses
.get(1)
.and_then(|r| r.get(1))
.ok_or_else(|| SeedError::Shape("missing x:Domain/get".to_owned()))?;
let list = get.get("list").and_then(Value::as_array);
if let Some(list) = list {
for entry in list {
if entry.get("name").and_then(Value::as_str) == Some(name)
&& let Some(id) = entry.get("id").and_then(Value::as_str)
{
return Ok(Some(id.to_owned()));
}
}
}
Ok(None)
}
pub fn ensure_domain(&self, name: &str) -> SeedResult<String> {
if let Some(id) = self.domain_id(name)? {
return Ok(id);
}
let response = self.jmap.set_create(
STALWART,
"x:Domain/set",
&self.account_id,
json!({ "v": { "name": name } }),
&[],
)?;
Self::created_id(&response, "v")
}
fn accounts_in_domain(&self, domain_id: &str) -> SeedResult<Vec<String>> {
let response = self.jmap.call(
STALWART,
"x:Account/get",
&self.account_id,
json!({ "properties": ["id", "domainId"] }),
)?;
let mut ids = Vec::new();
if let Some(list) = response.get("list").and_then(Value::as_array) {
for entry in list {
if entry.get("domainId").and_then(Value::as_str) == Some(domain_id)
&& let Some(id) = entry.get("id").and_then(Value::as_str)
{
ids.push(id.to_owned());
}
}
}
Ok(ids)
}
fn destroy(&self, method: &str, ids: &[String]) -> SeedResult<()> {
if ids.is_empty() {
return Ok(());
}
self.jmap.call(
STALWART,
method,
&self.account_id,
json!({ "destroy": ids }),
)?;
Ok(())
}
pub fn invalidate_caches(&self) -> SeedResult<()> {
self.jmap.call(
STALWART,
"x:Action/set",
&self.account_id,
json!({ "create": { "c": { "@type": "InvalidateCaches" } } }),
)?;
Ok(())
}
pub fn teardown_domain(&self, name: &str) -> SeedResult<()> {
let Some(domain_id) = self.domain_id(name)? else {
return Ok(());
};
let accounts = self.accounts_in_domain(&domain_id)?;
self.destroy("x:Account/set", &accounts)?;
for _ in 0..6 {
let response = self.jmap.call(
STALWART,
"x:Domain/set",
&self.account_id,
json!({ "destroy": [domain_id] }),
)?;
if response
.get("destroyed")
.and_then(Value::as_array)
.map(|d| d.iter().any(|v| v.as_str() == Some(domain_id.as_str())))
.unwrap_or(false)
{
return Ok(());
}
let linked = response
.get("notDestroyed")
.and_then(|n| n.get(&domain_id))
.and_then(|e| e.get("linkedObjects"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if linked.is_empty() {
return Err(SeedError::Method {
method: "x:Domain/set".to_owned(),
detail: format!("domain {name} not destroyed: {response}"),
});
}
let mut dkim = Vec::new();
let mut accounts = Vec::new();
for obj in &linked {
let kind = obj
.get("object")
.and_then(Value::as_str)
.unwrap_or_default();
let Some(id) = obj.get("id").and_then(Value::as_str) else {
continue;
};
match kind {
"DkimSignature" => dkim.push(id.to_owned()),
"Account" => accounts.push(id.to_owned()),
_ => {}
}
}
self.destroy("x:Account/set", &accounts)?;
self.destroy("x:DkimSignature/set", &dkim)?;
}
Err(SeedError::Method {
method: "x:Domain/set".to_owned(),
detail: format!("domain {name} still linked after retries"),
})
}
pub fn create_account(
&self,
localpart: &str,
domain_id: &str,
password: &str,
admin_role: bool,
) -> SeedResult<String> {
let role = if admin_role { "Admin" } else { "User" };
let create = json!({
"a": {
"@type": "User",
"name": localpart,
"domainId": domain_id,
"credentials": { "0": { "@type": "Password", "secret": password } },
"encryptionAtRest": { "@type": "Disabled" },
"permissions": { "@type": "Inherit" },
"roles": { "@type": role },
"locale": "en_US"
}
});
let response =
self.jmap
.set_create(STALWART, "x:Account/set", &self.account_id, create, &[])?;
Self::created_id(&response, "a")
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use calcard::icalendar::ICalendar;
use calcard::vcard::VCard;
use flate2::read::GzDecoder;
use mail_parser::mailbox::mbox::MessageIterator;
use serde_json::Value;
use super::error::{SeedError, SeedResult};
fn resources_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
}
pub struct MboxMessage {
pub raw: Vec<u8>,
pub received_at: i64,
}
pub fn load_mbox(limit: usize) -> SeedResult<Vec<MboxMessage>> {
let path = resources_dir().join("mailbox.gz");
let file = std::fs::File::open(&path)?;
let mut decoder = GzDecoder::new(file);
let mut bytes = Vec::new();
decoder.read_to_end(&mut bytes)?;
let reader = BufReader::new(std::io::Cursor::new(bytes));
let mut out = Vec::new();
for item in MessageIterator::new(reader) {
let message = item.map_err(|e| SeedError::Resource(format!("mbox parse: {e}")))?;
let received_at = message.internal_date() as i64;
out.push(MboxMessage {
raw: message.unwrap_contents(),
received_at,
});
if out.len() >= limit {
break;
}
}
if out.is_empty() {
return Err(SeedError::Resource("mbox yielded no messages".to_owned()));
}
Ok(out)
}
fn read_dir_sorted(sub: &str, ext: &str) -> SeedResult<Vec<PathBuf>> {
let dir = resources_dir().join(sub);
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some(ext))
.collect();
paths.sort();
Ok(paths)
}
pub fn load_contacts() -> SeedResult<Vec<Value>> {
let mut cards = Vec::new();
for path in read_dir_sorted("vcards", "vcf")? {
let text = std::fs::read_to_string(&path)?;
let vcard = VCard::parse(&text)
.map_err(|_| SeedError::Resource(format!("vcard parse failed: {path:?}")))?;
let js = vcard.into_jscontact::<String, String>();
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
if has_nonempty_uid(&value) {
cards.push(value);
}
}
Ok(cards)
}
pub fn load_events() -> SeedResult<Vec<Value>> {
let mut events = Vec::new();
for path in read_dir_sorted("icals", "ics")? {
let text = std::fs::read_to_string(&path)?;
let ical = ICalendar::parse(&text)
.map_err(|_| SeedError::Resource(format!("ical parse failed: {path:?}")))?;
let js = ical.into_jscalendar::<String, String>();
let value: Value = serde_json::from_str(&js.to_string_pretty())?;
collect_events(&value, &mut events);
}
Ok(events)
}
fn collect_events(value: &Value, out: &mut Vec<Value>) {
let at_type = value.get("@type").and_then(Value::as_str);
match at_type {
Some("Event") if has_nonempty_uid(value) => {
out.push(value.clone());
}
Some("Group") => {
if let Some(entries) = value.get("entries").and_then(Value::as_array) {
for entry in entries {
collect_events(entry, out);
}
}
}
_ => {}
}
}
fn has_nonempty_uid(value: &Value) -> bool {
value
.get("uid")
.and_then(Value::as_str)
.map(|u| !u.trim().is_empty())
.unwrap_or(false)
}
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#[derive(Debug, thiserror::Error)]
pub enum SeedError {
#[error("http error: {0}")]
Http(String),
#[error("jmap method error ({method}): {detail}")]
Method { method: String, detail: String },
#[error("unexpected jmap response: {0}")]
Shape(String),
#[error("resource error: {0}")]
Resource(String),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub type SeedResult<T> = Result<T, SeedError>;
+244
View File
@@ -0,0 +1,244 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::sync::Arc;
use base64::Engine;
use serde_json::{Value, json};
use ureq::Agent;
use ureq::config::RedirectAuthHeaders;
use ureq::tls::{TlsConfig, TlsProvider};
use super::error::{SeedError, SeedResult};
pub struct Jmap {
agent: Agent,
api_url: String,
upload_url: String,
auth: String,
pub account_id: String,
}
fn build_agent() -> Agent {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let tls = TlsConfig::builder()
.provider(TlsProvider::Rustls)
.unversioned_rustls_crypto_provider(provider)
.disable_verification(true)
.build();
Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.max_redirects(10)
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
.build()
.new_agent()
}
fn basic(user: &str, password: &str) -> String {
let mut raw = String::with_capacity(user.len() + password.len() + 1);
raw.push_str(user);
raw.push(':');
raw.push_str(password);
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
)
}
impl Jmap {
pub fn connect(base: &str, user: &str, password: &str) -> SeedResult<Jmap> {
let agent = build_agent();
let auth = basic(user, password);
let session = discover(&agent, base, &auth)?;
let account_id = primary_account(&session)?;
let api_url = string_field(&session, "apiUrl")?;
let upload_url = string_field(&session, "uploadUrl")?;
Ok(Jmap {
agent,
api_url,
upload_url,
auth,
account_id,
})
}
pub fn request(&self, using: &[&str], method_calls: Value) -> SeedResult<Value> {
let body = json!({ "using": using, "methodCalls": method_calls });
let mut resp = self
.agent
.post(&self.api_url)
.header("Authorization", &self.auth)
.header("Content-Type", "application/json")
.send_json(&body)
.map_err(|e| SeedError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp
.body_mut()
.read_to_string()
.map_err(|e| SeedError::Http(e.to_string()))?;
if status != 200 {
return Err(SeedError::Http(format!("status {status}: {text}")));
}
let parsed: Value = serde_json::from_str(&text)?;
Ok(parsed)
}
pub fn call(
&self,
using: &[&str],
method: &str,
account_id: &str,
mut args: Value,
) -> SeedResult<Value> {
if let Value::Object(map) = &mut args {
map.insert("accountId".to_owned(), Value::String(account_id.to_owned()));
}
let calls = json!([[method, args, "c0"]]);
let parsed = self.request(using, calls)?;
let response = first_response(&parsed, method)?;
Ok(response)
}
pub fn set_create(
&self,
using: &[&str],
method: &str,
account_id: &str,
creates: Value,
extra_args: &[(&str, Value)],
) -> SeedResult<Value> {
let mut args = json!({ "create": creates });
if let Value::Object(map) = &mut args {
for (k, v) in extra_args {
map.insert((*k).to_owned(), v.clone());
}
}
let response = self.call(using, method, account_id, args)?;
if let Some(not_created) = response.get("notCreated")
&& not_created.is_object()
&& !not_created
.as_object()
.map(|m| m.is_empty())
.unwrap_or(true)
{
return Err(SeedError::Method {
method: method.to_owned(),
detail: format!("notCreated: {not_created}"),
});
}
Ok(response)
}
pub fn upload(&self, account_id: &str, content_type: &str, bytes: &[u8]) -> SeedResult<String> {
let url = self.upload_url.replace("{accountId}", account_id);
let mut resp = self
.agent
.post(&url)
.header("Authorization", &self.auth)
.header("Content-Type", content_type)
.send(bytes)
.map_err(|e| SeedError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp
.body_mut()
.read_to_string()
.map_err(|e| SeedError::Http(e.to_string()))?;
if status != 200 {
return Err(SeedError::Http(format!("upload status {status}: {text}")));
}
let parsed: Value = serde_json::from_str(&text)?;
parsed
.get("blobId")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SeedError::Shape(format!("upload response missing blobId: {text}")))
}
}
fn discover(agent: &Agent, base: &str, auth: &str) -> SeedResult<Value> {
let candidates = if base.contains("/.well-known/jmap") {
vec![base.to_owned()]
} else {
vec![
base.to_owned(),
format!("{}/.well-known/jmap", base.trim_end_matches('/')),
]
};
let mut last = String::new();
for url in candidates {
let mut resp = agent
.get(&url)
.header("Authorization", auth)
.call()
.map_err(|e| SeedError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let text = resp
.body_mut()
.read_to_string()
.map_err(|e| SeedError::Http(e.to_string()))?;
if status == 200
&& let Ok(v) = serde_json::from_str::<Value>(&text)
&& v.get("apiUrl").is_some()
&& v.get("accounts")
.and_then(Value::as_object)
.map(|a| !a.is_empty())
.unwrap_or(false)
{
return Ok(v);
}
last = format!("status {status}: {text}");
}
Err(SeedError::Shape(format!(
"no JMAP session object found ({last})"
)))
}
fn string_field(session: &Value, key: &str) -> SeedResult<String> {
session
.get(key)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SeedError::Shape(format!("session missing {key}")))
}
fn primary_account(session: &Value) -> SeedResult<String> {
if let Some(id) = session
.get("primaryAccounts")
.and_then(|p| p.get("urn:ietf:params:jmap:mail"))
.and_then(Value::as_str)
{
return Ok(id.to_owned());
}
session
.get("accounts")
.and_then(Value::as_object)
.and_then(|m| m.keys().next().cloned())
.ok_or_else(|| SeedError::Shape("session has no accounts".to_owned()))
}
fn first_response(parsed: &Value, method: &str) -> SeedResult<Value> {
let responses = parsed
.get("methodResponses")
.and_then(Value::as_array)
.ok_or_else(|| SeedError::Shape("response missing methodResponses".to_owned()))?;
let first = responses
.first()
.and_then(Value::as_array)
.ok_or_else(|| SeedError::Shape("empty methodResponses".to_owned()))?;
let name = first.first().and_then(Value::as_str).unwrap_or_default();
let payload = first
.get(1)
.cloned()
.ok_or_else(|| SeedError::Shape("method response missing payload".to_owned()))?;
if name == "error" {
return Err(SeedError::Method {
method: method.to_owned(),
detail: payload.to_string(),
});
}
Ok(payload)
}
+265
View File
@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
pub mod admin;
pub mod data;
pub mod error;
pub mod jmap;
pub mod seed;
use admin::Admin;
use error::SeedResult;
use jmap::Jmap;
use seed::{FileSpec, Layout, MailboxSpec};
pub const ADMIN_USER: &str = "admin";
pub const ADMIN_PASSWORD: &str = "admin";
pub const DOMAIN: &str = "vandelay.org";
pub const USER_PASSWORD: &str = "VandelayUser#2026";
pub const ADMIN_ACCOUNT_PASSWORD: &str = "VandelayAdmin#2026";
pub const SYNC_IN: [&str; 3] = ["test1", "test2", "test3"];
pub const SYNC_OUT: [&str; 3] = ["test4", "test5", "test6"];
pub const ADMIN_LOCALPART: &str = "vandeladmin";
pub const CUSTOM_IDENTITY_NAME: &str = "Vandelay Test (Custom)";
macro_rules! mailbox {
($k:expr, $n:expr, $p:expr, $r:expr) => {
MailboxSpec {
key: $k,
name: $n,
parent: $p,
role: $r,
}
};
}
macro_rules! node {
($k:expr, $n:expr, $p:expr, $d:expr) => {
FileSpec {
key: $k,
name: $n,
parent: $p,
directory: $d,
}
};
}
static LAYOUT1_MAILBOXES: &[MailboxSpec] = &[
mailbox!("proj", "Projects", None, None),
mailbox!("alpha", "Alpha", Some("proj"), None),
mailbox!("sub", "Subtasks", Some("alpha"), None),
mailbox!("done", "Done", Some("sub"), None),
mailbox!("beta", "Beta", Some("proj"), None),
mailbox!("backlog", "Backlog", Some("beta"), None),
mailbox!("arch", "Archive", None, Some("archive")),
];
static LAYOUT2_MAILBOXES: &[MailboxSpec] = &[
mailbox!("work", "Work", None, None),
mailbox!("clients", "Clients", Some("work"), None),
mailbox!("acme", "Acme Corp", Some("clients"), None),
mailbox!("inv", "Invoices", Some("acme"), None),
mailbox!("globex", "Globex", Some("clients"), None),
mailbox!("internal", "Internal", Some("work"), None),
mailbox!("pers", "Personal", None, None),
mailbox!("rcpt", "Receipts", Some("pers"), None),
mailbox!("y2025", "2025", Some("rcpt"), None),
];
static LAYOUT3_MAILBOXES: &[MailboxSpec] = &[
mailbox!("lists", "Lists", None, None),
mailbox!("news", "Newsletters", Some("lists"), None),
mailbox!("tech", "Tech", Some("news"), None),
mailbox!("notif", "Notifications", Some("lists"), None),
mailbox!("misc", "Misc", None, None),
];
static LAYOUT1_FILES: &[FileSpec] = &[
node!("d_docs", "Documents", None, true),
node!("d_rep", "Reports", Some("d_docs"), true),
node!("d_24", "2024", Some("d_rep"), true),
node!("f_q4", "q4-results.bin", Some("d_24"), false),
node!("d_25", "2025", Some("d_rep"), true),
node!("f_q1", "q1-results.bin", Some("d_25"), false),
node!("d_spec", "Specs", Some("d_docs"), true),
node!("f_ovr", "overview.bin", Some("d_spec"), false),
node!("f_readme", "README.bin", None, false),
];
static LAYOUT2_FILES: &[FileSpec] = &[
node!("m_media", "Media", None, true),
node!("m_photos", "Photos", Some("m_media"), true),
node!("m_23", "2023", Some("m_photos"), true),
node!("f_trip", "trip.bin", Some("m_23"), false),
node!("m_audio", "Audio", Some("m_media"), true),
node!("f_notes", "notes.bin", Some("m_audio"), false),
node!("m_proj", "Projects", None, true),
node!("m_src", "src", Some("m_proj"), true),
node!("f_main", "main.bin", Some("m_src"), false),
];
static LAYOUT3_FILES: &[FileSpec] = &[
node!("b_bak", "Backup", None, true),
node!("b_db", "db", Some("b_bak"), true),
node!("f_snap", "snapshot.bin", Some("b_db"), false),
node!("f_scratch", "scratch.bin", None, false),
];
fn layout_for(localpart: &str) -> Layout {
match localpart {
"test1" => Layout {
mailboxes: LAYOUT1_MAILBOXES,
file_tree: LAYOUT1_FILES,
email_count: 400,
sieve_active: Some(true),
identity: true,
extra_address_book: true,
extra_calendar: true,
},
"test2" => Layout {
mailboxes: LAYOUT2_MAILBOXES,
file_tree: LAYOUT2_FILES,
email_count: 110,
sieve_active: Some(false),
identity: true,
extra_address_book: false,
extra_calendar: true,
},
_ => Layout {
mailboxes: LAYOUT3_MAILBOXES,
file_tree: LAYOUT3_FILES,
email_count: 70,
sieve_active: None,
identity: false,
extra_address_book: false,
extra_calendar: false,
},
}
}
pub struct AccountFixture {
pub localpart: String,
pub email: String,
pub password: String,
pub admin_role: bool,
pub account_id: String,
pub seeded: Option<seed::SeedStats>,
}
pub struct Fixture {
pub base_url: String,
pub domain: String,
pub domain_id: String,
pub admin_login: (String, String),
pub accounts: Vec<AccountFixture>,
}
impl Fixture {
pub fn account(&self, localpart: &str) -> Option<&AccountFixture> {
self.accounts.iter().find(|a| a.localpart == localpart)
}
}
fn email_of(localpart: &str) -> String {
format!("{localpart}@{DOMAIN}")
}
pub fn teardown(base_url: &str) -> SeedResult<()> {
let admin = Admin::connect(base_url, ADMIN_USER, ADMIN_PASSWORD)?;
admin.teardown_domain(DOMAIN)?;
admin.invalidate_caches()
}
pub fn provision(base_url: &str) -> SeedResult<Fixture> {
let admin = Admin::connect(base_url, ADMIN_USER, ADMIN_PASSWORD)?;
admin.teardown_domain(DOMAIN)?;
admin.invalidate_caches()?;
let domain_id = admin.ensure_domain(DOMAIN)?;
admin.invalidate_caches()?;
let mut specs: Vec<(String, String, bool)> = Vec::new();
for lp in SYNC_IN.iter().chain(SYNC_OUT.iter()) {
specs.push(((*lp).to_owned(), USER_PASSWORD.to_owned(), false));
}
specs.push((
ADMIN_LOCALPART.to_owned(),
ADMIN_ACCOUNT_PASSWORD.to_owned(),
true,
));
let mut accounts = Vec::new();
for (localpart, password, admin_role) in &specs {
admin.create_account(localpart, &domain_id, password, *admin_role)?;
}
admin.invalidate_caches()?;
for (localpart, password, admin_role) in &specs {
let email = email_of(localpart);
let user = Jmap::connect(base_url, &email, password)?;
accounts.push(AccountFixture {
localpart: localpart.clone(),
email,
password: password.clone(),
admin_role: *admin_role,
account_id: user.account_id.clone(),
seeded: None,
});
}
let messages = data::load_mbox(usize::MAX)?;
let contacts = data::load_contacts()?;
let events = data::load_events()?;
let mut offset = 0;
for localpart in SYNC_IN {
let index = accounts
.iter()
.position(|a| a.localpart == localpart)
.ok_or_else(|| error::SeedError::Shape(format!("missing account {localpart}")))?;
let layout = layout_for(localpart);
let (email, password, account_id) = {
let a = &accounts[index];
(a.email.clone(), a.password.clone(), a.account_id.clone())
};
let user = Jmap::connect(base_url, &email, &password)?;
let end = (offset + layout.email_count).min(messages.len());
let slice = &messages[offset..end];
offset = end;
let (c0, c1, e0, e1) = match localpart {
"test1" => (0, contacts.len().min(3), 0, events.len().min(4)),
"test2" => (
contacts.len().min(3),
contacts.len().min(6),
events.len().min(4),
events.len().min(8),
),
_ => (
contacts.len().min(6),
contacts.len(),
events.len().min(8),
events.len(),
),
};
let stats = seed::seed_account(
&user,
&account_id,
&email,
&layout,
slice,
&contacts[c0..c1],
&events[e0..e1],
)?;
accounts[index].seeded = Some(stats);
}
Ok(Fixture {
base_url: base_url.to_owned(),
domain: DOMAIN.to_owned(),
domain_id,
admin_login: (ADMIN_USER.to_owned(), ADMIN_PASSWORD.to_owned()),
accounts,
})
}
+414
View File
@@ -0,0 +1,414 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::io::Read;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use super::data::MboxMessage;
use super::error::{SeedError, SeedResult};
use super::jmap::Jmap;
const CORE: &str = "urn:ietf:params:jmap:core";
const MAIL: &str = "urn:ietf:params:jmap:mail";
const SIEVE: &str = "urn:ietf:params:jmap:sieve";
const CONTACTS: &str = "urn:ietf:params:jmap:contacts";
const CALENDARS: &str = "urn:ietf:params:jmap:calendars";
const FILENODE: &str = "urn:ietf:params:jmap:filenode";
pub struct MailboxSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
pub role: Option<&'static str>,
}
pub struct FileSpec {
pub key: &'static str,
pub name: &'static str,
pub parent: Option<&'static str>,
pub directory: bool,
}
pub struct Layout {
pub mailboxes: &'static [MailboxSpec],
pub file_tree: &'static [FileSpec],
pub email_count: usize,
pub sieve_active: Option<bool>,
pub identity: bool,
pub extra_address_book: bool,
pub extra_calendar: bool,
}
#[derive(Debug, Clone)]
pub struct SeedStats {
pub mailboxes_created: usize,
pub emails: usize,
pub contacts: usize,
pub events: usize,
pub sieve_active: Option<bool>,
pub identity: bool,
pub file_nodes: usize,
pub address_books: usize,
pub calendars: usize,
}
pub fn seed_account(
jmap: &Jmap,
account_id: &str,
account_email: &str,
layout: &Layout,
emails: &[MboxMessage],
contacts: &[Value],
events: &[Value],
) -> SeedResult<SeedStats> {
let leaf_mailboxes = create_mailboxes(jmap, account_id, layout)?;
let mut targets = vec![inbox_id(jmap, account_id)?];
targets.extend(leaf_mailboxes);
let imported = import_emails(
jmap,
account_id,
&targets,
&emails[..layout.email_count.min(emails.len())],
)?;
if let Some(active) = layout.sieve_active {
seed_sieve(jmap, account_id, active)?;
}
if layout.identity {
seed_identity(jmap, account_id, account_email)?;
}
if !layout.file_tree.is_empty() {
seed_filenodes(jmap, account_id, layout.file_tree)?;
}
let (n_contacts, n_events) =
seed_contacts_calendars(jmap, account_id, layout, contacts, events)?;
Ok(SeedStats {
mailboxes_created: layout.mailboxes.len(),
emails: imported,
contacts: n_contacts,
events: n_events,
sieve_active: layout.sieve_active,
identity: layout.identity,
file_nodes: layout.file_tree.len(),
address_books: 1 + usize::from(layout.extra_address_book),
calendars: 1 + usize::from(layout.extra_calendar),
})
}
fn create_mailboxes(jmap: &Jmap, account_id: &str, layout: &Layout) -> SeedResult<Vec<String>> {
if layout.mailboxes.is_empty() {
return Ok(Vec::new());
}
let mut create = serde_json::Map::new();
for spec in layout.mailboxes {
let mut obj = serde_json::Map::new();
obj.insert("name".to_owned(), Value::String(spec.name.to_owned()));
if let Some(parent) = spec.parent {
obj.insert("parentId".to_owned(), Value::String(format!("#{parent}")));
}
if let Some(role) = spec.role {
obj.insert("role".to_owned(), Value::String(role.to_owned()));
}
create.insert(spec.key.to_owned(), Value::Object(obj));
}
let response = jmap.set_create(
&[CORE, MAIL],
"Mailbox/set",
account_id,
Value::Object(create),
&[],
)?;
let created = response
.get("created")
.and_then(Value::as_object)
.ok_or_else(|| SeedError::Shape(format!("Mailbox/set created missing: {response}")))?;
let mut leaves = Vec::new();
let parents: std::collections::HashSet<&str> =
layout.mailboxes.iter().filter_map(|m| m.parent).collect();
for spec in layout.mailboxes {
if parents.contains(spec.key) {
continue;
}
if let Some(id) = created
.get(spec.key)
.and_then(|c| c.get("id"))
.and_then(Value::as_str)
{
leaves.push(id.to_owned());
}
}
Ok(leaves)
}
fn inbox_id(jmap: &Jmap, account_id: &str) -> SeedResult<String> {
let response = jmap.call(
&[CORE, MAIL],
"Mailbox/query",
account_id,
json!({ "filter": { "role": "inbox" } }),
)?;
response
.get("ids")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SeedError::Shape("no inbox mailbox".to_owned()))
}
fn import_emails(
jmap: &Jmap,
account_id: &str,
targets: &[String],
messages: &[MboxMessage],
) -> SeedResult<usize> {
if messages.is_empty() {
return Ok(0);
}
let mut imported = 0;
for (batch_index, chunk) in messages.chunks(50).enumerate() {
let mut emails = serde_json::Map::new();
for (i, message) in chunk.iter().enumerate() {
let blob_id = jmap.upload(account_id, "message/rfc822", &message.raw)?;
let mailbox = &targets[(batch_index * 50 + i) % targets.len()];
let received_at = OffsetDateTime::from_unix_timestamp(message.received_at)
.unwrap_or(OffsetDateTime::UNIX_EPOCH)
.format(&Rfc3339)
.map_err(|e| SeedError::Resource(format!("receivedAt format: {e}")))?;
emails.insert(
format!("e{batch_index}_{i}"),
json!({
"blobId": blob_id,
"mailboxIds": { mailbox.as_str(): true },
"keywords": { "$seen": true },
"receivedAt": received_at
}),
);
}
let response = jmap.call(
&[CORE, MAIL],
"Email/import",
account_id,
json!({ "emails": Value::Object(emails) }),
)?;
if let Some(created) = response.get("created").and_then(Value::as_object) {
imported += created.len();
}
if let Some(not_created) = response.get("notCreated").and_then(Value::as_object)
&& !not_created.is_empty()
{
return Err(SeedError::Method {
method: "Email/import".to_owned(),
detail: format!("notCreated: {not_created:?}"),
});
}
}
Ok(imported)
}
fn seed_sieve(jmap: &Jmap, account_id: &str, active: bool) -> SeedResult<()> {
let primary = "require [\"fileinto\"];\nif header :contains \"subject\" \"test\" {\n fileinto \"INBOX\";\n}\n";
let primary_blob = jmap.upload(account_id, "application/octet-stream", primary.as_bytes())?;
let secondary = "require [\"vacation\"];\nvacation :days 1 \"out of office\";\n";
let secondary_blob =
jmap.upload(account_id, "application/octet-stream", secondary.as_bytes())?;
let extra: Vec<(&str, Value)> = if active {
vec![("onSuccessActivateScript", Value::String("#s0".to_owned()))]
} else {
Vec::new()
};
jmap.set_create(
&[CORE, SIEVE],
"SieveScript/set",
account_id,
json!({
"s0": { "name": "vandelay-test-filter", "blobId": primary_blob },
"s1": { "name": "vandelay-vacation", "blobId": secondary_blob }
}),
&extra,
)?;
Ok(())
}
fn seed_identity(jmap: &Jmap, account_id: &str, account_email: &str) -> SeedResult<()> {
jmap.set_create(
&[CORE, MAIL],
"Identity/set",
account_id,
json!({
"i0": {
"name": super::CUSTOM_IDENTITY_NAME,
"email": account_email,
"textSignature": "-- \nVandelay Industries",
"htmlSignature": "<p>Vandelay Industries</p>"
}
}),
&[],
)?;
Ok(())
}
fn random_bytes(len: usize) -> SeedResult<Vec<u8>> {
let mut buf = vec![0u8; len];
let mut file = std::fs::File::open("/dev/urandom")?;
file.read_exact(&mut buf)?;
Ok(buf)
}
fn seed_filenodes(jmap: &Jmap, account_id: &str, tree: &[FileSpec]) -> SeedResult<()> {
let mut create = serde_json::Map::new();
for (i, spec) in tree.iter().enumerate() {
let mut obj = serde_json::Map::new();
obj.insert("name".to_owned(), Value::String(spec.name.to_owned()));
if let Some(parent) = spec.parent {
obj.insert("parentId".to_owned(), Value::String(format!("#{parent}")));
}
if spec.directory {
obj.insert("nodeType".to_owned(), Value::String("directory".to_owned()));
} else {
let bytes = random_bytes(128 + i * 17)?;
let blob_id = jmap.upload(account_id, "application/octet-stream", &bytes)?;
obj.insert("nodeType".to_owned(), Value::String("file".to_owned()));
obj.insert("blobId".to_owned(), Value::String(blob_id));
obj.insert(
"type".to_owned(),
Value::String("application/octet-stream".to_owned()),
);
}
create.insert(spec.key.to_owned(), Value::Object(obj));
}
jmap.set_create(
&[CORE, FILENODE],
"FileNode/set",
account_id,
Value::Object(create),
&[],
)?;
Ok(())
}
fn default_collection(
jmap: &Jmap,
account_id: &str,
method: &str,
using: &[&str],
) -> SeedResult<String> {
let response = jmap.call(
using,
method,
account_id,
json!({ "properties": ["id", "isDefault"] }),
)?;
let list = response
.get("list")
.and_then(Value::as_array)
.ok_or_else(|| SeedError::Shape(format!("{method} list missing")))?;
for entry in list {
if entry.get("isDefault").and_then(Value::as_bool) == Some(true)
&& let Some(id) = entry.get("id").and_then(Value::as_str)
{
return Ok(id.to_owned());
}
}
list.first()
.and_then(|e| e.get("id"))
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| SeedError::Shape(format!("{method} has no entries")))
}
fn seed_contacts_calendars(
jmap: &Jmap,
account_id: &str,
layout: &Layout,
contacts: &[Value],
events: &[Value],
) -> SeedResult<(usize, usize)> {
let default_ab = default_collection(jmap, account_id, "AddressBook/get", &[CORE, CONTACTS])?;
let mut address_books = vec![default_ab];
if layout.extra_address_book {
let response = jmap.set_create(
&[CORE, CONTACTS],
"AddressBook/set",
account_id,
json!({ "ab": { "name": "Vandelay Work Contacts" } }),
&[],
)?;
if let Some(id) = response
.get("created")
.and_then(|c| c.get("ab"))
.and_then(|o| o.get("id"))
.and_then(Value::as_str)
{
address_books.push(id.to_owned());
}
}
let default_cal = default_collection(jmap, account_id, "Calendar/get", &[CORE, CALENDARS])?;
let mut calendars = vec![default_cal];
if layout.extra_calendar {
let response = jmap.set_create(
&[CORE, CALENDARS],
"Calendar/set",
account_id,
json!({ "cal": { "name": "Vandelay Team Calendar", "color": "#3366cc" } }),
&[],
)?;
if let Some(id) = response
.get("created")
.and_then(|c| c.get("cal"))
.and_then(|o| o.get("id"))
.and_then(Value::as_str)
{
calendars.push(id.to_owned());
}
}
let mut n_contacts = 0;
for (i, card) in contacts.iter().enumerate() {
let mut obj = card
.as_object()
.ok_or_else(|| SeedError::Shape("contact not an object".to_owned()))?
.clone();
let book = &address_books[i % address_books.len()];
obj.insert("addressBookIds".to_owned(), json!({ book.as_str(): true }));
jmap.set_create(
&[CORE, CONTACTS],
"ContactCard/set",
account_id,
json!({ "card": Value::Object(obj) }),
&[],
)?;
n_contacts += 1;
}
let mut n_events = 0;
for (i, event) in events.iter().enumerate() {
let mut obj = event
.as_object()
.ok_or_else(|| SeedError::Shape("event not an object".to_owned()))?
.clone();
for immutable in ["method", "prodId"] {
obj.remove(immutable);
}
let calendar = &calendars[i % calendars.len()];
obj.insert("calendarIds".to_owned(), json!({ calendar.as_str(): true }));
jmap.set_create(
&[CORE, CALENDARS],
"CalendarEvent/set",
account_id,
json!({ "event": Value::Object(obj) }),
&[],
)?;
n_events += 1;
}
Ok((n_contacts, n_events))
}
+369
View File
@@ -0,0 +1,369 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
fn caldav_cfg(base_url: &str, domain: &str, localpart: &str) -> DavImportConfig {
DavImportConfig {
kind: DavKindArg::Caldav,
url: base_url.to_owned(),
auth: DavAuth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
},
allow_cleartext: false,
dav_connections: 4,
multiget_batch: 50,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn jmap_basic(localpart: &str, domain: &str) -> Auth {
Auth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
}
}
fn export_cfg(base_url: &str, domain: &str, localpart: &str, account_id: &str) -> ExportConfig {
ExportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
prune: false,
yes: true,
}
}
fn jmap_import_cfg(
base_url: &str,
domain: &str,
localpart: &str,
account_id: &str,
) -> ImportConfig {
ImportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
allow_source_change: false,
}
}
fn event_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn
.prepare("SELECT json_extract(data, '$.uid') FROM calendar_events")
.unwrap();
let rows = stmt
.query_map([], |r| r.get::<_, Option<String>>(0))
.unwrap();
let mut out = HashSet::new();
for r in rows {
if let Some(u) = r.unwrap() {
out.insert(u);
}
}
out
}
#[test]
#[ignore = "requires Docker"]
fn caldav_import_test1_yields_calendars_and_events() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("caldav");
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(!fx.domain_id.is_empty(), "domain id resolved");
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
assert!(!acc.admin_role, "test1 is a regular user, not admin");
let seeded = acc.seeded.as_ref().expect("test1 seed stats");
assert!(
seeded.calendars > 0,
"test1 layout requests an extra calendar"
);
assert!(seeded.events > 0, "test1 layout seeds events");
assert!(
seeded.mailboxes_created > 0,
"test1 has a seeded mailbox tree"
);
assert!(seeded.emails > 0, "test1 has seeded emails");
assert!(seeded.address_books > 0, "test1 has seeded address books");
assert!(seeded.contacts > 0, "test1 has seeded contacts");
assert!(seeded.file_nodes > 0, "test1 has seeded file nodes");
assert!(seeded.identity, "test1 has a custom identity");
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 has an active sieve script"
);
let summary = sync::import_dav::run(
common(&archive),
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let calendars = count(&conn, "calendars") as usize;
let events = count(&conn, "calendar_events") as usize;
assert!(
calendars >= seeded.calendars,
"imported calendars ({calendars}) covers seeded layout ({})",
seeded.calendars,
);
assert!(
events >= seeded.events,
"imported events ({events}) covers seeded layout ({})",
seeded.events,
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn caldav_import_covers_all_three_sync_in_accounts() {
let fx = seeder::provision(base_url()).expect("provision");
for localpart in ["test1", "test2", "test3"] {
let acc = fx.account(localpart).expect(localpart);
let archive = tmp_archive(&format!("caldav-all-{localpart}"));
let summary = sync::import_dav::run(
common(&archive),
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.unwrap_or_else(|e| panic!("import {localpart} failed: {e:?}"));
assert!(
!summary.any_failed(),
"{localpart} import had failures: {summary:?}"
);
let conn = Connection::open(&archive).unwrap();
let calendars = count(&conn, "calendars") as usize;
assert!(
calendars >= 1,
"{localpart} has at least one calendar (Stalwart default)"
);
}
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn caldav_import_with_tiny_multiget_batch_converges_identically() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive_big = tmp_archive("caldav-batch-big");
sync::import_dav::run(
common(&archive_big),
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import big batch");
let conn = Connection::open(&archive_big).unwrap();
let big = count(&conn, "calendar_events");
drop(conn);
let archive_small = tmp_archive("caldav-batch-small");
let mut cfg = caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart);
cfg.multiget_batch = 1;
sync::import_dav::run(common(&archive_small), cfg).expect("import small batch");
let conn = Connection::open(&archive_small).unwrap();
let small = count(&conn, "calendar_events");
assert_eq!(big, small, "batch size must not affect imported count");
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn caldav_round_trip_via_jmap_export_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let tgt = fx.account("test4").expect("test4");
let archive = tmp_archive("caldav-rt");
let imp = sync::import_dav::run(
common(&archive),
caldav_cfg(&fx.base_url, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!imp.any_failed(), "dav import had failures: {imp:?}");
let conn = Connection::open(&archive).unwrap();
let local_events = count(&conn, "calendar_events");
assert!(local_events > 0, "DAV import landed events in the archive");
drop(conn);
let e1 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 1");
assert!(!e1.any_failed(), "export 1 had failures: {e1:?}");
let created_events_1: u64 = e1
.per_type
.iter()
.filter(|(t, _)| *t == "CalendarEvent")
.map(|(_, c)| c.created)
.sum();
assert!(
created_events_1 > 0,
"first export created CalendarEvent rows on the target: {e1:?}"
);
let e2 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 2");
let created_2: u64 = e2.per_type.iter().map(|(_, c)| c.created).sum();
assert_eq!(
created_2, 0,
"second export must be convergent (no new creates): {e2:?}"
);
let skipped_2: u64 = e2.per_type.iter().map(|(_, c)| c.skipped).sum();
assert!(
skipped_2 > 0,
"second export matched existing target objects: {e2:?}"
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn caldav_cross_protocol_parity_with_jmap_matches_uid_set() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let archive_jmap = tmp_archive("caldav-parity-jmap");
let s_jmap = sync::import_jmap::run(
common(&archive_jmap),
jmap_import_cfg(&fx.base_url, &fx.domain, &src.localpart, &src.account_id),
)
.expect("jmap import");
assert!(!s_jmap.any_failed(), "jmap import failures: {s_jmap:?}");
let archive_dav = tmp_archive("caldav-parity-dav");
let s_dav = sync::import_dav::run(
common(&archive_dav),
caldav_cfg(&fx.base_url, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!s_dav.any_failed(), "dav import failures: {s_dav:?}");
let conn_jmap = Connection::open(&archive_jmap).unwrap();
let uids_jmap = event_uids(&conn_jmap);
drop(conn_jmap);
let conn_dav = Connection::open(&archive_dav).unwrap();
let uids_dav = event_uids(&conn_dav);
drop(conn_dav);
assert!(
!uids_jmap.is_empty(),
"jmap import should yield at least one event uid"
);
assert!(
!uids_dav.is_empty(),
"dav import should yield at least one event uid"
);
let only_in_jmap: HashSet<_> = uids_jmap.difference(&uids_dav).collect();
let only_in_dav: HashSet<_> = uids_dav.difference(&uids_jmap).collect();
assert!(
only_in_jmap.is_empty() && only_in_dav.is_empty(),
"uid sets must match across protocols: only-in-jmap={only_in_jmap:?} only-in-dav={only_in_dav:?}"
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn caldav_import_is_idempotent_on_second_run() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
assert!(!acc.admin_role);
let archive = tmp_archive("caldav-idem");
sync::import_dav::run(
common(&archive),
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import 1");
let conn1 = Connection::open(&archive).unwrap();
let n1 = count(&conn1, "calendar_events");
drop(conn1);
sync::import_dav::run(
common(&archive),
caldav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import 2");
let conn2 = Connection::open(&archive).unwrap();
let n2 = count(&conn2, "calendar_events");
assert_eq!(n1, n2, "second import does not duplicate events");
seeder::teardown(base_url()).expect("teardown");
}
+363
View File
@@ -0,0 +1,363 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
fn carddav_cfg(base_url: &str, domain: &str, localpart: &str) -> DavImportConfig {
DavImportConfig {
kind: DavKindArg::Carddav,
url: base_url.to_owned(),
auth: DavAuth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
},
allow_cleartext: false,
dav_connections: 4,
multiget_batch: 50,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn jmap_basic(localpart: &str, domain: &str) -> Auth {
Auth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
}
}
fn export_cfg(base_url: &str, domain: &str, localpart: &str, account_id: &str) -> ExportConfig {
ExportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
prune: false,
yes: true,
}
}
fn jmap_import_cfg(
base_url: &str,
domain: &str,
localpart: &str,
account_id: &str,
) -> ImportConfig {
ImportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
allow_source_change: false,
}
}
fn card_uids(conn: &Connection) -> HashSet<String> {
let mut stmt = conn.prepare("SELECT uid FROM contact_cards").unwrap();
let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
let mut out = HashSet::new();
for r in rows {
out.insert(r.unwrap());
}
out
}
#[test]
#[ignore = "requires Docker"]
fn carddav_import_test1_yields_address_books_and_cards() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("carddav");
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(!fx.domain_id.is_empty(), "domain id resolved");
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
assert!(!acc.admin_role, "test1 is a regular user, not admin");
let seeded = acc.seeded.as_ref().expect("test1 seed stats");
assert!(
seeded.address_books > 0,
"test1 layout requests an extra address book"
);
assert!(seeded.contacts > 0, "test1 layout seeds contacts");
assert!(
seeded.mailboxes_created > 0,
"test1 has a seeded mailbox tree"
);
assert!(seeded.emails > 0, "test1 has seeded emails");
assert!(seeded.calendars > 0, "test1 has seeded calendars");
assert!(seeded.events > 0, "test1 has seeded events");
assert!(seeded.file_nodes > 0, "test1 has seeded file nodes");
assert!(seeded.identity, "test1 has a custom identity");
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 has an active sieve script"
);
let summary = sync::import_dav::run(
common(&archive),
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let books = count(&conn, "address_books") as usize;
let cards = count(&conn, "contact_cards") as usize;
assert!(
books >= seeded.address_books,
"imported address books ({books}) covers seeded layout ({})",
seeded.address_books,
);
assert!(
cards >= seeded.contacts,
"imported cards ({cards}) covers seeded layout ({})",
seeded.contacts,
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn carddav_import_covers_all_three_sync_in_accounts() {
let fx = seeder::provision(base_url()).expect("provision");
for localpart in ["test1", "test2", "test3"] {
let acc = fx.account(localpart).expect(localpart);
let archive = tmp_archive(&format!("carddav-all-{localpart}"));
let summary = sync::import_dav::run(
common(&archive),
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.unwrap_or_else(|e| panic!("import {localpart} failed: {e:?}"));
assert!(
!summary.any_failed(),
"{localpart} import had failures: {summary:?}"
);
let conn = Connection::open(&archive).unwrap();
let books = count(&conn, "address_books") as usize;
assert!(
books >= 1,
"{localpart} has at least one address book (Stalwart default)"
);
}
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn carddav_import_with_tiny_multiget_batch_converges_identically() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive_big = tmp_archive("carddav-batch-big");
sync::import_dav::run(
common(&archive_big),
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import big batch");
let conn = Connection::open(&archive_big).unwrap();
let big = count(&conn, "contact_cards");
drop(conn);
let archive_small = tmp_archive("carddav-batch-small");
let mut cfg = carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart);
cfg.multiget_batch = 1;
sync::import_dav::run(common(&archive_small), cfg).expect("import small batch");
let conn = Connection::open(&archive_small).unwrap();
let small = count(&conn, "contact_cards");
assert_eq!(big, small, "batch size must not affect imported count");
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn carddav_round_trip_via_jmap_export_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let tgt = fx.account("test4").expect("test4");
let archive = tmp_archive("carddav-rt");
let imp = sync::import_dav::run(
common(&archive),
carddav_cfg(&fx.base_url, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!imp.any_failed(), "dav import had failures: {imp:?}");
let conn = Connection::open(&archive).unwrap();
let local_cards = count(&conn, "contact_cards");
assert!(local_cards > 0, "DAV import landed contact cards");
drop(conn);
let e1 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 1");
assert!(!e1.any_failed(), "export 1 had failures: {e1:?}");
let created_cards_1: u64 = e1
.per_type
.iter()
.filter(|(t, _)| *t == "ContactCard")
.map(|(_, c)| c.created)
.sum();
assert!(
created_cards_1 > 0,
"first export created ContactCard rows on the target: {e1:?}"
);
let e2 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 2");
let created_2: u64 = e2.per_type.iter().map(|(_, c)| c.created).sum();
assert_eq!(
created_2, 0,
"second export must be convergent (no new creates): {e2:?}"
);
let skipped_2: u64 = e2.per_type.iter().map(|(_, c)| c.skipped).sum();
assert!(
skipped_2 > 0,
"second export matched existing target objects: {e2:?}"
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn carddav_cross_protocol_parity_with_jmap_matches_uid_set() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let archive_jmap = tmp_archive("carddav-parity-jmap");
let s_jmap = sync::import_jmap::run(
common(&archive_jmap),
jmap_import_cfg(&fx.base_url, &fx.domain, &src.localpart, &src.account_id),
)
.expect("jmap import");
assert!(!s_jmap.any_failed(), "jmap import failures: {s_jmap:?}");
let archive_dav = tmp_archive("carddav-parity-dav");
let s_dav = sync::import_dav::run(
common(&archive_dav),
carddav_cfg(&fx.base_url, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!s_dav.any_failed(), "dav import failures: {s_dav:?}");
let conn_jmap = Connection::open(&archive_jmap).unwrap();
let uids_jmap = card_uids(&conn_jmap);
drop(conn_jmap);
let conn_dav = Connection::open(&archive_dav).unwrap();
let uids_dav = card_uids(&conn_dav);
drop(conn_dav);
assert!(
!uids_jmap.is_empty(),
"jmap import should yield at least one card uid"
);
assert!(
!uids_dav.is_empty(),
"dav import should yield at least one card uid"
);
let only_in_jmap: HashSet<_> = uids_jmap.difference(&uids_dav).collect();
let only_in_dav: HashSet<_> = uids_dav.difference(&uids_jmap).collect();
assert!(
only_in_jmap.is_empty() && only_in_dav.is_empty(),
"uid sets must match across protocols: only-in-jmap={only_in_jmap:?} only-in-dav={only_in_dav:?}"
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn carddav_import_is_idempotent_on_second_run() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
assert!(!acc.admin_role);
let archive = tmp_archive("carddav-idem");
sync::import_dav::run(
common(&archive),
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import 1");
let conn1 = Connection::open(&archive).unwrap();
let n1 = count(&conn1, "contact_cards");
drop(conn1);
sync::import_dav::run(
common(&archive),
carddav_cfg(&fx.base_url, &fx.domain, &acc.localpart),
)
.expect("import 2");
let conn2 = Connection::open(&archive).unwrap();
let n2 = count(&conn2, "contact_cards");
assert_eq!(n1, n2, "second import does not duplicate cards");
seeder::teardown(base_url()).expect("teardown");
}
+395
View File
@@ -0,0 +1,395 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
fn imaps_url() -> String {
let s = shared_stalwart();
format!("imaps://{}:{}", s.host, s.imaps_port)
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_imap::{ImapAuth, ImapImportConfig};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-imap-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 3,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
fn imap_basic_config(localpart: &str) -> ImapImportConfig {
ImapImportConfig {
url: imaps_url(),
auth: ImapAuth::Basic {
user: format!("{localpart}@{}", seeder::DOMAIN),
password: seeder::USER_PASSWORD.to_owned(),
},
allow_cleartext: false,
compress: false,
include: Vec::new(),
exclude: Vec::new(),
exclude_special: Vec::new(),
folder: Vec::new(),
subscribed_only: false,
automap: true,
include_deleted: false,
fetch_batch: 256,
imap_connections: 4,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn blob_hashes(conn: &Connection) -> HashSet<Vec<u8>> {
let mut stmt = conn.prepare("SELECT hash FROM blobs").unwrap();
let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0)).unwrap();
rows.filter_map(|r| r.ok()).collect()
}
#[test]
#[ignore = "requires Docker"]
fn imap_import_test1_lands_mailbox_tree_and_emails() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("test1");
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(!fx.domain_id.is_empty());
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
assert!(!acc.admin_role, "test1 is a regular user, not admin");
if let Some(seeded) = &acc.seeded {
assert!(seeded.emails > 0, "test1 was seeded with emails");
assert!(seeded.contacts > 0, "test1 should be seeded with contacts");
assert!(seeded.events > 0, "test1 should be seeded with events");
assert!(
seeded.file_nodes > 0,
"test1 should be seeded with file nodes"
);
assert!(
seeded.address_books > 0,
"test1 layout requests an extra address book"
);
assert!(
seeded.calendars > 0,
"test1 layout requests an extra calendar"
);
assert!(seeded.identity, "test1 layout requests a custom identity");
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 layout activates a sieve script"
);
}
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let mailbox_count = count(&conn, "mailboxes") as usize;
let email_count = count(&conn, "emails") as usize;
let blob_count = count(&conn, "blobs") as usize;
let seeded = acc.seeded.as_ref().expect("seed stats");
assert!(
mailbox_count >= seeded.mailboxes_created,
"mailboxes {mailbox_count} should cover the seeded tree ({}) plus Stalwart defaults",
seeded.mailboxes_created
);
assert!(email_count > 0, "at least some emails imported");
assert!(blob_count > 0, "blobs interned");
let inbox_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(inbox_role, Some("inbox".to_owned()));
let archive_role_exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM mailboxes WHERE role = 'archive')",
[],
|r| r.get(0),
)
.unwrap();
assert!(
archive_role_exists,
"seeded archive mailbox surfaces with archive role"
);
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_second_run_is_convergent() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test2").expect("test2");
let archive = tmp_archive("converge");
sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
.expect("first import");
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
.expect("second import");
for (name, counts) in &summary.per_type {
assert_eq!(
counts.created, 0,
"{name}: convergent run should create nothing"
);
assert_eq!(
counts.deleted, 0,
"{name}: convergent run should delete nothing"
);
}
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_and_jmap_imports_share_blob_set() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let imap_archive = tmp_archive("parity-imap");
let jmap_archive = tmp_archive("parity-jmap");
sync::import_imap::run(common(&imap_archive), imap_basic_config(&acc.localpart))
.expect("imap import");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: None,
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let imap_conn = Connection::open(&imap_archive).unwrap();
let jmap_conn = Connection::open(&jmap_archive).unwrap();
let imap_blobs = blob_hashes(&imap_conn);
let jmap_blobs = blob_hashes(&jmap_conn);
let missing: Vec<&Vec<u8>> = imap_blobs.difference(&jmap_blobs).collect();
assert!(
missing.is_empty(),
"IMAP archive has {} blobs the JMAP archive lacks; bytes differ between protocols",
missing.len()
);
let imap_emails = count(&imap_conn, "emails");
let jmap_emails = count(&jmap_conn, "emails");
assert!(
imap_emails >= jmap_emails,
"IMAP saw fewer emails ({imap_emails}) than JMAP ({jmap_emails})"
);
let _ = std::fs::remove_file(&imap_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_import_test3_lands_mailbox_tree_and_emails() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test3").expect("test3");
let archive = tmp_archive("test3");
let summary = sync::import_imap::run(common(&archive), imap_basic_config(&acc.localpart))
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(count(&conn, "mailboxes") > 0);
assert!(count(&conn, "blobs") >= 0);
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_multi_chunk_metadata_fetch_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("multi_chunk");
let mut cfg = imap_basic_config(&acc.localpart);
cfg.fetch_batch = 5;
let summary = sync::import_imap::run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let mut cfg2 = imap_basic_config(&acc.localpart);
cfg2.fetch_batch = 5;
let second = sync::import_imap::run(common(&archive), cfg2).expect("second");
for (name, c) in &second.per_type {
assert_eq!(c.created, 0, "{name}: convergent run creates nothing");
assert_eq!(c.deleted, 0, "{name}: convergent run deletes nothing");
}
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_dry_run_reports_new_diff_without_writing() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test2").expect("test2");
let archive = tmp_archive("dryrun");
let mut common_cfg = common(&archive);
common_cfg.dry_run = true;
let summary =
sync::import_imap::run(common_cfg, imap_basic_config(&acc.localpart)).expect("dry-run");
let mailbox = summary
.per_type
.iter()
.find(|(k, _)| *k == "mailbox")
.expect("mailbox counts");
assert!(mailbox.1.created > 0, "dry-run reports new mailboxes");
let conn = Connection::open(&archive).unwrap();
assert_eq!(
count(&conn, "mailboxes"),
0,
"dry-run must not persist mailboxes"
);
assert_eq!(count(&conn, "emails"), 0, "dry-run must not persist emails");
drop(conn);
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn imap_imported_archive_exports_via_jmap() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let dst = fx.account("test4").expect("test4");
let archive = tmp_archive("roundtrip");
sync::import_imap::run(common(&archive), imap_basic_config(&src.localpart))
.expect("imap import");
let export_common = CommonConfig {
archive: archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
let export_cfg = ExportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: dst.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(dst.account_id.clone()),
},
objects: None,
prune: false,
yes: false,
};
let first = sync::export::run(export_common, export_cfg).expect("first export");
assert!(!first.any_failed(), "first export had failures: {first:?}");
let export_common2 = CommonConfig {
archive: archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
let export_cfg2 = ExportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: dst.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(dst.account_id.clone()),
},
objects: None,
prune: false,
yes: false,
};
let second = sync::export::run(export_common2, export_cfg2).expect("second export");
for (name, counts) in &second.per_type {
assert_eq!(
counts.created, 0,
"{name}: round-trip second export should create nothing"
);
}
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
+1150
View File
File diff suppressed because it is too large Load Diff
+651
View File
@@ -0,0 +1,651 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#![allow(dead_code, clippy::needless_borrows_for_generic_args)]
mod integration;
mod seeder;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_maildir::{MaildirImportConfig, run as run_maildir};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-sync-maildir-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 1,
allow_invalid_certs: true,
logger: Logger::from_flags(true, 0),
}
}
fn base_cfg(root: &Path) -> MaildirImportConfig {
MaildirImportConfig {
maildir: root.to_path_buf(),
include: Vec::new(),
exclude: Vec::new(),
folder: Vec::new(),
automap: true,
include_deleted: false,
allow_source_change: false,
}
}
fn ensure_maildir(root: &Path) {
for sub in ["cur", "new", "tmp"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
}
fn ensure_subfolder(root: &Path, dotted: &str) -> PathBuf {
let sub = root.join(dotted);
for s in ["cur", "new", "tmp"] {
fs::create_dir_all(sub.join(s)).unwrap();
}
sub
}
fn write(folder: &Path, sub: &str, filename: &str, body: &[u8]) -> PathBuf {
let dir = folder.join(sub);
fs::create_dir_all(&dir).unwrap();
let p = dir.join(filename);
fs::write(&p, body).unwrap();
p
}
fn msg(id: &str, subject: &str, body: &str) -> Vec<u8> {
format!(
"From: [email protected]\r\nTo: [email protected]\r\n\
Subject: {subject}\r\nMessage-ID: <{id}@example.com>\r\n\
Date: Mon, 12 May 2025 10:00:00 +0000\r\n\r\n{body}\r\n"
)
.into_bytes()
}
fn build_fixture(root: &Path) {
ensure_maildir(root);
write(
root,
"cur",
"100.M001.host:2,S",
&msg("conv-i1", "i1", "body 1"),
);
write(
root,
"cur",
"101.M002.host:2,",
&msg("conv-i2", "i2", "body 2"),
);
write(
root,
"new",
"102.M003.host",
&msg("conv-i3", "i3", "body 3"),
);
let sent = ensure_subfolder(root, ".Sent");
write(
&sent,
"cur",
"200.M001.host:2,S",
&msg("conv-s1", "sent-1", "sent body"),
);
ensure_subfolder(root, ".Archive");
let arch25 = ensure_subfolder(root, ".Archive.2025");
write(
&arch25,
"cur",
"300.M001.host:2,S",
&msg("conv-a1", "arch-1", "arch body 1"),
);
write(
&arch25,
"cur",
"301.M002.host:2,SF",
&msg("conv-a2", "arch-2", "arch body 2"),
);
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn snapshot_hashes_by_folder(conn: &Connection) -> BTreeMap<String, BTreeMap<String, String>> {
let mut stmt = conn
.prepare(
"SELECT m.folder, b.hash, e.keywords
FROM emails e
JOIN blobs b ON b.id = e.blob_id
JOIN sync_id_maildir m ON m.local_id = e.id AND m.type_name = 'email'
ORDER BY m.folder, m.unique_id",
)
.unwrap();
let rows = stmt
.query_map([], |r| {
let folder: String = r.get(0)?;
let hash: Vec<u8> = r.get(1)?;
let kw: String = r.get(2)?;
Ok((folder, hex(&hash), kw))
})
.unwrap();
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for r in rows {
let (folder, hash, kw) = r.unwrap();
out.entry(folder).or_default().insert(hash, kw);
}
out
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
#[test]
fn second_run_with_no_changes_creates_nothing_and_deletes_nothing() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("idempotent");
let first = run_maildir(common(&archive), base_cfg(td.path())).expect("first");
assert!(!first.any_failed());
let (_, mbox_first) = &first.per_type[0];
let (_, email_first) = &first.per_type[1];
assert!(mbox_first.created >= 4);
assert_eq!(email_first.created, 6);
let second = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_second) = &second.per_type[0];
let (_, email_second) = &second.per_type[1];
assert_eq!(mbox_second.created, 0);
assert_eq!(mbox_second.deleted, 0);
assert_eq!(email_second.created, 0);
assert_eq!(email_second.deleted, 0);
assert_eq!(email_second.fetched, 0, "no body re-read on convergent run");
assert!(email_second.skipped >= 6);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn flag_change_between_runs_updates_keywords_only() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let path = write(
td.path(),
"cur",
"1.M0.host:2,",
&msg("flag-1", "subj", "body"),
);
let archive = tmp_archive("flag-update");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
let email_id: i64 = conn
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
let blob_id: i64 = conn
.query_row(
"SELECT blob_id FROM emails WHERE id = ?1",
[email_id],
|r| r.get(0),
)
.unwrap();
let kw: String = conn
.query_row(
"SELECT keywords FROM emails WHERE id = ?1",
[email_id],
|r| r.get(0),
)
.unwrap();
assert!(!kw.contains("$seen"));
drop(conn);
fs::rename(&path, path.with_file_name("1.M0.host:2,S")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.created, 0);
assert_eq!(email_counts.deleted, 0);
assert_eq!(email_counts.fetched, 1, "exactly one flag-only update");
let conn = Connection::open(&archive).unwrap();
let new_id: i64 = conn
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
let new_blob_id: i64 = conn
.query_row("SELECT blob_id FROM emails WHERE id = ?1", [new_id], |r| {
r.get(0)
})
.unwrap();
let new_kw: String = conn
.query_row("SELECT keywords FROM emails WHERE id = ?1", [new_id], |r| {
r.get(0)
})
.unwrap();
assert_eq!(new_id, email_id, "row identity preserved");
assert_eq!(new_blob_id, blob_id, "blob is unchanged");
assert!(new_kw.contains("$seen"));
let _ = std::fs::remove_file(&archive);
}
#[test]
fn delete_on_disk_removes_email_row_and_orphan_blob() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let p1 = write(
td.path(),
"cur",
"1.M0.host:2,",
&msg("del-1", "subj-1", "body-1"),
);
write(
td.path(),
"cur",
"2.M0.host:2,",
&msg("del-2", "subj-2", "body-2"),
);
let archive = tmp_archive("delete");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 2);
assert_eq!(count(&conn, "blobs"), 2);
drop(conn);
fs::remove_file(&p1).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.deleted, 1);
assert_eq!(email_counts.created, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 1);
assert_eq!(count(&conn, "blobs"), 1);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn add_on_disk_inserts_one_new_email_only() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
write(td.path(), "cur", "1.M0.host:2,", &msg("a-1", "1", "body"));
let archive = tmp_archive("add");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
write(td.path(), "cur", "2.M0.host:2,", &msg("a-2", "2", "body2"));
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.created, 1);
assert_eq!(email_counts.deleted, 0);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn folder_delete_vanishes_emails_leaf_first() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
ensure_subfolder(td.path(), ".Archive");
let arch25 = ensure_subfolder(td.path(), ".Archive.2025");
write(&arch25, "cur", "1.M0.host:2,", &msg("d-1", "del-1", "del"));
let archive = tmp_archive("folder-delete");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
.get::<_, i64>(0))
.is_ok()
);
drop(conn);
fs::remove_dir_all(td.path().join(".Archive.2025")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_counts) = &summary.per_type[0];
let (_, email_counts) = &summary.per_type[1];
assert_eq!(mbox_counts.deleted, 1);
assert_eq!(email_counts.deleted, 1);
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
.get::<_, i64>(0))
.is_err()
);
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Archive'", [], |r| {
r.get::<_, i64>(0)
})
.is_ok()
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn folder_rename_vanishes_old_and_inserts_new() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let old = ensure_subfolder(td.path(), ".Old");
write(
&old,
"cur",
"1.M0.host:2,",
&msg("r-1", "to-rename", "body"),
);
let archive = tmp_archive("folder-rename");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
fs::rename(td.path().join(".Old"), td.path().join(".New")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_counts) = &summary.per_type[0];
let (_, email_counts) = &summary.per_type[1];
assert_eq!(mbox_counts.deleted, 1);
assert_eq!(mbox_counts.created, 1);
assert_eq!(email_counts.deleted, 1);
assert_eq!(email_counts.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "blobs"), 1, "blob retained via dedup");
let _ = std::fs::remove_file(&archive);
}
fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn relative_path_resolves_to_same_canonical_source() {
let _g = cwd_lock();
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("canonical");
run_maildir(common(&archive), base_cfg(td.path())).expect("first import");
let parent = td.path().parent().unwrap();
let dir_name = td.path().file_name().unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(parent).unwrap();
let mut second = base_cfg(Path::new(dir_name));
second.allow_source_change = false;
let result = run_maildir(common(&archive), second);
std::env::set_current_dir(cwd).unwrap();
assert!(result.is_ok(), "{result:?}");
let _ = std::fs::remove_file(&archive);
}
#[test]
fn dry_run_then_real_run_produces_same_counts_for_new() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("dry-then-real");
let mut dry = common(&archive);
dry.dry_run = true;
let dry_summary = run_maildir(dry, base_cfg(td.path())).expect("dry");
let (_, dry_email) = &dry_summary.per_type[1];
let new_via_dry = dry_email.created;
let real_summary = run_maildir(common(&archive), base_cfg(td.path())).expect("real run");
let (_, real_email) = &real_summary.per_type[1];
assert_eq!(new_via_dry, real_email.created);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn trashed_flag_added_between_runs_deletes_present_row() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let path = write(
td.path(),
"cur",
"trash.M0.host:2,",
&msg("t-1", "subj", "body"),
);
let archive = tmp_archive("trash-on-present");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 1);
drop(conn);
fs::rename(&path, path.with_file_name("trash.M0.host:2,T")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.deleted, 1, "T flag drops the present row");
assert_eq!(email_counts.fetched, 0);
assert_eq!(email_counts.created, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
let _ = std::fs::remove_file(&archive);
}
#[cfg(unix)]
#[test]
fn symlinked_subfolder_is_followed_and_appears_as_its_own_folder() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let real = ensure_subfolder(td.path(), ".Real");
write(
&real,
"cur",
"1.M0.host:2,S",
&msg("sym-1", "shared", "shared body"),
);
std::os::unix::fs::symlink(&real, td.path().join(".Shared")).unwrap();
let archive = tmp_archive("symlink");
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Real'", [], |r| r
.get::<_, i64>(0))
.is_ok(),
"Real folder discovered"
);
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Shared'", [], |r| r
.get::<_, i64>(
0
))
.is_ok(),
"Shared (symlink) folder discovered"
);
assert_eq!(count(&conn, "blobs"), 1);
assert_eq!(count(&conn, "emails"), 2);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn convergence_is_stable_across_three_runs() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("triple-converge");
run_maildir(common(&archive), base_cfg(td.path())).expect("r1");
let snap1 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
run_maildir(common(&archive), base_cfg(td.path())).expect("r2");
let snap2 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
run_maildir(common(&archive), base_cfg(td.path())).expect("r3");
let snap3 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
assert_eq!(snap1, snap2);
assert_eq!(snap2, snap3);
let _ = std::fs::remove_file(&archive);
}
fn lay_seeder_mbox_as_maildir(root: &Path, messages: &[seeder::data::MboxMessage]) {
ensure_maildir(root);
for (i, m) in messages.iter().enumerate() {
let filename = format!("{seq}.M0.parity-host:2,S", seq = 1_700_000_000 + i as u64);
write(root, "cur", &filename, &m.raw);
}
}
#[test]
#[ignore = "requires Docker"]
fn maildir_and_jmap_imports_share_blob_bytes() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let corpus = seeder::data::load_mbox(50).expect("mbox corpus");
let td = tempfile::TempDir::new().unwrap();
lay_seeder_mbox_as_maildir(td.path(), &corpus);
let md_archive = tmp_archive("parity-maildir");
let jmap_archive = tmp_archive("parity-jmap");
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: Some(vec![
vandelay::types::ObjectType::Mailbox,
vandelay::types::ObjectType::Email,
]),
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let md_hashes = blob_hashes(&Connection::open(&md_archive).unwrap());
let jmap_hashes = blob_hashes(&Connection::open(&jmap_archive).unwrap());
assert!(
!md_hashes.is_empty(),
"maildir archive must have at least one blob"
);
let missing: Vec<&String> = md_hashes
.iter()
.filter(|h| !jmap_hashes.contains(*h))
.collect();
assert!(
missing.is_empty(),
"{} blobs in maildir archive missing from JMAP archive: {:?}",
missing.len(),
missing.iter().take(3).collect::<Vec<_>>()
);
let _ = std::fs::remove_file(&md_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}
fn blob_hashes(conn: &Connection) -> std::collections::HashSet<String> {
let mut stmt = conn.prepare("SELECT hash FROM blobs").unwrap();
stmt.query_map([], |r| Ok(hex(&r.get::<_, Vec<u8>>(0)?)))
.unwrap()
.filter_map(Result::ok)
.collect()
}
#[test]
#[ignore = "requires Docker"]
fn maildir_message_count_matches_jmap_for_same_corpus() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let corpus = seeder::data::load_mbox(30).expect("mbox corpus");
let td = tempfile::TempDir::new().unwrap();
lay_seeder_mbox_as_maildir(td.path(), &corpus);
let md_archive = tmp_archive("count-maildir");
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
let md_conn = Connection::open(&md_archive).unwrap();
let md_count = count(&md_conn, "emails");
assert_eq!(
md_count as usize,
corpus.len(),
"all corpus messages imported"
);
let jmap_archive = tmp_archive("count-jmap");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: Some(vec![
vandelay::types::ObjectType::Mailbox,
vandelay::types::ObjectType::Email,
]),
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let _ = std::fs::remove_file(&md_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}
+450
View File
@@ -0,0 +1,450 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
fn sieve_url() -> String {
let s = shared_stalwart();
format!("sieve://{}:{}", s.host, s.sieve_port)
}
fn sieves_url() -> String {
let s = shared_stalwart();
format!("sieves://{}:{}", s.host, s.sieve_port)
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_managesieve::{ManageSieveAuth, ManageSieveImportConfig};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-managesieve-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 3,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
fn basic_config(localpart: &str) -> ManageSieveImportConfig {
ManageSieveImportConfig {
url: sieve_url(),
auth: ManageSieveAuth::Basic {
user: format!("{localpart}@{}", seeder::DOMAIN),
password: seeder::USER_PASSWORD.to_owned(),
},
allow_cleartext: false,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_import_test1_lands_active_sieve_script() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("test1");
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(!fx.domain_id.is_empty(), "domain id resolved");
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
assert!(!fx.base_url.is_empty(), "base url set");
assert!(!acc.account_id.is_empty(), "JMAP account id resolved");
assert!(!acc.email.is_empty());
assert!(!acc.admin_role, "test1 is a regular user");
if let Some(seeded) = &acc.seeded {
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 layout activates a sieve"
);
assert!(seeded.emails > 0);
assert!(seeded.contacts > 0);
assert!(seeded.events > 0);
assert!(seeded.file_nodes > 0);
assert!(seeded.address_books > 0);
assert!(seeded.calendars > 0);
assert!(seeded.identity);
assert!(seeded.mailboxes_created > 0);
}
assert_eq!(acc.password, seeder::USER_PASSWORD);
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let scripts = count(&conn, "sieve_scripts");
assert!(scripts >= 2, "test1 layout seeds two scripts");
let active: i64 = conn
.query_row(
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active, 1, "exactly one of test1's two scripts is active");
let blob_count = count(&conn, "blobs");
assert!(blob_count >= 1, "blob interned for the script body");
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_import_test2_lands_inactive_sieve_script() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test2").expect("test2");
let archive = tmp_archive("test2");
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let scripts = count(&conn, "sieve_scripts");
assert!(scripts >= 1, "expected at least one sieve script for test2");
let active: i64 = conn
.query_row(
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active, 0, "test2's seeded script should be inactive");
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_import_test3_has_no_scripts() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test3").expect("test3");
let archive = tmp_archive("test3");
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "sieve_scripts"), 0);
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_second_run_is_convergent() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("converge");
let first = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
.expect("first import");
let (_, c1) = &first.per_type[0];
assert!(
c1.created >= 2,
"test1 seeds two scripts, both new on first run"
);
let summary = sync::import_managesieve::run(common(&archive), basic_config(&acc.localpart))
.expect("second import");
let (_, c) = &summary.per_type[0];
assert_eq!(c.created, 0, "convergent run should create nothing");
assert_eq!(c.deleted, 0, "convergent run should delete nothing");
assert_eq!(c.fetched, 0, "convergent run should not rewrite blobs");
assert!(c.skipped >= 2, "both present scripts should be skipped");
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_and_jmap_imports_share_blob_bytes() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let msieve_archive = tmp_archive("parity-msieve");
let jmap_archive = tmp_archive("parity-jmap");
sync::import_managesieve::run(common(&msieve_archive), basic_config(&acc.localpart))
.expect("managesieve import");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let msieve_conn = Connection::open(&msieve_archive).unwrap();
let jmap_conn = Connection::open(&jmap_archive).unwrap();
fn name_hash_active(conn: &Connection) -> Vec<(String, Vec<u8>, bool)> {
let mut stmt = conn
.prepare(
"SELECT s.name, b.hash, s.is_active
FROM sieve_scripts s JOIN blobs b ON b.id = s.blob_id
ORDER BY s.name",
)
.unwrap();
let rows = stmt
.query_map([], |r| {
let name: String = r.get(0)?;
let hash: Vec<u8> = r.get(1)?;
let active: i64 = r.get(2)?;
Ok((name, hash, active != 0))
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
}
let msieve = name_hash_active(&msieve_conn);
let jmap = name_hash_active(&jmap_conn);
assert!(
!msieve.is_empty(),
"managesieve archive must have at least one script row"
);
assert_eq!(
msieve, jmap,
"every script's (name, blake3, is_active) must match across protocols"
);
let _ = std::fs::remove_file(&msieve_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_dry_run_reports_diff_without_writing() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("dryrun");
let mut common_cfg = common(&archive);
common_cfg.dry_run = true;
let summary =
sync::import_managesieve::run(common_cfg, basic_config(&acc.localpart)).expect("dryrun");
let (_, c) = &summary.per_type[0];
assert!(
c.created >= 1,
"expected at least one 'new' in dry-run plan"
);
let conn = Connection::open(&archive).unwrap();
assert_eq!(
count(&conn, "sieve_scripts"),
0,
"dry-run must not write rows"
);
assert_eq!(count(&conn, "blobs"), 0, "dry-run must not intern blobs");
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_source_change_protection_refuses_second_account() {
let fx = seeder::provision(base_url()).expect("provision");
let acc1 = fx.account("test1").expect("test1");
let acc2 = fx.account("test2").expect("test2");
let archive = tmp_archive("source_change");
sync::import_managesieve::run(common(&archive), basic_config(&acc1.localpart))
.expect("first import");
let err = sync::import_managesieve::run(common(&archive), basic_config(&acc2.localpart))
.expect_err("should refuse the second source without override");
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_implicit_tls_path_succeeds_when_offered() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("implicit_tls");
let mut cfg = basic_config(&acc.localpart);
cfg.url = sieves_url();
let result = sync::import_managesieve::run(common(&archive), cfg);
if let Err(vandelay::error::Error::Connection(msg)) = &result {
eprintln!("(expected on cleartext-only deployments) {msg}");
}
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn managesieve_round_trip_via_jmap_export_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let dst = fx.account("test4").expect("test4");
let archive = tmp_archive("roundtrip");
sync::import_managesieve::run(common(&archive), basic_config(&src.localpart))
.expect("managesieve import");
let import_conn = Connection::open(&archive).unwrap();
let imported_scripts = count(&import_conn, "sieve_scripts");
assert!(
imported_scripts >= 1,
"expected at least one script in the source archive"
);
let imported_active: i64 = import_conn
.query_row(
"SELECT count(*) FROM sieve_scripts WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
imported_active, 1,
"test1 should have exactly one active script"
);
drop(import_conn);
let export_common = CommonConfig {
archive: archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
let export_cfg = vandelay::sync::ExportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: dst.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(dst.account_id.clone()),
},
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
prune: false,
yes: false,
};
let first = sync::export::run(export_common, export_cfg).expect("first export");
assert!(!first.any_failed(), "first export had failures: {first:?}");
let created_first: u64 = first
.per_type
.iter()
.filter(|(k, _)| *k == "SieveScript")
.map(|(_, c)| c.created)
.sum();
assert_eq!(
created_first as i64, imported_scripts,
"first export should create every imported script on the target ({first:?})"
);
let export_common2 = CommonConfig {
archive: archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
let export_cfg2 = vandelay::sync::ExportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: dst.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(dst.account_id.clone()),
},
objects: Some(vec![vandelay::types::ObjectType::SieveScript]),
prune: false,
yes: false,
};
let second = sync::export::run(export_common2, export_cfg2).expect("second export");
for (name, counts) in &second.per_type {
assert_eq!(
counts.created, 0,
"{name}: round-trip second export should create nothing"
);
assert_eq!(
counts.deleted, 0,
"{name}: round-trip second export should delete nothing"
);
}
let _ = std::fs::remove_file(&archive);
seeder::teardown(base_url()).expect("teardown");
}
+785
View File
@@ -0,0 +1,785 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use tempfile::TempDir;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
use vandelay::sync::import_takeout::{TakeoutImportConfig, run};
const RESOURCES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/resources");
fn common_for(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: Logger::from_flags(true, 0),
}
}
fn config_for(root: PathBuf) -> TakeoutImportConfig {
TakeoutImportConfig {
takeout_root: root,
allow_source_change: false,
automap: true,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn mailbox_names(conn: &Connection) -> HashSet<String> {
let mut stmt = conn.prepare("SELECT name FROM mailboxes").unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
struct SyntheticTakeout {
root: PathBuf,
}
impl SyntheticTakeout {
fn build(root: &Path) -> Self {
Self::write_mail(root);
Self::write_calendar(root);
Self::write_contacts(root);
Self::write_noise_files(root);
Self {
root: root.to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.root
}
fn write_mail(root: &Path) {
let mail_dir = root.join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
let mut all_mail = String::new();
let messages = [
(1, "Inbox,Opened", "Plain inbox read"),
(
2,
"Inbox,Important,Opened,WorkProject",
"Tagged with custom label",
),
(3, "Inbox,Opened,Starred", "Starred mail"),
(4, "Archived,Sent,Opened", "Sent and archived"),
(
5,
"Archived,Sent,Opened,WorkProject/2026-Q1",
"Nested custom label",
),
(
6,
"Trash,Category Social,Unread",
"Trash + Category dropped, unread",
),
(
7,
"Inbox,Opened,Category Promotions",
"Inbox + Category Promotions dropped",
),
];
for (n, labels, subject) in messages {
all_mail.push_str(&takeout_message(n, labels, subject));
}
fs::write(
mail_dir.join("All Mail Including Spam and Trash.mbox"),
all_mail,
)
.unwrap();
let mut per_label = String::new();
for n in 8..=9 {
per_label.push_str(&takeout_message(
n,
"Inbox,Opened,Github",
"Per-label export",
));
}
fs::write(mail_dir.join("Github.mbox"), per_label).unwrap();
}
fn write_calendar(root: &Path) {
let cal_dir = root.join("Takeout/Calendar");
fs::create_dir_all(&cal_dir).unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/000.ics"),
cal_dir.join("Weekly.ics"),
)
.unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("no_calname.ics"),
)
.unwrap();
fs::write(
cal_dir.join("meet_settings.json"),
br#"{"Meeting data":[{"Meeting code":"abc-defg-hij"}]}"#,
)
.unwrap();
}
fn write_contacts(root: &Path) {
let all_dir = root.join("Takeout/Contacts/All Contacts");
let my_dir = root.join("Takeout/Contacts/My Contacts");
let starred_dir = root.join("Takeout/Contacts/Starred in Android");
fs::create_dir_all(&all_dir).unwrap();
fs::create_dir_all(&my_dir).unwrap();
fs::create_dir_all(&starred_dir).unwrap();
let all = synthetic_vcards_with_and_without_uid();
fs::write(all_dir.join("All Contacts.vcf"), &all).unwrap();
fs::write(my_dir.join("My Contacts.vcf"), &all).unwrap();
fs::write(starred_dir.join("Starred in Android.vcf"), "").unwrap();
fs::write(
all_dir.join("Alice Example.jpg"),
[0xff_u8, 0xd8, 0xff, 0xe0],
)
.unwrap();
fs::write(
my_dir.join("Alice Example.jpg"),
[0xff_u8, 0xd8, 0xff, 0xe0],
)
.unwrap();
}
fn write_noise_files(root: &Path) {
fs::write(root.join("Takeout/archive_browser.html"), b"<html/>").unwrap();
fs::write(root.join("Takeout/user-generated-memory.json"), b"{}").unwrap();
}
}
fn takeout_message(n: u64, labels: &str, subject: &str) -> String {
format!(
"From {n}@xxx Thu Nov 13 20:20:33 +0000 2025\n\
X-GM-THRID: {n}\n\
X-Gmail-Labels: {labels}\n\
Message-ID: <synthetic-{n}@example.com>\n\
From: \"Anon\" <[email protected]>\n\
To: \"Anon\" <[email protected]>\n\
Subject: {subject}\n\
Date: Thu, 13 Nov 2025 20:20:33 +0000\n\
Content-Type: text/plain; charset=us-ascii\n\
\n\
Synthetic body for message {n}.\n\
\n"
)
}
fn synthetic_vcards_with_and_without_uid() -> String {
let mut buf = String::new();
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:synthetic-uid-1\r\n\
FN:Alice Example\r\n\
N:Example;Alice;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
TEL;TYPE=CELL:+1-555-0100\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:synthetic-uid-2\r\n\
FN:Bob Example\r\n\
N:Example;Bob;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
FN:Carol Example\r\n\
N:Example;Carol;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
item1.EMAIL;TYPE=INTERNET:[email protected]\r\n\
item1.X-ABLabel:\r\n\
END:VCARD\r\n",
);
buf
}
#[test]
fn empty_directory_fails_with_usage() {
let td = TempDir::new().unwrap();
let archive = td.path().join("a.sqlite");
let err = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap_err();
assert!(format!("{err}").contains("no .mbox"));
assert!(matches!(err, vandelay::error::Error::Usage(_)));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn nonexistent_path_fails_with_usage() {
let td = TempDir::new().unwrap();
let archive = td.path().join("a.sqlite");
let missing = td.path().join("does-not-exist");
let err = run(common_for(&archive), config_for(missing)).unwrap_err();
assert!(matches!(err, vandelay::error::Error::Usage(_)));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn synthetic_takeout_imports_expected_row_counts() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 9);
assert_eq!(count(&conn, "calendars"), 2);
assert!(count(&conn, "calendar_events") >= 2);
assert_eq!(count(&conn, "address_books"), 1);
assert_eq!(
count(&conn, "contact_cards"),
6,
"2 UID-bearing cards dedup across the two .vcf files (=2); \
2 UID-less cards get distinct synthetic UIDs per file (=4); total 6"
);
let by = |t: &str| {
summary
.per_type
.iter()
.find(|(n, _)| *n == t)
.unwrap()
.1
.clone()
};
assert!(by("mailbox").created >= 6);
assert_eq!(by("email").created, 9);
assert_eq!(by("email").failed, 0);
assert_eq!(by("calendarevent").failed, 0);
assert_eq!(by("contactcard").failed, 0);
}
#[test]
fn label_mapping_produces_expected_mailbox_tree() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let names = mailbox_names(&conn);
for required in [
"Inbox",
"Sent",
"Archive",
"Trash",
"WorkProject",
"2026-Q1",
"Github",
] {
assert!(
names.contains(required),
"missing mailbox {required:?}: have {names:?}"
);
}
assert!(
names.iter().all(|n| !n.starts_with("Category")),
"Category tokens must be dropped: {names:?}"
);
assert!(
!names.contains("Starred"),
"Starred is a keyword, not a mailbox: {names:?}"
);
assert!(
!names.contains("Important"),
"Important is a keyword, not a mailbox: {names:?}"
);
let nested_parent_id: i64 = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE name = '2026-Q1'",
[],
|r| r.get(0),
)
.unwrap();
let parent_name: String = conn
.query_row(
"SELECT name FROM mailboxes WHERE id = ?1",
[nested_parent_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(parent_name, "WorkProject");
}
#[test]
fn system_roles_assigned_when_automap_on() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let expected = [
("Inbox", "inbox"),
("Sent", "sent"),
("Trash", "trash"),
("Archive", "archive"),
];
for (name, role) in expected {
let got: String = conn
.query_row("SELECT role FROM mailboxes WHERE name = ?1", [name], |r| {
r.get(0)
})
.unwrap_or_else(|_| panic!("mailbox {name} missing"));
assert_eq!(got, role, "{name} should have role {role}");
}
let custom_role: Option<String> = conn
.query_row(
"SELECT role FROM mailboxes WHERE name = 'WorkProject'",
[],
|r| r.get(0),
)
.unwrap();
assert!(custom_role.is_none());
}
#[test]
fn keyword_translation_matches_label_table() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let starred: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$flagged%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(starred, 1);
let important: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$important%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(important, 1);
let seen: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$seen%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(seen, 8);
let unseen: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords NOT LIKE '%$seen%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(unseen, 1);
}
#[test]
fn calendar_xwr_calname_honored_else_falls_back_to_imported() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let names: HashSet<String> = {
let mut s = conn.prepare("SELECT name FROM calendars").unwrap();
s.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
};
assert!(
names.contains("weekly"),
"weekly from icals/000.ics: {names:?}"
);
assert!(
names.contains("Imported"),
"Imported fallback for icals/002.ics: {names:?}"
);
}
#[test]
fn contacts_dedup_by_uid_across_two_vcf_files() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "address_books"), 1);
let with_uid: i64 = conn
.query_row(
"SELECT COUNT(*) FROM contact_cards WHERE uid IN ('synthetic-uid-1', 'synthetic-uid-2')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(with_uid, 2);
let synthetic_uids: i64 = conn
.query_row(
"SELECT COUNT(*) FROM contact_cards WHERE uid LIKE 'vandelay-syn-%'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
synthetic_uids >= 2,
"expected synthetic UIDs for the 2 UID-less cards * 2 files"
);
}
#[test]
fn rerun_against_unchanged_source_is_idempotent() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let s2 = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let email_counts = &s2.per_type.iter().find(|(n, _)| *n == "email").unwrap().1;
assert_eq!(email_counts.created, 0);
assert_eq!(email_counts.failed, 0);
assert_eq!(email_counts.skipped, 9);
let event_counts = &s2
.per_type
.iter()
.find(|(n, _)| *n == "calendarevent")
.unwrap()
.1;
assert_eq!(event_counts.created, 0);
assert_eq!(event_counts.failed, 0);
assert!(event_counts.skipped > 0);
let card_counts = &s2
.per_type
.iter()
.find(|(n, _)| *n == "contactcard")
.unwrap()
.1;
assert_eq!(card_counts.created, 0);
assert_eq!(card_counts.failed, 0);
assert!(card_counts.skipped > 0);
}
#[test]
fn message_without_xgmail_labels_lands_in_filename_mailbox() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("ImportedFromThunderbird.mbox"),
b"From a@b Thu Nov 13 20:20:33 +0000 2025\nFrom: a@b\nSubject: nolabels\n\nbody\n",
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert!(mailbox_names(&conn).contains("ImportedFromThunderbird"));
}
#[test]
fn nonmatching_siblings_are_ignored_silently() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
assert_eq!(
summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1
.failed,
0,
"the .json / .html / .jpg sidecars must not cause failures"
);
}
#[test]
fn source_change_protection_refuses_different_path() {
let td = TempDir::new().unwrap();
let a = td.path().join("a");
let b = td.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
SyntheticTakeout::build(&a);
SyntheticTakeout::build(&b);
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(a.clone())).unwrap();
let err = run(common_for(&archive), config_for(b.clone())).unwrap_err();
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
let mut cfg2 = config_for(b);
cfg2.allow_source_change = true;
run(common_for(&archive), cfg2).expect("--allow-source-change permits");
}
#[test]
fn re_extraction_to_same_canonical_path_converges() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let initial: i64 = {
let c = Connection::open(&archive).unwrap();
count(&c, "emails")
};
fs::remove_dir_all(td.path().join("Takeout")).unwrap();
SyntheticTakeout::build(td.path());
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let c = Connection::open(&archive).unwrap();
assert_eq!(count(&c, "emails"), initial);
}
#[test]
fn noautomap_disables_role_assignment() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let mut cfg = config_for(td.path().to_path_buf());
cfg.automap = false;
run(common_for(&archive), cfg).unwrap();
let conn = Connection::open(&archive).unwrap();
let with_role: i64 = conn
.query_row(
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
with_role, 0,
"no mailbox should have a role under --noautomap"
);
}
#[test]
fn synthetic_takeout_layout_mirrors_real_takeout_directory_shape() {
let td = TempDir::new().unwrap();
let synth = SyntheticTakeout::build(td.path());
let root = synth.path();
assert!(
root.join("Takeout/Mail/All Mail Including Spam and Trash.mbox")
.exists()
);
assert!(root.join("Takeout/Mail/Github.mbox").exists());
assert!(root.join("Takeout/Calendar/Weekly.ics").exists());
assert!(root.join("Takeout/Calendar/no_calname.ics").exists());
assert!(root.join("Takeout/Calendar/meet_settings.json").exists());
assert!(
root.join("Takeout/Contacts/All Contacts/All Contacts.vcf")
.exists()
);
assert!(
root.join("Takeout/Contacts/All Contacts/Alice Example.jpg")
.exists()
);
assert!(
root.join("Takeout/Contacts/My Contacts/My Contacts.vcf")
.exists()
);
assert!(
root.join("Takeout/Contacts/Starred in Android/Starred in Android.vcf")
.exists()
);
assert!(root.join("Takeout/archive_browser.html").exists());
assert!(root.join("Takeout/user-generated-memory.json").exists());
}
#[test]
fn dry_run_creates_no_object_rows_in_archive() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let mut common = common_for(&archive);
common.dry_run = true;
let summary = run(common, config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
assert_eq!(count(&conn, "mailboxes"), 0);
assert_eq!(count(&conn, "calendars"), 0);
assert_eq!(count(&conn, "calendar_events"), 0);
assert_eq!(count(&conn, "address_books"), 0);
assert_eq!(count(&conn, "contact_cards"), 0);
assert_eq!(count(&conn, "blobs"), 0);
for (_, counts) in &summary.per_type {
assert_eq!(counts.created, 0);
assert_eq!(counts.fetched, 0);
assert_eq!(counts.failed, 0);
}
}
#[test]
fn byte_identical_message_in_two_mbox_files_dedupes_to_one_row() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
let body = takeout_message(1, "Inbox,Opened,Github", "shared subject");
fs::write(mail_dir.join("All.mbox"), &body).unwrap();
fs::write(mail_dir.join("Github.mbox"), &body).unwrap();
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(
count(&conn, "emails"),
1,
"the same bytes across two .mbox files must dedupe to one emails row"
);
assert_eq!(count(&conn, "blobs"), 1, "blob layer dedupes too");
let email = &summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1;
assert_eq!(email.created, 1);
assert!(
email.skipped + email.fetched >= 1,
"the second occurrence must go through the present-row path"
);
}
#[test]
fn two_ics_files_without_calname_merge_into_single_imported_calendar() {
let td = TempDir::new().unwrap();
let cal_dir = td.path().join("Takeout/Calendar");
fs::create_dir_all(&cal_dir).unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("first.ics"),
)
.unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("second.ics"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let imported_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM calendars WHERE name = 'Imported'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
imported_count, 1,
"two .ics files without X-WR-CALNAME must share one Imported calendar"
);
}
#[test]
fn empty_mbox_file_imports_with_no_failures() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(mail_dir.join("Empty.mbox"), b"").unwrap();
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let email = &summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1;
assert_eq!(email.created, 0);
assert_eq!(email.failed, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
assert_eq!(count(&conn, "mailboxes"), 0);
}
#[test]
fn chat_label_creates_chat_mailbox_with_no_role() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("All.mbox"),
takeout_message(1, "Chat,Opened", "hello"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert!(mailbox_names(&conn).contains("Chat"));
let role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Chat'", [], |r| {
r.get(0)
})
.unwrap();
assert!(role.is_none(), "Chat is a system label with no JMAP role");
}
#[test]
fn noautomap_leaves_nested_mailboxes_role_null() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("All.mbox"),
takeout_message(1, "Inbox,Opened,Project/2026-Q1", "nested"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
let mut cfg = config_for(td.path().to_path_buf());
cfg.automap = false;
run(common_for(&archive), cfg).unwrap();
let conn = Connection::open(&archive).unwrap();
let with_role: i64 = conn
.query_row(
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(with_role, 0);
let names = mailbox_names(&conn);
assert!(names.contains("Inbox"));
assert!(names.contains("Project"));
assert!(names.contains("2026-Q1"));
}
+326
View File
@@ -0,0 +1,326 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
mod integration;
mod seeder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_dav::{DavAuth, DavImportConfig, DavKindArg};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
}
}
fn webdav_cfg(root_url: String, domain: &str, localpart: &str) -> DavImportConfig {
DavImportConfig {
kind: DavKindArg::Webdav,
url: root_url,
auth: DavAuth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
},
allow_cleartext: false,
dav_connections: 4,
multiget_batch: 50,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn jmap_basic(localpart: &str, domain: &str) -> Auth {
Auth::Basic {
user: format!("{localpart}@{domain}"),
password: seeder::USER_PASSWORD.to_owned(),
}
}
fn export_cfg(base_url: &str, domain: &str, localpart: &str, account_id: &str) -> ExportConfig {
ExportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
prune: false,
yes: true,
}
}
fn jmap_import_cfg(
base_url: &str,
domain: &str,
localpart: &str,
account_id: &str,
) -> ImportConfig {
ImportConfig {
connect: ConnectConfig {
url: base_url.to_owned(),
auth: jmap_basic(localpart, domain),
account: AccountSelector::Id(account_id.to_owned()),
},
objects: None,
allow_source_change: false,
}
}
fn file_blob_hashes(conn: &Connection) -> HashSet<Vec<u8>> {
let mut stmt = conn
.prepare(
"SELECT blobs.hash FROM file_nodes
JOIN blobs ON blobs.id = file_nodes.blob_id
WHERE file_nodes.node_type = 'file'",
)
.unwrap();
let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0)).unwrap();
let mut out = HashSet::new();
for r in rows {
out.insert(r.unwrap());
}
out
}
#[test]
#[ignore = "requires Docker"]
fn webdav_import_test1_walks_directory_tree() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let archive = tmp_archive("webdav");
assert_eq!(fx.domain, seeder::DOMAIN);
assert!(!fx.domain_id.is_empty(), "domain id resolved");
assert_eq!(
fx.admin_login,
(
seeder::ADMIN_USER.to_owned(),
seeder::ADMIN_PASSWORD.to_owned()
)
);
assert!(!acc.admin_role, "test1 is a regular user, not admin");
let seeded = acc.seeded.as_ref().expect("test1 seed stats");
assert!(seeded.file_nodes > 0, "test1 layout seeds file nodes");
assert!(
seeded.mailboxes_created > 0,
"test1 has a seeded mailbox tree"
);
assert!(seeded.emails > 0, "test1 has seeded emails");
assert!(seeded.address_books > 0, "test1 has seeded address books");
assert!(seeded.contacts > 0, "test1 has seeded contacts");
assert!(seeded.calendars > 0, "test1 has seeded calendars");
assert!(seeded.events > 0, "test1 has seeded events");
assert!(seeded.identity, "test1 has a custom identity");
assert_eq!(
seeded.sieve_active,
Some(true),
"test1 has an active sieve script"
);
let root = format!("{}/dav/file/", fx.base_url);
let summary = sync::import_dav::run(
common(&archive),
webdav_cfg(root, &fx.domain, &acc.localpart),
)
.expect("import");
assert!(!summary.any_failed(), "import had failures: {summary:?}");
let conn = Connection::open(&archive).unwrap();
let nodes = count(&conn, "file_nodes") as usize;
assert!(
nodes >= seeded.file_nodes,
"imported file_nodes ({nodes}) covers seeded layout ({})",
seeded.file_nodes,
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn webdav_round_trip_via_jmap_export_converges() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let tgt = fx.account("test4").expect("test4");
let archive = tmp_archive("webdav-rt");
let root = format!("{}/dav/file/", fx.base_url);
let imp = sync::import_dav::run(
common(&archive),
webdav_cfg(root, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!imp.any_failed(), "dav import had failures: {imp:?}");
let conn = Connection::open(&archive).unwrap();
let local_files: i64 = conn
.query_row(
"SELECT count(*) FROM file_nodes WHERE node_type = 'file'",
[],
|r| r.get(0),
)
.unwrap();
assert!(local_files > 0, "DAV import landed files in the archive");
drop(conn);
let e1 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 1");
assert!(!e1.any_failed(), "export 1 had failures: {e1:?}");
let created_files_1: u64 = e1
.per_type
.iter()
.filter(|(t, _)| *t == "FileNode")
.map(|(_, c)| c.created)
.sum();
assert!(
created_files_1 > 0,
"first export created FileNode rows on the target: {e1:?}"
);
let e2 = sync::export::run(
common(&archive),
export_cfg(&fx.base_url, &fx.domain, &tgt.localpart, &tgt.account_id),
)
.expect("export 2");
let created_2: u64 = e2.per_type.iter().map(|(_, c)| c.created).sum();
assert_eq!(
created_2, 0,
"second export must be convergent (no new creates): {e2:?}"
);
let skipped_2: u64 = e2.per_type.iter().map(|(_, c)| c.skipped).sum();
assert!(
skipped_2 > 0,
"second export matched existing target objects: {e2:?}"
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn webdav_cross_protocol_parity_with_jmap_matches_blob_hashes() {
let fx = seeder::provision(base_url()).expect("provision");
let src = fx.account("test1").expect("test1");
let archive_jmap = tmp_archive("webdav-parity-jmap");
let s_jmap = sync::import_jmap::run(
common(&archive_jmap),
jmap_import_cfg(&fx.base_url, &fx.domain, &src.localpart, &src.account_id),
)
.expect("jmap import");
assert!(!s_jmap.any_failed(), "jmap import failures: {s_jmap:?}");
let archive_dav = tmp_archive("webdav-parity-dav");
let root = format!("{}/dav/file/", fx.base_url);
let s_dav = sync::import_dav::run(
common(&archive_dav),
webdav_cfg(root, &fx.domain, &src.localpart),
)
.expect("dav import");
assert!(!s_dav.any_failed(), "dav import failures: {s_dav:?}");
let conn_jmap = Connection::open(&archive_jmap).unwrap();
let hashes_jmap = file_blob_hashes(&conn_jmap);
drop(conn_jmap);
let conn_dav = Connection::open(&archive_dav).unwrap();
let hashes_dav = file_blob_hashes(&conn_dav);
drop(conn_dav);
assert!(
!hashes_jmap.is_empty(),
"jmap import should yield at least one file blob"
);
assert!(
!hashes_dav.is_empty(),
"dav import should yield at least one file blob"
);
let only_in_jmap: HashSet<_> = hashes_jmap.difference(&hashes_dav).collect();
let only_in_dav: HashSet<_> = hashes_dav.difference(&hashes_jmap).collect();
assert!(
only_in_jmap.is_empty() && only_in_dav.is_empty(),
"BLAKE3 hash sets must match across protocols (both store bytes verbatim): \
only-in-jmap={} only-in-dav={}",
only_in_jmap.len(),
only_in_dav.len(),
);
seeder::teardown(base_url()).expect("teardown");
}
#[test]
#[ignore = "requires Docker"]
fn webdav_import_is_idempotent_on_second_run() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
assert!(!acc.admin_role);
let archive = tmp_archive("webdav-idem");
let root = format!("{}/dav/file/", fx.base_url);
sync::import_dav::run(
common(&archive),
webdav_cfg(root.clone(), &fx.domain, &acc.localpart),
)
.expect("import 1");
let conn1 = Connection::open(&archive).unwrap();
let n1 = count(&conn1, "file_nodes");
let b1 = count(&conn1, "blobs");
drop(conn1);
sync::import_dav::run(
common(&archive),
webdav_cfg(root, &fx.domain, &acc.localpart),
)
.expect("import 2");
let conn2 = Connection::open(&archive).unwrap();
let n2 = count(&conn2, "file_nodes");
let b2 = count(&conn2, "blobs");
assert_eq!(n1, n2, "second import does not duplicate file_nodes");
assert_eq!(
b1, b2,
"second import does not duplicate blobs (BLAKE3 dedup)"
);
seeder::teardown(base_url()).expect("teardown");
}