1021 lines
34 KiB
Rust
1021 lines
34 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use indexmap::IndexMap;
|
|
use rusqlite::{Connection, Row, params};
|
|
use serde_json::{Map, Value};
|
|
|
|
use crate::jmap::blob::{export_blob_ids, import_blob_ids};
|
|
use crate::jmap::error::JmapError;
|
|
use crate::jmap::wire::JmapId;
|
|
use crate::jmap::wire::address_book::AddressBook;
|
|
use crate::jmap::wire::calendar::Calendar;
|
|
use crate::jmap::wire::calendar_event::CalendarEvent;
|
|
use crate::jmap::wire::contact_card::ContactCard;
|
|
use crate::jmap::wire::email::Email;
|
|
use crate::jmap::wire::file_node::FileNode;
|
|
use crate::jmap::wire::identity::Identity;
|
|
use crate::jmap::wire::mailbox::Mailbox;
|
|
use crate::jmap::wire::participant_identity::ParticipantIdentity;
|
|
use crate::jmap::wire::sieve_script::SieveScript;
|
|
use crate::types::ObjectType;
|
|
|
|
pub trait LocalResolver {
|
|
fn local(&self, ty: ObjectType, jmap_id: &str) -> Option<i64>;
|
|
}
|
|
|
|
pub trait TargetResolver {
|
|
fn target(&self, ty: ObjectType, local_id: i64) -> Option<JmapId>;
|
|
}
|
|
|
|
pub trait BlobIntern {
|
|
fn intern(&mut self, jmap_blob_id: &str) -> Result<i64, JmapError>;
|
|
}
|
|
|
|
pub trait BlobUpload {
|
|
fn upload(&mut self, local_id: i64) -> Result<JmapId, JmapError>;
|
|
}
|
|
|
|
fn translate_in(
|
|
ids: &IndexMap<JmapId, bool>,
|
|
ty: ObjectType,
|
|
resolver: &impl LocalResolver,
|
|
) -> Result<Vec<i64>, JmapError> {
|
|
let mut out = Vec::with_capacity(ids.len());
|
|
for (id, _) in ids {
|
|
match resolver.local(ty, &id.0) {
|
|
Some(local) => out.push(local),
|
|
None => {
|
|
return Err(JmapError::malformed(format!(
|
|
"unresolved {} reference {} on insert",
|
|
ty.jmap_name(),
|
|
id.0
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn translate_out(
|
|
locals: &[i64],
|
|
ty: ObjectType,
|
|
resolver: &impl TargetResolver,
|
|
) -> Result<IndexMap<JmapId, bool>, JmapError> {
|
|
let mut out = IndexMap::new();
|
|
for local in locals {
|
|
match resolver.target(ty, *local) {
|
|
Some(target) => {
|
|
out.insert(target, true);
|
|
}
|
|
None => {
|
|
return Err(JmapError::malformed(format!(
|
|
"unresolved local {} id {local} on export",
|
|
ty.jmap_name()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn id_array_json(ids: &[i64]) -> String {
|
|
let vals: Vec<Value> = ids.iter().map(|i| Value::from(*i)).collect();
|
|
Value::Array(vals).to_string()
|
|
}
|
|
|
|
fn parse_local_id_array(text: &str) -> Result<Vec<i64>, JmapError> {
|
|
let v: Value = serde_json::from_str(text)?;
|
|
v.as_array()
|
|
.ok_or_else(|| JmapError::malformed("id column is not a JSON array"))?
|
|
.iter()
|
|
.map(|x| {
|
|
x.as_i64()
|
|
.ok_or_else(|| JmapError::malformed("id array element is not an integer"))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn opt_parent(
|
|
resolver: &impl LocalResolver,
|
|
ty: ObjectType,
|
|
parent: &Option<JmapId>,
|
|
) -> Option<i64> {
|
|
parent.as_ref().and_then(|p| resolver.local(ty, &p.0))
|
|
}
|
|
|
|
pub fn insert_mailbox(
|
|
conn: &Connection,
|
|
wire: &Mailbox,
|
|
resolver: &impl LocalResolver,
|
|
) -> Result<i64, JmapError> {
|
|
let parent = opt_parent(resolver, ObjectType::Mailbox, &wire.parent_id);
|
|
conn.execute(
|
|
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
params![
|
|
wire.name,
|
|
parent,
|
|
wire.role,
|
|
wire.sort_order,
|
|
wire.is_subscribed as i64
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const MAILBOX_SELECT: &str =
|
|
"SELECT id, name, parent_id, role, sort_order, is_subscribed FROM mailboxes";
|
|
|
|
pub fn row_to_mailbox(row: &Row, resolver: &impl TargetResolver) -> Result<Mailbox, JmapError> {
|
|
let parent_local: Option<i64> = row.get(2)?;
|
|
let parent_id = match parent_local {
|
|
Some(p) => Some(
|
|
resolver
|
|
.target(ObjectType::Mailbox, p)
|
|
.ok_or_else(|| JmapError::malformed("unresolved mailbox parent on export"))?,
|
|
),
|
|
None => None,
|
|
};
|
|
Ok(Mailbox {
|
|
id: None,
|
|
name: row.get(1)?,
|
|
parent_id,
|
|
role: row.get(3)?,
|
|
sort_order: row.get::<_, i64>(4)? as u32,
|
|
is_subscribed: row.get::<_, i64>(5)? != 0,
|
|
})
|
|
}
|
|
|
|
pub fn insert_email(
|
|
conn: &Connection,
|
|
wire: &Email,
|
|
blob_local_id: i64,
|
|
message_match: &str,
|
|
resolver: &impl LocalResolver,
|
|
) -> Result<i64, JmapError> {
|
|
let mailbox_locals = translate_in(&wire.mailbox_ids, ObjectType::Mailbox, resolver)?;
|
|
if mailbox_locals.is_empty() {
|
|
return Err(JmapError::malformed("email has no resolvable mailbox"));
|
|
}
|
|
let keywords: Vec<String> = wire.keywords.keys().cloned().collect();
|
|
conn.execute(
|
|
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords, message_match)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
params![
|
|
blob_local_id,
|
|
format_utc(&wire.received_at)?,
|
|
id_array_json(&mailbox_locals),
|
|
Value::Array(keywords.iter().map(|k| Value::from(k.as_str())).collect()).to_string(),
|
|
message_match
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct EmailRow {
|
|
pub blob_local_id: i64,
|
|
pub received_at: String,
|
|
pub mailbox_locals: Vec<i64>,
|
|
pub keywords: Vec<String>,
|
|
pub message_match: String,
|
|
}
|
|
|
|
pub const EMAIL_SELECT: &str =
|
|
"SELECT id, blob_id, received_at, mailbox_ids, keywords, message_match FROM emails";
|
|
|
|
pub fn row_to_email(row: &Row) -> Result<EmailRow, JmapError> {
|
|
let mailbox_text: String = row.get(3)?;
|
|
let keyword_text: String = row.get(4)?;
|
|
let keywords: Vec<String> = serde_json::from_str(&keyword_text)?;
|
|
Ok(EmailRow {
|
|
blob_local_id: row.get(1)?,
|
|
received_at: row.get(2)?,
|
|
mailbox_locals: parse_local_id_array(&mailbox_text)?,
|
|
keywords,
|
|
message_match: row.get(5)?,
|
|
})
|
|
}
|
|
|
|
pub fn insert_identity(conn: &Connection, wire: &Identity) -> Result<i64, JmapError> {
|
|
conn.execute(
|
|
"INSERT INTO identities (name, email, reply_to, bcc, text_signature, html_signature)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
params![
|
|
wire.name,
|
|
wire.email,
|
|
opt_json(&wire.reply_to)?,
|
|
opt_json(&wire.bcc)?,
|
|
wire.text_signature,
|
|
wire.html_signature
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const IDENTITY_SELECT: &str =
|
|
"SELECT id, name, email, reply_to, bcc, text_signature, html_signature FROM identities";
|
|
|
|
pub fn row_to_identity(row: &Row) -> Result<Identity, JmapError> {
|
|
Ok(Identity {
|
|
id: None,
|
|
name: row.get(1)?,
|
|
email: row.get(2)?,
|
|
reply_to: from_opt_json(row.get::<_, Option<String>>(3)?)?,
|
|
bcc: from_opt_json(row.get::<_, Option<String>>(4)?)?,
|
|
text_signature: row.get(5)?,
|
|
html_signature: row.get(6)?,
|
|
})
|
|
}
|
|
|
|
pub fn insert_sieve_script(
|
|
conn: &Connection,
|
|
wire: &SieveScript,
|
|
blob_local_id: i64,
|
|
) -> Result<i64, JmapError> {
|
|
conn.execute(
|
|
"INSERT INTO sieve_scripts (name, is_active, blob_id) VALUES (?1, ?2, ?3)",
|
|
params![wire.name, wire.is_active as i64, blob_local_id],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const SIEVE_SELECT: &str = "SELECT id, name, is_active, blob_id FROM sieve_scripts";
|
|
|
|
pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64, JmapError> {
|
|
conn.execute(
|
|
"INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
params![
|
|
wire.name,
|
|
wire.description,
|
|
wire.sort_order,
|
|
wire.is_default as i64,
|
|
wire.is_subscribed as i64
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const ADDRESS_BOOK_SELECT: &str =
|
|
"SELECT id, name, description, sort_order, is_default, is_subscribed FROM address_books";
|
|
|
|
pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapError> {
|
|
conn.execute(
|
|
"INSERT INTO calendars (name, description, color, sort_order, is_subscribed, is_visible,
|
|
is_default, include_in_availability, default_alerts_with_time,
|
|
default_alerts_without_time, time_zone)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
params![
|
|
wire.name,
|
|
wire.description,
|
|
wire.color,
|
|
wire.sort_order,
|
|
wire.is_subscribed as i64,
|
|
wire.is_visible as i64,
|
|
wire.is_default as i64,
|
|
wire.include_in_availability,
|
|
opt_json(&wire.default_alerts_with_time)?,
|
|
opt_json(&wire.default_alerts_without_time)?,
|
|
wire.time_zone
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const CALENDAR_SELECT: &str = "SELECT id, name, description, color, sort_order, is_subscribed, \
|
|
is_visible, is_default, include_in_availability, default_alerts_with_time, \
|
|
default_alerts_without_time, time_zone FROM calendars";
|
|
|
|
pub fn insert_participant_identity(
|
|
conn: &Connection,
|
|
wire: &ParticipantIdentity,
|
|
) -> Result<i64, JmapError> {
|
|
conn.execute(
|
|
"INSERT INTO participant_identities (name, calendar_address, is_default)
|
|
VALUES (?1, ?2, ?3)",
|
|
params![wire.name, wire.calendar_address, wire.is_default as i64],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const PARTICIPANT_IDENTITY_SELECT: &str =
|
|
"SELECT id, name, calendar_address, is_default FROM participant_identities";
|
|
|
|
pub fn insert_file_node(
|
|
conn: &Connection,
|
|
wire: &FileNode,
|
|
blob_local_id: Option<i64>,
|
|
resolver: &impl LocalResolver,
|
|
) -> Result<i64, JmapError> {
|
|
let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id);
|
|
let node_type = serde_json::to_value(wire.node_type)?
|
|
.as_str()
|
|
.unwrap_or("file")
|
|
.to_owned();
|
|
let target = match &wire.target {
|
|
Some(t) => Some(serde_json::to_string(t)?),
|
|
None => None,
|
|
};
|
|
conn.execute(
|
|
"INSERT INTO file_nodes (parent_id, node_type, blob_id, target, name, media_type,
|
|
created, modified, is_subscribed, role)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
params![
|
|
parent,
|
|
node_type,
|
|
blob_local_id,
|
|
target,
|
|
wire.name,
|
|
wire.media_type,
|
|
format_utc(&wire.created)?,
|
|
opt_format_utc(&wire.modified)?,
|
|
wire.is_subscribed as i64,
|
|
wire.role
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const FILE_NODE_SELECT: &str = "SELECT id, parent_id, node_type, blob_id, target, name, \
|
|
media_type, created, modified, is_subscribed, role FROM file_nodes";
|
|
|
|
pub fn insert_contact_card(
|
|
conn: &Connection,
|
|
wire: &ContactCard,
|
|
resolver: &impl LocalResolver,
|
|
blobs: &mut impl BlobIntern,
|
|
) -> Result<i64, JmapError> {
|
|
let address_books = translate_in(&wire.address_book_ids, ObjectType::AddressBook, resolver)?;
|
|
let mut data = Value::Object(value_map(&wire.rest));
|
|
let uid = take_string(&mut data, "uid")
|
|
.ok_or_else(|| JmapError::malformed("ContactCard has no uid"))?;
|
|
rewrite_blobs_in(&mut data, blobs)?;
|
|
conn.execute(
|
|
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
|
|
params![uid, id_array_json(&address_books), data.to_string()],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const CONTACT_CARD_SELECT: &str = "SELECT id, uid, address_book_ids, data FROM contact_cards";
|
|
|
|
pub fn insert_calendar_event(
|
|
conn: &Connection,
|
|
wire: &CalendarEvent,
|
|
resolver: &impl LocalResolver,
|
|
blobs: &mut impl BlobIntern,
|
|
) -> Result<i64, JmapError> {
|
|
let calendars = translate_in(&wire.calendar_ids, ObjectType::Calendar, resolver)?;
|
|
let mut data = Value::Object(value_map(&wire.rest));
|
|
for drop_key in ["method", "utcStart", "utcEnd", "isOrigin", "baseEventId"] {
|
|
if let Value::Object(m) = &mut data {
|
|
m.remove(drop_key);
|
|
}
|
|
}
|
|
rewrite_blobs_in(&mut data, blobs)?;
|
|
conn.execute(
|
|
"INSERT INTO calendar_events (calendar_ids, is_draft, use_default_alerts, data)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
params![
|
|
id_array_json(&calendars),
|
|
wire.is_draft as i64,
|
|
wire.use_default_alerts as i64,
|
|
data.to_string()
|
|
],
|
|
)?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
pub const CALENDAR_EVENT_SELECT: &str = "SELECT id, calendar_ids, is_draft, use_default_alerts, data FROM calendar_events \
|
|
WHERE data_type = 'Event'";
|
|
|
|
pub fn contact_card_to_wire(
|
|
uid: &str,
|
|
address_book_ids: &str,
|
|
data: &str,
|
|
resolver: &impl TargetResolver,
|
|
blobs: &mut impl BlobUpload,
|
|
) -> Result<Value, JmapError> {
|
|
let mut value: Value = serde_json::from_str(data)?;
|
|
restore_blobs_in(&mut value, blobs)?;
|
|
let locals = parse_local_id_array(address_book_ids)?;
|
|
let abids = translate_out(&locals, ObjectType::AddressBook, resolver)?;
|
|
if let Value::Object(map) = &mut value {
|
|
map.insert("uid".to_owned(), Value::from(uid));
|
|
map.insert("addressBookIds".to_owned(), id_bool_map(&abids));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
pub fn calendar_event_to_wire(
|
|
calendar_ids: &str,
|
|
is_draft: bool,
|
|
use_default_alerts: bool,
|
|
data: &str,
|
|
resolver: &impl TargetResolver,
|
|
blobs: &mut impl BlobUpload,
|
|
) -> Result<Value, JmapError> {
|
|
let mut value: Value = serde_json::from_str(data)?;
|
|
restore_blobs_in(&mut value, blobs)?;
|
|
let locals = parse_local_id_array(calendar_ids)?;
|
|
let calids = translate_out(&locals, ObjectType::Calendar, resolver)?;
|
|
if let Value::Object(map) = &mut value {
|
|
map.insert("calendarIds".to_owned(), id_bool_map(&calids));
|
|
map.insert("isDraft".to_owned(), Value::Bool(is_draft));
|
|
map.insert(
|
|
"useDefaultAlerts".to_owned(),
|
|
Value::Bool(use_default_alerts),
|
|
);
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn id_bool_map(ids: &IndexMap<JmapId, bool>) -> Value {
|
|
let mut m = Map::new();
|
|
for (id, _) in ids {
|
|
m.insert(id.0.clone(), Value::Bool(true));
|
|
}
|
|
Value::Object(m)
|
|
}
|
|
|
|
fn value_map(rest: &IndexMap<String, Value>) -> Map<String, Value> {
|
|
let mut m = Map::new();
|
|
for (k, v) in rest {
|
|
m.insert(k.clone(), v.clone());
|
|
}
|
|
m
|
|
}
|
|
|
|
fn take_string(value: &mut Value, key: &str) -> Option<String> {
|
|
if let Value::Object(map) = value
|
|
&& let Some(Value::String(s)) = map.remove(key)
|
|
{
|
|
return Some(s);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn rewrite_blobs_in(data: &mut Value, blobs: &mut impl BlobIntern) -> Result<(), JmapError> {
|
|
import_blob_ids(data, |jmap_blob_id| {
|
|
blobs
|
|
.intern(jmap_blob_id)
|
|
.map_err(|e| crate::jmap::blob::BlobWalkError::Resolver(e.to_string()))
|
|
})
|
|
.map_err(JmapError::from)
|
|
}
|
|
|
|
fn restore_blobs_in(data: &mut Value, blobs: &mut impl BlobUpload) -> Result<(), JmapError> {
|
|
export_blob_ids(data, |local_id| {
|
|
blobs
|
|
.upload(local_id)
|
|
.map(|id| id.0)
|
|
.map_err(|e| crate::jmap::blob::BlobWalkError::Resolver(e.to_string()))
|
|
})
|
|
.map_err(JmapError::from)
|
|
}
|
|
|
|
fn opt_json<T: serde::Serialize>(value: &Option<T>) -> Result<Option<String>, JmapError> {
|
|
match value {
|
|
Some(v) => Ok(Some(serde_json::to_string(v)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn from_opt_json<T: serde::de::DeserializeOwned>(
|
|
text: Option<String>,
|
|
) -> Result<Option<T>, JmapError> {
|
|
match text {
|
|
Some(t) => Ok(Some(serde_json::from_str(&t)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub fn row_to_address_book(row: &Row) -> Result<AddressBook, JmapError> {
|
|
Ok(AddressBook {
|
|
id: None,
|
|
name: row.get(1)?,
|
|
description: row.get(2)?,
|
|
sort_order: row.get::<_, i64>(3)? as u32,
|
|
is_default: row.get::<_, i64>(4)? != 0,
|
|
is_subscribed: row.get::<_, i64>(5)? != 0,
|
|
})
|
|
}
|
|
|
|
pub fn row_to_calendar(row: &Row) -> Result<Calendar, JmapError> {
|
|
Ok(Calendar {
|
|
id: None,
|
|
name: row.get(1)?,
|
|
description: row.get(2)?,
|
|
color: row.get(3)?,
|
|
sort_order: row.get::<_, i64>(4)? as u32,
|
|
is_subscribed: row.get::<_, i64>(5)? != 0,
|
|
is_visible: row.get::<_, i64>(6)? != 0,
|
|
is_default: row.get::<_, i64>(7)? != 0,
|
|
include_in_availability: row.get(8)?,
|
|
default_alerts_with_time: from_opt_json(row.get::<_, Option<String>>(9)?)?,
|
|
default_alerts_without_time: from_opt_json(row.get::<_, Option<String>>(10)?)?,
|
|
time_zone: row.get(11)?,
|
|
})
|
|
}
|
|
|
|
pub fn row_to_participant_identity(row: &Row) -> Result<ParticipantIdentity, JmapError> {
|
|
Ok(ParticipantIdentity {
|
|
id: None,
|
|
name: row.get(1)?,
|
|
calendar_address: row.get(2)?,
|
|
is_default: row.get::<_, i64>(3)? != 0,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SieveRow {
|
|
pub name: Option<String>,
|
|
pub is_active: bool,
|
|
pub blob_local_id: i64,
|
|
}
|
|
|
|
pub fn row_to_sieve_script(row: &Row) -> Result<SieveRow, JmapError> {
|
|
Ok(SieveRow {
|
|
name: row.get(1)?,
|
|
is_active: row.get::<_, i64>(2)? != 0,
|
|
blob_local_id: row.get(3)?,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FileNodeRow {
|
|
pub wire: FileNode,
|
|
pub blob_local_id: Option<i64>,
|
|
}
|
|
|
|
pub fn row_to_file_node(
|
|
row: &Row,
|
|
resolver: &impl TargetResolver,
|
|
) -> Result<FileNodeRow, JmapError> {
|
|
let parent_local: Option<i64> = row.get(1)?;
|
|
let parent_id = match parent_local {
|
|
Some(p) => Some(
|
|
resolver
|
|
.target(ObjectType::FileNode, p)
|
|
.ok_or_else(|| JmapError::malformed("unresolved file_node parent on export"))?,
|
|
),
|
|
None => None,
|
|
};
|
|
let node_type = serde_json::from_value(Value::from(row.get::<_, String>(2)?))?;
|
|
let target: Option<Vec<String>> = from_opt_json(row.get::<_, Option<String>>(4)?)?;
|
|
let blob_local_id: Option<i64> = row.get(3)?;
|
|
Ok(FileNodeRow {
|
|
wire: FileNode {
|
|
id: None,
|
|
parent_id,
|
|
node_type,
|
|
blob_id: None,
|
|
target,
|
|
name: row.get(5)?,
|
|
media_type: row.get(6)?,
|
|
created: parse_utc(&row.get::<_, String>(7)?)?,
|
|
modified: opt_parse_utc(row.get::<_, Option<String>>(8)?)?,
|
|
is_subscribed: row.get::<_, i64>(9)? != 0,
|
|
role: row.get(10)?,
|
|
},
|
|
blob_local_id,
|
|
})
|
|
}
|
|
|
|
fn parse_utc(text: &str) -> Result<time::OffsetDateTime, JmapError> {
|
|
time::OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339)
|
|
.map_err(|e| JmapError::malformed(format!("cannot parse date {text}: {e}")))
|
|
}
|
|
|
|
fn opt_parse_utc(text: Option<String>) -> Result<Option<time::OffsetDateTime>, JmapError> {
|
|
match text {
|
|
Some(t) => Ok(Some(parse_utc(&t)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn format_utc(value: &time::OffsetDateTime) -> Result<String, JmapError> {
|
|
value
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.map_err(|e| JmapError::malformed(format!("cannot format date: {e}")))
|
|
}
|
|
|
|
fn opt_format_utc(value: &Option<time::OffsetDateTime>) -> Result<Option<String>, JmapError> {
|
|
match value {
|
|
Some(v) => Ok(Some(format_utc(v)?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::init;
|
|
use std::collections::HashMap;
|
|
|
|
struct MapResolver {
|
|
to_local: HashMap<(ObjectType, String), i64>,
|
|
to_target: HashMap<(ObjectType, i64), String>,
|
|
}
|
|
impl LocalResolver for MapResolver {
|
|
fn local(&self, ty: ObjectType, jmap_id: &str) -> Option<i64> {
|
|
self.to_local.get(&(ty, jmap_id.to_owned())).copied()
|
|
}
|
|
}
|
|
impl TargetResolver for MapResolver {
|
|
fn target(&self, ty: ObjectType, local_id: i64) -> Option<JmapId> {
|
|
self.to_target
|
|
.get(&(ty, local_id))
|
|
.map(|s| JmapId(s.clone()))
|
|
}
|
|
}
|
|
|
|
struct FakeBlobs;
|
|
impl BlobIntern for FakeBlobs {
|
|
fn intern(&mut self, _jmap_blob_id: &str) -> Result<i64, JmapError> {
|
|
Ok(42)
|
|
}
|
|
}
|
|
impl BlobUpload for FakeBlobs {
|
|
fn upload(&mut self, local_id: i64) -> Result<JmapId, JmapError> {
|
|
Ok(JmapId(format!("T{local_id}")))
|
|
}
|
|
}
|
|
|
|
fn mem() -> Connection {
|
|
let c = Connection::open_in_memory().unwrap();
|
|
init::apply_schema(&c).unwrap();
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn mailbox_roundtrips_with_parent_translation() {
|
|
let c = mem();
|
|
let root: Mailbox = serde_json::from_value(serde_json::json!({
|
|
"id": "M1", "name": "Root", "role": "archive", "sortOrder": 3
|
|
}))
|
|
.unwrap();
|
|
let empty = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
let root_local = insert_mailbox(&c, &root, &empty).unwrap();
|
|
|
|
let child: Mailbox = serde_json::from_value(serde_json::json!({
|
|
"id": "M2", "name": "Child", "parentId": "M1"
|
|
}))
|
|
.unwrap();
|
|
let mut res = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
res.to_local
|
|
.insert((ObjectType::Mailbox, "M1".to_owned()), root_local);
|
|
let child_local = insert_mailbox(&c, &child, &res).unwrap();
|
|
|
|
res.to_target
|
|
.insert((ObjectType::Mailbox, root_local), "TGT1".to_owned());
|
|
let wire = c
|
|
.query_row(
|
|
&format!("{MAILBOX_SELECT} WHERE id = ?1"),
|
|
params![child_local],
|
|
|row| Ok(row_to_mailbox(row, &res)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(wire.name, "Child");
|
|
assert_eq!(wire.parent_id, Some(JmapId("TGT1".to_owned())));
|
|
}
|
|
|
|
#[test]
|
|
fn email_keywords_keep_verbatim_case_and_mailbox_translation() {
|
|
let c = mem();
|
|
let mut res = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
res.to_local
|
|
.insert((ObjectType::Mailbox, "MB".to_owned()), 7);
|
|
let email: Email = serde_json::from_value(serde_json::json!({
|
|
"id": "E1",
|
|
"blobId": "B1",
|
|
"receivedAt": "2021-05-04T10:00:00Z",
|
|
"mailboxIds": { "MB": true },
|
|
"keywords": { "$Seen": true, "MyTag": true }
|
|
}))
|
|
.unwrap();
|
|
let blob_id = crate::db::blobs::intern_blob(&c, b"rfc5322 bytes").unwrap();
|
|
let local = insert_email(&c, &email, blob_id, "{}", &res).unwrap();
|
|
let got = c
|
|
.query_row(
|
|
&format!("{EMAIL_SELECT} WHERE id = ?1"),
|
|
params![local],
|
|
|row| Ok(row_to_email(row)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(got.blob_local_id, blob_id);
|
|
assert_eq!(got.mailbox_locals, vec![7]);
|
|
assert_eq!(got.keywords, vec!["$Seen".to_owned(), "MyTag".to_owned()]);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_foreign_key_on_insert_is_malformed() {
|
|
let c = mem();
|
|
let empty = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
let email: Email = serde_json::from_value(serde_json::json!({
|
|
"id": "E1",
|
|
"blobId": "B1",
|
|
"receivedAt": "2021-05-04T10:00:00Z",
|
|
"mailboxIds": { "UNKNOWN": true },
|
|
"keywords": {}
|
|
}))
|
|
.unwrap();
|
|
let err = insert_email(&c, &email, 1, "{}", &empty).unwrap_err();
|
|
assert!(matches!(err, JmapError::Malformed(_)), "got {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_target_on_export_is_malformed() {
|
|
let c = mem();
|
|
let root: Mailbox = serde_json::from_value(serde_json::json!({
|
|
"id": "M1", "name": "Root", "parentId": "P9"
|
|
}))
|
|
.unwrap();
|
|
let mut res = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
res.to_local
|
|
.insert((ObjectType::Mailbox, "P9".to_owned()), 1);
|
|
let local = insert_mailbox(&c, &root, &res).unwrap();
|
|
let err = c
|
|
.query_row(
|
|
&format!("{MAILBOX_SELECT} WHERE id = ?1"),
|
|
params![local],
|
|
|row| Ok(row_to_mailbox(row, &res)),
|
|
)
|
|
.unwrap()
|
|
.unwrap_err();
|
|
assert!(matches!(err, JmapError::Malformed(_)), "got {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn contact_card_strips_uid_and_rewrites_blob() {
|
|
let c = mem();
|
|
let res = MapResolver {
|
|
to_local: HashMap::from([((ObjectType::AddressBook, "AB".to_owned()), 1)]),
|
|
to_target: HashMap::from([((ObjectType::AddressBook, 1), "TAB".to_owned())]),
|
|
};
|
|
let card: ContactCard = serde_json::from_value(serde_json::json!({
|
|
"id": "C1",
|
|
"addressBookIds": { "AB": true },
|
|
"uid": "urn:uuid:42",
|
|
"name": { "full": "Jane" },
|
|
"photos": { "p1": { "blobId": "PB", "mediaType": "image/png" } }
|
|
}))
|
|
.unwrap();
|
|
let mut blobs = FakeBlobs;
|
|
let local = insert_contact_card(&c, &card, &res, &mut blobs).unwrap();
|
|
let (uid, abids, data): (String, String, String) = c
|
|
.query_row(
|
|
&format!("{CONTACT_CARD_SELECT} WHERE id = ?1"),
|
|
params![local],
|
|
|r| Ok((r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(uid, "urn:uuid:42");
|
|
let stored: Value = serde_json::from_str(&data).unwrap();
|
|
assert!(stored.get("uid").is_none());
|
|
assert_eq!(stored["photos"]["p1"]["@blob"], Value::from(42));
|
|
|
|
let mut up = FakeBlobs;
|
|
let wire = contact_card_to_wire(&uid, &abids, &data, &res, &mut up).unwrap();
|
|
assert_eq!(wire["uid"], Value::from("urn:uuid:42"));
|
|
assert_eq!(wire["addressBookIds"]["TAB"], Value::Bool(true));
|
|
assert_eq!(wire["photos"]["p1"]["blobId"], Value::from("T42"));
|
|
assert!(wire["photos"]["p1"].get("@blob").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn calendar_event_extracts_columns_and_drops_method() {
|
|
let c = mem();
|
|
let res = MapResolver {
|
|
to_local: HashMap::from([((ObjectType::Calendar, "CAL".to_owned()), 5)]),
|
|
to_target: HashMap::from([((ObjectType::Calendar, 5), "TCAL".to_owned())]),
|
|
};
|
|
let ev: CalendarEvent = serde_json::from_value(serde_json::json!({
|
|
"id": "EV1",
|
|
"calendarIds": { "CAL": true },
|
|
"isDraft": true,
|
|
"useDefaultAlerts": false,
|
|
"method": "request",
|
|
"utcStart": "2020-01-01T00:00:00Z",
|
|
"title": "Sprint",
|
|
"@type": "Event"
|
|
}))
|
|
.unwrap();
|
|
let mut blobs = FakeBlobs;
|
|
let local = insert_calendar_event(&c, &ev, &res, &mut blobs).unwrap();
|
|
let (cal, dr, ud, data): (String, i64, i64, String) = c
|
|
.query_row(
|
|
&format!("{CALENDAR_EVENT_SELECT} AND id = ?1"),
|
|
params![local],
|
|
|r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(dr, 1);
|
|
assert_eq!(ud, 0);
|
|
let stored: Value = serde_json::from_str(&data).unwrap();
|
|
assert!(stored.get("method").is_none());
|
|
assert!(stored.get("utcStart").is_none());
|
|
assert!(stored.get("calendarIds").is_none());
|
|
assert_eq!(stored["title"], Value::from("Sprint"));
|
|
|
|
let mut up = FakeBlobs;
|
|
let wire = calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, &res, &mut up).unwrap();
|
|
assert_eq!(wire["calendarIds"]["TCAL"], Value::Bool(true));
|
|
assert_eq!(wire["isDraft"], Value::Bool(true));
|
|
assert_eq!(wire["title"], Value::from("Sprint"));
|
|
}
|
|
|
|
#[test]
|
|
fn identity_json_columns_roundtrip() {
|
|
let c = mem();
|
|
let id: Identity = serde_json::from_value(serde_json::json!({
|
|
"id": "I1",
|
|
"name": "Alice",
|
|
"email": "[email protected]",
|
|
"replyTo": [ { "name": "A", "email": "[email protected]" } ]
|
|
}))
|
|
.unwrap();
|
|
let local = insert_identity(&c, &id).unwrap();
|
|
let wire = c
|
|
.query_row(
|
|
&format!("{IDENTITY_SELECT} WHERE id = ?1"),
|
|
params![local],
|
|
|row| Ok(row_to_identity(row)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(wire.email, "[email protected]");
|
|
assert_eq!(wire.reply_to.unwrap()[0].email, "[email protected]");
|
|
assert!(wire.bcc.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn file_node_enum_and_target_roundtrip() {
|
|
let c = mem();
|
|
let empty = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
let node: FileNode = serde_json::from_value(serde_json::json!({
|
|
"id": "F1",
|
|
"nodeType": "symlink",
|
|
"target": ["..", "etc", "hosts"],
|
|
"name": "link",
|
|
"created": "2022-02-02T02:02:02Z"
|
|
}))
|
|
.unwrap();
|
|
let local = insert_file_node(&c, &node, None, &empty).unwrap();
|
|
let (nt, tgt): (String, Option<String>) = c
|
|
.query_row(
|
|
&format!("{FILE_NODE_SELECT} WHERE id = ?1"),
|
|
params![local],
|
|
|r| Ok((r.get(2)?, r.get(4)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(nt, "symlink");
|
|
assert_eq!(
|
|
serde_json::from_str::<Vec<String>>(&tgt.unwrap()).unwrap(),
|
|
vec!["..", "etc", "hosts"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn address_book_and_calendar_row_to_wire_roundtrip() {
|
|
let c = mem();
|
|
let ab: AddressBook = serde_json::from_value(serde_json::json!({
|
|
"id": "A1", "name": "Personal", "description": "d", "sortOrder": 2,
|
|
"isDefault": true, "isSubscribed": false
|
|
}))
|
|
.unwrap();
|
|
let abl = insert_address_book(&c, &ab).unwrap();
|
|
let got = c
|
|
.query_row(
|
|
&format!("{ADDRESS_BOOK_SELECT} WHERE id = ?1"),
|
|
params![abl],
|
|
|r| Ok(row_to_address_book(r)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(got.name, "Personal");
|
|
assert!(got.is_default);
|
|
assert!(!got.is_subscribed);
|
|
|
|
let cal: Calendar = serde_json::from_value(serde_json::json!({
|
|
"id": "C1", "name": "Work", "color": "#ff0000", "timeZone": "Europe/Rome",
|
|
"isVisible": false
|
|
}))
|
|
.unwrap();
|
|
let cl = insert_calendar(&c, &cal).unwrap();
|
|
let gc = c
|
|
.query_row(
|
|
&format!("{CALENDAR_SELECT} WHERE id = ?1"),
|
|
params![cl],
|
|
|r| Ok(row_to_calendar(r)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(gc.name, "Work");
|
|
assert_eq!(gc.color.as_deref(), Some("#ff0000"));
|
|
assert_eq!(gc.time_zone.as_deref(), Some("Europe/Rome"));
|
|
assert!(!gc.is_visible);
|
|
}
|
|
|
|
#[test]
|
|
fn participant_identity_and_sieve_row_to_wire_roundtrip() {
|
|
let c = mem();
|
|
let pi: ParticipantIdentity = serde_json::from_value(serde_json::json!({
|
|
"id": "P1", "name": "Me", "calendarAddress": "mailto:[email protected]",
|
|
"isDefault": true
|
|
}))
|
|
.unwrap();
|
|
let pil = insert_participant_identity(&c, &pi).unwrap();
|
|
let gp = c
|
|
.query_row(
|
|
&format!("{PARTICIPANT_IDENTITY_SELECT} WHERE id = ?1"),
|
|
params![pil],
|
|
|r| Ok(row_to_participant_identity(r)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(gp.calendar_address, "mailto:[email protected]");
|
|
assert!(gp.is_default);
|
|
|
|
let ss: SieveScript = serde_json::from_value(serde_json::json!({
|
|
"id": "S1", "name": "main", "isActive": true, "blobId": "B1"
|
|
}))
|
|
.unwrap();
|
|
let blob = crate::db::blobs::intern_blob(&c, b"keep;").unwrap();
|
|
let ssl = insert_sieve_script(&c, &ss, blob).unwrap();
|
|
let gs = c
|
|
.query_row(
|
|
&format!("{SIEVE_SELECT} WHERE id = ?1"),
|
|
params![ssl],
|
|
|r| Ok(row_to_sieve_script(r)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(gs.name.as_deref(), Some("main"));
|
|
assert!(gs.is_active);
|
|
assert_eq!(gs.blob_local_id, blob);
|
|
}
|
|
|
|
#[test]
|
|
fn file_node_row_to_wire_translates_parent() {
|
|
let c = mem();
|
|
let mut res = MapResolver {
|
|
to_local: HashMap::new(),
|
|
to_target: HashMap::new(),
|
|
};
|
|
let dir: FileNode = serde_json::from_value(serde_json::json!({
|
|
"id": "D1", "nodeType": "directory", "name": "Docs",
|
|
"created": "2022-02-02T02:02:02Z"
|
|
}))
|
|
.unwrap();
|
|
let dirl = insert_file_node(&c, &dir, None, &res).unwrap();
|
|
res.to_local
|
|
.insert((ObjectType::FileNode, "D1".to_owned()), dirl);
|
|
let file: FileNode = serde_json::from_value(serde_json::json!({
|
|
"id": "F1", "nodeType": "file", "parentId": "D1", "name": "a.bin",
|
|
"type": "application/octet-stream", "created": "2022-02-02T02:02:02Z"
|
|
}))
|
|
.unwrap();
|
|
let blob = crate::db::blobs::intern_blob(&c, b"bytes").unwrap();
|
|
let fl = insert_file_node(&c, &file, Some(blob), &res).unwrap();
|
|
res.to_target
|
|
.insert((ObjectType::FileNode, dirl), "TGTD".to_owned());
|
|
let got = c
|
|
.query_row(
|
|
&format!("{FILE_NODE_SELECT} WHERE id = ?1"),
|
|
params![fl],
|
|
|r| Ok(row_to_file_node(r, &res)),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(got.wire.name, "a.bin");
|
|
assert_eq!(got.wire.parent_id, Some(JmapId("TGTD".to_owned())));
|
|
assert_eq!(got.blob_local_id, Some(blob));
|
|
}
|
|
}
|