Initial commit
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user