415 lines
13 KiB
Rust
415 lines
13 KiB
Rust
/*
|
|
* 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))
|
|
}
|