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
+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))
}