/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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, 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, 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 { 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> { 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 { 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 { 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": "

Vandelay Industries

" } }), &[], )?; Ok(()) } fn random_bytes(len: usize) -> SeedResult> { 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 { 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)) }