Files
inbuxa-migrate/tests/seeder/seed.rs
T
2026-05-29 18:02:15 +02:00

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