Initial commit
This commit is contained in:
@@ -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