Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+711
View File
@@ -0,0 +1,711 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{jmap::JmapUtils, server::TestServer};
use calcard::jscalendar::JSCalendarProperty;
use jmap_proto::{
object::{calendar::CalendarProperty, share_notification::ShareNotificationProperty},
request::method::MethodObject,
};
use serde_json::json;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Calendar ACL tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let john_id = john.id_string().to_string();
let jane_id = jane.id_string().to_string();
// Create test calendars
let response = john
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Test #1",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let john_calendar_id = response.created(0).id().to_string();
let john_event_id = john
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"@type": "Event",
"uid": "a8df6573-0474-496d-8496-033ad45d7fea",
"updated": "2020-01-02T18:23:04Z",
"title": "John's Simple Event",
"start": "2020-01-15T13:00:00",
"timeZone": "America/New_York",
"duration": "PT1H",
"calendarIds": {
&john_calendar_id: true
},
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let response = jane
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Test #1",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let jane_calendar_id = response.created(0).id().to_string();
let jane_event_id = jane
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"uid": "a8df6575-0474-496d-8496-033ad45d7fea",
"updated": "2020-01-02T18:23:04Z",
"title": "Jane's Simple Event",
"start": "2020-01-15T13:00:00",
"timeZone": "America/New_York",
"duration": "PT1H",
"calendarIds": {
&jane_calendar_id: true
},
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
// Verify myRights
john.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::MyRights,
CalendarProperty::ShareWith,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"myRights": {
"mayReadItems": true,
"mayWriteAll": true,
"mayDelete": true,
"mayShare": true,
"mayWriteOwn": true,
"mayReadFreeBusy": true,
"mayUpdatePrivate": true,
"mayRSVP": true
},
"shareWith": {}
}));
// Obtain share notifications
let mut jane_share_change_id = jane
.jmap_get(
MethodObject::ShareNotification,
Vec::<&str>::new(),
Vec::<&str>::new(),
)
.await
.state()
.to_string();
// Make sure Jane has no access
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::Calendar,
Vec::<&str>::new(),
[john_calendar_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Share calendar with Jane
john.jmap_update(
MethodObject::Calendar,
[(
&john_calendar_id,
json!({
"shareWith": {
&jane_id : {
"mayReadItems": true,
}
}
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_calendar_id);
john.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::ShareWith,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"shareWith": {
&jane_id : {
"mayReadItems": true,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
}
}
}));
// Verify Jane can access the event
jane.jmap_get_account(
john,
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::MyRights,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"myRights": {
"mayReadItems": true,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
}
}));
jane.jmap_get_account(
john,
MethodObject::CalendarEvent,
[JSCalendarProperty::<Id>::Id, JSCalendarProperty::Title],
[john_event_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_event_id,
"title": "John's Simple Event",
}));
// Verify Jane received a share notification
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "Calendar",
"objectAccountId": &john_id,
"objectId": &john_calendar_id,
"oldRights": {
"mayReadItems": false,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"newRights": {
"mayReadItems": true,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"name": null
}));
// Updating and deleting should fail
assert_eq!(
jane.jmap_update_account(
john,
MethodObject::Calendar,
[(&john_calendar_id, json!({}))],
Vec::<(&str, &str)>::new(),
)
.await
.not_updated(&john_calendar_id)
.description(),
"You are not allowed to modify this calendar."
);
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::Calendar,
[&john_calendar_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&john_calendar_id)
.description(),
"You are not allowed to delete this calendar."
);
assert!(
jane.jmap_update_account(
john,
MethodObject::CalendarEvent,
[(&john_event_id, json!({}))],
Vec::<(&str, &str)>::new(),
)
.await
.not_updated(&john_event_id)
.description()
.contains("You are not allowed to modify calendar"),
);
assert!(
jane.jmap_destroy_account(
john,
MethodObject::CalendarEvent,
[&john_event_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&john_event_id)
.description()
.contains("You are not allowed to remove events from calendar"),
);
// Grant Jane write access
john.jmap_update(
MethodObject::Calendar,
[(
&john_calendar_id,
json!({
format!("shareWith/{jane_id}/mayWriteAll"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_calendar_id);
jane.jmap_get_account(
john,
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::MyRights,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"myRights": {
"mayReadItems": true,
"mayWriteAll": true,
"mayDelete": true,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
}
}));
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "Calendar",
"objectAccountId": &john_id,
"objectId": &john_calendar_id,
"oldRights": {
"mayReadItems": true,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"newRights": {
"mayReadItems": true,
"mayWriteAll": true,
"mayDelete": true,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"name": null
}));
// Creating a root folder should fail
assert_eq!(
jane.jmap_create_account(
john,
MethodObject::Calendar,
[json!({
"name": "A new shared calendar",
})],
Vec::<(&str, &str)>::new()
)
.await
.not_created(0)
.description(),
"Cannot create calendars in a shared account."
);
// Copy Jane's event into John's calendar
let john_copied_event_id = jane
.jmap_copy(
jane,
john,
MethodObject::CalendarEvent,
[(
&jane_event_id,
json!({
"calendarIds": {
&john_calendar_id: true
}
}),
)],
false,
)
.await
.copied(&jane_event_id)
.id()
.to_string();
jane.jmap_get_account(
john,
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::CalendarIds,
JSCalendarProperty::Title,
],
[john_copied_event_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_copied_event_id,
"title": "Jane's Simple Event",
"calendarIds": {
&john_calendar_id: true
}
}));
// Destroy the copied event
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::CalendarEvent,
[john_copied_event_id.as_str()],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[&john_copied_event_id]
);
// Update John's event
jane.jmap_update_account(
john,
MethodObject::CalendarEvent,
[(
&john_event_id,
json!({
"title": "John's Updated Event",
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_event_id);
jane.jmap_get_account(
john,
MethodObject::CalendarEvent,
[JSCalendarProperty::<Id>::Id, JSCalendarProperty::Title],
[john_event_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_event_id,
"title": "John's Updated Event",
}));
// Update John's calendar name
jane.jmap_update_account(
john,
MethodObject::Calendar,
[(
&john_calendar_id,
json!({
"name": "Jane's version of John's Calendar",
"description": "This is John's calendar, but Jane can edit it now"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_calendar_id);
jane.jmap_get_account(
john,
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::Description,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Jane's version of John's Calendar",
"description": "This is John's calendar, but Jane can edit it now"
}));
// John should still see the old name
john.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::Description,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"description": null
}));
// Revoke Jane's access
john.jmap_update(
MethodObject::Calendar,
[(
&john_calendar_id,
json!({
format!("shareWith/{jane_id}"): ()
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_calendar_id);
john.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::ShareWith,
],
[john_calendar_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_calendar_id,
"name": "Test #1",
"shareWith": {}
}));
// Verify Jane can no longer access the calendar or its events
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::Calendar,
Vec::<&str>::new(),
[john_calendar_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "Calendar",
"objectAccountId": &john_id,
"objectId": &john_calendar_id,
"oldRights": {
"mayReadItems": true,
"mayWriteAll": true,
"mayDelete": true,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"newRights": {
"mayReadItems": false,
"mayWriteAll": false,
"mayDelete": false,
"mayShare": false,
"mayWriteOwn": false,
"mayReadFreeBusy": false,
"mayUpdatePrivate": false,
"mayRSVP": false
},
"name": null
}));
// Grant Jane delete access once again
john.jmap_update(
MethodObject::Calendar,
[(
&john_calendar_id,
json!({
format!("shareWith/{jane_id}/mayReadItems"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_calendar_id);
// Verify Jane can delete the calendar
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::Calendar,
[john_calendar_id.as_str()],
[("onDestroyRemoveEvents", true)],
)
.await
.destroyed()
.collect::<Vec<_>>(),
[john_calendar_id.as_str()]
);
// Destroy all mailboxes
john.destroy_all_calendars().await;
jane.destroy_all_calendars().await;
test.assert_is_empty().await;
}
+175
View File
@@ -0,0 +1,175 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
jmap::{IntoJmapSet, JmapUtils},
server::TestServer,
};
use futures::StreamExt;
use jmap_client::{
CalendarAlert, PushObject, client_ws::WebSocketMessage, event_source::PushNotification,
};
use jmap_proto::request::method::MethodObject;
use mail_parser::DateTime;
use serde_json::json;
use std::time::Instant;
use store::write::now;
use tokio::sync::mpsc;
pub async fn test(test: &TestServer) {
println!("Running Calendar Alarm tests...");
let account = test.account("[email protected]");
let account_id = account.id_string();
let client = account.jmap_client().await;
let client_ws = account.jmap_client().await;
// Create test calendar
let response = account
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Alarming Calendar",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let calendar_id = response.created(0).id().to_string();
// Connect to EventSource
let (event_tx, mut event_rx) = mpsc::channel::<PushNotification>(100);
let mut notifications = client
.event_source(None::<Vec<_>>, false, 1.into(), None)
.await
.unwrap();
tokio::spawn(async move {
while let Some(notification) = notifications.next().await {
if let Err(_err) = event_tx.send(notification.unwrap()).await {
break;
}
}
});
// Connect to WebSocket
let mut ws_stream = client_ws.connect_ws().await.unwrap();
let (stream_tx, mut stream_rx) = mpsc::channel::<WebSocketMessage>(100);
tokio::spawn(async move {
while let Some(change) = ws_stream.next().await {
if stream_tx.send(change.unwrap()).await.is_err() {
break;
}
}
});
client_ws
.enable_push_ws(None::<Vec<_>>, None::<&str>)
.await
.unwrap();
// Create test event
let response = account
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"@type": "Event",
"calendarIds": ([calendar_id.as_str()].into_jmap_set()),
"description": "What mirror where?!",
"timeZone": "Etc/UTC",
"start": DateTime::from_timestamp(now() as i64 + 5)
.to_rfc3339().trim_end_matches("Z").to_string(),
"title": "See the pretty girl in that mirror there",
"alerts": {
"k1": {
"@type": "Alert",
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT2S"
},
"action": "display"
},
"k2": {
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT4S"
},
"action": "display",
"@type": "Alert"
}
},
"locations": {
"0b7168ae-ed3e-5eae-9540-89ba3a469b16": {
"name": "West Side",
"@type": "Location"
}
},
"uid": "2371c2d9-a136-43b0-bba3-f6ab249ad46e",
"duration": "P1D"
})],
Vec::<(&str, &str)>::new(),
)
.await;
let event_id = response.created(0).id().to_string();
// Wait for alarm notifications
let start = Instant::now();
let mut ws_events = Vec::new();
let mut es_events = Vec::new();
while start.elapsed().as_secs() < 7 && (ws_events.len() < 2 || es_events.len() < 2) {
tokio::select! {
Some(notification) = event_rx.recv() => {
if let PushNotification::CalendarAlert(alert) = notification {
es_events.push(alert);
}
}
Some(message) = stream_rx.recv() => {
match message {
WebSocketMessage::PushNotification(PushObject::CalendarAlert(alert)) => {
ws_events.push(alert);
}
WebSocketMessage::PushNotification(PushObject::Group {entries} ) => {
ws_events.extend(entries.into_iter().filter_map(|entry| {
if let PushObject::CalendarAlert(alert) = entry {
Some(alert)
} else {
None
}
}));
}
_ => {}
}
}
_ = tokio::time::sleep(std::time::Duration::from_secs(6)) => {
break;
}
}
}
let expected_alerts = vec![
CalendarAlert {
account_id: account_id.to_string(),
calendar_event_id: event_id.clone(),
uid: "2371c2d9-a136-43b0-bba3-f6ab249ad46e".to_string(),
recurrence_id: None,
alert_id: "k2".to_string(),
},
CalendarAlert {
account_id: account_id.to_string(),
calendar_event_id: event_id.clone(),
uid: "2371c2d9-a136-43b0-bba3-f6ab249ad46e".to_string(),
recurrence_id: None,
alert_id: "k1".to_string(),
},
];
assert_eq!(
es_events, expected_alerts,
"EventSource alarms do not match"
);
assert_eq!(ws_events, expected_alerts, "WebSocket alarms do not match");
// Cleanup
account.destroy_all_calendars().await;
test.assert_is_empty().await;
}
+377
View File
@@ -0,0 +1,377 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
jmap::{ChangeType, JmapUtils},
server::TestServer,
};
use jmap_proto::{object::calendar::CalendarProperty, request::method::MethodObject};
use serde_json::json;
pub async fn test(test: &TestServer) {
println!("Running Calendar tests...");
let account = test.account("[email protected]");
// Make sure the default calendar exists
let response = account
.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::Description,
CalendarProperty::SortOrder,
CalendarProperty::Color,
CalendarProperty::TimeZone,
CalendarProperty::IsSubscribed,
CalendarProperty::IsDefault,
CalendarProperty::IsVisible,
CalendarProperty::IncludeInAvailability,
CalendarProperty::DefaultAlertsWithTime,
CalendarProperty::DefaultAlertsWithoutTime,
],
Vec::<&str>::new(),
)
.await;
let list = response.list();
assert_eq!(list.len(), 1);
let default_calendar_id = list[0].id().to_string();
assert_eq!(
list[0],
json!({
"id": default_calendar_id,
"name": "Stalwart Calendar ([email protected])",
"description": null,
"sortOrder": 0,
"isSubscribed": true,
"isDefault": true,
"color": null,
"timeZone": null,
"isVisible": true,
"includeInAvailability": "all",
"defaultAlertsWithTime": {},
"defaultAlertsWithoutTime": {}
})
);
let change_id = response.state();
// Create Calendar
let calendar_id = account
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Test calendar",
"description": "My personal calendar",
"sortOrder": 1,
"isSubscribed": true,
"color": "#ff0000",
"timeZone": "Indian/Christmas",
"isVisible": false,
"includeInAvailability": "attending",
"defaultAlertsWithTime": {
"0": {
"action": "display",
"trigger": {
"relativeTo": "start",
"offset": "PT15M"
}
},
"1": {
"action": "email",
"trigger": {
"relativeTo": "end",
"offset": "PT30M"
}
}
},
"defaultAlertsWithoutTime": {
"0": {
"action": "display",
"trigger": {
"relativeTo": "start",
"offset": "P1D"
}
},
"1": {
"action": "email",
"trigger": {
"relativeTo": "end",
"offset": "P2D"
}
}
}
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
// Validate changes
assert_eq!(
account
.jmap_changes(MethodObject::Calendar, change_id)
.await
.changes()
.collect::<Vec<_>>(),
[ChangeType::Created(&calendar_id)]
);
// Get Calendar
let response = account
.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::Description,
CalendarProperty::SortOrder,
CalendarProperty::Color,
CalendarProperty::TimeZone,
CalendarProperty::IsSubscribed,
CalendarProperty::IsDefault,
CalendarProperty::IsVisible,
CalendarProperty::IncludeInAvailability,
CalendarProperty::DefaultAlertsWithTime,
CalendarProperty::DefaultAlertsWithoutTime,
],
[&calendar_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"name": "Test calendar",
"description": "My personal calendar",
"sortOrder": 1,
"isSubscribed": true,
"isVisible": false,
"isDefault": false,
"color": "#ff0000",
"timeZone": "Indian/Christmas",
"includeInAvailability": "attending",
"defaultAlertsWithTime": {
"0": {
"@type": "Alert",
"action": "display",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "start",
"offset": "PT15M"
}
},
"1": {
"@type": "Alert",
"action": "email",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "end",
"offset": "PT30M"
}
}
},
"defaultAlertsWithoutTime": {
"0": {
"@type": "Alert",
"action": "display",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "start",
"offset": "P1D"
}
},
"1": {
"@type": "Alert",
"action": "email",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "end",
"offset": "P2D"
}
}
},
"id": calendar_id,
}));
// Update Calendar and set it as default
account
.jmap_update(
MethodObject::Calendar,
[(
calendar_id.as_str(),
json!({
"name": "Updated calendar",
"description": "My updated personal calendar",
"sortOrder": 2,
"isSubscribed": false,
"isVisible": true,
"timeZone": null,
"color": null,
"includeInAvailability": "none",
"defaultAlertsWithTime": {
"0": {
"action": "email",
"trigger": {
"relativeTo": "start",
"offset": "PT10M"
}
}
},
"defaultAlertsWithoutTime/0": {
"action": "email",
"trigger": {
"relativeTo": "start",
"offset": "P3D"
}
},
"defaultAlertsWithoutTime/1": null,
"defaultAlertsWithoutTime/2": {
"action": "display",
"trigger": {
"relativeTo": "end",
"offset": "P1W"
}
}
}),
)],
[("onSuccessSetIsDefault", calendar_id.as_str())],
)
.await
.updated(&calendar_id);
// Validate changes
let response = account
.jmap_get(
MethodObject::Calendar,
[
CalendarProperty::Id,
CalendarProperty::Name,
CalendarProperty::Description,
CalendarProperty::SortOrder,
CalendarProperty::Color,
CalendarProperty::TimeZone,
CalendarProperty::IsSubscribed,
CalendarProperty::IsDefault,
CalendarProperty::IsVisible,
CalendarProperty::IncludeInAvailability,
CalendarProperty::DefaultAlertsWithTime,
CalendarProperty::DefaultAlertsWithoutTime,
],
[&calendar_id, &default_calendar_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"id": calendar_id,
"name": "Updated calendar",
"description": "My updated personal calendar",
"sortOrder": 2,
"isSubscribed": false,
"isDefault": true,
"color": null,
"timeZone": null,
"isVisible": true,
"includeInAvailability": "none",
"defaultAlertsWithTime": {
"0": {
"@type": "Alert",
"action": "email",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "start",
"offset": "PT10M"
}
}
},
"defaultAlertsWithoutTime": {
"0": {
"@type": "Alert",
"action": "email",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "start",
"offset": "P3D"
}
},
"2": {
"@type": "Alert",
"action": "display",
"trigger": {
"@type": "OffsetTrigger",
"relativeTo": "end",
"offset": "P1W"
}
}
}
}));
response.list()[1].assert_is_equal(json!({
"id": default_calendar_id,
"name": "Stalwart Calendar ([email protected])",
"description": (),
"sortOrder": 0,
"isSubscribed": true,
"isDefault": false,
"color": null,
"timeZone": null,
"isVisible": true,
"includeInAvailability": "all",
"defaultAlertsWithTime": {},
"defaultAlertsWithoutTime": {}
}));
// Create an event
let _ = account
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"calendarIds": {
&calendar_id: true
},
"@type": "Event",
"uid": "a8df6573-0474-496d-8496-033ad45d7fea",
"updated": "2020-01-02T18:23:04Z",
"title": "Some event",
"start": "2020-01-15T13:00:00",
"timeZone": "America/New_York",
"duration": "PT1H"
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id();
// Try destroying the calendar (should fail)
assert_eq!(
account
.jmap_destroy(
MethodObject::Calendar,
[&calendar_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&calendar_id)
.typ(),
"calendarHasEvent"
);
// Destroy using force
assert_eq!(
account
.jmap_destroy(
MethodObject::Calendar,
[&calendar_id],
[("onDestroyRemoveEvents", true)],
)
.await
.destroyed()
.collect::<Vec<_>>(),
vec![&calendar_id]
);
// Destroy all mailboxes
account.destroy_all_calendars().await;
test.assert_is_empty().await;
}
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{jmap::JmapUtils, server::TestServer};
use jmap_proto::{
object::participant_identity::ParticipantIdentityProperty, request::method::MethodObject,
};
use serde_json::json;
use store::write::BatchBuilder;
use types::{collection::Collection, field::PrincipalField};
pub async fn test(test: &TestServer) {
println!("Running Participant Identity tests...");
let account = test.account("[email protected]");
// Obtain all identities
let response = account
.jmap_get(
MethodObject::ParticipantIdentity,
[
ParticipantIdentityProperty::Id,
ParticipantIdentityProperty::Name,
ParticipantIdentityProperty::CalendarAddress,
ParticipantIdentityProperty::IsDefault,
],
Vec::<&str>::new(),
)
.await;
response.list_array().assert_is_equal(json!([
{
"id": "a",
"name": "John Doe",
"calendarAddress": "mailto:[email protected]",
"isDefault": true
},
{
"id": "b",
"name": "John Doe",
"calendarAddress": "mailto:[email protected]",
"isDefault": false
}
]));
// Destroy identity b
let response = account
.jmap_destroy(
MethodObject::ParticipantIdentity,
["b"],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.destroyed().next(), Some("b"));
let response = account
.jmap_get(
MethodObject::ParticipantIdentity,
[
ParticipantIdentityProperty::Id,
ParticipantIdentityProperty::Name,
ParticipantIdentityProperty::CalendarAddress,
ParticipantIdentityProperty::IsDefault,
],
Vec::<&str>::new(),
)
.await;
response.list_array().assert_is_equal(json!([
{
"id": "a",
"name": "John Doe",
"calendarAddress": "mailto:[email protected]",
"isDefault": true
}
]));
// Creating a new identity with an unauthorized calendar address should fail
let response = account
.jmap_create(
MethodObject::ParticipantIdentity,
[
json!({
"name": "Work",
"calendarAddress": "mailto:[email protected]"
}),
json!({
"name": "Work",
"calendarAddress": "[email protected]"
}),
],
[("onSuccessSetIsDefault", "#i0")],
)
.await;
assert_eq!(
response.not_created(0).description(),
"Calendar address not configured for this account."
);
assert_eq!(
response.not_created(1).description(),
"Calendar address not configured for this account."
);
// Create a new identity and set it as default
let response = account
.jmap_create(
MethodObject::ParticipantIdentity,
[json!({
"name": "Johnny B Goode",
"calendarAddress": "mailto:[email protected]"
})],
[("onSuccessSetIsDefault", "#i0")],
)
.await;
response.created(0);
let response = account
.jmap_get(
MethodObject::ParticipantIdentity,
[
ParticipantIdentityProperty::Id,
ParticipantIdentityProperty::Name,
ParticipantIdentityProperty::CalendarAddress,
ParticipantIdentityProperty::IsDefault,
],
Vec::<&str>::new(),
)
.await;
response.list_array().assert_is_equal(json!([
{
"id": "a",
"name": "John Doe",
"calendarAddress": "mailto:[email protected]",
"isDefault": false
},
{
"id": "b",
"name": "Johnny B Goode",
"calendarAddress": "mailto:[email protected]",
"isDefault": true
}
]));
// Cleanup
let mut batch = BatchBuilder::new();
batch
.with_account_id(account.id().document_id())
.with_collection(Collection::Principal)
.with_document(0)
.clear(PrincipalField::ParticipantIdentities);
test.server.commit_batch(batch).await.unwrap();
test.assert_is_empty().await;
}
+991
View File
@@ -0,0 +1,991 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::calendar::event::assert_eq_ignoring_updated,
utils::{account::Account, jmap::JmapUtils, server::TestServer},
};
use groupware::cache::GroupwareCache;
use hyper::StatusCode;
use jmap_proto::request::method::MethodObject;
use serde_json::{Value, json};
use std::str::FromStr;
use types::{collection::SyncCollection, id::Id};
pub async fn test(test: &TestServer) {
println!("Running Calendar Event instance tests...");
let account = test.account("[email protected]");
let calendar_id = account
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Recurring events",
"timeZone": "US/Eastern",
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let response = account
.jmap_create(
MethodObject::CalendarEvent,
[
json!({
"@type": "Event",
"uid": "[email protected]",
"title": "Daily standup",
"start": "2007-03-05T12:00:00",
"duration": "PT1H",
"timeZone": "US/Eastern",
"updated": "2007-02-06T00:11:21Z",
"recurrenceRule": {
"frequency": "daily",
"count": 5
},
"locations": {
"loc1": {
"@type": "Location",
"name": "Room A"
}
},
"recurrenceOverrides": {
"2007-03-07T12:00:00": {
"title": "Moved standup",
"start": "2007-03-07T15:00:00",
"duration": "PT1H",
"updated": "2007-02-06T00:11:21Z"
}
},
"calendarIds": {
&calendar_id: true
}
}),
json!({
"@type": "Event",
"uid": "[email protected]",
"title": "One off",
"start": "2007-03-12T09:00:00",
"duration": "PT2H",
"timeZone": "US/Eastern",
"updated": "2007-02-06T00:11:21Z",
"calendarIds": {
&calendar_id: true
}
}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let recurring_id = response.created(0).id().to_string();
let single_id = response.created(1).id().to_string();
test.wait_for_tasks().await;
// The overridden occurrence keeps its original recurrence id
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-07T15:00:00",
"2007-03-08T12:00:00",
"2007-03-09T12:00:00",
"2007-03-12T09:00:00"
]
);
assert_eq!(
instance(&instances, "2007-03-07T15:00:00").text_field("recurrenceId"),
"2007-03-07T12:00:00"
);
// Synthetic instances return null recurrence properties when requested
for start in [
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-07T15:00:00",
"2007-03-08T12:00:00",
"2007-03-09T12:00:00",
] {
let instance = instance(&instances, start);
for property in ["recurrenceRule", "recurrenceOverrides"] {
assert_eq!(
instance.get(property),
Some(&Value::Null),
"{property} on {start}: {instance:?}"
);
}
}
// Unknown instances are reported as not found
let unknown_id =
Id::from_parts(1000, Id::from_str(&recurring_id).unwrap().document_id()).to_string();
let response = account
.jmap_update(
MethodObject::CalendarEvent,
[(&unknown_id, json!({"title": "Nope"}))],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.not_updated(&unknown_id).typ(), "notFound");
// Updating an instance generated by the recurrence rule creates an override
let id = instance_id(&instances, "2007-03-06T12:00:00");
account
.jmap_update(
MethodObject::CalendarEvent,
[(
&id,
json!({
"title": "Standup with guests",
"locations/loc1/name": "Room B"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
let updated = instance(&instances, "2007-03-06T12:00:00");
assert_eq!(updated.text_field("title"), "Standup with guests");
assert_eq!(updated.text_field("duration"), "PT1H");
assert_eq!(
updated
.pointer("/locations/loc1/name")
.and_then(|v| v.as_str()),
Some("Room B")
);
assert_eq!(
instance(&instances, "2007-03-05T12:00:00").text_field("title"),
"Daily standup"
);
// Updating an occurrence that is already overridden patches the existing override
let id = instance_id(&instances, "2007-03-07T15:00:00");
account
.jmap_update(
MethodObject::CalendarEvent,
[(
&id,
json!({
"title": "Moved standup, renamed"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
assert_eq_ignoring_updated(
&base_event(account, &recurring_id).await,
json!({
"id": &recurring_id,
"title": "Daily standup",
"locations": {
"loc1": {
"@type": "Location",
"name": "Room A"
}
},
"recurrenceOverrides": {
"2007-03-06T12:00:00": {
"title": "Standup with guests",
"start": "2007-03-06T12:00:00",
"duration": "PT1H",
"locations": {
"loc1": {
"@type": "Location",
"name": "Room B"
}
}
},
"2007-03-07T12:00:00": {
"title": "Moved standup, renamed",
"start": "2007-03-07T15:00:00",
"duration": "PT1H"
}
}
}),
);
// A rejected instance is not written, even when another instance of the same event is
let instances = expand_instances(account, &calendar_id).await;
let good_id = instance_id(&instances, "2007-03-09T12:00:00");
let bad_id = instance_id(&instances, "2007-03-08T12:00:00");
let response = account
.jmap_update(
MethodObject::CalendarEvent,
[
(&good_id, json!({"title": "Applied"})),
(
&bad_id,
json!({"title": "Rejected", "utcStart": "2007-03-08T17:00:00Z"}),
),
],
Vec::<(&str, &str)>::new(),
)
.await;
response.updated(&good_id);
assert_eq!(response.not_updated(&bad_id).typ(), "invalidProperties");
test.wait_for_tasks().await;
let base = base_event(account, &recurring_id).await;
let mut overrides = base
.pointer("/recurrenceOverrides")
.and_then(|value| value.as_object())
.unwrap()
.keys()
.map(String::as_str)
.collect::<Vec<_>>();
overrides.sort_unstable();
assert_eq!(
overrides,
[
"2007-03-06T12:00:00",
"2007-03-07T12:00:00",
"2007-03-09T12:00:00"
]
);
// Destroying an instance removes just that occurrence
let instances = expand_instances(account, &calendar_id).await;
let id = instance_id(&instances, "2007-03-08T12:00:00");
assert_eq!(
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[id.as_str()]
);
test.wait_for_tasks().await;
assert_eq!(
starts(&expand_instances(account, &calendar_id).await),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-07T15:00:00",
"2007-03-09T12:00:00",
"2007-03-12T09:00:00"
]
);
// Destroying an overridden instance does not bring back the original occurrence
let instances = expand_instances(account, &calendar_id).await;
let id = instance_id(&instances, "2007-03-07T15:00:00");
assert_eq!(
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[id.as_str()]
);
test.wait_for_tasks().await;
assert_eq!(
starts(&expand_instances(account, &calendar_id).await),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-09T12:00:00",
"2007-03-12T09:00:00"
]
);
// Several instances of the same event may be changed in a single request
let instances = expand_instances(account, &calendar_id).await;
let update_id = instance_id(&instances, "2007-03-05T12:00:00");
let destroy_id = instance_id(&instances, "2007-03-09T12:00:00");
let response = account
.jmap_method_calls(json!([[
"CalendarEvent/set",
{
"accountId": account.id_string(),
"update": {
&update_id: {
"title": "First standup"
}
},
"destroy": [&destroy_id]
},
"0"
]]))
.await;
response.updated(&update_id);
assert_eq!(
response.destroyed().collect::<Vec<_>>(),
[destroy_id.as_str()]
);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-12T09:00:00"
]
);
assert_eq!(
instance(&instances, "2007-03-05T12:00:00").text_field("title"),
"First standup"
);
// Excluding the occurrence the event starts on is allowed
let id = instance_id(&instances, "2007-03-05T12:00:00");
assert_eq!(
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[id.as_str()]
);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
["2007-03-06T12:00:00", "2007-03-12T09:00:00"]
);
// A base event and its instances cannot be modified in the same request
let id = instance_id(&instances, "2007-03-06T12:00:00");
let response = account
.jmap_update(
MethodObject::CalendarEvent,
[
(&recurring_id, json!({"title": "Renamed"})),
(&id, json!({"title": "Renamed instance"})),
],
Vec::<(&str, &str)>::new(),
)
.await;
for id in [&recurring_id, &id] {
assert_eq!(response.not_updated(id).typ(), "invalidProperties");
assert_eq!(
response.not_updated(id).description(),
"A base event and its instances cannot be modified in the same request."
);
}
// Properties that are not per-occurrence are rejected
for property in [
json!({"calendarIds": {&calendar_id: true}}),
json!({"isDraft": true}),
json!({"utcStart": "2007-03-06T17:00:00Z"}),
json!({"utcEnd": "2007-03-06T18:00:00Z"}),
json!({"mayInviteSelf": true}),
json!({"useDefaultAlerts": true}),
] {
let response = account
.jmap_update(
MethodObject::CalendarEvent,
[(&id, property)],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.not_updated(&id).typ(), "invalidProperties");
assert_eq!(
response.not_updated(&id).description(),
"This property cannot be modified on a single occurrence."
);
}
// Properties an occurrence inherits from the base event are ignored, not rejected
account
.jmap_update(
MethodObject::CalendarEvent,
[(
&id,
json!({
"@type": "Event",
"title": "Ignoring inherited properties",
"uid": "[email protected]",
"recurrenceRule": {"frequency": "weekly"},
"privacy": "private",
"participants/xyz/calendarAddress": "mailto:[email protected]"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
let base = account
.jmap_get(
MethodObject::CalendarEvent,
["id", "uid", "recurrenceRule", "privacy", "participants"],
[&recurring_id],
)
.await;
let base = &base.list()[0];
assert_eq!(base.text_field("uid"), "[email protected]");
assert_eq!(
base.pointer("/recurrenceRule/frequency")
.and_then(|v| v.as_str()),
Some("daily")
);
assert_eq!(base.pointer("/privacy"), None);
assert_eq!(base.pointer("/participants"), None);
assert_eq!(
instance(
&expand_instances(account, &calendar_id).await,
"2007-03-06T12:00:00"
)
.text_field("title"),
"Ignoring inherited properties"
);
// Destroying an event that is also being updated through one of its instances fails
let response = account
.jmap_method_calls(json!([[
"CalendarEvent/set",
{
"accountId": account.id_string(),
"update": {
&id: {
"title": "Renamed instance"
}
},
"destroy": [&recurring_id]
},
"0"
]]))
.await;
assert_eq!(response.not_updated(&id).typ(), "willDestroy");
assert_eq!(
response.destroyed().collect::<Vec<_>>(),
[recurring_id.as_str()]
);
test.wait_for_tasks().await;
// A synthetic id of a non-recurring event refers to the event itself
let instances = expand_instances(account, &calendar_id).await;
let id = instance_id(&instances, "2007-03-12T09:00:00");
assert_ne!(id, single_id);
account
.jmap_update(
MethodObject::CalendarEvent,
[(&id, json!({"title": "One off, renamed"}))],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
assert_eq_ignoring_updated(
&base_event(account, &single_id).await,
json!({
"id": &single_id,
"title": "One off, renamed"
}),
);
let instances = expand_instances(account, &calendar_id).await;
let id = instance_id(&instances, "2007-03-12T09:00:00");
assert_eq!(
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[id.as_str()]
);
test.wait_for_tasks().await;
assert!(expand_instances(account, &calendar_id).await.is_empty());
// Instances of floating all-day events keep their local time and duration
let all_day_id = account
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"@type": "Event",
"uid": "[email protected]",
"title": "Spring break",
"start": "2007-04-02T00:00:00",
"duration": "P1D",
"showWithoutTime": true,
"updated": "2007-02-06T00:11:21Z",
"recurrenceRule": {
"frequency": "daily",
"count": 3
},
"calendarIds": {
&calendar_id: true
}
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
[
"2007-04-02T00:00:00",
"2007-04-03T00:00:00",
"2007-04-04T00:00:00"
]
);
let update_id = instance_id(&instances, "2007-04-03T00:00:00");
let destroy_id = instance_id(&instances, "2007-04-04T00:00:00");
let response = account
.jmap_method_calls(json!([[
"CalendarEvent/set",
{
"accountId": account.id_string(),
"update": {
&update_id: {
"title": "Spring break, day two"
}
},
"destroy": [&destroy_id]
},
"0"
]]))
.await;
response.updated(&update_id);
assert_eq!(
response.destroyed().collect::<Vec<_>>(),
[destroy_id.as_str()]
);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
["2007-04-02T00:00:00", "2007-04-03T00:00:00"]
);
let updated = instance(&instances, "2007-04-03T00:00:00");
assert_eq!(updated.text_field("title"), "Spring break, day two");
assert_eq!(updated.text_field("duration"), "P1D");
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&all_day_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.next()
.unwrap();
// Occurrences covered by a this-and-future change cannot be modified individually
let dav_client = account.webdav_client();
let account_id = account.id().document_id();
let resources = test
.server
.fetch_dav_resources(account_id, account_id, SyncCollection::Calendar)
.await
.unwrap();
let calendar_document_id = Id::from_str(&calendar_id).unwrap().document_id();
let calendar_path = resources
.paths
.iter()
.find(|path| {
path.parent_id.is_none()
&& resources.resources[path.resource_idx].document_id == calendar_document_id
})
.map(|path| format!("{}{}", resources.base_path, path.path))
.unwrap();
dav_client
.request(
"PUT",
&format!("{calendar_path}/this-and-future.ics"),
THIS_AND_FUTURE_ICAL,
)
.await
.with_status(StatusCode::CREATED);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-07T14:00:00",
"2007-03-08T14:00:00",
"2007-03-09T14:00:00"
]
);
assert_eq!(
instance(&instances, "2007-03-07T14:00:00").text_field("recurrenceId"),
"2007-03-07T12:00:00"
);
for start in ["2007-03-08T14:00:00", "2007-03-09T14:00:00"] {
assert_eq!(
instance(&instances, start).text_field("recurrenceId"),
start
);
let id = instance_id(&instances, start);
let response = account
.jmap_update(
MethodObject::CalendarEvent,
[(&id, json!({"title": "Should not apply"}))],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.not_updated(&id).typ(), "invalidProperties");
assert_eq!(
response.not_updated(&id).description(),
"Occurrences of a this-and-future change cannot be modified individually."
);
let response = account
.jmap_destroy(
MethodObject::CalendarEvent,
[&id],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.not_destroyed(&id).typ(), "invalidProperties");
}
// The occurrence the this-and-future change starts on is still editable
let id = instance_id(&instances, "2007-03-07T14:00:00");
account
.jmap_update(
MethodObject::CalendarEvent,
[(&id, json!({"title": "Split renamed"}))],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
[
"2007-03-05T12:00:00",
"2007-03-06T12:00:00",
"2007-03-07T14:00:00",
"2007-03-08T14:00:00",
"2007-03-09T14:00:00"
]
);
assert_eq!(
instance(&instances, "2007-03-07T14:00:00").text_field("title"),
"Split renamed"
);
dav_client
.request(
"DELETE",
&format!("{calendar_path}/this-and-future.ics"),
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
// Patching an occurrence does not recompute its duration
dav_client
.request(
"PUT",
&format!("{calendar_path}/night-shift.ics"),
NIGHT_SHIFT_ICAL,
)
.await
.with_status(StatusCode::CREATED);
test.wait_for_tasks().await;
let instances = expand_instances(account, &calendar_id).await;
assert_eq!(
starts(&instances),
["2007-03-10T23:00:00", "2007-03-11T23:00:00"]
);
assert_eq!(
instance(&instances, "2007-03-10T23:00:00").text_field("duration"),
"PT5H"
);
let id = instance_id(&instances, "2007-03-10T23:00:00");
account
.jmap_update(
MethodObject::CalendarEvent,
[(&id, json!({"title": "Night shift, renamed"}))],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&id);
test.wait_for_tasks().await;
let updated = expand_instances(account, &calendar_id).await;
let updated = instance(&updated, "2007-03-10T23:00:00");
assert_eq!(updated.text_field("title"), "Night shift, renamed");
assert_eq!(updated.text_field("duration"), "PT5H");
dav_client
.request("DELETE", &format!("{calendar_path}/night-shift.ics"), "")
.await
.with_status(StatusCode::NO_CONTENT);
// Synthetic ids keep identifying the same occurrence across writes
let series_calendar_id = account
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Stable instance ids",
"timeZone": "US/Eastern",
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
account
.jmap_create(
MethodObject::CalendarEvent,
[json!({
"@type": "Event",
"uid": "[email protected]",
"title": "Weekly sync",
"start": "2007-04-02T09:00:00",
"duration": "PT1H",
"timeZone": "US/Eastern",
"updated": "2007-02-06T00:11:21Z",
"recurrenceRule": {
"frequency": "weekly",
"count": 5
},
"calendarIds": {
&series_calendar_id: true
}
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0);
test.wait_for_tasks().await;
let instances = expand_instances(account, &series_calendar_id).await;
let held_ids = instances
.iter()
.map(|instance| instance.id().to_string())
.collect::<Vec<_>>();
assert_eq!(
starts(&instances),
[
"2007-04-02T09:00:00",
"2007-04-09T09:00:00",
"2007-04-16T09:00:00",
"2007-04-23T09:00:00",
"2007-04-30T09:00:00"
]
);
let moved_id = instance_id(&instances, "2007-04-09T09:00:00");
account
.jmap_update(
MethodObject::CalendarEvent,
[(
&moved_id,
json!({
"title": "Weekly sync, moved",
"start": "2007-04-09T14:00:00"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&moved_id);
test.wait_for_tasks().await;
let held = account
.jmap_get(
MethodObject::CalendarEvent,
["id", "start", "recurrenceId"],
held_ids.clone(),
)
.await
.list()
.to_vec();
let held_start = |id: &str| {
held.iter()
.find(|event| event.id() == id)
.unwrap_or_else(|| panic!("Missing instance {id}: {held:?}"))
.text_field("start")
};
assert_eq!(
held_ids.iter().map(|id| held_start(id)).collect::<Vec<_>>(),
[
"2007-04-02T09:00:00",
"2007-04-09T14:00:00",
"2007-04-16T09:00:00",
"2007-04-23T09:00:00",
"2007-04-30T09:00:00"
]
);
assert_eq!(
held.iter()
.find(|event| event.id() == held_ids[1])
.map(|event| event.text_field("recurrenceId")),
Some("2007-04-09T09:00:00")
);
// Destroying an id held across a write removes the occurrence it was issued for
assert_eq!(
account
.jmap_destroy(
MethodObject::CalendarEvent,
[&held_ids[2]],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[held_ids[2].as_str()]
);
test.wait_for_tasks().await;
assert_eq!(
starts(&expand_instances(account, &series_calendar_id).await),
[
"2007-04-02T09:00:00",
"2007-04-09T14:00:00",
"2007-04-23T09:00:00",
"2007-04-30T09:00:00"
]
);
// Clean up
for calendar_id in [&calendar_id, &series_calendar_id] {
account
.jmap_destroy(
MethodObject::Calendar,
[calendar_id],
[("onDestroyRemoveEvents", true)],
)
.await;
}
}
async fn expand_instances(account: &Account, calendar_id: &str) -> Vec<Value> {
let ids = account
.jmap_query(
MethodObject::CalendarEvent,
[
("inCalendar", Value::String(calendar_id.to_string())),
("after", Value::String("2007-03-01T00:00:00".to_string())),
("before", Value::String("2007-05-01T00:00:00".to_string())),
],
["start"],
[
("timeZone", Value::String("US/Eastern".to_string())),
("expandRecurrences", Value::Bool(true)),
],
)
.await
.ids()
.map(|id| id.to_string())
.collect::<Vec<_>>();
if ids.is_empty() {
return Vec::new();
}
account
.jmap_get(
MethodObject::CalendarEvent,
[
"id",
"baseEventId",
"start",
"duration",
"title",
"recurrenceId",
"locations",
"recurrenceRule",
"recurrenceOverrides",
],
ids,
)
.await
.list()
.to_vec()
}
async fn base_event(account: &Account, id: &str) -> Value {
account
.jmap_get(
MethodObject::CalendarEvent,
["id", "title", "locations", "recurrenceOverrides"],
[id],
)
.await
.list()[0]
.clone()
}
fn starts(instances: &[Value]) -> Vec<&str> {
instances
.iter()
.map(|instance| instance.text_field("start"))
.collect()
}
fn instance<'x>(instances: &'x [Value], start: &str) -> &'x Value {
instances
.iter()
.find(|instance| instance.text_field("start") == start)
.unwrap_or_else(|| panic!("Missing instance starting at {start}: {instances:?}"))
}
fn instance_id(instances: &[Value], start: &str) -> String {
instance(instances, start).id().to_string()
}
const THIS_AND_FUTURE_ICAL: &str = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n",
"BEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20070206T001121Z\r\n",
"SUMMARY:Standup\r\nDTSTART;TZID=US/Eastern:20070305T120000\r\nDURATION:PT1H\r\n",
"RRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n",
"BEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20070206T001121Z\r\n",
"SUMMARY:Standup moved\r\n",
"RECURRENCE-ID;TZID=US/Eastern;RANGE=THISANDFUTURE:20070307T120000\r\n",
"DTSTART;TZID=US/Eastern:20070307T140000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n",
"END:VCALENDAR\r\n"
);
const NIGHT_SHIFT_ICAL: &str = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n",
"BEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20070206T001121Z\r\n",
"SUMMARY:Night shift\r\nDTSTART;TZID=US/Eastern:20070310T230000\r\n",
"DTEND;TZID=US/Eastern:20070311T050000\r\nRRULE:FREQ=DAILY;COUNT=2\r\n",
"END:VEVENT\r\nEND:VCALENDAR\r\n"
);
+13
View File
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod acl;
pub mod alarm;
pub mod calendars;
pub mod event;
pub mod identity;
pub mod instance;
pub mod notification;
+582
View File
@@ -0,0 +1,582 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
jmap::{IntoJmapSet, JmapUtils},
server::TestServer,
};
use calcard::jscalendar::JSCalendarProperty;
use jmap_proto::{
object::calendar_event_notification::CalendarEventNotificationProperty,
request::method::MethodObject,
};
use mail_parser::DateTime;
use serde_json::{Value, json};
use store::write::now;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Calendar Event Notification tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let bill = test.account("[email protected]");
let john_id = john.id_string().to_string();
let jane_id = jane.id_string().to_string();
let bill_id = bill.id_string().to_string();
let mut john_change_id = String::new();
let mut jane_change_id = String::new();
let mut bill_change_id = String::new();
// Obtain share notification change ids for all accounts
for (change_id, client) in [
(&mut john_change_id, john),
(&mut jane_change_id, jane),
(&mut bill_change_id, bill),
] {
let response = client
.jmap_get(
MethodObject::CalendarEventNotification,
[CalendarEventNotificationProperty::Id],
Vec::<&str>::new(),
)
.await;
response.list_array().assert_is_equal(json!([]));
*change_id = response.state().to_string();
let response = client
.jmap_changes(MethodObject::CalendarEventNotification, &change_id)
.await;
assert_eq!(response.changes().next(), None);
assert_eq!(response.new_state(), change_id.as_str());
}
// Create test calendars
let response = john
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Test Calendar",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let john_calendar_id = response.created(0).id().to_string();
// Sent invitation to Jane and Bill
let john_event = test_event();
let response = john
.jmap_create(
MethodObject::CalendarEvent,
[john_event.clone().with_property(
JSCalendarProperty::<Id>::CalendarIds,
[john_calendar_id.as_str()].into_jmap_set(),
)],
[("sendSchedulingMessages", true)],
)
.await;
let john_event_id = response.created(0).id().to_string();
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
test.wait_for_tasks().await;
// Verify Jane and Bill received the share notification
let mut jane_event_id = String::new();
let mut bill_event_id = String::new();
for (change_id, event_id, client) in [
(&mut jane_change_id, &mut jane_event_id, jane),
(&mut bill_change_id, &mut bill_event_id, bill),
] {
// Obtain changes
let response = client
.jmap_changes(MethodObject::CalendarEventNotification, &change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
*change_id = response.new_state().to_string();
let notification_id = changes[0].as_created();
// Obtain and verify notification
let response = client
.jmap_get(
MethodObject::CalendarEventNotification,
[
CalendarEventNotificationProperty::Id,
CalendarEventNotificationProperty::Created,
CalendarEventNotificationProperty::ChangedBy,
CalendarEventNotificationProperty::Comment,
CalendarEventNotificationProperty::Type,
CalendarEventNotificationProperty::CalendarEventId,
CalendarEventNotificationProperty::IsDraft,
CalendarEventNotificationProperty::Event,
CalendarEventNotificationProperty::EventPatch,
],
[notification_id],
)
.await;
let notification = &response.list()[0];
*event_id = notification.text_field("calendarEventId").to_string();
notification.assert_is_equal(json!({
"id": &notification_id,
"created": &notification.text_field("created"),
"changedBy": {
"name": "John Doe",
"email": "[email protected]",
"principalId": &john_id
},
"type": "created",
"calendarEventId": event_id,
"isDraft": false,
"event": john_event
.clone()
.with_property(
"updated",
notification
.text_field("event/updated")
)
}));
// Verify the event exists
let response = client
.jmap_get(
MethodObject::CalendarEvent,
[JSCalendarProperty::<Id>::Id, JSCalendarProperty::Title],
[&event_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"id": &event_id,
"title": "Lunch"
}));
}
// Jane and Bill accept the invitation
let response = jane
.jmap_update(
MethodObject::CalendarEvent,
[(
&jane_event_id,
json!({
"participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus":
"accepted"}),
)],
[("sendSchedulingMessages", true)],
)
.await;
response.updated(&jane_event_id);
let response = bill
.jmap_update(
MethodObject::CalendarEvent,
[(
&bill_event_id,
json!({
"participants/86720268-d67c-58c3-9217-03df7d7ee4d8/participationStatus":
"accepted"}),
)],
[("sendSchedulingMessages", true)],
)
.await;
response.updated(&bill_event_id);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Verify John received two share notifications
let response = john
.jmap_changes(MethodObject::CalendarEventNotification, &john_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 2);
for (i, change) in changes.into_iter().enumerate() {
let notification_id = change.as_created();
// Obtain and verify notification
let response = john
.jmap_get(
MethodObject::CalendarEventNotification,
[
CalendarEventNotificationProperty::Id,
CalendarEventNotificationProperty::ChangedBy,
CalendarEventNotificationProperty::Comment,
CalendarEventNotificationProperty::Type,
CalendarEventNotificationProperty::CalendarEventId,
CalendarEventNotificationProperty::IsDraft,
],
[notification_id],
)
.await;
let changed_by = if i == 0 {
json!({
"name": "Jane Smith",
"email": "[email protected]",
"principalId": &jane_id,
})
} else {
json!({
"name": "Bill Foobar",
"email": "[email protected]",
"principalId": &bill_id,
})
};
response.list()[0].assert_is_equal(json!({
"id": &notification_id,
"changedBy": changed_by,
"type": "updated",
"calendarEventId": &john_event_id,
"isDraft": false
}));
}
// Verify the event was updated
let response = john
.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::Title,
JSCalendarProperty::Participants,
],
[&john_event_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"participants": {
"8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"roles": {
"chair": true,
"owner": true
},
"participationStatus": "accepted"
},
"a0171748-fe8d-57d8-879e-56036a5251d1": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"participationStatus": "accepted",
"kind": "individual"
},
"86720268-d67c-58c3-9217-03df7d7ee4d8": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"kind": "individual",
"participationStatus": "accepted"
}
},
"title": "Lunch",
"id": &john_event_id
}));
// Jane later declines the invitation
let response = jane
.jmap_update(
MethodObject::CalendarEvent,
[(
&jane_event_id,
json!({
"participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus":
"declined"}),
)],
[("sendSchedulingMessages", true)],
)
.await;
response.updated(&jane_event_id);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Make sure John received the update
let response = john
.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::Title,
JSCalendarProperty::Participants,
],
[&john_event_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"participants": {
"8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"roles": {
"chair": true,
"owner": true
},
"participationStatus": "accepted"
},
"a0171748-fe8d-57d8-879e-56036a5251d1": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"participationStatus": "declined",
"kind": "individual"
},
"86720268-d67c-58c3-9217-03df7d7ee4d8": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"kind": "individual",
"participationStatus": "accepted"
}
},
"title": "Lunch",
"id": &john_event_id
}));
// John deletes the event
let response = john
.jmap_destroy(
MethodObject::CalendarEvent,
[&john_event_id],
[("sendSchedulingMessages", true)],
)
.await;
assert_eq!(response.destroyed().collect::<Vec<_>>(), [&john_event_id]);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Verify that only Bill received the cancellation
let response = jane
.jmap_changes(MethodObject::CalendarEventNotification, &jane_change_id)
.await;
assert_eq!(response.changes().next(), None);
let response = bill
.jmap_changes(MethodObject::CalendarEventNotification, &bill_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let notification_id = changes[0].as_created();
let response = bill
.jmap_get(
MethodObject::CalendarEventNotification,
[
CalendarEventNotificationProperty::Id,
CalendarEventNotificationProperty::ChangedBy,
CalendarEventNotificationProperty::Comment,
CalendarEventNotificationProperty::Type,
CalendarEventNotificationProperty::CalendarEventId,
CalendarEventNotificationProperty::IsDraft,
],
[notification_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"id": &notification_id,
"changedBy": {
"name": "John Doe",
"email": "[email protected]",
"principalId": &john_id
},
"type": "updated",
"calendarEventId": &bill_event_id,
"isDraft": false
}));
// Verify Bill's event was updated
let response = bill
.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::Title,
JSCalendarProperty::Status,
],
[&bill_event_id],
)
.await;
response.list()[0].assert_is_equal(json!({
"id": &bill_event_id,
"title": "Lunch",
"status": "cancelled"
}));
// Scheduling messages are sent for a server-assigned organizer
let jane_change_id = jane
.jmap_get(
MethodObject::CalendarEventNotification,
[CalendarEventNotificationProperty::Id],
Vec::<&str>::new(),
)
.await
.state()
.to_string();
let response = john
.jmap_create(
MethodObject::CalendarEvent,
[test_event_without_organizer().with_property(
JSCalendarProperty::<Id>::CalendarIds,
[john_calendar_id.as_str()].into_jmap_set(),
)],
[("sendSchedulingMessages", true)],
)
.await;
let john_event_id = response.created(0).id().to_string();
john.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::OrganizerCalendarAddress,
],
[&john_event_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &john_event_id,
"organizerCalendarAddress": "mailto:[email protected]"
}));
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
test.wait_for_tasks().await;
let response = jane
.jmap_changes(MethodObject::CalendarEventNotification, &jane_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let notification_id = changes[0].as_created();
let jane_event_id = jane
.jmap_get(
MethodObject::CalendarEventNotification,
[
CalendarEventNotificationProperty::Id,
CalendarEventNotificationProperty::CalendarEventId,
],
[notification_id],
)
.await
.list()[0]
.text_field("calendarEventId")
.to_string();
jane.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::Title,
JSCalendarProperty::OrganizerCalendarAddress,
],
[&jane_event_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &jane_event_id,
"title": "Brunch",
"organizerCalendarAddress": "mailto:[email protected]"
}));
// An attendee replying to the invitation does not take over as organizer
jane.jmap_update(
MethodObject::CalendarEvent,
[(
&jane_event_id,
json!({
"participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus":
"accepted"}),
)],
[("sendSchedulingMessages", true)],
)
.await
.updated(&jane_event_id);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
jane.jmap_get(
MethodObject::CalendarEvent,
[
JSCalendarProperty::<Id>::Id,
JSCalendarProperty::OrganizerCalendarAddress,
],
[&jane_event_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &jane_event_id,
"organizerCalendarAddress": "mailto:[email protected]"
}));
// Cleanup
test.wait_for_tasks().await;
for client in [john, jane, bill] {
client.destroy_all_calendars().await;
client.destroy_all_event_notifications().await;
test.destroy_all_mailboxes(client).await;
}
test.assert_is_empty().await;
}
fn test_event_without_organizer() -> Value {
json!({
"uid": "9263504FD3AE",
"title": "Brunch",
"timeZone": "Europe/London",
"start": DateTime::from_timestamp(now() as i64 + 60 * 60)
.to_rfc3339().trim_end_matches("Z").to_string(),
"duration": "PT1H",
"freeBusyStatus": "busy",
"updated": "2009-06-02T17:00:00Z",
"sequence": 0,
"@type": "Event",
"participants": {
"8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": {
"calendarAddress": "mailto:[email protected]",
"participationStatus": "accepted",
"roles": {
"chair": true,
"owner": true
},
"@type": "Participant"
},
"a0171748-fe8d-57d8-879e-56036a5251d1": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"participationStatus": "needs-action",
"kind": "individual"
}
}
})
}
fn test_event() -> Value {
json!({
"uid": "9263504FD3AD",
"title": "Lunch",
"timeZone": "Europe/London",
"start": DateTime::from_timestamp(now() as i64 + 60 * 60)
.to_rfc3339().trim_end_matches("Z").to_string(),
"duration": "PT1H",
"freeBusyStatus": "busy",
"updated": "2009-06-02T17:00:00Z",
"sequence": 0,
"@type": "Event",
"participants": {
"8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": {
"calendarAddress": "mailto:[email protected]",
"participationStatus": "accepted",
"roles": {
"chair": true,
"owner": true
},
"@type": "Participant"
},
"a0171748-fe8d-57d8-879e-56036a5251d1": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"participationStatus": "needs-action",
"kind": "individual"
},
"86720268-d67c-58c3-9217-03df7d7ee4d8": {
"calendarAddress": "mailto:[email protected]",
"participationStatus": "needs-action",
"@type": "Participant",
"kind": "individual"
}
},
"organizerCalendarAddress": "mailto:[email protected]"
})
}
+275
View File
@@ -0,0 +1,275 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq, skip};
use crate::utils::jmap::JmapUtils;
use serde_json::json;
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] binary");
ctx.run("binary/upload-basic", upload_basic(ctx)).await;
ctx.run("binary/upload-binary-content", upload_binary_content(ctx))
.await;
ctx.run("binary/upload-large-data", upload_large_data(ctx))
.await;
ctx.run(
"binary/upload-preserves-content-type",
upload_preserves_content_type(ctx),
)
.await;
ctx.run(
"binary/upload-returns-valid-blob-id",
upload_returns_valid_blob_id(ctx),
)
.await;
ctx.run("binary/download-uploaded-blob", download_uploaded_blob(ctx))
.await;
ctx.run("binary/download-email-blob", download_email_blob(ctx))
.await;
ctx.run(
"binary/download-nonexistent-blob",
download_nonexistent_blob(ctx),
)
.await;
ctx.run(
"binary/download-respects-type-param",
download_respects_type_param(ctx),
)
.await;
ctx.run(
"binary/blob-copy-same-account-error",
blob_copy_same_account_error(ctx),
)
.await;
ctx.run(
"binary/blob-copy-cross-account",
blob_copy_cross_account(ctx),
)
.await;
ctx.run("binary/blob-copy-not-found", blob_copy_not_found(ctx))
.await;
ctx.run(
"binary/blob-copy-response-structure",
blob_copy_response_structure(ctx),
)
.await;
}
const CORE: &str = "urn:ietf:params:jmap:core";
const BLOB: &str = "urn:ietf:params:jmap:blob";
async fn upload_basic(ctx: &CompCtx<'_>) -> TestOutcome {
let data = b"Hello, JMAP upload test!".to_vec();
let len = data.len() as i64;
let result = ctx.upload(ctx.primary, "text/plain", data).await;
check(!result.blob_id().is_empty(), "Must return blobId")?;
let typ = result.typ();
check(
typ == "text/plain" || typ.starts_with("text/plain;"),
format!("Expected type to be text/plain (possibly with params), got \"{typ}\""),
)?;
check_eq(result.integer_field("size"), len, "size")?;
check_eq(
result.text_field("accountId"),
ctx.account_id(),
"accountId",
)
}
async fn upload_binary_content(ctx: &CompCtx<'_>) -> TestOutcome {
let data = vec![0x00u8, 0x01, 0x02, 0xff, 0xfe, 0xfd];
let result = ctx
.upload(ctx.primary, "application/octet-stream", data)
.await;
check(!result.blob_id().is_empty(), "Must return blobId")?;
check_eq(result.integer_field("size"), 6, "size")
}
async fn upload_large_data(ctx: &CompCtx<'_>) -> TestOutcome {
let len = 100 * 1024;
let data: Vec<u8> = (0..len).map(|i| (i & 0xff) as u8).collect();
let result = ctx
.upload(ctx.primary, "application/octet-stream", data)
.await;
check(!result.blob_id().is_empty(), "Must return blobId")?;
check_eq(result.integer_field("size"), len as i64, "size")
}
async fn upload_preserves_content_type(ctx: &CompCtx<'_>) -> TestOutcome {
let data = b"<html><body>test</body></html>".to_vec();
let result = ctx.upload(ctx.primary, "text/html", data).await;
let typ = result.typ();
check(
typ == "text/html" || typ.starts_with("text/html;"),
format!("Expected type to be text/html (possibly with params), got \"{typ}\""),
)
}
async fn upload_returns_valid_blob_id(ctx: &CompCtx<'_>) -> TestOutcome {
let data = b"test".to_vec();
let result = ctx.upload(ctx.primary, "text/plain", data).await;
let blob_id = result.blob_id();
check(!blob_id.is_empty(), "blobId must not be empty")?;
check(blob_id.len() <= 255, "blobId must be <= 255 chars")
}
async fn download_uploaded_blob(ctx: &CompCtx<'_>) -> TestOutcome {
let original = b"Download test content 12345".to_vec();
let upload = ctx
.upload(ctx.primary, "text/plain", original.clone())
.await;
let url = ctx.download_url(ctx.account_id(), upload.blob_id(), "text/plain", "test.txt");
let result = ctx.primary.http_get_raw(&url, None).await;
check_eq(result.status, 200, "status")?;
check_eq(result.body.len(), original.len(), "downloaded length")?;
check(
result.body == original,
"downloaded bytes must match original",
)
}
async fn download_email_blob(ctx: &CompCtx<'_>) -> TestOutcome {
let email_id = ctx.email("plain-simple");
let resp = ctx.primary.jmap_get("Email", ["blobId"], [email_id]).await;
let email = &resp.list()[0];
let blob_id = email.blob_id();
check(!blob_id.is_empty(), "Must return blobId")?;
let url = ctx.download_url(ctx.account_id(), blob_id, "message/rfc5322", "email.eml");
let download = ctx.primary.http_get_raw(&url, None).await;
check_eq(download.status, 200, "status")?;
check(!download.body.is_empty(), "body must not be empty")
}
async fn download_nonexistent_blob(ctx: &CompCtx<'_>) -> TestOutcome {
let url = ctx.download_url(
ctx.account_id(),
"nonexistent-blob-id-xyz",
"application/octet-stream",
"missing.bin",
);
let result = ctx.primary.http_get_raw(&url, None).await;
check_eq(result.status, 404, "status")
}
async fn download_respects_type_param(ctx: &CompCtx<'_>) -> TestOutcome {
let data = b"type test".to_vec();
let upload = ctx.upload(ctx.primary, "text/plain", data).await;
let url = ctx.download_url(
ctx.account_id(),
upload.blob_id(),
"application/octet-stream",
"test.bin",
);
let result = ctx.primary.http_get_raw(&url, None).await;
check_eq(result.status, 200, "status")?;
let ct = result.content_type().unwrap_or("");
check_contains(ct, "application/octet-stream", "content-type")
}
async fn blob_copy_same_account_error(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&[CORE, BLOB],
json!([[
"Blob/copy",
{
"fromAccountId": ctx.account_id(),
"accountId": ctx.account_id(),
"blobIds": ["placeholder"]
},
"c0"
]]),
)
.await;
if resp.is_error_at(0) {
check_eq(
resp.error_type_at(0).unwrap_or(""),
"invalidArguments",
"Same-account copy must return invalidArguments",
)
} else {
Ok(())
}
}
async fn blob_copy(
ctx: &CompCtx<'_>,
cross: &str,
blob_ids: &[&str],
) -> crate::utils::jmap::JmapResponse {
ctx.primary
.jmap_request(
&[CORE, BLOB],
json!([[
"Blob/copy",
{
"fromAccountId": ctx.account_id(),
"accountId": cross,
"blobIds": blob_ids
},
"c0"
]]),
)
.await
}
async fn blob_copy_cross_account(ctx: &CompCtx<'_>) -> TestOutcome {
let Some(cross) = ctx.cross_account_id.as_deref() else {
return skip("No cross-account access available");
};
let upload = ctx
.upload(
ctx.primary,
"text/plain",
b"blob cross-account copy test".to_vec(),
)
.await;
let blob_id = upload.blob_id();
let resp = blob_copy(ctx, cross, &[blob_id]).await;
let r = resp.response_at(0);
check(!r["copied"].is_null(), "copied must not be null")?;
check(
!r["copied"][blob_id].is_null(),
"Blob should be in copied map",
)
}
async fn blob_copy_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let Some(cross) = ctx.cross_account_id.as_deref() else {
return skip("No cross-account access available");
};
let resp = blob_copy(ctx, cross, &["nonexistent-blob-xyz"]).await;
let r = resp.response_at(0);
check(!r["notCopied"].is_null(), "notCopied must be present")?;
let nc = &r["notCopied"]["nonexistent-blob-xyz"];
check(!nc.is_null(), "Invalid blob should be in notCopied")?;
check_eq(
nc["type"].as_str().unwrap_or(""),
"blobNotFound",
"notCopied type",
)
}
async fn blob_copy_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let Some(cross) = ctx.cross_account_id.as_deref() else {
return skip("No cross-account access available");
};
let upload = ctx
.upload(ctx.primary, "text/plain", b"structure test".to_vec())
.await;
let resp = blob_copy(ctx, cross, &[upload.blob_id()]).await;
let r = resp.response_at(0);
check_eq(
r["fromAccountId"].as_str().unwrap_or(""),
ctx.account_id(),
"fromAccountId",
)?;
check_eq(r["accountId"].as_str().unwrap_or(""), cross, "accountId")
}
+742
View File
@@ -0,0 +1,742 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq};
use serde_json::json;
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] core");
// --- echo ---
ctx.run("core/echo-basic", echo_basic(ctx)).await;
ctx.run("core/echo-empty", echo_empty(ctx)).await;
ctx.run("core/echo-nested", echo_nested(ctx)).await;
// --- session ---
ctx.run(
"core/session-has-capabilities",
session_has_capabilities(ctx),
)
.await;
ctx.run(
"core/session-has-core-capability",
session_has_core_capability(ctx),
)
.await;
ctx.run(
"core/session-has-mail-capability",
session_has_mail_capability(ctx),
)
.await;
ctx.run(
"core/session-core-capability-properties",
session_core_capability_properties(ctx),
)
.await;
ctx.run(
"core/session-accounts-present",
session_accounts_present(ctx),
)
.await;
ctx.run(
"core/session-account-properties",
session_account_properties(ctx),
)
.await;
ctx.run(
"core/session-primary-accounts",
session_primary_accounts(ctx),
)
.await;
ctx.run("core/session-username", session_username(ctx))
.await;
ctx.run("core/session-api-url", session_api_url(ctx)).await;
ctx.run(
"core/session-download-url-template",
session_download_url_template(ctx),
)
.await;
ctx.run("core/session-upload-url", session_upload_url(ctx))
.await;
ctx.run(
"core/session-event-source-url",
session_event_source_url(ctx),
)
.await;
ctx.run("core/session-state", session_state(ctx)).await;
ctx.run(
"core/session-account-capabilities-mail",
session_account_capabilities_mail(ctx),
)
.await;
ctx.run(
"core/session-mail-capability-properties",
session_mail_capability_properties(ctx),
)
.await;
// --- request errors ---
ctx.run("core/error-not-json", error_not_json(ctx)).await;
ctx.run("core/error-not-request", error_not_request(ctx))
.await;
ctx.run(
"core/error-unknown-capability",
error_unknown_capability(ctx),
)
.await;
ctx.run("core/error-empty-using", error_empty_using(ctx))
.await;
ctx.run(
"core/error-wrong-content-type",
error_wrong_content_type(ctx),
)
.await;
ctx.run(
"core/error-method-calls-not-array",
error_method_calls_not_array(ctx),
)
.await;
// --- method errors ---
ctx.run("core/error-unknown-method", error_unknown_method(ctx))
.await;
ctx.run(
"core/error-invalid-arguments-missing-account",
error_invalid_arguments_missing_account(ctx),
)
.await;
ctx.run("core/error-account-not-found", error_account_not_found(ctx))
.await;
ctx.run(
"core/error-invalid-arguments-bad-type",
error_invalid_arguments_bad_type(ctx),
)
.await;
ctx.run(
"core/error-method-level-has-type",
error_method_level_has_type(ctx),
)
.await;
ctx.run("core/error-state-mismatch", error_state_mismatch(ctx))
.await;
ctx.run(
"core/error-multiple-method-responses",
error_multiple_method_responses(ctx),
)
.await;
ctx.run(
"core/error-response-has-session-state",
error_response_has_session_state(ctx),
)
.await;
// --- result references ---
ctx.run("core/result-ref-simple", result_ref_simple(ctx))
.await;
ctx.run("core/result-ref-chained", result_ref_chained(ctx))
.await;
ctx.run(
"core/result-ref-invalid-result-of",
result_ref_invalid_result_of(ctx),
)
.await;
ctx.run(
"core/result-ref-wrong-method-name",
result_ref_wrong_method_name(ctx),
)
.await;
ctx.run(
"core/result-ref-path-single-value",
result_ref_path_single_value(ctx),
)
.await;
ctx.run(
"core/result-ref-call-id-preserved",
result_ref_call_id_preserved(ctx),
)
.await;
}
const CORE: &str = "urn:ietf:params:jmap:core";
const MAIL: &str = "urn:ietf:params:jmap:mail";
fn default_using() -> Vec<&'static str> {
vec![CORE, MAIL]
}
// --- echo ---
async fn echo_basic(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call("Core/echo", json!({ "hello": "world", "number": 42 }))
.await;
let r = resp.method_response();
check_eq(&r["hello"], &json!("world"), "hello")?;
check_eq(&r["number"], &json!(42), "number")
}
async fn echo_empty(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx.primary.jmap_method_call("Core/echo", json!({})).await;
check_eq(resp.method_response(), &json!({}), "echo empty")
}
async fn echo_nested(ctx: &CompCtx<'_>) -> TestOutcome {
let args = json!({
"string": "test",
"number": 42,
"bool": true,
"null": null,
"array": [1, "two", false],
"object": { "nested": { "deep": "value" } },
});
let resp = ctx
.primary
.jmap_method_call("Core/echo", args.clone())
.await;
check_eq(resp.method_response(), &args, "echo nested")
}
// --- session ---
async fn session_has_capabilities(ctx: &CompCtx<'_>) -> TestOutcome {
let caps = &ctx.session["capabilities"];
check(caps.is_object(), "capabilities must be object")?;
check(
caps.as_object().map(|o| !o.is_empty()).unwrap_or(false),
"capabilities must not be empty",
)
}
async fn session_has_core_capability(ctx: &CompCtx<'_>) -> TestOutcome {
check(
!ctx.session["capabilities"][CORE].is_null(),
"Must have core capability",
)
}
async fn session_has_mail_capability(ctx: &CompCtx<'_>) -> TestOutcome {
check(
!ctx.session["capabilities"][MAIL].is_null(),
"Must have mail capability",
)
}
async fn session_core_capability_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let core = &ctx.session["capabilities"][CORE];
for prop in [
"maxSizeUpload",
"maxConcurrentUpload",
"maxSizeRequest",
"maxConcurrentRequests",
"maxCallsInRequest",
"maxObjectsInGet",
"maxObjectsInSet",
] {
check(core[prop].is_number(), format!("{prop} must be a number"))?;
}
check(
core["collationAlgorithms"].is_array(),
"collationAlgorithms must be an array",
)
}
async fn session_accounts_present(ctx: &CompCtx<'_>) -> TestOutcome {
let accounts = &ctx.session["accounts"];
check(accounts.is_object(), "accounts must be object")?;
check(
accounts.as_object().map(|o| !o.is_empty()).unwrap_or(false),
"Must have at least one account",
)
}
async fn session_account_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let account = &ctx.session["accounts"][ctx.account_id()];
check(account.is_object(), "Primary account must exist")?;
check(account["name"].is_string(), "name must be string")?;
check(
account["isPersonal"].is_boolean(),
"isPersonal must be bool",
)?;
check(
account["isReadOnly"].is_boolean(),
"isReadOnly must be bool",
)?;
check(
account["accountCapabilities"].is_object(),
"accountCapabilities must be object",
)
}
async fn session_primary_accounts(ctx: &CompCtx<'_>) -> TestOutcome {
let pa = &ctx.session["primaryAccounts"];
check(pa.is_object(), "primaryAccounts must be object")?;
let mail_acct = &pa[MAIL];
check(mail_acct.is_string(), "Must have primary mail account")?;
check_eq(
mail_acct.as_str().unwrap_or(""),
ctx.account_id(),
"primary mail account",
)
}
async fn session_username(ctx: &CompCtx<'_>) -> TestOutcome {
let u = &ctx.session["username"];
check(u.is_string(), "username must be string")?;
check(
u.as_str().map(|s| !s.is_empty()).unwrap_or(false),
"username must not be empty",
)
}
async fn session_api_url(ctx: &CompCtx<'_>) -> TestOutcome {
let u = &ctx.session["apiUrl"];
check(u.is_string(), "apiUrl must be string")?;
check(
u.as_str().map(|s| !s.is_empty()).unwrap_or(false),
"apiUrl must not be empty",
)
}
async fn session_download_url_template(ctx: &CompCtx<'_>) -> TestOutcome {
let u = ctx.session["downloadUrl"].as_str().unwrap_or("");
check(!u.is_empty(), "downloadUrl must be string")?;
for v in ["{accountId}", "{blobId}", "{name}", "{type}"] {
check_contains(u, v, "downloadUrl template")?;
}
Ok(())
}
async fn session_upload_url(ctx: &CompCtx<'_>) -> TestOutcome {
let u = ctx.session["uploadUrl"].as_str().unwrap_or("");
check(!u.is_empty(), "uploadUrl must be string")?;
check_contains(u, "{accountId}", "uploadUrl template")
}
async fn session_event_source_url(ctx: &CompCtx<'_>) -> TestOutcome {
let u = &ctx.session["eventSourceUrl"];
check(u.is_string(), "eventSourceUrl must be string")?;
check(
u.as_str().map(|s| !s.is_empty()).unwrap_or(false),
"eventSourceUrl must not be empty",
)
}
async fn session_state(ctx: &CompCtx<'_>) -> TestOutcome {
let u = &ctx.session["state"];
check(u.is_string(), "state must be string")?;
check(
u.as_str().map(|s| !s.is_empty()).unwrap_or(false),
"state must not be empty",
)
}
async fn session_account_capabilities_mail(ctx: &CompCtx<'_>) -> TestOutcome {
let account = &ctx.session["accounts"][ctx.account_id()];
check(
!account["accountCapabilities"][MAIL].is_null(),
"Account must have mail capability",
)
}
async fn session_mail_capability_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let account = &ctx.session["accounts"][ctx.account_id()];
let mail = &account["accountCapabilities"][MAIL];
check(mail.is_object(), "Account must have mail capability object")?;
check(
mail["maxMailboxesPerEmail"].is_null() || mail["maxMailboxesPerEmail"].is_number(),
"maxMailboxesPerEmail must be null or number",
)?;
check(
mail["maxMailboxDepth"].is_null() || mail["maxMailboxDepth"].is_number(),
"maxMailboxDepth must be null or number",
)?;
check(mail["maxSizeMailboxName"].is_number(), "maxSizeMailboxName")?;
check(
mail["maxSizeAttachmentsPerEmail"].is_number(),
"maxSizeAttachmentsPerEmail",
)?;
check(
mail["emailQuerySortOptions"].is_array(),
"emailQuerySortOptions must be an array",
)?;
check(
mail["mayCreateTopLevelMailbox"].is_boolean(),
"mayCreateTopLevelMailbox",
)
}
// --- request errors ---
async fn error_not_json(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_raw_post("this is not json", "application/json")
.await;
check(
resp.is_client_error(),
format!("Expected 4xx client error, got {}", resp.status),
)
}
async fn error_not_request(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_raw_post(json!({ "foo": "bar" }).to_string(), "application/json")
.await;
check(
resp.is_client_error(),
format!("Expected 4xx client error, got {}", resp.status),
)
}
async fn error_unknown_capability(ctx: &CompCtx<'_>) -> TestOutcome {
let body = json!({
"using": [CORE, "urn:fake:nonexistent"],
"methodCalls": [["Core/echo", {}, "c0"]]
});
let resp = ctx
.primary
.jmap_raw_post(body.to_string(), "application/json")
.await;
check(
resp.is_client_error(),
format!(
"Expected HTTP 4xx for unknown capability, got {}",
resp.status
),
)
}
async fn error_empty_using(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(&[], json!([["Core/echo", {}, "c0"]]))
.await;
check_eq(
resp.name_at(0),
"error",
"With empty using, method call must return error",
)?;
check_eq(
resp.error_type_at(0).unwrap_or(""),
"unknownMethod",
"Error type must be unknownMethod when no capabilities in using",
)
}
async fn error_wrong_content_type(ctx: &CompCtx<'_>) -> TestOutcome {
let body = json!({
"using": [CORE],
"methodCalls": [["Core/echo", {}, "c0"]]
});
let resp = ctx
.primary
.jmap_raw_post(body.to_string(), "text/plain")
.await;
check(
resp.is_client_error(),
format!("Expected 4xx for wrong content type, got {}", resp.status),
)
}
async fn error_method_calls_not_array(ctx: &CompCtx<'_>) -> TestOutcome {
let body = json!({
"using": [CORE],
"methodCalls": "not-an-array"
});
let resp = ctx
.primary
.jmap_raw_post(body.to_string(), "application/json")
.await;
check(
resp.is_client_error(),
format!("Expected 4xx client error, got {}", resp.status),
)
}
// --- method errors ---
async fn error_unknown_method(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(&[CORE], json!([["Fake/nonexistent", {}, "c0"]]))
.await;
check_eq(resp.name_at(0), "error", "name")?;
check_eq(resp.error_type_at(0).unwrap_or(""), "unknownMethod", "type")
}
async fn error_invalid_arguments_missing_account(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(&default_using(), json!([["Mailbox/get", {}, "c0"]]))
.await;
check_eq(resp.name_at(0), "error", "name")?;
check_eq(
resp.error_type_at(0).unwrap_or(""),
"invalidArguments",
"Missing accountId must return invalidArguments",
)
}
async fn error_account_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([["Mailbox/get", { "accountId": "nonexistent-account-id-xyz" }, "c0"]]),
)
.await;
check_eq(resp.name_at(0), "error", "name")?;
check_eq(
resp.error_type_at(0).unwrap_or(""),
"accountNotFound",
"type",
)
}
async fn error_invalid_arguments_bad_type(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([["Mailbox/get", { "accountId": ctx.account_id(), "ids": "not-an-array" }, "c0"]]),
)
.await;
check_eq(resp.name_at(0), "error", "name")?;
check_eq(
resp.error_type_at(0).unwrap_or(""),
"invalidArguments",
"type",
)
}
async fn error_method_level_has_type(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(&[CORE], json!([["Fake/method", {}, "c0"]]))
.await;
check_eq(resp.name_at(0), "error", "name")?;
check(
!resp.response_at(0)["type"].is_null(),
"Method-level error must include 'type'",
)
}
async fn error_state_mismatch(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([[
"Mailbox/set",
{ "accountId": ctx.account_id(), "ifInState": "invalid-state-that-does-not-exist", "update": {} },
"c0"
]]),
)
.await;
match resp
.0
.pointer("/methodResponses/0/0")
.and_then(|v| v.as_str())
{
Some("error") => check_eq(resp.error_type_at(0).unwrap_or(""), "stateMismatch", "type"),
_ => Ok(()),
}
}
async fn error_multiple_method_responses(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
["Mailbox/get", { "accountId": ctx.account_id(), "ids": [] }, "call1"],
["Core/echo", { "test": true }, "call2"],
["Fake/nonexistent", {}, "call3"],
]),
)
.await;
check_eq(resp.num_responses(), 3, "must have 3 responses")?;
check_eq(resp.call_id_at(0), "call1", "call1")?;
check_eq(resp.call_id_at(1), "call2", "call2")?;
check_eq(resp.call_id_at(2), "call3", "call3")?;
check_eq(resp.name_at(2), "error", "third must be error")
}
async fn error_response_has_session_state(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(&[CORE], json!([["Core/echo", {}, "c0"]]))
.await;
check(
resp.session_state().map(|s| !s.is_empty()).unwrap_or(false),
"Response must include non-empty sessionState",
)
}
// --- result references ---
async fn result_ref_simple(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
["Mailbox/get", { "accountId": ctx.account_id(), "ids": null }, "getMailboxes"],
[
"Mailbox/get",
{
"accountId": ctx.account_id(),
"#ids": { "resultOf": "getMailboxes", "name": "Mailbox/get", "path": "/list/*/id" }
},
"getById"
]
]),
)
.await;
check_eq(resp.num_responses(), 2, "responses")?;
check_eq(resp.name_at(0), "Mailbox/get", "name1")?;
check_eq(resp.name_at(1), "Mailbox/get", "name2")?;
let list2 = resp.response_at(1)["list"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
check(list2 > 0, "Should have resolved mailbox ids")
}
async fn result_ref_chained(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
[
"Email/query",
{ "accountId": ctx.account_id(), "filter": { "inMailbox": ctx.role("inbox") }, "limit": 3 },
"query"
],
[
"Email/get",
{
"accountId": ctx.account_id(),
"#ids": { "resultOf": "query", "name": "Email/query", "path": "/ids" },
"properties": ["id", "subject"]
},
"getEmails"
]
]),
)
.await;
check_eq(resp.num_responses(), 2, "responses")?;
check_eq(resp.name_at(0), "Email/query", "name1")?;
check_eq(resp.name_at(1), "Email/get", "name2")?;
let query_ids = resp.response_at(0)["ids"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
let get_list = resp.response_at(1)["list"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
check_eq(get_list, query_ids, "get list length == query ids length")
}
async fn result_ref_invalid_result_of(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([[
"Email/get",
{
"accountId": ctx.account_id(),
"#ids": { "resultOf": "nonexistent", "name": "Email/query", "path": "/ids" }
},
"c0"
]]),
)
.await;
check_eq(resp.name_at(0), "error", "name")?;
check_eq(
resp.error_type_at(0).unwrap_or(""),
"invalidResultReference",
"type",
)
}
async fn result_ref_wrong_method_name(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
[
"Email/query",
{ "accountId": ctx.account_id(), "filter": { "inMailbox": ctx.role("inbox") }, "limit": 1 },
"query"
],
[
"Email/get",
{
"accountId": ctx.account_id(),
"#ids": { "resultOf": "query", "name": "Mailbox/get", "path": "/ids" }
},
"get"
]
]),
)
.await;
check_eq(resp.name_at(1), "error", "name")?;
check_eq(
resp.error_type_at(1).unwrap_or(""),
"invalidResultReference",
"type",
)
}
async fn result_ref_path_single_value(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
["Mailbox/get", { "accountId": ctx.account_id(), "ids": [] }, "getState"],
[
"Mailbox/changes",
{
"accountId": ctx.account_id(),
"#sinceState": { "resultOf": "getState", "name": "Mailbox/get", "path": "/state" }
},
"changes"
]
]),
)
.await;
check_eq(resp.num_responses(), 2, "responses")?;
check_eq(resp.name_at(1), "Mailbox/changes", "name2")?;
let r = resp.response_at(1);
check(!r["oldState"].is_null(), "Should have oldState")?;
check(!r["newState"].is_null(), "Should have newState")
}
async fn result_ref_call_id_preserved(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_request(
&default_using(),
json!([
["Core/echo", { "value": 1 }, "first"],
["Core/echo", { "value": 2 }, "second"],
["Core/echo", { "value": 3 }, "third"],
]),
)
.await;
check_eq(resp.num_responses(), 3, "responses")?;
check_eq(resp.call_id_at(0), "first", "first")?;
check_eq(resp.call_id_at(1), "second", "second")?;
check_eq(resp.call_id_at(2), "third", "third")
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::jmap::compliance::CompCtx;
pub mod inspect;
pub mod mutate;
pub mod query;
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] email");
inspect::run(ctx).await;
mutate::run(ctx).await;
query::run(ctx).await;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+317
View File
@@ -0,0 +1,317 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub const EXPECTED: &[(&str, &str)] = &[
("binary/blob-copy-cross-account", "SKIP"),
("binary/blob-copy-not-found", "SKIP"),
("binary/blob-copy-response-structure", "SKIP"),
("binary/blob-copy-same-account-error", "PASS"),
("binary/download-email-blob", "PASS"),
("binary/download-nonexistent-blob", "PASS"),
("binary/download-respects-type-param", "PASS"),
("binary/download-uploaded-blob", "PASS"),
("binary/upload-basic", "PASS"),
("binary/upload-binary-content", "PASS"),
("binary/upload-large-data", "PASS"),
("binary/upload-preserves-content-type", "PASS"),
("binary/upload-returns-valid-blob-id", "PASS"),
("core/echo-basic", "PASS"),
("core/echo-empty", "PASS"),
("core/echo-nested", "PASS"),
("core/error-account-not-found", "FAIL"),
("core/error-empty-using", "FAIL"),
("core/error-invalid-arguments-bad-type", "PASS"),
("core/error-invalid-arguments-missing-account", "FAIL"),
("core/error-method-calls-not-array", "PASS"),
("core/error-method-level-has-type", "PASS"),
("core/error-multiple-method-responses", "PASS"),
("core/error-not-json", "PASS"),
("core/error-not-request", "FAIL"),
("core/error-response-has-session-state", "PASS"),
("core/error-state-mismatch", "PASS"),
("core/error-unknown-capability", "PASS"),
("core/error-unknown-method", "PASS"),
("core/error-wrong-content-type", "FAIL"),
("core/result-ref-call-id-preserved", "PASS"),
("core/result-ref-chained", "PASS"),
("core/result-ref-invalid-result-of", "PASS"),
("core/result-ref-path-single-value", "PASS"),
("core/result-ref-simple", "PASS"),
("core/result-ref-wrong-method-name", "PASS"),
("core/session-account-capabilities-mail", "PASS"),
("core/session-account-properties", "PASS"),
("core/session-accounts-present", "PASS"),
("core/session-api-url", "PASS"),
("core/session-core-capability-properties", "PASS"),
("core/session-download-url-template", "PASS"),
("core/session-event-source-url", "PASS"),
("core/session-has-capabilities", "PASS"),
("core/session-has-core-capability", "PASS"),
("core/session-has-mail-capability", "PASS"),
("core/session-mail-capability-properties", "PASS"),
("core/session-primary-accounts", "PASS"),
("core/session-state", "PASS"),
("core/session-upload-url", "PASS"),
("core/session-username", "PASS"),
("email/body-attachment-blob-id", "PASS"),
("email/body-attachments", "PASS"),
("email/body-html-body", "PASS"),
("email/body-inline-attachment-cid", "PASS"),
("email/body-invalid-ascii-handling", "PASS"),
("email/body-max-body-value-bytes", "PASS"),
("email/body-multipart-alternative-text-and-html", "PASS"),
("email/body-non-utf8-charset", "PASS"),
("email/body-properties-filter", "PASS"),
("email/body-structure", "PASS"),
("email/body-text-body", "PASS"),
("email/body-values-all", "PASS"),
("email/body-values-html", "PASS"),
("email/body-values-text", "PASS"),
("email/changes-after-create-and-destroy", "PASS"),
("email/changes-after-keyword-change", "PASS"),
("email/changes-no-changes", "PASS"),
("email/changes-response-structure", "PASS"),
("email/collapse-threads-basic", "FAIL"),
("email/collapse-threads-calculate-total", "FAIL"),
("email/collapse-threads-one-per-thread", "PASS"),
("email/collapse-threads-sort-determines-representative", "PASS"),
("email/collapse-threads-with-filter", "PASS"),
("email/copy-cross-account", "SKIP"),
("email/copy-not-found", "SKIP"),
("email/copy-same-account-error", "PASS"),
("email/filter-after", "PASS"),
("email/filter-before", "PASS"),
("email/filter-before-and-after", "PASS"),
("email/filter-body", "PASS"),
("email/filter-cc", "PASS"),
("email/filter-custom-keyword", "PASS"),
("email/filter-empty-matches-all", "PASS"),
("email/filter-from", "PASS"),
("email/filter-from-display-name", "PASS"),
("email/filter-has-attachment-false", "FAIL"),
("email/filter-has-attachment-true", "FAIL"),
("email/filter-has-keyword", "PASS"),
("email/filter-header-name-only", "FAIL"),
("email/filter-header-name-value", "FAIL"),
("email/filter-in-child-mailbox", "PASS"),
("email/filter-in-mailbox", "PASS"),
("email/filter-in-mailbox-other-than", "PASS"),
("email/filter-max-size", "PASS"),
("email/filter-min-size", "PASS"),
("email/filter-multiple-conditions-on-one-filter", "PASS"),
("email/filter-nested-operators", "PASS"),
("email/filter-none-in-thread-have-keyword", "PASS"),
("email/filter-not-keyword", "PASS"),
("email/filter-null-accepted", "FAIL"),
("email/filter-operator-and", "PASS"),
("email/filter-operator-not", "PASS"),
("email/filter-operator-or", "PASS"),
("email/filter-some-in-thread-have-keyword", "PASS"),
("email/filter-subject", "PASS"),
("email/filter-text-search-body", "PASS"),
("email/filter-text-search-headers", "PASS"),
("email/filter-to", "PASS"),
("email/get-by-id", "PASS"),
("email/get-has-attachment-false", "PASS"),
("email/get-has-attachment-true", "PASS"),
("email/get-keywords", "PASS"),
("email/get-mailbox-ids", "PASS"),
("email/get-metadata-properties", "PASS"),
("email/get-multiple-emails", "PASS"),
("email/get-not-found", "FAIL"),
("email/get-preview-is-text", "PASS"),
("email/get-properties-filter", "PASS"),
("email/get-received-at-is-utc-date", "PASS"),
("email/get-state-returned", "PASS"),
("email/get-thread-id-consistent", "PASS"),
("email/header-as-addresses", "PASS"),
("email/header-as-date", "PASS"),
("email/header-as-grouped-addresses", "PASS"),
("email/header-as-message-ids", "PASS"),
("email/header-as-urls", "PASS"),
("email/header-bcc", "PASS"),
("email/header-case-insensitive", "PASS"),
("email/header-cc", "PASS"),
("email/header-custom-header", "PASS"),
("email/header-from", "PASS"),
("email/header-in-reply-to", "PASS"),
("email/header-intl-from-decoded", "PASS"),
("email/header-message-id", "PASS"),
("email/header-raw-access", "PASS"),
("email/header-raw-form", "PASS"),
("email/header-references", "PASS"),
("email/header-sent-at", "PASS"),
("email/header-subject", "PASS"),
("email/header-subject-empty", "PASS"),
("email/header-to", "PASS"),
("email/import-invalid-blob", "FAIL"),
("email/import-multiple", "PASS"),
("email/import-not-found-blob", "PASS"),
("email/import-sets-keywords", "PASS"),
("email/import-sets-mailbox", "PASS"),
("email/import-sets-received-at", "PASS"),
("email/import-state-changes", "PASS"),
("email/import-valid-message", "PASS"),
("email/paging-anchor", "FAIL"),
("email/paging-anchor-not-found", "FAIL"),
("email/paging-anchor-offset", "FAIL"),
("email/paging-calculate-total", "PASS"),
("email/paging-limit", "PASS"),
("email/paging-negative-position", "PASS"),
("email/paging-position-beyond-total", "PASS"),
("email/paging-position-zero", "PASS"),
("email/paging-positive-position", "PASS"),
("email/paging-response-position", "PASS"),
("email/parse-body-values", "PASS"),
("email/parse-not-found", "FAIL"),
("email/parse-not-parsable", "FAIL"),
("email/parse-null-metadata", "PASS"),
("email/parse-response-structure", "PASS"),
("email/parse-valid-message", "PASS"),
("email/query-changes-after-add", "PASS"),
("email/query-changes-after-remove", "PASS"),
("email/query-changes-filter-null-accepted", "FAIL"),
("email/query-changes-no-changes", "PASS"),
("email/query-changes-response-structure", "PASS"),
("email/set-create-creation-id-reference", "PASS"),
("email/set-create-html", "PASS"),
("email/set-create-multipart-alternative", "PASS"),
("email/set-create-plain-text", "PASS"),
("email/set-create-server-set-properties", "PASS"),
("email/set-create-state-changes", "PASS"),
("email/set-create-with-attachment", "PASS"),
("email/set-create-with-keywords", "PASS"),
("email/set-destroy-multiple", "PASS"),
("email/set-destroy-not-found", "FAIL"),
("email/set-destroy-removes-from-all-mailboxes", "PASS"),
("email/set-destroy-single", "PASS"),
("email/set-update-add-keyword", "PASS"),
("email/set-update-add-mailbox", "PASS"),
("email/set-update-if-in-state", "PASS"),
("email/set-update-move-mailbox", "PASS"),
("email/set-update-not-found", "FAIL"),
("email/set-update-remove-keyword", "PASS"),
("email/set-update-remove-mailbox", "PASS"),
("email/set-update-replace-keywords", "PASS"),
("email/sort-default-no-sort", "PASS"),
("email/sort-from", "PASS"),
("email/sort-has-keyword", "FAIL"),
("email/sort-multi-property", "PASS"),
("email/sort-received-at-asc", "PASS"),
("email/sort-received-at-desc", "PASS"),
("email/sort-sent-at", "PASS"),
("email/sort-size", "PASS"),
("email/sort-subject", "PASS"),
("email/sort-to", "PASS"),
("identity/changes-after-update", "PASS"),
("identity/changes-no-changes", "PASS"),
("identity/changes-response-structure", "PASS"),
("identity/get-all-identities", "PASS"),
("identity/get-identity-by-id", "PASS"),
("identity/get-identity-email-matches", "PASS"),
("identity/get-identity-not-found", "FAIL"),
("identity/get-identity-properties", "PASS"),
("identity/set-not-found", "FAIL"),
("identity/set-update-html-signature", "PASS"),
("identity/set-update-name", "PASS"),
("identity/set-update-reply-to", "PASS"),
("identity/set-update-text-signature", "PASS"),
("mailbox/changes-after-create", "PASS"),
("mailbox/changes-after-rename", "PASS"),
("mailbox/changes-has-more-changes", "PASS"),
("mailbox/changes-no-changes", "PASS"),
("mailbox/changes-response-structure", "PASS"),
("mailbox/get-account-id-returned", "PASS"),
("mailbox/get-all", "PASS"),
("mailbox/get-by-ids", "PASS"),
("mailbox/get-inbox-exists", "PASS"),
("mailbox/get-mailbox-properties", "PASS"),
("mailbox/get-not-found", "FAIL"),
("mailbox/get-parent-id-correct", "PASS"),
("mailbox/get-properties-filter", "PASS"),
("mailbox/get-state-returned", "PASS"),
("mailbox/get-total-emails-accurate", "PASS"),
("mailbox/query-all", "PASS"),
("mailbox/query-changes-after-create", "PASS"),
("mailbox/query-changes-filter-null-accepted", "FAIL"),
("mailbox/query-changes-no-changes", "PASS"),
("mailbox/query-changes-response-structure", "PASS"),
("mailbox/query-filter-by-name", "PASS"),
("mailbox/query-filter-by-parent-id", "PASS"),
("mailbox/query-filter-by-parent-id-null", "PASS"),
("mailbox/query-filter-by-role", "PASS"),
("mailbox/query-filter-has-any-role", "PASS"),
("mailbox/query-filter-has-any-role-false", "PASS"),
("mailbox/query-filter-null-accepted", "FAIL"),
("mailbox/query-limit", "PASS"),
("mailbox/query-position", "PASS"),
("mailbox/query-response-structure", "PASS"),
("mailbox/query-sort-by-name", "PASS"),
("mailbox/query-sort-by-sort-order", "PASS"),
("mailbox/set-cannot-destroy-with-children", "PASS"),
("mailbox/set-change-sort-order", "PASS"),
("mailbox/set-create-child", "PASS"),
("mailbox/set-create-returns-server-set-props", "PASS"),
("mailbox/set-create-top-level", "PASS"),
("mailbox/set-destroy-empty", "PASS"),
("mailbox/set-destroy-not-found", "FAIL"),
("mailbox/set-duplicate-name-same-parent", "FAIL"),
("mailbox/set-move-parent", "PASS"),
("mailbox/set-on-destroy-remove-emails", "PASS"),
("mailbox/set-on-destroy-remove-emails-with-children", "PASS"),
("mailbox/set-rename", "PASS"),
("mailbox/set-state-changes", "PASS"),
("push-eventsource/eventsource-closeafter", "PASS"),
("push-eventsource/eventsource-connect", "PASS"),
("push-eventsource/eventsource-receives-state-change", "PASS"),
("push-eventsource/eventsource-types-filter", "PASS"),
("push-subscription/push-subscription-create", "PASS"),
("push-subscription/push-subscription-destroy", "PASS"),
("push-subscription/push-subscription-get", "PASS"),
("push-subscription/push-subscription-receives-notification", "PASS"),
("push-subscription/push-subscription-reject-non-https", "PASS"),
("push-subscription/push-subscription-types-filter", "PASS"),
("push-subscription/push-subscription-verification", "PASS"),
("search-snippet/snippet-body-match", "PASS"),
("search-snippet/snippet-mark-tags", "PASS"),
("search-snippet/snippet-not-found", "FAIL"),
("search-snippet/snippet-null-when-no-match", "FAIL"),
("search-snippet/snippet-response-structure", "FAIL"),
("search-snippet/snippet-subject-match", "PASS"),
("submission/changes-no-changes", "PASS"),
("submission/changes-response-structure", "PASS"),
("submission/get-empty", "PASS"),
("submission/get-not-found", "FAIL"),
("submission/get-response-structure", "PASS"),
("submission/query-all", "PASS"),
("submission/query-filter-null-accepted", "FAIL"),
("submission/query-filter-undo-status", "PASS"),
("submission/query-response-structure", "PASS"),
("submission/set-create-submission", "FAIL"),
("submission/set-create-with-envelope", "PASS"),
("submission/set-no-recipients-error", "PASS"),
("submission/set-on-success-update-email", "PASS"),
("submission/set-submission-properties", "PASS"),
("thread/changes-after-email-destroy", "FAIL"),
("thread/changes-after-new-email", "PASS"),
("thread/changes-no-changes", "PASS"),
("thread/changes-response-structure", "PASS"),
("thread/get-single-email-thread", "PASS"),
("thread/get-thread-by-id", "PASS"),
("thread/get-thread-email-ids-order", "PASS"),
("thread/get-thread-not-found", "FAIL"),
("thread/get-thread-response-structure", "PASS"),
("vacation/get-not-found-invalid-id", "FAIL"),
("vacation/get-singleton", "FAIL"),
("vacation/get-singleton-null-ids", "FAIL"),
("vacation/get-singleton-properties", "FAIL"),
("vacation/set-cannot-create", "FAIL"),
("vacation/set-cannot-destroy", "FAIL"),
("vacation/set-dates", "PASS"),
("vacation/set-disable-vacation", "PASS"),
("vacation/set-enable-vacation", "FAIL"),
("vacation/set-html-body", "PASS"),
];
+452
View File
@@ -0,0 +1,452 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq, skip};
use serde_json::json;
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] identity");
ctx.run("identity/get-all-identities", get_all_identities(ctx))
.await;
ctx.run("identity/get-identity-by-id", get_identity_by_id(ctx))
.await;
ctx.run(
"identity/get-identity-email-matches",
get_identity_email_matches(ctx),
)
.await;
ctx.run(
"identity/get-identity-not-found",
get_identity_not_found(ctx),
)
.await;
ctx.run(
"identity/get-identity-properties",
get_identity_properties(ctx),
)
.await;
ctx.run("identity/changes-after-update", changes_after_update(ctx))
.await;
ctx.run("identity/changes-no-changes", changes_no_changes(ctx))
.await;
ctx.run(
"identity/changes-response-structure",
changes_response_structure(ctx),
)
.await;
ctx.run("identity/set-not-found", set_not_found(ctx)).await;
ctx.run(
"identity/set-update-html-signature",
set_update_html_signature(ctx),
)
.await;
ctx.run("identity/set-update-name", set_update_name(ctx))
.await;
ctx.run("identity/set-update-reply-to", set_update_reply_to(ctx))
.await;
ctx.run(
"identity/set-update-text-signature",
set_update_text_signature(ctx),
)
.await;
}
async fn get_all_identities(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), Vec::<String>::new())
.await;
let len = resp.method_response()["list"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
check(len > 0, "Must have at least one identity")
}
async fn get_identity_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), Vec::<String>::new())
.await;
let identity = &resp.method_response()["list"][0];
check(identity["id"].is_string(), "id must be string")?;
check(identity["name"].is_string(), "name must be string")?;
check(identity["email"].is_string(), "email must be string")?;
check(
identity["textSignature"].is_string(),
"textSignature must be string",
)?;
check(
identity["htmlSignature"].is_string(),
"htmlSignature must be string",
)?;
check(
identity["mayDelete"].is_boolean(),
"mayDelete must be boolean",
)?;
check(
identity["replyTo"].is_null() || identity["replyTo"].is_array(),
"replyTo must be null or array",
)?;
check(
identity["bcc"].is_null() || identity["bcc"].is_array(),
"bcc must be null or array",
)
}
async fn get_identity_by_id(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let id = &ctx.identity_ids[0];
let resp = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [id])
.await;
let list = resp.list();
check_eq(list.len(), 1, "list length")?;
check_eq(list[0]["id"].as_str().unwrap_or(""), id.as_str(), "id")
}
async fn get_identity_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get(
"Identity",
Vec::<String>::new(),
["nonexistent-identity-xyz"],
)
.await;
let not_found = &resp.method_response()["notFound"];
check(
not_found.is_array(),
format!("Identity/get notFound MUST be a String[], got {not_found}"),
)?;
let found = resp.not_found().any(|id| id == "nonexistent-identity-xyz");
check(found, "notFound must include nonexistent-identity-xyz")
}
async fn get_identity_email_matches(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let id = &ctx.identity_ids[0];
let resp = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [id])
.await;
let email = resp.method_response()["list"][0]["email"]
.as_str()
.unwrap_or("");
check_contains(email, "@", "email must contain @")
}
async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), Vec::<String>::new())
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("Identity", &state).await;
let r = resp.method_response();
check_eq(
r["oldState"].as_str().unwrap_or(""),
state.as_str(),
"oldState",
)?;
check_eq(
r["created"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"created length",
)?;
check_eq(
r["updated"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"updated length",
)?;
check_eq(
r["destroyed"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"destroyed length",
)
}
async fn changes_after_update(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), Vec::<String>::new())
.await;
let old_state = get_result.state().to_string();
let identity_id = ctx.identity_ids[0].clone();
let identity_get = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let old_name = identity_get.method_response()["list"][0]["name"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "name": "Updated Name For Test" }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let changes = ctx.primary.jmap_changes("Identity", &old_state).await;
let updated_contains = changes.method_response()["updated"]
.as_array()
.map(|a| a.iter().any(|v| v.as_str() == Some(identity_id.as_str())))
.unwrap_or(false);
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "name": old_name }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
check(updated_contains, "updated must include the identity id")
}
async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), Vec::<String>::new())
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("Identity", &state).await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["oldState"].is_string(), "oldState must be string")?;
check(r["newState"].is_string(), "newState must be string")?;
check(
r["hasMoreChanges"].is_boolean(),
"hasMoreChanges must be boolean",
)
}
async fn set_update_name(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let identity_id = ctx.identity_ids[0].clone();
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let original_name = get_result.method_response()["list"][0]["name"]
.as_str()
.unwrap_or("")
.to_string();
let set_result = ctx
.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "name": "Test Updated Name" }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let updated_truthy = set_result.method_response()["updated"]
.as_object()
.map(|o| !o.is_empty())
.unwrap_or(false);
let verify_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let new_name = verify_result.method_response()["list"][0]["name"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "name": original_name }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
check(updated_truthy, "updated must be present")?;
check_eq(new_name.as_str(), "Test Updated Name", "name")
}
async fn set_update_text_signature(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let identity_id = ctx.identity_ids[0].clone();
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let original_sig = get_result.method_response()["list"][0]["textSignature"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(
&identity_id,
json!({ "textSignature": "-- \nTest Signature" }),
)],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let sig = verify.method_response()["list"][0]["textSignature"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "textSignature": original_sig }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
check_contains(&sig, "Test Signature", "textSignature")
}
async fn set_update_html_signature(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let identity_id = ctx.identity_ids[0].clone();
let get_result = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let original_sig = get_result.method_response()["list"][0]["htmlSignature"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(
&identity_id,
json!({ "htmlSignature": "<p><b>Test</b> HTML Signature</p>" }),
)],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let sig = verify.method_response()["list"][0]["htmlSignature"]
.as_str()
.unwrap_or("")
.to_string();
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "htmlSignature": original_sig }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
check_contains(&sig, "HTML Signature", "htmlSignature")
}
async fn set_update_reply_to(ctx: &CompCtx<'_>) -> TestOutcome {
if ctx.identity_ids.is_empty() {
return skip("No identities available");
}
let identity_id = ctx.identity_ids[0].clone();
ctx.primary
.jmap_update(
"Identity",
[(
&identity_id,
json!({ "replyTo": [{ "name": "Reply Test", "email": "[email protected]" }] }),
)],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get("Identity", Vec::<String>::new(), [&identity_id])
.await;
let reply_to = verify.method_response()["list"][0]["replyTo"].clone();
ctx.primary
.jmap_update(
"Identity",
[(&identity_id, json!({ "replyTo": null }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
check(
reply_to.is_array() && !reply_to.as_array().unwrap().is_empty(),
"replyTo must be a non-empty array",
)?;
check_eq(
reply_to[0]["email"].as_str().unwrap_or(""),
"[email protected]",
"replyTo email",
)
}
async fn set_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_update(
"Identity",
[("nonexistent-identity-xyz", json!({ "name": "test" }))],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let not_updated = &resp.method_response()["notUpdated"];
check(
not_updated.is_object(),
"notUpdated must not be null when updating a nonexistent id",
)?;
check(
!not_updated["nonexistent-identity-xyz"].is_null(),
"Expected notUpdated to contain error for 'nonexistent-identity-xyz'",
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+628
View File
@@ -0,0 +1,628 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq};
use crate::{AssertConfig, utils::server::TestServer};
use common::{config::server::Listeners, network::SessionData};
use futures::StreamExt;
use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body};
use hyper::{body, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use registry::{
schema::{
enums::NetworkListenerProtocol,
prelude::{ObjectType, SocketAddr},
structs::{NetworkListener, SystemSettings},
},
types::{id::ObjectId, map::Map},
};
use serde_json::{Value, json};
use std::{str::FromStr, time::Duration};
use store::registry::{RegistryObject, bootstrap::Bootstrap};
use tokio::sync::{Mutex, mpsc};
const PUSH_URL: &str = "https://127.0.0.1:19000/push";
struct PushState {
rx: Mutex<mpsc::Receiver<Value>>,
}
#[derive(Clone)]
struct SessionManager {
tx: mpsc::Sender<Value>,
}
pub async fn run(test: &TestServer, ctx: &CompCtx<'_>) {
println!("[compliance] push");
let (event_tx, event_rx) = mpsc::channel::<Value>(100);
let mut bp = Bootstrap::new_uninitialized(test.server.registry().clone());
let mut servers = Listeners::default();
servers.parse_server(
&mut bp,
RegistryObject {
id: ObjectId::new(ObjectType::NetworkListener, 0u64.into()),
object: NetworkListener {
name: "mock-push-compliance".into(),
bind: Map::new(vec![SocketAddr::from_str("127.0.0.1:19000").unwrap()]),
protocol: NetworkListenerProtocol::Http,
tls_implicit: true,
use_tls: true,
socket_reuse_address: true,
socket_reuse_port: true,
..Default::default()
},
revision: 0,
},
&SystemSettings::default(),
);
servers
.parse_tcp_acceptors(&mut bp, test.server.inner.clone())
.await;
servers.bind_and_drop_priv(&mut bp);
bp.assert_no_errors();
let _shutdown_tx = servers.spawn(|server, acceptor, shutdown_rx| {
server.spawn(
SessionManager {
tx: event_tx.clone(),
},
test.server.inner.clone(),
acceptor,
shutdown_rx,
);
});
let state = PushState {
rx: Mutex::new(event_rx),
};
ctx.run(
"push-subscription/push-subscription-reject-non-https",
reject_non_https(ctx),
)
.await;
ctx.run(
"push-subscription/push-subscription-receives-notification",
receives_notification(ctx, &state),
)
.await;
ctx.run("push-subscription/push-subscription-create", create(ctx))
.await;
ctx.run("push-subscription/push-subscription-get", get(ctx))
.await;
ctx.run("push-subscription/push-subscription-destroy", destroy(ctx))
.await;
ctx.run(
"push-subscription/push-subscription-types-filter",
types_filter(ctx),
)
.await;
ctx.run(
"push-subscription/push-subscription-verification",
verification(ctx),
)
.await;
ctx.run(
"push-eventsource/eventsource-connect",
eventsource_connect(ctx),
)
.await;
ctx.run(
"push-eventsource/eventsource-receives-state-change",
eventsource_receives_state_change(ctx),
)
.await;
ctx.run(
"push-eventsource/eventsource-types-filter",
eventsource_types_filter(ctx),
)
.await;
ctx.run(
"push-eventsource/eventsource-closeafter",
eventsource_closeafter(ctx),
)
.await;
}
impl common::network::SessionManager for SessionManager {
#[allow(clippy::manual_async_fn)]
fn handle<T: common::network::SessionStream>(
self,
session: SessionData<T>,
) -> impl std::future::Future<Output = ()> + Send {
async move {
let tx = self.tx;
let _ = http1::Builder::new()
.keep_alive(false)
.serve_connection(
TokioIo::new(session.stream),
service_fn(|mut req: hyper::Request<body::Incoming>| {
let tx = tx.clone();
async move {
let body = fetch_body(&mut req, 1024 * 1024, 0).await.unwrap();
if let Ok(message) = serde_json::from_slice::<Value>(&body) {
let _ = tx.send(message).await;
}
Ok::<_, hyper::Error>(
HtmlResponse::new("ok".to_string())
.into_http_response()
.build(),
)
}
}),
)
.await;
}
}
#[allow(clippy::manual_async_fn)]
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send {
async {}
}
}
async fn expect_push(state: &PushState, predicate: impl Fn(&Value) -> bool) -> Option<Value> {
let mut rx = state.rx.lock().await;
let deadline = Duration::from_secs(10);
loop {
match tokio::time::timeout(deadline, rx.recv()).await {
Ok(Some(message)) => {
if predicate(&message) {
return Some(message);
}
}
_ => return None,
}
}
}
async fn destroy_subscription(ctx: &CompCtx<'_>, id: &str) {
ctx.primary
.jmap_method_call("PushSubscription/set", json!({ "destroy": [id] }))
.await;
}
async fn reject_non_https(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"bad": {
"deviceClientId": "jmap-test-reject-http",
"url": "http://example.com/push"
}
}
}),
)
.await;
check(
!resp.method_response()["notCreated"]["bad"].is_null(),
"Server MUST reject PushSubscription with non-https URL",
)
}
async fn receives_notification(ctx: &CompCtx<'_>, state: &PushState) -> TestOutcome {
let create_resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"psNotify": {
"deviceClientId": "jmap-test-device-notify",
"url": PUSH_URL,
"types": null
}
}
}),
)
.await;
let ps_id = create_resp.method_response()["created"]["psNotify"]["id"]
.as_str()
.map(|s| s.to_string());
let ps_id = match ps_id {
Some(id) => id,
None => {
return check(false, "Subscription should be created");
}
};
let verification = expect_push(state, |e| {
e["@type"] == json!("PushVerification") && e["pushSubscriptionId"] == json!(ps_id)
})
.await;
if let Some(verification) = verification
&& let Some(code) = verification["verificationCode"].as_str()
{
let mut update = serde_json::Map::new();
update.insert(ps_id.clone(), json!({ "verificationCode": code }));
ctx.primary
.jmap_method_call("PushSubscription/set", json!({ "update": update }))
.await;
}
let mut mailbox_ids = serde_json::Map::new();
mailbox_ids.insert(ctx.role("inbox").to_string(), json!(true));
let email_resp = ctx
.primary
.jmap_method_call(
"Email/set",
json!({
"accountId": ctx.account_id(),
"create": {
"pushEmail": {
"mailboxIds": mailbox_ids,
"from": [{ "name": "Push", "email": "[email protected]" }],
"to": [{ "name": "User", "email": "[email protected]" }],
"subject": "Push notification test",
"bodyStructure": { "type": "text/plain", "partId": "1" },
"bodyValues": { "1": { "value": "trigger push" } }
}
}
}),
)
.await;
let email_id = email_resp.method_response()["created"]["pushEmail"]["id"]
.as_str()
.map(|s| s.to_string());
let notification = expect_push(state, |e| e["@type"] == json!("StateChange")).await;
let result = match &notification {
Some(n) => check_eq(&n["@type"], &json!("StateChange"), "@type"),
None => check(
false,
"Server MUST send push notification after state change",
),
};
destroy_subscription(ctx, &ps_id).await;
if let Some(email_id) = email_id {
ctx.primary
.jmap_method_call(
"Email/set",
json!({ "accountId": ctx.account_id(), "destroy": [email_id] }),
)
.await;
}
result
}
async fn create(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"ps1": {
"deviceClientId": "jmap-test-device-001",
"url": PUSH_URL,
"types": null
}
}
}),
)
.await;
let created = &resp.method_response()["created"]["ps1"];
let outcome = check(!created.is_null(), "Subscription should be created")
.and_then(|_| check(created["id"].is_string(), "Subscription should have an id"));
if let Some(id) = created["id"].as_str() {
destroy_subscription(ctx, id).await;
}
outcome
}
async fn get(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"psGet": {
"deviceClientId": "jmap-test-device-get",
"url": PUSH_URL,
"types": ["Email"]
}
}
}),
)
.await;
let ps_id = match resp.method_response()["created"]["psGet"]["id"].as_str() {
Some(id) => id.to_string(),
None => return check(false, "Subscription should be created"),
};
let get_resp = ctx
.primary
.jmap_method_call("PushSubscription/get", json!({ "ids": [ps_id.clone()] }))
.await;
let list = get_resp.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
let outcome = (|| {
check_eq(list.len(), 1, "list length")?;
check_eq(&list[0]["id"], &json!(ps_id), "id")?;
check_eq(
&list[0]["deviceClientId"],
&json!("jmap-test-device-get"),
"deviceClientId",
)?;
if let Some(url) = list[0]["url"].as_str() {
check_contains(url, PUSH_URL, "url")?;
}
Ok(())
})();
destroy_subscription(ctx, &ps_id).await;
outcome
}
async fn destroy(ctx: &CompCtx<'_>) -> TestOutcome {
let create_resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"psDel": {
"deviceClientId": "jmap-test-device-del",
"url": PUSH_URL
}
}
}),
)
.await;
let ps_id = match create_resp.method_response()["created"]["psDel"]["id"].as_str() {
Some(id) => id.to_string(),
None => return check(false, "Subscription should be created"),
};
let destroy_resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({ "destroy": [ps_id.clone()] }),
)
.await;
let destroyed = &destroy_resp.method_response()["destroyed"];
check(
destroyed.is_array(),
format!(
"PushSubscription/set destroyed must be an array, got {}",
destroyed
),
)?;
check(
destroyed
.as_array()
.map(|a| a.iter().any(|v| v == &json!(ps_id)))
.unwrap_or(false),
"destroyed must include subscription id",
)?;
let get_resp = ctx
.primary
.jmap_method_call("PushSubscription/get", json!({ "ids": [ps_id.clone()] }))
.await;
let not_found = &get_resp.method_response()["notFound"];
check(
not_found.is_array(),
format!(
"PushSubscription/get notFound MUST be a String[], got {}",
not_found
),
)?;
check(
not_found
.as_array()
.map(|a| a.iter().any(|v| v == &json!(ps_id)))
.unwrap_or(false),
"notFound must include subscription id",
)
}
async fn types_filter(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"psTypes": {
"deviceClientId": "jmap-test-device-types",
"url": PUSH_URL,
"types": ["Email", "Mailbox"]
}
}
}),
)
.await;
let created = &resp.method_response()["created"]["psTypes"];
let outcome = check(!created.is_null(), "Subscription should be created");
if let Some(id) = created["id"].as_str() {
destroy_subscription(ctx, id).await;
}
outcome
}
async fn verification(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"PushSubscription/set",
json!({
"create": {
"psVerify": {
"deviceClientId": "jmap-test-device-verify",
"url": PUSH_URL
}
}
}),
)
.await;
let created = &resp.method_response()["created"]["psVerify"];
if created.is_null() {
return Ok(());
}
let outcome = check(created["id"].is_string(), "Subscription should have an id");
if let Some(id) = created["id"].as_str() {
destroy_subscription(ctx, id).await;
}
outcome
}
fn event_source_client() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.build()
.unwrap()
}
async fn eventsource_connect(ctx: &CompCtx<'_>) -> TestOutcome {
let url = ctx.event_source_url("*", "no", "0");
let response = event_source_client()
.get(&url)
.header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth())
.header(reqwest::header::ACCEPT, "text/event-stream")
.send()
.await
.map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
drop(response);
check_eq(status, 200, "status")?;
check_contains(&content_type, "text/event-stream", "content-type")
}
async fn eventsource_receives_state_change(ctx: &CompCtx<'_>) -> TestOutcome {
let url = ctx.event_source_url("*", "no", "0");
let response = event_source_client()
.get(&url)
.header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth())
.header(reqwest::header::ACCEPT, "text/event-stream")
.send()
.await
.map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?;
check_eq(response.status().as_u16(), 200, "status")?;
let mut stream = response.bytes_stream();
tokio::time::sleep(Duration::from_millis(500)).await;
let mut mailbox_ids = serde_json::Map::new();
mailbox_ids.insert(ctx.role("inbox").to_string(), json!(true));
let email_resp = ctx
.primary
.jmap_method_call(
"Email/set",
json!({
"accountId": ctx.account_id(),
"create": {
"esTest": {
"mailboxIds": mailbox_ids,
"from": [{ "name": "ES", "email": "[email protected]" }],
"to": [{ "name": "User", "email": "[email protected]" }],
"subject": "EventSource test",
"bodyStructure": { "type": "text/plain", "partId": "1" },
"bodyValues": { "1": { "value": "trigger state change" } }
}
}
}),
)
.await;
let email_id = email_resp.method_response()["created"]["esTest"]["id"]
.as_str()
.map(|s| s.to_string());
let mut buffer = String::new();
let mut state_change: Option<Value> = None;
let read_result = tokio::time::timeout(Duration::from_secs(5), async {
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(_) => break,
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buffer.find('\n') {
let line = buffer[..idx].trim().to_string();
buffer = buffer[idx + 1..].to_string();
if let Some(data) = line.strip_prefix("data:")
&& let Ok(value) = serde_json::from_str::<Value>(data.trim())
&& value["@type"] == json!("StateChange")
{
state_change = Some(value);
return;
}
}
}
})
.await;
let _ = read_result;
drop(stream);
if let Some(email_id) = email_id {
ctx.primary
.jmap_method_call(
"Email/set",
json!({ "accountId": ctx.account_id(), "destroy": [email_id] }),
)
.await;
}
let value = state_change
.ok_or_else(|| super::Fail::Assert("Did not receive StateChange event".to_string()))?;
check_eq(&value["@type"], &json!("StateChange"), "@type")?;
check(
!value["changed"].is_null(),
"StateChange must have changed property",
)
}
async fn eventsource_types_filter(ctx: &CompCtx<'_>) -> TestOutcome {
let url = ctx.event_source_url("Email", "no", "0");
let response = event_source_client()
.get(&url)
.header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth())
.header(reqwest::header::ACCEPT, "text/event-stream")
.send()
.await
.map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?;
let status = response.status().as_u16();
drop(response);
check_eq(status, 200, "status")
}
async fn eventsource_closeafter(ctx: &CompCtx<'_>) -> TestOutcome {
let url = ctx.event_source_url("*", "state", "0");
let response = event_source_client()
.get(&url)
.header(reqwest::header::AUTHORIZATION, ctx.primary.basic_auth())
.header(reqwest::header::ACCEPT, "text/event-stream")
.send()
.await
.map_err(|e| super::Fail::Assert(format!("EventSource connection failed: {e}")))?;
let status = response.status().as_u16();
drop(response);
check_eq(status, 200, "status")
}
+263
View File
@@ -0,0 +1,263 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq};
use serde_json::{Value, json};
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] search-snippet");
ctx.run("search-snippet/snippet-body-match", snippet_body_match(ctx))
.await;
ctx.run(
"search-snippet/snippet-subject-match",
snippet_subject_match(ctx),
)
.await;
ctx.run("search-snippet/snippet-mark-tags", snippet_mark_tags(ctx))
.await;
ctx.run("search-snippet/snippet-not-found", snippet_not_found(ctx))
.await;
ctx.run(
"search-snippet/snippet-null-when-no-match",
snippet_null_when_no_match(ctx),
)
.await;
ctx.run(
"search-snippet/snippet-response-structure",
snippet_response_structure(ctx),
)
.await;
}
async fn snippet_body_match(ctx: &CompCtx<'_>) -> TestOutcome {
let query = ctx
.primary
.jmap_query(
"Email",
[("text", json!("xylophone"))],
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let email_ids = query.ids().map(|s| s.to_string()).collect::<Vec<_>>();
check(
!email_ids.is_empty(),
"expected at least one matching email",
)?;
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": email_ids,
"filter": { "text": "xylophone" }
}),
)
.await;
let list = resp.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
check(!list.is_empty(), "expected at least one snippet")?;
let snippet = list
.iter()
.find(|s| s["emailId"].as_str() == Some(ctx.email("thread-reply-2")));
check(snippet.is_some(), "Should have snippet for matching email")?;
let snippet = snippet.unwrap();
if let Some(preview) = snippet["preview"].as_str() {
check(
preview.to_lowercase().contains("xylophone") || preview.contains("<mark>"),
"Preview should highlight the match",
)?;
}
Ok(())
}
async fn snippet_subject_match(ctx: &CompCtx<'_>) -> TestOutcome {
let query = ctx
.primary
.jmap_query(
"Email",
[("text", json!("Financial Report"))],
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let email_ids = query.ids().map(|s| s.to_string()).collect::<Vec<_>>();
if email_ids.is_empty() {
return Ok(());
}
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": email_ids,
"filter": { "text": "Financial Report" }
}),
)
.await;
let list = resp.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
let snippet = list
.iter()
.find(|s| s["emailId"].as_str() == Some(ctx.email("html-attachment")));
if let Some(snippet) = snippet
&& let Some(subject) = snippet["subject"].as_str()
{
check(
subject.contains("Financial") || subject.contains("<mark>"),
"Subject snippet should highlight match",
)?;
}
Ok(())
}
async fn snippet_null_when_no_match(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": [ctx.email("plain-simple")],
"filter": { "text": "xylophone" }
}),
)
.await;
let list = resp.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
if !list.is_empty() {
let snippet = list[0].as_object();
check_eq(
snippet.and_then(|o| o.get("subject")),
Some(&json!(null)),
"subject must be present and null",
)?;
check_eq(
snippet.and_then(|o| o.get("preview")),
Some(&json!(null)),
"preview must be present and null",
)?;
}
Ok(())
}
async fn snippet_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": [ctx.email("plain-simple")],
"filter": { "text": "meeting" }
}),
)
.await;
let obj = resp.method_response().as_object();
check(
obj.and_then(|o| o.get("accountId"))
.map(|v| v.is_string())
.unwrap_or(false),
"accountId must be a string",
)?;
check(
obj.and_then(|o| o.get("list"))
.map(|v| v.is_array())
.unwrap_or(false),
"list must be array",
)?;
let not_found = obj.and_then(|o| o.get("notFound"));
check(
matches!(not_found, Some(Value::Null))
|| not_found
.and_then(|v| v.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false),
"notFound must be present and null or a non-empty array",
)
}
async fn snippet_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": ["nonexistent-email-xyz"],
"filter": { "text": "test" }
}),
)
.await;
let r = resp.method_response();
check(
r["notFound"].is_array(),
"Expected notFound to contain 'nonexistent-email-xyz', but got null (server claims all email ids were found)",
)?;
let not_found = r["notFound"].as_array().cloned().unwrap_or_default();
check(
not_found
.iter()
.any(|v| v.as_str() == Some("nonexistent-email-xyz")),
"notFound must include nonexistent-email-xyz",
)
}
async fn snippet_mark_tags(ctx: &CompCtx<'_>) -> TestOutcome {
let query = ctx
.primary
.jmap_query(
"Email",
[("text", json!("conference"))],
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let email_ids = query.ids().map(|s| s.to_string()).collect::<Vec<_>>();
if email_ids.is_empty() {
return Ok(());
}
let resp = ctx
.primary
.jmap_method_call(
"SearchSnippet/get",
json!({
"accountId": ctx.account_id(),
"emailIds": email_ids,
"filter": { "text": "conference" }
}),
)
.await;
let list = resp.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
let snippet = list.iter().find(|s| !s["preview"].is_null());
if let Some(snippet) = snippet
&& let Some(preview) = snippet["preview"].as_str()
{
check_contains(preview, "<mark>", "preview should contain <mark>")?;
check_contains(preview, "</mark>", "preview should contain </mark>")?;
}
Ok(())
}
+569
View File
@@ -0,0 +1,569 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_eq, skip};
use crate::utils::jmap::JmapUtils;
use serde_json::{Value, json};
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] submission");
ctx.run("submission/get-empty", get_empty(ctx)).await;
ctx.run("submission/get-not-found", get_not_found(ctx))
.await;
ctx.run(
"submission/get-response-structure",
get_response_structure(ctx),
)
.await;
ctx.run(
"submission/set-create-submission",
set_create_submission(ctx),
)
.await;
ctx.run(
"submission/set-create-with-envelope",
set_create_with_envelope(ctx),
)
.await;
ctx.run(
"submission/set-no-recipients-error",
set_no_recipients_error(ctx),
)
.await;
ctx.run(
"submission/set-on-success-update-email",
set_on_success_update_email(ctx),
)
.await;
ctx.run(
"submission/set-submission-properties",
set_submission_properties(ctx),
)
.await;
ctx.run("submission/query-all", query_all(ctx)).await;
ctx.run(
"submission/query-filter-undo-status",
query_filter_undo_status(ctx),
)
.await;
ctx.run(
"submission/query-filter-null-accepted",
query_filter_null_accepted(ctx),
)
.await;
ctx.run(
"submission/query-response-structure",
query_response_structure(ctx),
)
.await;
ctx.run("submission/changes-no-changes", changes_no_changes(ctx))
.await;
ctx.run(
"submission/changes-response-structure",
changes_response_structure(ctx),
)
.await;
}
fn drafts_or_inbox(ctx: &CompCtx<'_>) -> String {
ctx.role_opt("drafts")
.unwrap_or_else(|| ctx.role("inbox"))
.to_string()
}
async fn create_draft(ctx: &CompCtx<'_>, mailbox: &str, subject: &str, with_to: bool) -> String {
let mut email = json!({
"mailboxIds": { (mailbox): true },
"from": [{ "name": "Test", "email": ctx.identity_email }],
"subject": subject,
"keywords": { "$seen": true, "$draft": true },
"bodyStructure": { "type": "text/plain", "partId": "1" },
"bodyValues": { "1": { "value": "Test email body" } },
});
if with_to {
email["to"] = json!([{ "name": "Secondary", "email": ctx.secondary_email }]);
}
let resp = ctx
.primary
.jmap_create("Email", [email], Vec::<(String, Value)>::new())
.await;
resp.created(0).id().to_string()
}
async fn destroy_email(ctx: &CompCtx<'_>, email_id: &str) {
ctx.primary
.jmap_destroy("Email", [email_id], Vec::<(String, Value)>::new())
.await;
}
async fn destroy_submission(ctx: &CompCtx<'_>, sub_id: &str) {
ctx.primary
.jmap_destroy("EmailSubmission", [sub_id], Vec::<(String, Value)>::new())
.await;
}
async fn get_empty(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get(
"EmailSubmission",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["state"].is_string(), "state must be string")?;
check(r["list"].is_array(), "list must be array")
}
async fn get_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get(
"EmailSubmission",
Vec::<String>::new(),
["nonexistent-submission-xyz"],
)
.await;
let r = resp.method_response();
check(
r["notFound"].is_array(),
format!("notFound must be a String[], got {}", r["notFound"]),
)?;
let found = r["notFound"]
.as_array()
.map(|a| {
a.iter()
.any(|v| v.as_str() == Some("nonexistent-submission-xyz"))
})
.unwrap_or(false);
check(found, "notFound must include nonexistent-submission-xyz")
}
async fn get_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"EmailSubmission/get",
json!({ "accountId": ctx.account_id(), "ids": [] }),
)
.await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["state"].is_string(), "state must be string")?;
check(r["list"].is_array(), "list must be array")?;
check(r["notFound"].is_array(), "notFound must be array")
}
async fn set_create_submission(ctx: &CompCtx<'_>) -> TestOutcome {
let identity = match ctx.identity_ids.first() {
Some(id) => id,
None => return skip("No identities available"),
};
let mailbox = drafts_or_inbox(ctx);
let email_id = create_draft(ctx, &mailbox, "Submission test", true).await;
let resp = ctx
.primary
.jmap_create(
"EmailSubmission",
[json!({ "identityId": identity, "emailId": email_id })],
Vec::<(String, Value)>::new(),
)
.await;
let created = resp.pointer("/methodResponses/0/1/created/i0").cloned();
let outcome = match &created {
Some(sub) => {
let mut res = check(sub["id"].is_string(), "id is set by server and required");
if res.is_ok() {
res = check(
sub["sendAt"].is_string(),
"sendAt is set by server and required",
);
}
if res.is_ok() {
let undo = sub["undoStatus"].as_str().unwrap_or("");
res = check(
undo == "pending" || undo == "final",
"undoStatus must be pending or final",
);
}
res
}
None => check(false, "Submission should be created"),
};
if let Some(sub) = &created
&& let Some(id) = sub["id"].as_str()
{
destroy_submission(ctx, id).await;
}
destroy_email(ctx, &email_id).await;
outcome
}
async fn set_create_with_envelope(ctx: &CompCtx<'_>) -> TestOutcome {
let identity = match ctx.identity_ids.first() {
Some(id) => id,
None => return skip("No identities available"),
};
let mailbox = drafts_or_inbox(ctx);
let email_id = create_draft(ctx, &mailbox, "Envelope test", true).await;
let resp = ctx
.primary
.jmap_create(
"EmailSubmission",
[json!({
"identityId": identity,
"emailId": email_id,
"envelope": {
"mailFrom": { "email": ctx.identity_email, "parameters": null },
"rcptTo": [{ "email": ctx.secondary_email, "parameters": null }],
},
})],
Vec::<(String, Value)>::new(),
)
.await;
let created = resp.pointer("/methodResponses/0/1/created/i0").cloned();
let outcome = check(created.is_some(), "Submission should be created");
if let Some(sub) = &created
&& let Some(id) = sub["id"].as_str()
{
destroy_submission(ctx, id).await;
}
destroy_email(ctx, &email_id).await;
outcome
}
async fn set_no_recipients_error(ctx: &CompCtx<'_>) -> TestOutcome {
let identity = match ctx.identity_ids.first() {
Some(id) => id,
None => return skip("No identities available"),
};
let resp = ctx
.primary
.jmap_create(
"Email",
[json!({
"mailboxIds": { (ctx.role("inbox")): true },
"from": [{ "name": "Test", "email": "[email protected]" }],
"subject": "No recipients",
"bodyStructure": { "type": "text/plain", "partId": "1" },
"bodyValues": { "1": { "value": "body" } },
})],
Vec::<(String, Value)>::new(),
)
.await;
let email_id = resp.created(0).id().to_string();
let resp = ctx
.primary
.jmap_create(
"EmailSubmission",
[json!({ "identityId": identity, "emailId": email_id })],
Vec::<(String, Value)>::new(),
)
.await;
let not_created = resp.pointer("/methodResponses/0/1/notCreated/i0").cloned();
let outcome = match &not_created {
Some(err) => check(
err["type"].is_string(),
"Server MUST reject submission of email with no recipients",
),
None => check(
false,
"Server MUST reject submission of email with no recipients",
),
};
destroy_email(ctx, &email_id).await;
outcome
}
async fn set_on_success_update_email(ctx: &CompCtx<'_>) -> TestOutcome {
let identity = match ctx.identity_ids.first() {
Some(id) => id,
None => return skip("No identities available"),
};
let sent_mailbox = match ctx.role_opt("sent") {
Some(id) => id.to_string(),
None => return skip("No sent mailbox found"),
};
let drafts = drafts_or_inbox(ctx);
let email_id = create_draft(ctx, &drafts, "onSuccess test", true).await;
let resp = ctx
.primary
.jmap_method_calls(json!([[
"EmailSubmission/set",
{
"accountId": ctx.account_id(),
"create": {
"osuSub": { "identityId": identity, "emailId": email_id }
},
"onSuccessUpdateEmail": {
"#osuSub": {
(format!("mailboxIds/{sent_mailbox}")): true,
(format!("mailboxIds/{drafts}")): null,
"keywords/$draft": null
}
}
},
"submit"
]]))
.await;
let mut outcome = check(
resp.num_responses() >= 2,
"Response must include both EmailSubmission/set and implicit Email/set",
);
if outcome.is_ok() {
outcome = check_eq(
resp.name_at(0),
"EmailSubmission/set",
"first response name",
);
}
if outcome.is_ok() {
let has_email_set = (0..resp.num_responses()).any(|n| resp.name_at(n) == "Email/set");
outcome = check(
has_email_set,
"Implicit Email/set from onSuccessUpdateEmail must appear in methodResponses",
);
}
if outcome.is_ok() {
let get_result = ctx
.primary
.jmap_method_call(
"Email/get",
json!({
"accountId": ctx.account_id(),
"ids": [email_id],
"properties": ["mailboxIds", "keywords"]
}),
)
.await;
let list = get_result.method_response()["list"]
.as_array()
.cloned()
.unwrap_or_default();
if let Some(email) = list.first() {
let in_sent = email["mailboxIds"][sent_mailbox.as_str()]
.as_bool()
.unwrap_or(false);
if in_sent {
outcome = check_eq(
email["mailboxIds"][sent_mailbox.as_str()]
.as_bool()
.unwrap_or(false),
true,
"email in sent",
);
}
if outcome.is_ok() {
outcome = check(
!email["keywords"]["$draft"].as_bool().unwrap_or(false),
"$draft should be removed",
);
}
}
}
let sub_id = resp
.pointer("/methodResponses/0/1/created/osuSub/id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(id) = sub_id {
destroy_submission(ctx, &id).await;
}
destroy_email(ctx, &email_id).await;
outcome
}
async fn set_submission_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let identity = match ctx.identity_ids.first() {
Some(id) => id,
None => return skip("No identities available"),
};
let email_id = create_draft(ctx, ctx.role("inbox"), "Properties test", true).await;
let resp = ctx
.primary
.jmap_create(
"EmailSubmission",
[json!({ "identityId": identity, "emailId": email_id })],
Vec::<(String, Value)>::new(),
)
.await;
let created = resp.pointer("/methodResponses/0/1/created/i0").cloned();
let mut outcome = Ok(());
let mut sub_id_for_cleanup = None;
if let Some(sub) = &created {
outcome = check(sub["id"].is_string(), "submission id must be string");
if outcome.is_ok() {
let sub_id = sub["id"].as_str().unwrap_or("").to_string();
sub_id_for_cleanup = Some(sub_id.clone());
let get_result = ctx
.primary
.jmap_get("EmailSubmission", Vec::<String>::new(), [sub_id.clone()])
.await;
let fetched = get_result.method_response()["list"]
.as_array()
.and_then(|a| a.first())
.cloned();
match fetched {
Some(f) => {
outcome = check(f["identityId"].is_string(), "identityId");
if outcome.is_ok() {
outcome = check(f["emailId"].is_string(), "emailId");
}
if outcome.is_ok() {
outcome = check(!f["sendAt"].is_null(), "sendAt");
}
if outcome.is_ok() {
outcome = check(!f["undoStatus"].is_null(), "undoStatus");
}
}
None => {
let not_found = get_result.method_response()["notFound"].clone();
outcome = check_eq(
&not_found,
&json!([sub_id]),
"Item not returned and missing from notFound",
);
}
}
}
}
if let Some(id) = sub_id_for_cleanup {
destroy_submission(ctx, &id).await;
}
destroy_email(ctx, &email_id).await;
outcome
}
async fn query_all(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_query(
"EmailSubmission",
Vec::<(String, Value)>::new(),
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let r = resp.method_response();
check(r["queryState"].is_string(), "queryState must be string")?;
check(r["ids"].is_array(), "ids must be array")
}
async fn query_filter_undo_status(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_query(
"EmailSubmission",
[("undoStatus", json!("final"))],
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let r = resp.method_response();
check(r["ids"].is_array(), "ids must be array")
}
async fn query_filter_null_accepted(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_method_call(
"EmailSubmission/query",
json!({ "accountId": ctx.account_id(), "filter": null }),
)
.await;
let r = resp.method_response();
check(r["queryState"].is_string(), "queryState must be string")?;
check(r["ids"].is_array(), "ids must be array")
}
async fn query_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_query(
"EmailSubmission",
Vec::<(String, Value)>::new(),
Vec::<String>::new(),
Vec::<(String, Value)>::new(),
)
.await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["queryState"].is_string(), "queryState must be string")?;
check(
r["canCalculateChanges"].is_boolean(),
"canCalculateChanges must be boolean",
)?;
check(r["position"].is_number(), "position must be number")?;
check(r["ids"].is_array(), "ids must be array")
}
async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get(
"EmailSubmission",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("EmailSubmission", &state).await;
let r = resp.method_response();
check_eq(
r["oldState"].as_str().unwrap_or(""),
state.as_str(),
"oldState",
)?;
let count = |k: &str| r[k].as_array().map(|a| a.len()).unwrap_or(usize::MAX);
check_eq(count("created"), 0, "created length")?;
check_eq(count("updated"), 0, "updated length")?;
check_eq(count("destroyed"), 0, "destroyed length")
}
async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get(
"EmailSubmission",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("EmailSubmission", &state).await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["oldState"].is_string(), "oldState must be string")?;
check(r["newState"].is_string(), "newState must be string")?;
check(
r["hasMoreChanges"].is_boolean(),
"hasMoreChanges must be boolean",
)
}
+314
View File
@@ -0,0 +1,314 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_eq};
use crate::utils::jmap::JmapUtils;
use serde_json::json;
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] thread");
ctx.run("thread/get-thread-by-id", get_thread_by_id(ctx))
.await;
ctx.run(
"thread/get-single-email-thread",
get_single_email_thread(ctx),
)
.await;
ctx.run(
"thread/get-thread-email-ids-order",
get_thread_email_ids_order(ctx),
)
.await;
ctx.run(
"thread/get-thread-response-structure",
get_thread_response_structure(ctx),
)
.await;
ctx.run("thread/get-thread-not-found", get_thread_not_found(ctx))
.await;
ctx.run(
"thread/changes-after-new-email",
changes_after_new_email(ctx),
)
.await;
ctx.run(
"thread/changes-after-email-destroy",
changes_after_email_destroy(ctx),
)
.await;
ctx.run("thread/changes-no-changes", changes_no_changes(ctx))
.await;
ctx.run(
"thread/changes-response-structure",
changes_response_structure(ctx),
)
.await;
}
async fn thread_id_of(ctx: &CompCtx<'_>, email_key: &str) -> String {
let resp = ctx
.primary
.jmap_get("Email", ["threadId"], [ctx.email(email_key)])
.await;
resp.list()[0].text_field("threadId").to_string()
}
async fn get_thread_by_id(ctx: &CompCtx<'_>) -> TestOutcome {
let thread_id = thread_id_of(ctx, "thread-starter").await;
let resp = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), [thread_id.as_str()])
.await;
let list = resp.list();
check_eq(list.len(), 1, "list length")?;
check_eq(list[0].text_field("id"), thread_id.as_str(), "thread id")?;
let email_ids = list[0]["emailIds"].as_array();
check(email_ids.is_some(), "emailIds must be array")?;
check(
email_ids.map(|a| a.len()).unwrap_or(0) >= 3,
"Thread should have at least 3 emails",
)
}
async fn get_single_email_thread(ctx: &CompCtx<'_>) -> TestOutcome {
let email_id = ctx.email("plain-simple");
let thread_id = thread_id_of(ctx, "plain-simple").await;
let resp = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), [thread_id.as_str()])
.await;
let list = resp.list();
let email_ids = list[0]["emailIds"].as_array().map(|a| a.len()).unwrap_or(0);
check_eq(email_ids, 1, "single email thread length")?;
check_eq(
list[0]["emailIds"][0].as_str().unwrap_or(""),
email_id,
"emailIds[0]",
)
}
async fn get_thread_email_ids_order(ctx: &CompCtx<'_>) -> TestOutcome {
let thread_id = thread_id_of(ctx, "thread-starter").await;
let resp = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), [thread_id.as_str()])
.await;
let email_ids = resp.list()[0]["emailIds"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>();
let get_result = ctx
.primary
.jmap_get("Email", ["receivedAt"], email_ids.iter())
.await;
let mut id_to_date = std::collections::HashMap::new();
for email in get_result.list() {
let id = email.text_field("id").to_string();
let received = email["receivedAt"].as_str().unwrap_or("").to_string();
id_to_date.insert(id, received);
}
for i in 1..email_ids.len() {
let prev = id_to_date.get(&email_ids[i - 1]);
let curr = id_to_date.get(&email_ids[i]);
if let (Some(prev), Some(curr)) = (prev, curr) {
check(prev <= curr, "emailIds should be ordered by receivedAt")?;
}
}
Ok(())
}
async fn get_thread_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), Vec::<String>::new())
.await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["state"].is_string(), "state must be string")?;
check(r["list"].is_array(), "list must be array")?;
check(r["notFound"].is_array(), "notFound must be array")
}
async fn get_thread_not_found(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), ["nonexistent-thread-xyz"])
.await;
let r = resp.method_response();
check(
r["notFound"].is_array(),
format!(
"Thread/get notFound MUST be a String[] (RFC 8620 5.1), got {}",
r["notFound"]
),
)?;
let not_found = resp.not_found().collect::<Vec<_>>();
check(
not_found.contains(&"nonexistent-thread-xyz"),
"notFound should include nonexistent-thread-xyz",
)
}
async fn changes_no_changes(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), Vec::<String>::new())
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("Thread", &state).await;
let r = resp.method_response();
check_eq(
r["oldState"].as_str().unwrap_or(""),
state.as_str(),
"oldState",
)?;
check_eq(
r["created"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"created length",
)?;
check_eq(
r["updated"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"updated length",
)?;
check_eq(
r["destroyed"]
.as_array()
.map(|a| a.len())
.unwrap_or(usize::MAX),
0,
"destroyed length",
)
}
fn email_create_item(ctx: &CompCtx<'_>, subject: &str) -> serde_json::Value {
json!({
"mailboxIds": { (ctx.role("inbox")): true },
"from": [{ "name": "Test", "email": "[email protected]" }],
"to": [{ "name": "User", "email": "[email protected]" }],
"subject": subject,
"bodyStructure": { "type": "text/plain", "partId": "1" },
"bodyValues": { "1": { "value": "body" } },
})
}
async fn changes_after_new_email(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), Vec::<String>::new())
.await;
let old_state = get_result.state().to_string();
let create_result = ctx
.primary
.jmap_create(
"Email",
[email_create_item(ctx, "New thread for changes test")],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let email_id = create_result.created(0).text_field("id").to_string();
let changes = ctx.primary.jmap_changes("Thread", &old_state).await;
let created_len = changes.method_response()["created"]
.as_array()
.map(|a| a.len())
.unwrap_or(0);
let outcome = check(created_len > 0, "Should have at least one new thread");
ctx.primary
.jmap_destroy(
"Email",
[email_id.as_str()],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
outcome
}
async fn changes_after_email_destroy(ctx: &CompCtx<'_>) -> TestOutcome {
let create_result = ctx
.primary
.jmap_create(
"Email",
[email_create_item(ctx, "Thread to destroy")],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let email_id = create_result.created(0).text_field("id").to_string();
let email_get = ctx
.primary
.jmap_get("Email", ["threadId"], [email_id.as_str()])
.await;
let thread_id = email_get.list()[0].text_field("threadId").to_string();
let thread_get = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), Vec::<String>::new())
.await;
let mid_state = thread_get.state().to_string();
ctx.primary
.jmap_destroy(
"Email",
[email_id.as_str()],
Vec::<(String, serde_json::Value)>::new(),
)
.await;
let changes = ctx.primary.jmap_changes("Thread", &mid_state).await;
let destroyed = changes.method_response()["destroyed"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>();
check(
destroyed.contains(&thread_id),
format!("destroyed should include {thread_id}"),
)
}
async fn changes_response_structure(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get("Thread", Vec::<String>::new(), Vec::<String>::new())
.await;
let state = get_result.state().to_string();
let resp = ctx.primary.jmap_changes("Thread", &state).await;
let r = resp.method_response();
check(r["accountId"].is_string(), "accountId must be string")?;
check(r["oldState"].is_string(), "oldState must be string")?;
check(r["newState"].is_string(), "newState must be string")?;
check(
r["hasMoreChanges"].is_boolean(),
"hasMoreChanges must be boolean",
)?;
check(r["created"].is_array(), "created must be array")?;
check(r["updated"].is_array(), "updated must be array")?;
check(r["destroyed"].is_array(), "destroyed must be array")
}
+305
View File
@@ -0,0 +1,305 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{CompCtx, TestOutcome, check, check_contains, check_eq};
use crate::utils::jmap::JmapUtils;
use serde_json::{Value, json};
pub async fn run(ctx: &CompCtx<'_>) {
println!("[compliance] vacation");
ctx.run("vacation/get-singleton", get_singleton(ctx)).await;
ctx.run(
"vacation/get-singleton-null-ids",
get_singleton_null_ids(ctx),
)
.await;
ctx.run(
"vacation/get-singleton-properties",
get_singleton_properties(ctx),
)
.await;
ctx.run(
"vacation/get-not-found-invalid-id",
get_not_found_invalid_id(ctx),
)
.await;
ctx.run("vacation/set-enable-vacation", set_enable_vacation(ctx))
.await;
ctx.run("vacation/set-disable-vacation", set_disable_vacation(ctx))
.await;
ctx.run("vacation/set-dates", set_dates(ctx)).await;
ctx.run("vacation/set-html-body", set_html_body(ctx)).await;
ctx.run("vacation/set-cannot-create", set_cannot_create(ctx))
.await;
ctx.run("vacation/set-cannot-destroy", set_cannot_destroy(ctx))
.await;
}
async fn get_singleton(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("VacationResponse", Vec::<String>::new(), ["singleton"])
.await;
let list = resp.list();
check_eq(list.len(), 1, "list length")?;
check_eq(list[0].id(), "singleton", "id")
}
async fn get_singleton_null_ids(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let list = resp.list();
check_eq(list.len(), 1, "list length")?;
check_eq(list[0].id(), "singleton", "id")
}
async fn get_singleton_properties(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let vr = &resp.list()[0];
check_eq(vr.id(), "singleton", "id")?;
check(vr["isEnabled"].is_boolean(), "isEnabled must be boolean")?;
check(
vr["fromDate"].is_null() || vr["fromDate"].is_string(),
"fromDate must be null or string",
)?;
check(
vr["toDate"].is_null() || vr["toDate"].is_string(),
"toDate must be null or string",
)?;
check(
vr["subject"].is_null() || vr["subject"].is_string(),
"subject must be null or string",
)?;
check(
vr["textBody"].is_null() || vr["textBody"].is_string(),
"textBody must be null or string",
)?;
check(
vr["htmlBody"].is_null() || vr["htmlBody"].is_string(),
"htmlBody must be null or string",
)
}
async fn get_not_found_invalid_id(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_get("VacationResponse", Vec::<String>::new(), ["not-singleton"])
.await;
let not_found = &resp.method_response()["notFound"];
check(
not_found.is_array(),
format!(
"VacationResponse/get notFound MUST be a String[], got {}",
not_found
),
)?;
let contains = not_found
.as_array()
.map(|a| a.iter().any(|v| v.as_str() == Some("not-singleton")))
.unwrap_or(false);
check(contains, "notFound must include not-singleton")
}
async fn set_enable_vacation(ctx: &CompCtx<'_>) -> TestOutcome {
let get_result = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let original = get_result.list()[0].clone();
ctx.primary
.jmap_update(
"VacationResponse",
[(
"singleton",
json!({
"isEnabled": true,
"subject": "Out of Office - Test",
"textBody": "I am currently out of the office for testing.",
}),
)],
Vec::<(String, Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let vr = &verify.list()[0];
let outcome = check_eq(&vr["isEnabled"], &json!(true), "isEnabled")
.and_then(|_| check_eq(&vr["subject"], &json!("Out of Office - Test"), "subject"));
ctx.primary
.jmap_update(
"VacationResponse",
[(
"singleton",
json!({
"isEnabled": original["isEnabled"],
"subject": original["subject"],
"textBody": original["textBody"],
}),
)],
Vec::<(String, Value)>::new(),
)
.await;
outcome
}
async fn set_disable_vacation(ctx: &CompCtx<'_>) -> TestOutcome {
ctx.primary
.jmap_update(
"VacationResponse",
[("singleton", json!({ "isEnabled": false }))],
Vec::<(String, Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let vr = &verify.list()[0];
check_eq(&vr["isEnabled"], &json!(false), "isEnabled")
}
async fn set_dates(ctx: &CompCtx<'_>) -> TestOutcome {
let from_date = "2026-03-01T00:00:00Z";
let to_date = "2026-03-15T00:00:00Z";
ctx.primary
.jmap_update(
"VacationResponse",
[(
"singleton",
json!({ "fromDate": from_date, "toDate": to_date }),
)],
Vec::<(String, Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let vr = &verify.list()[0];
let outcome = check_eq(&vr["fromDate"], &json!(from_date), "fromDate")
.and_then(|_| check_eq(&vr["toDate"], &json!(to_date), "toDate"));
ctx.primary
.jmap_update(
"VacationResponse",
[("singleton", json!({ "fromDate": null, "toDate": null }))],
Vec::<(String, Value)>::new(),
)
.await;
outcome
}
async fn set_html_body(ctx: &CompCtx<'_>) -> TestOutcome {
ctx.primary
.jmap_update(
"VacationResponse",
[(
"singleton",
json!({ "htmlBody": "<p>I am out of office.</p>" }),
)],
Vec::<(String, Value)>::new(),
)
.await;
let verify = ctx
.primary
.jmap_get(
"VacationResponse",
Vec::<String>::new(),
Vec::<String>::new(),
)
.await;
let vr = &verify.list()[0];
let outcome = check_contains(
vr["htmlBody"].as_str().unwrap_or(""),
"out of office",
"htmlBody",
);
ctx.primary
.jmap_update(
"VacationResponse",
[("singleton", json!({ "htmlBody": null }))],
Vec::<(String, Value)>::new(),
)
.await;
outcome
}
async fn set_cannot_create(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_create(
"VacationResponse",
[json!({ "isEnabled": false })],
Vec::<(String, Value)>::new(),
)
.await;
let not_created = resp.not_created(0);
check(
!not_created.is_null(),
"Should not allow creating new VacationResponse",
)?;
check_eq(not_created.typ(), "singleton", "type")
}
async fn set_cannot_destroy(ctx: &CompCtx<'_>) -> TestOutcome {
let resp = ctx
.primary
.jmap_destroy(
"VacationResponse",
["singleton"],
Vec::<(String, Value)>::new(),
)
.await;
let not_destroyed = resp.not_destroyed("singleton");
check(
!not_destroyed.is_null(),
"Should not allow destroying VacationResponse singleton",
)?;
check_eq(not_destroyed.typ(), "singleton", "type")
}
+674
View File
@@ -0,0 +1,674 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{jmap::JmapUtils, server::TestServer};
use calcard::jscontact::JSContactProperty;
use jmap_proto::{
object::{addressbook::AddressBookProperty, share_notification::ShareNotificationProperty},
request::method::MethodObject,
};
use serde_json::json;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Contacts ACL tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let john_id = john.id_string().to_string();
let jane_id = jane.id_string().to_string();
// Create test address books
let response = john
.jmap_create(
MethodObject::AddressBook,
[json!({
"name": "Test #1",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let john_book_id = response.created(0).id().to_string();
let john_contact_id = john
.jmap_create(
MethodObject::ContactCard,
[json!({
"uid": "abc123",
"name": {
"full": "John's Simple Contact",
},
"addressBookIds": {
&john_book_id: true
},
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let response = jane
.jmap_create(
MethodObject::AddressBook,
[json!({
"name": "Test #1",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let jane_book_id = response.created(0).id().to_string();
let jane_contact_id = jane
.jmap_create(
MethodObject::ContactCard,
[json!({
"uid": "abc456",
"name": {
"full": "Jane's Simple Contact",
},
"addressBookIds": {
&jane_book_id: true
},
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
// Verify myRights
john.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::MyRights,
AddressBookProperty::ShareWith,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayDelete": true,
"mayShare": true
},
"shareWith": {}
}));
// Obtain share notifications
let mut jane_share_change_id = jane
.jmap_get(
MethodObject::ShareNotification,
Vec::<&str>::new(),
Vec::<&str>::new(),
)
.await
.state()
.to_string();
// Make sure Jane has no access
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::AddressBook,
Vec::<&str>::new(),
[john_book_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Share address book with Jane
john.jmap_update(
MethodObject::AddressBook,
[(
&john_book_id,
json!({
"shareWith": {
&jane_id : {
"mayRead": true,
}
}
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_book_id);
john.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::ShareWith,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"shareWith": {
&jane_id : {
"mayRead": true,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
}
}
}));
// Verify Jane can access the contact
jane.jmap_get_account(
john,
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::MyRights,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
}
}));
jane.jmap_get_account(
john,
MethodObject::ContactCard,
[AddressBookProperty::Id, AddressBookProperty::Name],
[john_contact_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_contact_id,
"name": {
"full": "John's Simple Contact"
},
}));
// Verify Jane received a share notification
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "AddressBook",
"objectAccountId": &john_id,
"objectId": &john_book_id,
"oldRights": {
"mayRead": false,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
},
"newRights": {
"mayRead": true,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
},
"name": null
}));
// Updating and deleting should fail
assert_eq!(
jane.jmap_update_account(
john,
MethodObject::AddressBook,
[(&john_book_id, json!({}))],
Vec::<(&str, &str)>::new(),
)
.await
.not_updated(&john_book_id)
.description(),
"You are not allowed to modify this address book."
);
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::AddressBook,
[&john_book_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&john_book_id)
.description(),
"You are not allowed to delete this address book."
);
assert!(
jane.jmap_update_account(
john,
MethodObject::ContactCard,
[(&john_contact_id, json!({}))],
Vec::<(&str, &str)>::new(),
)
.await
.not_updated(&john_contact_id)
.description()
.contains("You are not allowed to modify address book"),
);
assert!(
jane.jmap_destroy_account(
john,
MethodObject::ContactCard,
[&john_contact_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&john_contact_id)
.description()
.contains("You are not allowed to remove contacts from address book"),
);
// Grant Jane write access
john.jmap_update(
MethodObject::AddressBook,
[(
&john_book_id,
json!({
format!("shareWith/{jane_id}/mayWrite"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_book_id);
jane.jmap_get_account(
john,
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::MyRights,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayWrite": true,
"mayDelete": true,
"mayShare": false
}
}));
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "AddressBook",
"objectAccountId": &john_id,
"objectId": &john_book_id,
"oldRights": {
"mayRead": true,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
},
"newRights": {
"mayRead": true,
"mayWrite": true,
"mayDelete": true,
"mayShare": false
},
"name": null
}));
// Creating a root folder should fail
assert_eq!(
jane.jmap_create_account(
john,
MethodObject::AddressBook,
[json!({
"name": "A new shared address book",
})],
Vec::<(&str, &str)>::new()
)
.await
.not_created(0)
.description(),
"Cannot create address books in a shared account."
);
// Copy Jane's contact into John's address book
let john_copied_contact_id = jane
.jmap_copy(
jane,
john,
MethodObject::ContactCard,
[(
&jane_contact_id,
json!({
"addressBookIds": {
&john_book_id: true
}
}),
)],
false,
)
.await
.copied(&jane_contact_id)
.id()
.to_string();
jane.jmap_get_account(
john,
MethodObject::ContactCard,
[
JSContactProperty::<Id>::Id,
JSContactProperty::AddressBookIds,
JSContactProperty::Name,
],
[john_copied_contact_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_copied_contact_id,
"name": {
"full": "Jane's Simple Contact"
},
"addressBookIds": {
&john_book_id: true
}
}));
// Destroy the copied contact
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::ContactCard,
[john_copied_contact_id.as_str()],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.collect::<Vec<_>>(),
[&john_copied_contact_id]
);
// Update John's contact
jane.jmap_update_account(
john,
MethodObject::ContactCard,
[(
&john_contact_id,
json!({
"name": {
"full": "John's Updated Contact",
}
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_contact_id);
jane.jmap_get_account(
john,
MethodObject::ContactCard,
[JSContactProperty::<Id>::Id, JSContactProperty::Name],
[john_contact_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_contact_id,
"name": {
"full": "John's Updated Contact"
},
}));
// Update John's address book name
jane.jmap_update_account(
john,
MethodObject::AddressBook,
[(
&john_book_id,
json!({
"name": "Jane's version of John's Address Book",
"description": "This is John's address book, but Jane can edit it now"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_book_id);
jane.jmap_get_account(
john,
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::Description,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Jane's version of John's Address Book",
"description": "This is John's address book, but Jane can edit it now"
}));
// John should still see the old name
john.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::Description,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"description": null
}));
// Revoke Jane's access
john.jmap_update(
MethodObject::AddressBook,
[(
&john_book_id,
json!({
format!("shareWith/{jane_id}"): ()
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_book_id);
john.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::ShareWith,
],
[john_book_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_book_id,
"name": "Test #1",
"shareWith": {}
}));
// Verify Jane can no longer access the address book or its contacts
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::AddressBook,
Vec::<&str>::new(),
[john_book_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "AddressBook",
"objectAccountId": &john_id,
"objectId": &john_book_id,
"oldRights": {
"mayRead": true,
"mayWrite": true,
"mayDelete": true,
"mayShare": false
},
"newRights": {
"mayRead": false,
"mayWrite": false,
"mayDelete": false,
"mayShare": false
},
"name": null
}));
// Grant Jane delete access once again
john.jmap_update(
MethodObject::AddressBook,
[(
&john_book_id,
json!({
format!("shareWith/{jane_id}/mayRead"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_book_id);
// Verify Jane can delete the address book
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::AddressBook,
[john_book_id.as_str()],
[("onDestroyRemoveContents", true)],
)
.await
.destroyed()
.collect::<Vec<_>>(),
[john_book_id.as_str()]
);
// Destroy all mailboxes
john.destroy_all_addressbooks().await;
jane.destroy_all_addressbooks().await;
test.assert_is_empty().await;
}
+216
View File
@@ -0,0 +1,216 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
jmap::{ChangeType, JmapUtils},
server::TestServer,
};
use jmap_proto::{object::addressbook::AddressBookProperty, request::method::MethodObject};
use serde_json::json;
pub async fn test(test: &TestServer) {
println!("Running AddressBook tests...");
let account = test.account("[email protected]");
// Make sure the default address book exists
let response = account
.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::Description,
AddressBookProperty::SortOrder,
AddressBookProperty::IsSubscribed,
AddressBookProperty::IsDefault,
],
Vec::<&str>::new(),
)
.await;
let list = response.list();
assert_eq!(list.len(), 1);
let default_addressbook_id = list[0].id().to_string();
assert_eq!(
list[0],
json!({
"name": "Stalwart Address Book ([email protected])",
"description": (),
"sortOrder": 0,
"isSubscribed": true,
"isDefault": true,
"id": default_addressbook_id,
})
);
let change_id = response.state();
// Create Address Book
let addressbook_id = account
.jmap_create(
MethodObject::AddressBook,
[json!({
"name": "Test address book",
"description": "My personal address book",
"sortOrder": 1,
"isSubscribed": true
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
// Validate changes
assert_eq!(
account
.jmap_changes(MethodObject::AddressBook, change_id)
.await
.changes()
.collect::<Vec<_>>(),
[ChangeType::Created(&addressbook_id)]
);
// Get Address Book
let response = account
.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::Description,
AddressBookProperty::SortOrder,
AddressBookProperty::IsSubscribed,
AddressBookProperty::IsDefault,
],
[&addressbook_id],
)
.await;
assert_eq!(
response.list()[0],
json!({
"name": "Test address book",
"description": "My personal address book",
"sortOrder": 1,
"isSubscribed": true,
"isDefault": false,
"id": addressbook_id,
})
);
// Update Address Book and set it as default
account
.jmap_update(
MethodObject::AddressBook,
[(
addressbook_id.as_str(),
json!({
"name": "Updated address book",
"description": "My updated personal address book",
"sortOrder": 2,
"isSubscribed": false
}),
)],
[("onSuccessSetIsDefault", addressbook_id.as_str())],
)
.await
.updated(&addressbook_id);
// Validate changes
assert_eq!(
account
.jmap_get(
MethodObject::AddressBook,
[
AddressBookProperty::Id,
AddressBookProperty::Name,
AddressBookProperty::Description,
AddressBookProperty::SortOrder,
AddressBookProperty::IsSubscribed,
AddressBookProperty::IsDefault,
],
[&addressbook_id, &default_addressbook_id],
)
.await
.list(),
vec![
json!({
"name": "Updated address book",
"description": "My updated personal address book",
"sortOrder": 2,
"isSubscribed": false,
"isDefault": true,
"id": addressbook_id,
}),
json!({
"name": "Stalwart Address Book ([email protected])",
"description": (),
"sortOrder": 0,
"isSubscribed": true,
"isDefault": false,
"id": default_addressbook_id,
})
]
);
// Create a contact
let _ = account
.jmap_create(
MethodObject::ContactCard,
[json!({
"addressBookIds": {
&addressbook_id: true
},
"name": {
"components": [
{ "kind": "given", "value": "Joe" },
{ "kind": "surname", "value": "Bloggs" }
]
},
"emails": {
"0": {
"address": "[email protected]"
}
}
})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id();
// Try destroying the address book (should fail)
assert_eq!(
account
.jmap_destroy(
MethodObject::AddressBook,
[&addressbook_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&addressbook_id)
.typ(),
"addressBookHasContents"
);
// Destroy using force
assert_eq!(
account
.jmap_destroy(
MethodObject::AddressBook,
[&addressbook_id],
[("onDestroyRemoveContents", true)],
)
.await
.destroyed()
.collect::<Vec<_>>(),
vec![&addressbook_id]
);
// Destroy all mailboxes
account.destroy_all_addressbooks().await;
test.assert_is_empty().await;
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod acl;
pub mod addressbook;
pub mod contact;
+429
View File
@@ -0,0 +1,429 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use email::mailbox::INBOX_ID;
use serde_json::{Value, json};
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running blob tests...");
let account = test.account("[email protected]");
test.blob_expire_all().await;
// Blob/set simple test
let response = account.jmap_method_call("Blob/upload", json!({
"accountId": account.id_string(),
"create": {
"abc": {
"data" : [
{
"data:asBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlN/gFy0ywAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII="
}
],
"type": "image/png"
}
}
})).await;
assert_eq!(
response
.pointer("/methodResponses/0/1/created/abc/type")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"image/png",
"Response: {:?}",
response
);
assert_eq!(
response
.pointer("/methodResponses/0/1/created/abc/size")
.and_then(|v| v.as_i64())
.unwrap_or_default(),
95,
"Response: {:?}",
response
);
// Blob/get simple test
let blob_id = account
.jmap_method_call(
"Blob/upload",
json!({
"accountId": account.id_string(),
"create": {
"abc": {
"data" : [
{
"data:asText": "The quick brown fox jumped over the lazy dog."
}
]
}
}
}),
)
.await
.pointer("/methodResponses/0/1/created/abc/id")
.and_then(|v| v.as_str())
.unwrap()
.to_string();
let response = account
.jmap_method_calls(json!([[
"Blob/get",
{
"accountId": account.id_string(),
"ids" : [
blob_id
],
"properties" : [
"data:asText",
"digest:sha",
"size"
]
},
"R1"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"ids" : [
blob_id
],
"properties" : [
"data:asText",
"digest:sha",
"digest:sha-256",
"size"
],
"offset" : 4,
"length" : 9
},
"R2"
]
]))
.await;
for (pointer, expected) in [
(
"/methodResponses/0/1/list/0/data:asText",
"The quick brown fox jumped over the lazy dog.",
),
(
"/methodResponses/0/1/list/0/digest:sha",
"wIVPufsDxBzOOALLDSIFKebu+U4=",
),
("/methodResponses/0/1/list/0/size", "45"),
("/methodResponses/1/1/list/0/data:asText", "quick bro"),
(
"/methodResponses/1/1/list/0/digest:sha",
"QiRAPtfyX8K6tm1iOAtZ87Xj3Ww=",
),
(
"/methodResponses/1/1/list/0/digest:sha-256",
"gdg9INW7lwHK6OQ9u0dwDz2ZY/gubi0En0xlFpKt0OA=",
),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
test.blob_expire_all().await;
// Blob/upload Complex Example
let response = account
.jmap_method_calls(json!([
[
"Blob/upload",
{
"accountId": account.id_string(),
"create": {
"b4": {
"data": [
{
"data:asText": "The quick brown fox jumped over the lazy dog."
}
]
}
}
},
"S4"
],
[
"Blob/upload",
{
"accountId": account.id_string(),
"create": {
"cat": {
"data": [
{
"data:asText": "How"
},
{
"blobId": "#b4",
"length": 7,
"offset": 3
},
{
"data:asText": "was t"
},
{
"blobId": "#b4",
"length": 1,
"offset": 1
},
{
"data:asBase64": "YXQ/"
}
]
}
}
},
"CAT"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"properties": [
"data:asText",
"size"
],
"ids": [
"#cat"
]
},
"G4"
]
]))
.await;
for (pointer, expected) in [
(
"/methodResponses/2/1/list/0/data:asText",
"How quick was that?",
),
("/methodResponses/2/1/list/0/size", "19"),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
test.blob_expire_all().await;
// Blob/get Example with Range and Encoding Errors
let response = account.jmap_method_calls(json!([
[
"Blob/upload",
{
"accountId": account.id_string(),
"create": {
"b1": {
"data": [
{
"data:asBase64": "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg=="
}
]
},
"b2": {
"data": [
{
"data:asText": "hello world"
}
],
"type" : "text/plain"
}
}
},
"S1"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"ids": [
"#b1",
"#b2"
]
},
"G1"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"ids": [
"#b1",
"#b2"
],
"properties": [
"data:asText",
"size"
]
},
"G2"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"ids": [
"#b1",
"#b2"
],
"properties": [
"data:asBase64",
"size"
]
},
"G3"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"offset": 0,
"length": 5,
"ids": [
"#b1",
"#b2"
]
},
"G4"
],
[
"Blob/get",
{
"accountId": account.id_string(),
"offset": 20,
"length": 100,
"ids": [
"#b1",
"#b2"
]
},
"G5"
]
])).await;
for (pointer, expected) in [
(
"/methodResponses/1/1/list/0/data:asBase64",
"VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==",
),
("/methodResponses/1/1/list/1/data:asText", "hello world"),
("/methodResponses/2/1/list/0/isEncodingProblem", "true"),
("/methodResponses/2/1/list/1/data:asText", "hello world"),
(
"/methodResponses/3/1/list/0/data:asBase64",
"VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==",
),
(
"/methodResponses/3/1/list/1/data:asBase64",
"aGVsbG8gd29ybGQ=",
),
("/methodResponses/4/1/list/0/data:asText", "The q"),
("/methodResponses/4/1/list/1/data:asText", "hello"),
("/methodResponses/5/1/list/0/isEncodingProblem", "true"),
("/methodResponses/5/1/list/0/isTruncated", "true"),
("/methodResponses/5/1/list/1/isTruncated", "true"),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
test.blob_expire_all().await;
// Blob/lookup
let client = account.jmap_client().await;
let blob_id = client
.email_import(
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
)
.as_bytes()
.to_vec(),
[&Id::from(INBOX_ID).to_string()],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_blob_id();
let response = account
.jmap_method_call(
"Blob/lookup",
json!({
"accountId": account.id_string(),
"typeNames": [
"Mailbox",
"Thread",
"Email"
],
"ids": [
blob_id,
"not-a-blob"
]
}),
)
.await;
for pointer in [
"/methodResponses/0/1/list/0/matchedIds/Email",
"/methodResponses/0/1/list/0/matchedIds/Mailbox",
"/methodResponses/0/1/list/0/matchedIds/Thread",
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or_default(),
1,
"Pointer {pointer:?} Response: {response:#?}",
);
}
// Remove test data
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
+153
View File
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use email::mailbox::INBOX_ID;
use futures::StreamExt;
use jmap_client::{
DataType,
event_source::{Changes, PushNotification},
mailbox::Role,
};
use std::time::Duration;
use store::ahash::AHashSet;
use tokio::sync::mpsc;
use types::id::Id;
use crate::utils::{server::TestServer, smtp::SmtpConnection};
pub async fn test(test: &TestServer) {
println!("Running EventSource tests...");
// Create test account
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mut changes = client
.event_source(None::<Vec<_>>, false, 1.into(), None)
.await
.unwrap();
let (event_tx, mut event_rx) = mpsc::channel::<Changes>(100);
tokio::spawn(async move {
while let Some(change) = changes.next().await {
if let Err(_err) = event_tx
.send(match change.unwrap() {
PushNotification::StateChange(changes) => changes,
PushNotification::CalendarAlert(_) => unreachable!(),
})
.await
{
//println!("Error sending event: {}", _err);
break;
}
}
});
assert_ping(&mut event_rx).await;
// Create mailbox and expect state change
let mailbox_id = client
.mailbox_create("EventSource Test", None::<String>, Role::None)
.await
.unwrap()
.take_id();
assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await;
// Multiple changes should be grouped and delivered in intervals
for num in 0..5 {
client
.mailbox_update_sort_order(&mailbox_id, num)
.await
.unwrap();
}
assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await;
assert_ping(&mut event_rx).await; // Pings are only received in cfg(test)
// Ingest email and expect state change
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
lmtp.quit().await;
assert_state(
&mut event_rx,
account.id_string(),
&[
DataType::EmailDelivery,
DataType::Email,
DataType::Thread,
DataType::Mailbox,
],
)
.await;
assert_ping(&mut event_rx).await;
// Destroy mailbox
client.mailbox_destroy(&mailbox_id, true).await.unwrap();
assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await;
// Destroy Inbox
client
.mailbox_destroy(&Id::from(INBOX_ID).to_string(), true)
.await
.unwrap();
assert_state(
&mut event_rx,
account.id_string(),
&[DataType::Email, DataType::Thread, DataType::Mailbox],
)
.await;
assert_ping(&mut event_rx).await;
assert_ping(&mut event_rx).await;
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
async fn assert_state(
event_rx: &mut mpsc::Receiver<Changes>,
account_id: &str,
state: &[DataType],
) {
match tokio::time::timeout(Duration::from_millis(700), event_rx.recv()).await {
Ok(Some(changes)) => {
assert_eq!(
changes
.changes(account_id)
.unwrap()
.map(|x| x.0)
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}
result => {
panic!("Timeout waiting for event {:?}: {:?}", state, result);
}
}
}
async fn assert_ping(event_rx: &mut mpsc::Receiver<Changes>) {
match tokio::time::timeout(Duration::from_millis(1100), event_rx.recv()).await {
Ok(Some(changes)) => {
assert!(changes.changes("ping").is_some(),);
}
_ => {
panic!("Did not receive ping.");
}
}
}
+10
View File
@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod blob;
pub mod event_source;
pub mod push_subscription;
pub mod websocket;
+786
View File
@@ -0,0 +1,786 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
AssertConfig,
utils::{server::TestServer, smtp::SmtpConnection},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use common::{config::server::Listeners, network::SessionData};
use ece::EcKeyComponents;
use email::push::{EmailPush, Urgency};
use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body};
use hyper::{
StatusCode, body,
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE},
server::conn::http1,
service::service_fn,
};
use hyper_util::rt::TokioIo;
use jmap_client::{mailbox::Role, push_subscription::Keys};
use jmap_proto::{
method::query::Filter,
object::{email::EmailFilter, push_subscription::EmailPushProperty},
request::capability::{Capabilities, Capability},
types::state::State,
};
use registry::{
schema::{
enums::NetworkListenerProtocol,
prelude::{ObjectType, SocketAddr},
structs::{NetworkListener, SystemSettings},
},
types::{id::ObjectId, map::Map},
};
use serde_json::json;
use services::state_manager::ece::ece_encrypt;
use services::state_manager::email_push::build_email_push_object;
use std::{
str::FromStr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use store::{
ahash::AHashSet,
registry::{RegistryObject, bootstrap::Bootstrap},
};
use tokio::sync::mpsc;
use types::{id::Id, keyword::Keyword, type_state::DataType};
use utils::map::vec_map::VecMap;
pub async fn test(test: &TestServer) {
println!("Running Push Subscription tests...");
// ECE roundtrip test
ece_roundtrip();
// Create test account
let account = test.account("[email protected]");
let client = account.jmap_client().await;
// Create channels
let (event_tx, mut event_rx) = mpsc::channel::<PushMessage>(100);
// Create subscription keys
let (keypair, auth_secret) = ece::generate_keypair_and_auth_secret().unwrap();
let pubkey = keypair.pub_as_raw().unwrap();
let keys = Keys::new(&pubkey, &auth_secret);
// The server must expose a VAPID key and advertise it in the session capabilities
let vapid_public_key = test
.server
.core
.jmap
.vapid
.as_ref()
.expect("A VAPID key must be configured")
.public_key()
.to_string();
let advertised_key = test
.server
.core
.jmap
.capabilities
.session
.iter()
.find_map(
|(capability, capabilities)| match (capability, capabilities) {
(Capability::WebPushVapid, Capabilities::WebPush(webpush)) => {
Some(webpush.application_server_key.as_str())
}
_ => None,
},
)
.expect("The webpush-vapid capability must be advertised");
assert_eq!(
advertised_key, vapid_public_key,
"The advertised applicationServerKey must match the signing key"
);
let push_server = Arc::new(PushServer {
keypair: keypair.raw_components().unwrap(),
auth_secret: auth_secret.to_vec(),
vapid_public_key,
endpoint_origin: "https://127.0.0.1:19000".to_string(),
tx: event_tx,
fail_requests: false.into(),
});
// Start mock push server
let mut bp = Bootstrap::new_uninitialized(test.server.registry().clone());
let mut servers = Listeners::default();
servers.parse_server(
&mut bp,
RegistryObject {
id: ObjectId::new(ObjectType::NetworkListener, 0u64.into()),
object: NetworkListener {
name: "mock-push".into(),
bind: Map::new(vec![SocketAddr::from_str("127.0.0.1:19000").unwrap()]),
protocol: NetworkListenerProtocol::Http,
tls_implicit: true,
use_tls: true,
socket_reuse_address: true,
socket_reuse_port: true,
..Default::default()
},
revision: 0,
},
&SystemSettings::default(),
);
servers
.parse_tcp_acceptors(&mut bp, test.server.inner.clone())
.await;
servers.bind_and_drop_priv(&mut bp);
bp.assert_no_errors();
let _shutdown_tx = servers.spawn(|server, acceptor, shutdown_rx| {
server.spawn(
SessionManager::from(push_server.clone()),
test.server.inner.clone(),
acceptor,
shutdown_rx,
);
});
// Register push notification (no encryption)
let push_id = client
.push_subscription_create("123", "https://127.0.0.1:19000/push", None)
.await
.unwrap()
.take_id();
// Expect push verification
let verification = expect_push(&mut event_rx).await.unwrap_verification();
assert_eq!(verification.push_subscription_id, push_id);
// Update verification code
client
.push_subscription_verify(&push_id, verification.verification_code)
.await
.unwrap();
// Create a mailbox and expect a state change
let mailbox_id = client
.mailbox_create("PushSubscription Test", None::<String>, Role::None)
.await
.unwrap()
.take_id();
assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await;
// Receive states just for the requested types
client
.push_subscription_update_types(&push_id, [jmap_client::DataType::Email].into())
.await
.unwrap();
client
.mailbox_update_sort_order(&mailbox_id, 123)
.await
.unwrap();
expect_nothing(&mut event_rx).await;
// Destroy subscription
client.push_subscription_destroy(&push_id).await.unwrap();
// Only one verification per minute is allowed
let push_id = client
.push_subscription_create("invalid", "https://127.0.0.1:19000/push", None)
.await
.unwrap()
.take_id();
expect_nothing(&mut event_rx).await;
client.push_subscription_destroy(&push_id).await.unwrap();
// Register push notification (with encryption)
let push_id = client
.push_subscription_create(
"123",
"https://127.0.0.1:19000/push?skip_checks=true", // skip_checks only works in cfg(test)
keys.into(),
)
.await
.unwrap()
.take_id();
// Expect push verification
let verification = expect_push(&mut event_rx).await.unwrap_verification();
assert_eq!(verification.push_subscription_id, push_id);
// Update verification code
client
.push_subscription_verify(&push_id, verification.verification_code)
.await
.unwrap();
// Failed deliveries should be re-attempted
push_server.fail_requests.store(true, Ordering::Relaxed);
client
.mailbox_update_sort_order(&mailbox_id, 101)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
push_server.fail_requests.store(false, Ordering::Relaxed);
assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await;
// Make a mailbox change and expect state change
client
.mailbox_rename(&mailbox_id, "My Mailbox")
.await
.unwrap();
assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await;
//expect_nothing(&mut event_rx).await;
// Multiple change updates should be grouped and pushed in intervals
for num in 0..5 {
client
.mailbox_update_sort_order(&mailbox_id, num)
.await
.unwrap();
}
assert_state(&mut event_rx, account.id(), &[DataType::Mailbox]).await;
expect_nothing(&mut event_rx).await;
// Destroy mailbox
client.push_subscription_destroy(&push_id).await.unwrap();
client.mailbox_destroy(&mailbox_id, true).await.unwrap();
expect_nothing(&mut event_rx).await;
let account_id_str = account.id_string().to_string();
let p256dh = URL_SAFE_NO_PAD.encode(&pubkey);
let auth = URL_SAFE_NO_PAD.encode(auth_secret);
let create = account
.jmap_request(
&[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:emailpush",
],
json!([[
"PushSubscription/set",
{
"create": {
"i0": {
"deviceClientId": "emailpush",
"url": "https://127.0.0.1:19000/push?skip_checks=true",
"keys": { "p256dh": p256dh, "auth": auth },
"types": [],
"emailPush": {
(account_id_str.clone()): {
"filter": { "subject": "urgent" },
"properties": ["from", "subject", "id"],
"urgency": "high"
}
}
}
}
},
"0"
]]),
)
.await;
let ep_id = create.created_id(0);
let verification = expect_push(&mut event_rx).await.unwrap_verification();
assert_eq!(verification.push_subscription_id, ep_id.to_string());
account
.jmap_request(
&["urn:ietf:params:jmap:core"],
json!([[
"PushSubscription/set",
{ "update": { (ep_id.to_string()): { "verificationCode": verification.verification_code } } },
"0"
]]),
)
.await;
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: Sender <[email protected]>\r\n",
"To: [email protected]\r\n",
"Subject: Urgent: action required\r\n",
"\r\n",
"Please respond as soon as possible."
),
)
.await;
lmtp.quit().await;
let (push_account, emails, state) = expect_push(&mut event_rx).await.unwrap_email_push();
assert_eq!(push_account.to_string(), account_id_str);
assert!(state.is_some(), "EmailPush must carry the Email state");
assert_eq!(emails.len(), 1, "expected exactly one email in the push");
let email = emails[0].to_string();
assert!(
email.contains("Urgent: action required"),
"subject missing: {email}"
);
assert!(
email.contains("[email protected]"),
"from missing: {email}"
);
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: Sender <[email protected]>\r\n",
"To: [email protected]\r\n",
"Subject: weekly newsletter\r\n",
"\r\n",
"Nothing important here."
),
)
.await;
lmtp.quit().await;
expect_nothing(&mut event_rx).await;
account
.jmap_request(
&["urn:ietf:params:jmap:core"],
json!([[
"PushSubscription/set",
{ "destroy": [ep_id.to_string()] },
"0"
]]),
)
.await;
// Test the EmailPush object builder (filters and size limits) directly
test_email_push_object(test).await;
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
async fn test_email_push_object(test: &TestServer) {
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let account_id = account.id().document_id();
let mailbox_id = client
.mailbox_create("EmailPush Object Test", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let mailbox_doc_id = Id::from_str(&mailbox_id).unwrap().document_id();
let email_id = client
.email_import(
b"From: Alice <[email protected]>\r\nTo: [email protected]\r\nSubject: Urgent meeting tonight\r\n\r\nPlease join the urgent meeting tonight.".to_vec(),
[&mailbox_id],
Some(["$notify"]),
None,
)
.await
.unwrap()
.take_id();
let document_id = Id::from_str(&email_id).unwrap().document_id();
test.wait_for_tasks().await;
let properties = vec![
EmailPushProperty::Id,
EmailPushProperty::From,
EmailPushProperty::Subject,
];
let config = |filter: Vec<Filter<EmailFilter>>| EmailPush {
account_id,
properties: properties.clone(),
filter,
urgency: Urgency::Normal,
};
// Matching subject (case-insensitive substring), full object is produced
let value = build_email_push_object(
&test.server,
account_id,
document_id,
&config(vec![Filter::Property(EmailFilter::Subject(
"URGENT".into(),
))]),
4096,
)
.await
.unwrap()
.expect("matching subject filter must produce an object");
let json = serde_json::to_string(&value).unwrap();
assert!(
json.contains("Urgent meeting tonight"),
"subject missing: {json}"
);
assert!(json.contains("[email protected]"), "from missing: {json}");
// A collection of filters that should each either match (Some) or not (None)
for (expected_match, filter) in [
(
false,
vec![Filter::Property(EmailFilter::Subject(
"does-not-appear".into(),
))],
),
(
true,
vec![Filter::Property(EmailFilter::InMailbox(Id::from(
mailbox_doc_id,
)))],
),
(
false,
vec![Filter::Property(EmailFilter::InMailbox(Id::from(
mailbox_doc_id + 1,
)))],
),
(
true,
vec![Filter::Property(EmailFilter::HasKeyword(Keyword::parse(
"$notify",
)))],
),
(
false,
vec![Filter::Property(EmailFilter::NotKeyword(Keyword::parse(
"$notify",
)))],
),
(true, vec![]),
(
true,
vec![
Filter::Or,
Filter::Property(EmailFilter::Subject("urgent".into())),
Filter::Property(EmailFilter::From("[email protected]".into())),
Filter::Close,
],
),
(
false,
vec![
Filter::And,
Filter::Property(EmailFilter::Subject("urgent".into())),
Filter::Property(EmailFilter::From("[email protected]".into())),
Filter::Close,
],
),
] {
let result = build_email_push_object(
&test.server,
account_id,
document_id,
&config(filter.clone()),
4096,
)
.await
.unwrap();
assert_eq!(
result.is_some(),
expected_match,
"filter produced the wrong match result: {filter:?}"
);
}
// Size limit: a generous budget keeps every property, a tiny budget drops some (in order)
let (full, _) =
build_email_push_object(&test.server, account_id, document_id, &config(vec![]), 4096)
.await
.unwrap()
.expect("object");
assert_eq!(
full.as_object().unwrap().as_vec().len(),
3,
"all requested properties must fit under a generous budget"
);
let (truncated, _) =
build_email_push_object(&test.server, account_id, document_id, &config(vec![]), 50)
.await
.unwrap()
.expect("object");
assert!(
truncated.as_object().unwrap().as_vec().len() < 3,
"a tiny size budget must drop properties: {}",
serde_json::to_string(&truncated).unwrap()
);
}
#[derive(Clone)]
pub struct SessionManager {
pub inner: Arc<PushServer>,
}
impl From<Arc<PushServer>> for SessionManager {
fn from(inner: Arc<PushServer>) -> Self {
SessionManager { inner }
}
}
pub struct PushServer {
keypair: EcKeyComponents,
auth_secret: Vec<u8>,
vapid_public_key: String,
endpoint_origin: String,
tx: mpsc::Sender<PushMessage>,
fail_requests: AtomicBool,
}
#[derive(serde::Deserialize, Debug)]
#[serde(untagged)]
enum PushMessage {
PushObject(PushObject),
Verification(PushVerification),
}
#[allow(dead_code)]
#[derive(serde::Deserialize, Debug)]
#[serde(tag = "@type")]
enum PushObject {
StateChange {
changed: VecMap<Id, VecMap<DataType, State>>,
},
EmailPush {
#[serde(rename = "accountId")]
account_id: Id,
#[serde(default)]
emails: Vec<serde_json::Value>,
#[serde(default)]
state: Option<State>,
},
CalendarAlert {
#[serde(rename = "accountId")]
account_id: Id,
#[serde(rename = "calendarEventId")]
calendar_event_id: Id,
uid: String,
#[serde(rename = "recurrenceId")]
recurrence_id: Option<String>,
#[serde(rename = "alertId")]
alert_id: String,
},
}
impl PushMessage {
pub fn unwrap_state_change(self) -> VecMap<Id, VecMap<DataType, State>> {
match self {
PushMessage::PushObject(PushObject::StateChange { changed }) => changed,
_ => panic!("Expected PushObject"),
}
}
pub fn unwrap_verification(self) -> PushVerification {
match self {
PushMessage::Verification(verification) => verification,
_ => panic!("Expected Verification"),
}
}
pub fn unwrap_email_push(self) -> (Id, Vec<serde_json::Value>, Option<State>) {
match self {
PushMessage::PushObject(PushObject::EmailPush {
account_id,
emails,
state,
}) => (account_id, emails, state),
other => panic!("Expected EmailPush, got: {other:?}"),
}
}
}
#[derive(serde::Deserialize, Debug)]
enum PushVerificationType {
PushVerification,
}
#[derive(serde::Deserialize, Debug)]
struct PushVerification {
#[serde(rename = "@type")]
_type: PushVerificationType,
#[serde(rename = "pushSubscriptionId")]
pub push_subscription_id: String,
#[serde(rename = "verificationCode")]
pub verification_code: String,
}
impl common::network::SessionManager for SessionManager {
#[allow(clippy::manual_async_fn)]
fn handle<T: common::network::SessionStream>(
self,
session: SessionData<T>,
) -> impl std::future::Future<Output = ()> + Send {
async move {
let push = self.inner;
let _ = http1::Builder::new()
.keep_alive(false)
.serve_connection(
TokioIo::new(session.stream),
service_fn(|mut req: hyper::Request<body::Incoming>| {
let push = push.clone();
async move {
if push.fail_requests.load(Ordering::Relaxed) {
return Ok(HtmlResponse::with_status(
StatusCode::TOO_MANY_REQUESTS,
"too many requests".to_string(),
)
.into_http_response()
.build());
}
// Every push POST must be authenticated with a VAPID token (RFC 9749)
let authorization = req
.headers()
.get(AUTHORIZATION)
.map(|value| value.to_str().unwrap().to_string())
.expect("Push POST must carry a VAPID Authorization header");
assert_vapid_authorization(
&authorization,
&push.vapid_public_key,
&push.endpoint_origin,
);
let is_encrypted = req
.headers()
.get(CONTENT_ENCODING)
.is_some_and(|encoding| encoding.to_str().unwrap() == "aes128gcm");
let content_type = req
.headers()
.get(CONTENT_TYPE)
.map(|value| value.to_str().unwrap())
.expect("Push POST must carry a Content-Type header");
assert_eq!(
content_type,
if is_encrypted {
"application/octet-stream"
} else {
"application/json"
},
"unexpected Content-Type for encrypted={is_encrypted} push"
);
let body = fetch_body(&mut req, 1024 * 1024, 0).await.unwrap();
let message = serde_json::from_slice::<PushMessage>(&if is_encrypted {
ece::decrypt(&push.keypair, &push.auth_secret, &body).unwrap()
} else {
body
})
.unwrap();
//println!("Push received ({}): {:?}", is_encrypted, message);
push.tx.send(message).await.unwrap();
Ok::<_, hyper::Error>(
HtmlResponse::new("ok".to_string())
.into_http_response()
.build(),
)
}
}),
)
.await;
}
}
#[allow(clippy::manual_async_fn)]
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send {
async {}
}
}
fn assert_vapid_authorization(header: &str, expected_key: &str, expected_origin: &str) {
let (token, key) = header
.strip_prefix("vapid ")
.and_then(|rest| rest.split_once(", "))
.expect("VAPID header must be 'vapid t=<jwt>, k=<key>'");
let jwt = token.strip_prefix("t=").expect("Missing t= parameter");
let key = key.strip_prefix("k=").expect("Missing k= parameter");
assert_eq!(
key, expected_key,
"The k= parameter must match the advertised applicationServerKey"
);
let parts = jwt.split('.').collect::<Vec<_>>();
assert_eq!(parts.len(), 3, "A JWT must have three parts");
let decode = |part: &str| {
URL_SAFE_NO_PAD
.decode(part)
.expect("Each JWT part must be base64url encoded")
};
assert_eq!(
decode(parts[0]),
br#"{"typ":"JWT","alg":"ES256"}"#,
"The JWT header must declare typ JWT and alg ES256"
);
let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap();
assert_eq!(
claims["aud"], expected_origin,
"The aud claim must be the push endpoint origin"
);
let now = store::write::now();
let exp = claims["exp"]
.as_u64()
.expect("The exp claim must be a number");
assert!(
exp > now && exp <= now + 24 * 3600,
"The exp claim must be no more than 24 hours in the future (exp={exp}, now={now})"
);
}
async fn expect_push(event_rx: &mut mpsc::Receiver<PushMessage>) -> PushMessage {
match tokio::time::timeout(Duration::from_millis(1500), event_rx.recv()).await {
Ok(Some(push)) => {
//println!("Push received: {:?}", push);
push
}
result => {
panic!("Timeout waiting for push: {:?}", result);
}
}
}
async fn expect_nothing(event_rx: &mut mpsc::Receiver<PushMessage>) {
match tokio::time::timeout(Duration::from_millis(1000), event_rx.recv()).await {
Err(_) => {}
message => {
panic!("Received a message when expecting nothing: {:?}", message);
}
}
}
async fn assert_state(event_rx: &mut mpsc::Receiver<PushMessage>, id: Id, state: &[DataType]) {
assert_eq!(
expect_push(event_rx)
.await
.unwrap_state_change()
.get(&id)
.unwrap()
.iter()
.map(|x| x.0)
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}
fn ece_roundtrip() {
for len in [1, 2, 5, 16, 256, 1024, 2048, 4096, 1024 * 1024] {
let (keypair, auth_secret) = ece::generate_keypair_and_auth_secret().unwrap();
let bytes: Vec<u8> = (0..len).map(|_| store::rand::random::<u8>()).collect();
let encrypted_bytes =
ece_encrypt(&keypair.pub_as_raw().unwrap(), &auth_secret, &bytes).unwrap();
let decrypted_bytes = ece::decrypt(
&keypair.raw_components().unwrap(),
&auth_secret,
&encrypted_bytes,
)
.unwrap();
assert_eq!(bytes, decrypted_bytes, "len: {}", len);
}
}
+149
View File
@@ -0,0 +1,149 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use ahash::AHashSet;
use futures::StreamExt;
use jmap_client::{
DataType, PushObject,
client_ws::WebSocketMessage,
core::{
response::{Response, TaggedMethodResponse},
set::SetObject,
},
};
use std::time::Duration;
use tokio::sync::mpsc;
pub async fn test(test: &TestServer) {
println!("Running WebSockets tests...");
// Authenticate all accounts
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mut ws_stream = client.connect_ws().await.unwrap();
let (stream_tx, mut stream_rx) = mpsc::channel::<WebSocketMessage>(100);
tokio::spawn(async move {
while let Some(change) = ws_stream.next().await {
stream_tx.send(change.unwrap()).await.unwrap();
}
});
// Create mailbox
let mut request = client.build();
let create_id = request
.set_mailbox()
.create()
.name("WebSocket Test")
.create_id()
.unwrap();
let request_id = request.send_ws().await.unwrap();
let mut response = expect_response(&mut stream_rx).await;
assert_eq!(request_id, response.request_id().unwrap());
let mailbox_id = response
.pop_method_response()
.unwrap()
.unwrap_set_mailbox()
.unwrap()
.created(&create_id)
.unwrap()
.take_id();
// Enable push notifications
client
.enable_push_ws(None::<Vec<_>>, None::<&str>)
.await
.unwrap();
// Make changes over standard HTTP and expect a push notification via WebSockets
client
.mailbox_update_sort_order(&mailbox_id, 1)
.await
.unwrap();
assert_state(&mut stream_rx, account.id_string(), &[DataType::Mailbox]).await;
// Multiple changes should be grouped and delivered in intervals
for num in 0..5 {
client
.mailbox_update_sort_order(&mailbox_id, num)
.await
.unwrap();
}
tokio::time::sleep(Duration::from_millis(500)).await;
assert_state(&mut stream_rx, account.id_string(), &[DataType::Mailbox]).await;
expect_nothing(&mut stream_rx).await;
// Disable push notifications
client.disable_push_ws().await.unwrap();
// No more changes should be received
let mut request = client.build();
request.set_mailbox().destroy([&mailbox_id]);
request.send_ws().await.unwrap();
expect_response(&mut stream_rx)
.await
.pop_method_response()
.unwrap()
.unwrap_set_mailbox()
.unwrap()
.destroyed(&mailbox_id)
.unwrap();
expect_nothing(&mut stream_rx).await;
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
async fn expect_response(
stream_rx: &mut mpsc::Receiver<WebSocketMessage>,
) -> Response<TaggedMethodResponse> {
match tokio::time::timeout(Duration::from_millis(100), stream_rx.recv()).await {
Ok(Some(message)) => match message {
WebSocketMessage::Response(response) => response,
_ => panic!("Expected response, got: {:?}", message),
},
result => {
panic!("Timeout waiting for websocket: {:?}", result);
}
}
}
async fn assert_state(
stream_rx: &mut mpsc::Receiver<WebSocketMessage>,
id: &str,
state: &[DataType],
) {
match tokio::time::timeout(Duration::from_millis(700), stream_rx.recv()).await {
Ok(Some(message)) => match message {
WebSocketMessage::PushNotification(PushObject::StateChange { changed }) => {
assert_eq!(
changed
.get(id)
.unwrap()
.keys()
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}
_ => panic!("Expected state change, got: {:?}", message),
},
result => {
panic!("Timeout waiting for websocket: {:?}", result);
}
}
}
async fn expect_nothing(stream_rx: &mut mpsc::Receiver<WebSocketMessage>) {
match tokio::time::timeout(Duration::from_millis(1000), stream_rx.recv()).await {
Err(_) => {}
message => {
panic!("Received a message when expecting nothing: {:?}", message);
}
}
}
+586
View File
@@ -0,0 +1,586 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{jmap::JmapUtils, server::TestServer};
use jmap_proto::{
object::{file_node::FileNodeProperty, share_notification::ShareNotificationProperty},
request::method::MethodObject,
};
use serde_json::json;
pub async fn test(test: &TestServer) {
println!("Running File Storage ACL tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let john_id = john.id_string().to_string();
let jane_id = jane.id_string().to_string();
// Create test folders
let response = john
.jmap_create(
MethodObject::FileNode,
[json!({
"name": "Test #1",
})],
Vec::<(&str, &str)>::new(),
)
.await;
let john_folder_id = response.created(0).id().to_string();
// Verify myRights
john.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::MyRights,
FileNodeProperty::ShareWith,
],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayAddChildren": true,
"mayRename": true,
"mayDelete": true,
"mayModifyContent": true,
"mayShare": true
},
"shareWith": {}
}));
// Obtain share notifications
let mut jane_share_change_id = jane
.jmap_get(
MethodObject::ShareNotification,
Vec::<&str>::new(),
Vec::<&str>::new(),
)
.await
.state()
.to_string();
// Make sure Jane has no access
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::FileNode,
Vec::<&str>::new(),
[john_folder_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Share folder with Jane
john.jmap_update(
MethodObject::FileNode,
[(
&john_folder_id,
json!({
"shareWith": {
&jane_id : {
"mayRead": true,
}
}
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_folder_id);
john.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ShareWith,
],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Test #1",
"shareWith": {
&jane_id : {
"mayRead": true,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
}
}
}));
// Verify Jane can access the contact
jane.jmap_get_account(
john,
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::MyRights,
],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
}
}));
// Verify Jane received a share notification
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "FileNode",
"objectAccountId": &john_id,
"objectId": &john_folder_id,
"oldRights": {
"mayRead": false,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
},
"newRights": {
"mayRead": true,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
},
"name": null
}));
// Updating and deleting should fail
assert_eq!(
jane.jmap_update_account(
john,
MethodObject::FileNode,
[(&john_folder_id, json!({}))],
Vec::<(&str, &str)>::new(),
)
.await
.not_updated(&john_folder_id)
.description(),
"You are not allowed to modify this file node."
);
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::FileNode,
[&john_folder_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&john_folder_id)
.description(),
"You are not allowed to delete this file node."
);
// Grant Jane write access
john.jmap_update(
MethodObject::FileNode,
[(
&john_folder_id,
json!({
format!("shareWith/{jane_id}/mayAddChildren"): true,
format!("shareWith/{jane_id}/mayRename"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
format!("shareWith/{jane_id}/mayModifyContent"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_folder_id);
jane.jmap_get_account(
john,
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::MyRights,
],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Test #1",
"myRights": {
"mayRead": true,
"mayAddChildren": true,
"mayRename": true,
"mayDelete": true,
"mayModifyContent": true,
"mayShare": false
}
}));
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
jane_share_change_id = response.new_state().to_string();
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "FileNode",
"objectAccountId": &john_id,
"objectId": &john_folder_id,
"oldRights": {
"mayRead": true,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
},
"newRights": {
"mayRead": true,
"mayAddChildren": true,
"mayRename": true,
"mayDelete": true,
"mayModifyContent": true,
"mayShare": false
},
"name": null
}));
// Creating a root folder should fail
assert_eq!(
jane.jmap_create_account(
john,
MethodObject::FileNode,
[json!({
"name": "A new shared folder",
})],
Vec::<(&str, &str)>::new()
)
.await
.not_created(0)
.description(),
"Cannot create top-level folder in a shared account."
);
// Update John's folder name
jane.jmap_update_account(
john,
MethodObject::FileNode,
[(
&john_folder_id,
json!({
"name": "Jane's updated name",
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_folder_id);
jane.jmap_get_account(
john,
MethodObject::FileNode,
[FileNodeProperty::Id, FileNodeProperty::Name],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Jane's updated name",
}));
// Revoke Jane's access
john.jmap_update(
MethodObject::FileNode,
[(
&john_folder_id,
json!({
format!("shareWith/{jane_id}"): ()
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_folder_id);
john.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ShareWith,
],
[john_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": john_folder_id,
"name": "Jane's updated name",
"shareWith": {}
}));
// Verify Jane can no longer access the folder or its contacts
assert_eq!(
jane.jmap_get_account(
john,
MethodObject::FileNode,
Vec::<&str>::new(),
[john_folder_id.as_str()],
)
.await
.method_response()
.typ(),
"forbidden"
);
// Verify Jane received a share notification with the updated rights
let response = jane
.jmap_changes(MethodObject::ShareNotification, &jane_share_change_id)
.await;
let changes = response.changes().collect::<Vec<_>>();
assert_eq!(changes.len(), 1);
let share_id = changes[0].as_created();
jane.jmap_get(
MethodObject::ShareNotification,
[
ShareNotificationProperty::Id,
ShareNotificationProperty::ChangedBy,
ShareNotificationProperty::ObjectType,
ShareNotificationProperty::ObjectAccountId,
ShareNotificationProperty::ObjectId,
ShareNotificationProperty::OldRights,
ShareNotificationProperty::NewRights,
ShareNotificationProperty::Name,
],
[share_id],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &share_id,
"changedBy": {
"principalId": &john_id,
"name": "John Doe",
"email": "[email protected]"
},
"objectType": "FileNode",
"objectAccountId": &john_id,
"objectId": &john_folder_id,
"oldRights": {
"mayRead": true,
"mayAddChildren": true,
"mayRename": true,
"mayDelete": true,
"mayModifyContent": true,
"mayShare": false
},
"newRights": {
"mayRead": false,
"mayAddChildren": false,
"mayRename": false,
"mayDelete": false,
"mayModifyContent": false,
"mayShare": false
},
"name": null
}));
// Grant Jane delete access once again
john.jmap_update(
MethodObject::FileNode,
[(
&john_folder_id,
json!({
format!("shareWith/{jane_id}/mayRead"): true,
format!("shareWith/{jane_id}/mayAddChildren"): true,
format!("shareWith/{jane_id}/mayRename"): true,
format!("shareWith/{jane_id}/mayDelete"): true,
format!("shareWith/{jane_id}/mayModifyContent"): true,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&john_folder_id);
// FileNode/copy: Jane copies a node from her own account into John's shared folder
let jane_folder_id = jane
.jmap_create(
MethodObject::FileNode,
[json!({"name": "jane-src"})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let copied = jane
.jmap_copy(
jane,
john,
MethodObject::FileNode,
[(
&jane_folder_id,
json!({ "parentId": &john_folder_id, "name": "copied-here" }),
)],
false,
)
.await;
let copied_id = copied.copied(&jane_folder_id).id().to_string();
assert_ne!(copied_id, jane_folder_id);
jane.jmap_get_account(
john,
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
],
[copied_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({
"id": &copied_id,
"name": "copied-here",
"parentId": &john_folder_id,
}));
// Original still exists in Jane's account (onSuccessDestroyOriginal=false)
jane.jmap_get(
MethodObject::FileNode,
[FileNodeProperty::Id],
[jane_folder_id.as_str()],
)
.await
.list()[0]
.assert_is_equal(json!({ "id": &jane_folder_id }));
// onExists=rename on copy: colliding into John's folder again must echo the new name
let renamed_copy = jane
.jmap_method_calls(json!([[
"FileNode/copy",
{
"fromAccountId": jane.id_string(),
"accountId": john.id_string(),
"onExists": "rename",
"create": {
&jane_folder_id: { "parentId": &john_folder_id, "name": "copied-here" }
}
},
"0"
]]))
.await;
let renamed_entry = renamed_copy.copied(&jane_folder_id);
let renamed_copy_id = renamed_entry.id().to_string();
assert_eq!(renamed_entry.text_field("name"), "copied-here (2)");
jane.jmap_destroy(
MethodObject::FileNode,
[&jane_folder_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Verify Jane can delete the folder (and the node copied into it)
assert_eq!(
jane.jmap_destroy_account(
john,
MethodObject::FileNode,
[john_folder_id.as_str()],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.collect::<std::collections::HashSet<_>>(),
[
john_folder_id.as_str(),
copied_id.as_str(),
renamed_copy_id.as_str()
]
.into_iter()
.collect::<std::collections::HashSet<_>>()
);
// Destroy all mailboxes
test.assert_is_empty().await;
}
+8
View File
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod acl;
pub mod node;
+729
View File
@@ -0,0 +1,729 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
jmap::{ChangeType, JmapUtils},
server::TestServer,
};
use ahash::AHashSet;
use jmap_proto::{object::file_node::FileNodeProperty, request::method::MethodObject};
use serde_json::json;
pub async fn test(test: &TestServer) {
println!("Running File Storage tests...");
let account = test.account("[email protected]");
// Obtain change id
let change_id = account
.jmap_get(
MethodObject::FileNode,
[FileNodeProperty::Id],
Vec::<&str>::new(),
)
.await
.state()
.to_string();
let response = account
.jmap_changes(MethodObject::FileNode, &change_id)
.await;
assert_eq!(response.changes().next(), None);
assert_eq!(response.new_state(), change_id);
// Create test folders
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({
"name": "Root Folder",
"parentId": null,
}),
json!({
"name": "Sub Folder",
"parentId": "#i0",
}),
json!({
"name": "Sub-sub Folder",
"parentId": "#i1",
}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let root_folder_id = response.created(0).id().to_string();
let sub_folder_id = response.created(1).id().to_string();
let sub_sub_folder_id = response.created(2).id().to_string();
// Validate changes
assert_eq!(
account
.jmap_changes(MethodObject::FileNode, change_id)
.await
.changes()
.collect::<AHashSet<_>>(),
[
ChangeType::Created(&root_folder_id),
ChangeType::Created(&sub_folder_id),
ChangeType::Created(&sub_sub_folder_id)
]
.into_iter()
.collect::<AHashSet<_>>()
);
// Verify folder structure
let response = account
.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
],
[&root_folder_id, &sub_folder_id, &sub_sub_folder_id],
)
.await;
let list = response.list();
assert_eq!(list.len(), 3);
list[0].assert_is_equal(json!({
"id": &root_folder_id,
"name": "Root Folder",
"parentId": null,
}));
list[1].assert_is_equal(json!({
"id": &sub_folder_id,
"name": "Sub Folder",
"parentId": &root_folder_id,
}));
list[2].assert_is_equal(json!({
"id": &sub_sub_folder_id,
"name": "Sub-sub Folder",
"parentId": &sub_folder_id,
}));
// Create file in root folder
let response = account
.jmap_method_calls(json!([
[
"Blob/upload",
{
"accountId": account.id_string(),
"create": {
"hello": {
"data": [
{
"data:asText": r#"hello world"#
}
]
}
}
},
"S4"
],
[
"FileNode/set",
{
"accountId": account.id_string(),
"create": {
"i0": {
"name": "hello.txt",
"parentId": &root_folder_id,
"blobId": "#hello",
"type": "text/plain",
}
}
},
"G4"
]
]))
.await;
let file_id = response
.pointer("/methodResponses/1/1/created/i0")
.unwrap()
.id()
.to_string();
// Verify file creation
let response = account
.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::BlobId,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
FileNodeProperty::Type,
FileNodeProperty::Size,
],
[&file_id],
)
.await;
let blob_id = response.list()[0].blob_id().to_string();
response.list()[0].assert_is_equal(json!({
"id": &file_id,
"name": "hello.txt",
"parentId": &root_folder_id,
"type": "text/plain",
"size": 11,
"blobId": &blob_id,
}));
assert_eq!(
account
.jmap_get(MethodObject::Blob, ["data:asText"], [&blob_id])
.await
.list()[0]
.text_field("data:asText"),
"hello world"
);
// Creating folders with invalid names or parent ids should fail
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({
"name": "Sub Folder",
"parentId": &root_folder_id,
}),
json!({
"name": "Folder under file",
"parentId": &file_id,
}),
json!({
"name": "My/Sub/Folder",
}),
json!({
"name": ".",
}),
json!({
"name": "..",
}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let err = response.not_created(0);
assert_eq!(err.typ(), "alreadyExists");
assert_eq!(err.text_field("existingId"), sub_folder_id.as_str());
assert_eq!(
response.not_created(1).description(),
"Parent ID does not exist or is not a folder."
);
assert_eq!(
response.not_created(2).description(),
"Name contains a forbidden character."
);
assert_eq!(
response.not_created(3).description(),
"Name is reserved and cannot be used."
);
assert_eq!(
response.not_created(4).description(),
"Name is reserved and cannot be used."
);
// Circular folder references should fail
let response = account
.jmap_update(
MethodObject::FileNode,
[(
&root_folder_id,
json!({
"parentId": &sub_sub_folder_id,
}),
)],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(
response.not_updated(&root_folder_id).description(),
"Circular reference in parent ids."
);
// Rename folder and file
let response = account
.jmap_update(
MethodObject::FileNode,
[
(
&sub_folder_id,
json!({
"name": "Renamed Sub Folder",
}),
),
(
&file_id,
json!({
"name": "renamed-hello.txt",
}),
),
],
Vec::<(&str, &str)>::new(),
)
.await;
response.updated(&sub_folder_id);
response.updated(&file_id);
// Verify rename
let response = account
.jmap_get(
MethodObject::FileNode,
[
FileNodeProperty::Id,
FileNodeProperty::Name,
FileNodeProperty::ParentId,
],
[&sub_folder_id, &file_id],
)
.await;
let list = response.list();
assert_eq!(list.len(), 2);
list[0].assert_is_equal(json!({
"id": &sub_folder_id,
"name": "Renamed Sub Folder",
"parentId": &root_folder_id,
}));
list[1].assert_is_equal(json!({
"id": &file_id,
"name": "renamed-hello.txt",
"parentId": &root_folder_id,
}));
// Destroying a folder with children should fail
assert_eq!(
account
.jmap_destroy(
MethodObject::FileNode,
[&root_folder_id],
Vec::<(&str, &str)>::new(),
)
.await
.not_destroyed(&root_folder_id)
.description(),
"Cannot delete non-empty folder."
);
// Delete file and sub folders
assert_eq!(
account
.jmap_destroy(
MethodObject::FileNode,
[&file_id],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.collect::<AHashSet<_>>(),
[file_id.as_str(),].into_iter().collect::<AHashSet<_>>()
);
assert_eq!(
account
.jmap_destroy(
MethodObject::FileNode,
[&root_folder_id],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.collect::<AHashSet<_>>(),
[
sub_sub_folder_id.as_str(),
sub_folder_id.as_str(),
root_folder_id.as_str()
]
.into_iter()
.collect::<AHashSet<_>>()
);
// fetchParents: requesting a leaf should return its ancestors too
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "fp-root"}),
json!({"name": "fp-sub", "parentId": "#i0"}),
json!({"name": "fp-leaf", "parentId": "#i1"}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let fp_root = response.created(0).id().to_string();
let fp_sub = response.created(1).id().to_string();
let fp_leaf = response.created(2).id().to_string();
let response = account
.jmap_method_calls(json!([[
"FileNode/get",
{
"accountId": account.id_string(),
"ids": [&fp_leaf],
"fetchParents": true,
"properties": ["id"]
},
"0"
]]))
.await;
let ids = response
.pointer("/methodResponses/0/1/list")
.and_then(|v| v.as_array())
.map(|list| {
list.iter()
.map(|n| n.text_field("id").to_string())
.collect::<AHashSet<_>>()
})
.expect("fetchParents response");
assert_eq!(
ids,
[fp_leaf.as_str(), fp_sub.as_str(), fp_root.as_str()]
.into_iter()
.map(str::to_string)
.collect::<AHashSet<_>>()
);
account
.jmap_destroy(
MethodObject::FileNode,
[&fp_root],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.for_each(drop);
// onExists=rename should produce a unique sibling name
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "dupe.txt", "parentId": null, "blobId": null})],
Vec::<(&str, &str)>::new(),
)
.await;
let dupe_orig = response.created(0).id().to_string();
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "dupe.txt"})],
[("onExists", "rename")],
)
.await;
let dupe_renamed = response.created(0);
let dupe_renamed_id = dupe_renamed.id().to_string();
assert_eq!(dupe_renamed.text_field("name"), "dupe (2).txt");
// onExists=reject (default) should return alreadyExists with existingId
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "dupe.txt"})],
Vec::<(&str, &str)>::new(),
)
.await;
let err = response.not_created(0);
assert_eq!(err.typ(), "alreadyExists");
assert_eq!(err.text_field("existingId"), dupe_orig.as_str());
// onExists=replace should destroy the existing sibling
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "dupe.txt"})],
[("onExists", "replace")],
)
.await;
let dupe_replacement = response.created(0).id().to_string();
let destroyed = response.destroyed().collect::<AHashSet<_>>();
assert!(
destroyed.contains(dupe_orig.as_str()),
"Expected old id {dupe_orig} to be destroyed, got {destroyed:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&dupe_renamed_id, &dupe_replacement],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.for_each(drop);
// compareCaseInsensitively should treat sibling names as case-insensitive
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "CASE"})],
Vec::<(&str, &str)>::new(),
)
.await;
let case_id = response.created(0).id().to_string();
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "case"})],
[("compareCaseInsensitively", true)],
)
.await;
let err = response.not_created(0);
assert_eq!(err.typ(), "alreadyExists");
assert_eq!(err.text_field("existingId"), case_id.as_str());
account
.jmap_destroy(
MethodObject::FileNode,
[&case_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Reject: two creates with the same name in one batch, default onExists
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-reject"}),
json!({"name": "twin-reject"}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let twin_first = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
assert!(
err.pointer("/existingId").is_none(),
"Pending Create collision has no committed existingId, got {err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_first],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Rename: second create within the batch should auto-rename
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-rename"}),
json!({"name": "twin-rename"}),
],
[("onExists", "rename")],
)
.await;
let twin_a = response.created(0).id().to_string();
let twin_b_entry = response.created(1);
let twin_b = twin_b_entry.id().to_string();
assert_eq!(twin_b_entry.text_field("name"), "twin-rename (2)");
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_a, &twin_b],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Replace: within-batch replace is intentionally not supported; second is rejected
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-replace"}),
json!({"name": "twin-replace"}),
],
[("onExists", "replace")],
)
.await;
let twin_survivor = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
assert!(
err.pointer("/existingId").is_none(),
"Pending Create + Replace returns alreadyExists with no existingId, got {err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_survivor],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Pending+Newest: in-batch newest comparison is intentionally not supported; second is rejected
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "twin-newest", "modified": "2020-01-01T00:00:00Z"}),
json!({"name": "twin-newest", "modified": "2040-01-01T00:00:00Z"}),
],
[("onExists", "newest")],
)
.await;
let twin_keep = response.created(0).id().to_string();
let err = response.not_created(1);
assert_eq!(err.typ(), "alreadyExists");
account
.jmap_destroy(
MethodObject::FileNode,
[&twin_keep],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// Create+Update collision in one batch
let setup = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "lhs"})],
Vec::<(&str, &str)>::new(),
)
.await;
let lhs_id = setup.created(0).id().to_string();
let response = account
.jmap_method_calls(json!([[
"FileNode/set",
{
"accountId": account.id_string(),
"update": { &lhs_id: { "name": "merged" } },
"create": { "new1": { "name": "merged" } }
},
"0"
]]))
.await;
let created_new = response
.pointer("/methodResponses/0/1/created/new1")
.expect("new1 should be in created");
let new1_id = created_new.id().to_string();
let upd_err = response
.pointer(&format!("/methodResponses/0/1/notUpdated/{lhs_id}"))
.expect("update should fail");
assert_eq!(upd_err.typ(), "alreadyExists");
assert!(
upd_err.pointer("/existingId").is_none(),
"Pending-from-Create collision has no existingId, got {upd_err:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&lhs_id, &new1_id],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// compareCaseInsensitively + Pending: in-batch "FOO"/"foo" collide when the flag is set
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "FOO"}), json!({"name": "foo"})],
[("compareCaseInsensitively", true)],
)
.await;
let case_keep = response.created(0).id().to_string();
assert_eq!(response.not_created(1).typ(), "alreadyExists");
account
.jmap_destroy(
MethodObject::FileNode,
[&case_keep],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// onExists=newest: incoming must have a strictly later modified to win
let response = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2030-01-01T00:00:00Z"})],
Vec::<(&str, &str)>::new(),
)
.await;
let stamped_id = response.created(0).id().to_string();
let older_attempt = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2020-01-01T00:00:00Z"})],
[("onExists", "newest")],
)
.await;
let err = older_attempt.not_created(0);
assert_eq!(err.typ(), "alreadyExists");
assert_eq!(err.text_field("existingId"), stamped_id.as_str());
let newer_attempt = account
.jmap_create(
MethodObject::FileNode,
[json!({"name": "stamped", "modified": "2040-01-01T00:00:00Z"})],
[("onExists", "newest")],
)
.await;
let stamped_winner = newer_attempt.created(0).id().to_string();
assert_ne!(stamped_winner, stamped_id);
let destroyed = newer_attempt.destroyed().collect::<AHashSet<_>>();
assert!(
destroyed.contains(stamped_id.as_str()),
"Expected {stamped_id} to be destroyed by newer onExists=newest, got {destroyed:?}"
);
account
.jmap_destroy(
MethodObject::FileNode,
[&stamped_winner],
Vec::<(&str, &str)>::new(),
)
.await
.destroyed()
.for_each(drop);
// WebDAV compatibility: names created over JMAP are percent-encoded in hrefs
let dav_client = account.webdav_client();
let response = account
.jmap_create(
MethodObject::FileNode,
[
json!({"name": "Ünterlagen 2026", "parentId": null}),
json!({"name": "Q1 & Q2 (final)", "parentId": "#i0"}),
],
Vec::<(&str, &str)>::new(),
)
.await;
let dav_parent_id = response.created(0).id().to_string();
response.created(1);
let dav_parent_path = "/dav/file/jdoe%40example.com/%C3%9Cnterlagen%202026";
let dav_child_path =
"/dav/file/jdoe%40example.com/%C3%9Cnterlagen%202026/Q1%20%26%20Q2%20%28final%29";
dav_client
.propfind(dav_parent_path, ["D:getetag"])
.await
.with_hrefs([
format!("{dav_parent_path}/").as_str(),
format!("{dav_child_path}/").as_str(),
]);
dav_client
.propfind(dav_child_path, ["D:getetag"])
.await
.with_hrefs([format!("{dav_child_path}/").as_str()]);
account
.jmap_destroy(
MethodObject::FileNode,
[&dav_parent_id],
[("onDestroyRemoveChildren", true)],
)
.await
.destroyed()
.for_each(drop);
// Make sure everything is gone
test.assert_is_empty().await;
}
+755
View File
@@ -0,0 +1,755 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use ::email::mailbox::{INBOX_ID, TRASH_ID};
use jmap_client::{
core::{
error::{MethodError, MethodErrorType},
set::{SetError, SetErrorType},
},
email::{self, Property, import::EmailImportResponse, query::Filter},
mailbox::{self, Role},
principal::ACL,
};
use registry::schema::prelude::ObjectType;
use serde_json::json;
use std::fmt::Debug;
use store::ahash::AHashMap;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running ACL tests...");
// Create a group and three test accounts
let inbox_id = Id::new(INBOX_ID as u64).to_string();
let trash_id = Id::new(TRASH_ID as u64).to_string();
let admin = test.account("[email protected]");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let bill = test.account("[email protected]");
let sales = test.account("[email protected]");
// Authenticate all accounts
let mut john_client = john.jmap_client().await;
let mut jane_client = jane.jmap_client().await;
let mut bill_client = bill.jmap_client().await;
// Insert two emails in each account
let mut email_ids = AHashMap::default();
for (client, account_id, name) in [
(&mut john_client, john.id(), "john"),
(&mut jane_client, jane.id(), "jane"),
(&mut bill_client, bill.id(), "bill"),
(&mut admin.jmap_client().await, sales.id(), "sales"),
] {
let user_name = client.session().username().to_string();
let mut ids = Vec::with_capacity(2);
for (mailbox_id, mailbox_name) in [(&inbox_id, "inbox"), (&trash_id, "trash")] {
ids.push(
client
.set_default_account_id(account_id.to_string())
.email_import(
format!(
concat!(
"From: [email protected]\r\n",
"To: {}\r\n",
"Subject: Owned by {} in {}\r\n",
"\r\n",
"This message is owned by {}.",
),
user_name, name, mailbox_name, name
)
.into_bytes(),
[mailbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id(),
);
}
email_ids.insert(name, ids);
}
// John should have access to his emails only
assert_eq!(
john_client
.email_get(
email_ids.get("john").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Owned by john in inbox"
);
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_get(
email_ids.get("jane").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await,
);
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_get(&inbox_id, None::<Vec<_>>)
.await,
);
assert_forbidden(
john_client
.set_default_account_id(sales.id_string())
.email_get(
email_ids.get("sales").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await,
);
assert_forbidden(
john_client
.set_default_account_id(sales.id_string())
.mailbox_get(&inbox_id, None::<Vec<_>>)
.await,
);
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_query(None::<Filter>, None::<Vec<_>>)
.await,
);
// Jane grants Inbox ReadItems access to John
jane_client
.mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems])
.await
.unwrap();
// John should have ReadItems access to Inbox
assert_eq!(
john_client
.set_default_account_id(jane.id_string())
.email_get(
email_ids.get("jane").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Owned by jane in inbox"
);
assert_eq!(
john_client
.set_default_account_id(jane.id_string())
.email_query(None::<Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids(),
[email_ids.get("jane").unwrap().first().unwrap().as_str()]
);
// John's session resource should contain Jane's account details
john_client.refresh_session().await.unwrap();
assert_eq!(
john_client
.session()
.account(jane.id_string())
.unwrap()
.name(),
"[email protected]"
);
// John should not have access to emails in Jane's Trash folder
assert!(
john_client
.set_default_account_id(jane.id_string())
.email_get(
email_ids.get("jane").unwrap().last().unwrap(),
[Property::Subject].into(),
)
.await
.unwrap()
.is_none()
);
// Email/changes must not leak ids of emails in folders John cannot read
let jane_inbox_email = email_ids.get("jane").unwrap().first().unwrap().clone();
let jane_trash_email = email_ids.get("jane").unwrap().last().unwrap().clone();
let changed_ids = john_client
.set_default_account_id(jane.id_string())
.email_changes("n", None)
.await
.unwrap()
.created()
.to_vec();
assert!(
changed_ids.contains(&jane_inbox_email),
"Email/changes should report the shared Inbox email"
);
assert!(
!changed_ids.contains(&jane_trash_email),
"Email/changes leaked the id of a non-shared Trash email"
);
// John should only be able to copy blobs he has access to
let blob_id = jane_client
.email_get(
email_ids.get("jane").unwrap().first().unwrap(),
[Property::BlobId].into(),
)
.await
.unwrap()
.unwrap()
.take_blob_id();
john_client
.set_default_account_id(john.id_string())
.blob_copy(jane.id_string(), &blob_id)
.await
.unwrap();
let blob_id = jane_client
.email_get(
email_ids.get("jane").unwrap().last().unwrap(),
[Property::BlobId].into(),
)
.await
.unwrap()
.unwrap()
.take_blob_id();
assert_forbidden(
john_client
.set_default_account_id(john.id_string())
.blob_copy(jane.id_string(), &blob_id)
.await,
);
// John only has ReadItems access to Inbox
jane_client
.mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems])
.await
.unwrap();
assert_eq!(
john_client
.set_default_account_id(jane.id_string())
.mailbox_get(&inbox_id, [mailbox::Property::MyRights].into())
.await
.unwrap()
.unwrap()
.my_rights()
.unwrap()
.acl_list(),
vec![ACL::ReadItems]
);
// Try to add items using import and copy
let blob_id = john_client
.set_default_account_id(john.id_string())
.upload(
Some(john.id_string()),
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Created by john in jane's inbox\r\n",
"\r\n",
"This message is owned by jane.",
)
.as_bytes()
.to_vec(),
None,
)
.await
.unwrap()
.take_blob_id();
let mut request = john_client.set_default_account_id(jane.id_string()).build();
let email_id = request
.import_email()
.email(&blob_id)
.mailbox_ids([&inbox_id])
.create_id();
assert_forbidden(
request
.send_single::<EmailImportResponse>()
.await
.unwrap()
.created(&email_id),
);
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_copy(
john.id_string(),
email_ids.get("john").unwrap().last().unwrap(),
[&inbox_id],
None::<Vec<&str>>,
None,
)
.await,
);
// Grant access and try again
jane_client
.mailbox_update_acl(&inbox_id, john.id_string(), [ACL::ReadItems, ACL::AddItems])
.await
.unwrap();
let mut request = john_client.set_default_account_id(jane.id_string()).build();
let email_id = request
.import_email()
.email(&blob_id)
.mailbox_ids([&inbox_id])
.create_id();
let email_id = request
.send_single::<EmailImportResponse>()
.await
.unwrap()
.created(&email_id)
.unwrap()
.take_id();
let email_id_2 = john_client
.set_default_account_id(jane.id_string())
.email_copy(
john.id_string(),
email_ids.get("john").unwrap().last().unwrap(),
[&inbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
assert_eq!(
jane_client
.email_get(&email_id, [Property::Subject].into(),)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Created by john in jane's inbox"
);
assert_eq!(
jane_client
.email_get(&email_id_2, [Property::Subject].into(),)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Owned by john in trash"
);
// Try removing items
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_destroy(&email_id)
.await,
);
jane_client
.mailbox_update_acl(
&inbox_id,
john.id_string(),
[ACL::ReadItems, ACL::AddItems, ACL::RemoveItems],
)
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.email_destroy(&email_id)
.await
.unwrap();
// Try to set keywords
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_set_keyword(&email_id_2, "$seen", true)
.await,
);
jane_client
.mailbox_update_acl(
&inbox_id,
john.id_string(),
[
ACL::ReadItems,
ACL::AddItems,
ACL::RemoveItems,
ACL::SetKeywords,
],
)
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.email_set_keyword(&email_id_2, "$seen", true)
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.email_set_keyword(&email_id_2, "my-keyword", true)
.await
.unwrap();
// Try to create a child
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_create("John's mailbox", None::<&str>, Role::None)
.await,
);
jane_client
.mailbox_update_acl(
&inbox_id,
john.id_string(),
[
ACL::ReadItems,
ACL::AddItems,
ACL::RemoveItems,
ACL::SetKeywords,
ACL::CreateChild,
],
)
.await
.unwrap();
let mailbox_id = john_client
.set_default_account_id(jane.id_string())
.mailbox_create("John's mailbox", Some(&inbox_id), Role::None)
.await
.unwrap()
.take_id();
// Try renaming a mailbox
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_rename(&mailbox_id, "John's private mailbox")
.await,
);
jane_client
.mailbox_update_acl(&mailbox_id, john.id_string(), [ACL::ReadItems, ACL::Rename])
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.mailbox_rename(&mailbox_id, "John's private mailbox")
.await
.unwrap();
// Try moving a message
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_set_mailbox(&email_id_2, &mailbox_id, true)
.await,
);
jane_client
.mailbox_update_acl(
&mailbox_id,
john.id_string(),
[ACL::ReadItems, ACL::Rename, ACL::AddItems],
)
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.email_set_mailbox(&email_id_2, &mailbox_id, true)
.await
.unwrap();
// Try deleting a mailbox
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_destroy(&mailbox_id, true)
.await,
);
jane_client
.mailbox_update_acl(
&mailbox_id,
john.id_string(),
[ACL::ReadItems, ACL::Rename, ACL::AddItems, ACL::Delete],
)
.await
.unwrap();
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_destroy(&mailbox_id, true)
.await,
);
jane_client
.mailbox_update_acl(
&mailbox_id,
john.id_string(),
[
ACL::ReadItems,
ACL::Rename,
ACL::AddItems,
ACL::Delete,
ACL::RemoveItems,
],
)
.await
.unwrap();
john_client
.set_default_account_id(jane.id_string())
.mailbox_destroy(&mailbox_id, true)
.await
.unwrap();
// Try changing ACL
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems])
.await,
);
assert_forbidden(
bill_client
.set_default_account_id(jane.id_string())
.email_query(None::<Filter>, None::<Vec<_>>)
.await,
);
jane_client
.mailbox_update_acl(
&inbox_id,
john.id_string(),
[
ACL::ReadItems,
ACL::AddItems,
ACL::RemoveItems,
ACL::SetKeywords,
ACL::CreateChild,
ACL::Rename,
ACL::Administer,
],
)
.await
.unwrap();
assert_eq!(
john_client
.set_default_account_id(jane.id_string())
.mailbox_get(&inbox_id, [mailbox::Property::MyRights].into())
.await
.unwrap()
.unwrap()
.my_rights()
.unwrap()
.acl_list(),
vec![
ACL::ReadItems,
ACL::AddItems,
ACL::RemoveItems,
ACL::SetSeen,
ACL::SetKeywords,
ACL::CreateChild,
ACL::Rename
]
);
john_client
.set_default_account_id(jane.id_string())
.mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems])
.await
.unwrap();
assert_eq!(
bill_client
.set_default_account_id(jane.id_string())
.email_query(
None::<Filter>,
vec![email::query::Comparator::subject()].into()
)
.await
.unwrap()
.ids(),
[
email_ids.get("jane").unwrap().first().unwrap().as_str(),
&email_id_2
]
);
// Revoke all access to John
jane_client
.mailbox_update_acl(&inbox_id, john.id_string(), [])
.await
.unwrap();
assert_forbidden(
john_client
.set_default_account_id(jane.id_string())
.email_get(
email_ids.get("jane").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await,
);
john_client.refresh_session().await.unwrap();
assert!(john_client.session().account(jane.id_string()).is_none());
assert_eq!(
bill_client
.set_default_account_id(jane.id_string())
.email_get(
email_ids.get("jane").unwrap().first().unwrap(),
[Property::Subject].into(),
)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Owned by jane in inbox"
);
// Add John and Jane to the Sales group
let sales_id = test.account("[email protected]").id();
for name in ["[email protected]", "[email protected]"] {
admin
.registry_update_object(
ObjectType::Account,
test.account(name).id(),
json!({
"memberGroupIds": { sales_id: true },
}),
)
.await;
}
john_client.refresh_session().await.unwrap();
jane_client.refresh_session().await.unwrap();
bill_client.refresh_session().await.unwrap();
assert_eq!(
john_client
.session()
.account(sales.id_string())
.unwrap()
.name(),
"[email protected]"
);
assert!(
!john_client
.session()
.account(sales.id_string())
.unwrap()
.is_personal()
);
assert_eq!(
jane_client
.session()
.account(sales.id_string())
.unwrap()
.name(),
"[email protected]"
);
assert!(bill_client.session().account(sales.id_string()).is_none());
// Insert a message in Sales's inbox
let blob_id = john_client
.set_default_account_id(sales.id_string())
.upload(
Some(sales.id_string()),
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Created by john in sales\r\n",
"\r\n",
"This message is owned by sales.",
)
.as_bytes()
.to_vec(),
None,
)
.await
.unwrap()
.take_blob_id();
let mut request = john_client.build();
let email_id = request
.import_email()
.email(&blob_id)
.mailbox_ids([&inbox_id])
.create_id();
let email_id = request
.send_single::<EmailImportResponse>()
.await
.unwrap()
.created(&email_id)
.unwrap()
.take_id();
// Both Jane and John should be able to see this message, but not Bill
assert_eq!(
john_client
.set_default_account_id(sales.id_string())
.email_get(&email_id, [Property::Subject].into(),)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Created by john in sales"
);
assert_eq!(
jane_client
.set_default_account_id(sales.id_string())
.email_get(&email_id, [Property::Subject].into(),)
.await
.unwrap()
.unwrap()
.subject()
.unwrap(),
"Created by john in sales"
);
assert_forbidden(
bill_client
.set_default_account_id(sales.id_string())
.email_get(&email_id, [Property::Subject].into())
.await,
);
// Remove John from the sales group
admin
.registry_update_object(
ObjectType::Account,
test.account("[email protected]").id(),
json!({
"memberGroupIds": { sales_id: false },
}),
)
.await;
assert_forbidden(
john_client
.set_default_account_id(sales.id_string())
.email_get(&email_id, [Property::Subject].into())
.await,
);
// Destroy test account data
for account in [john, bill, jane, sales] {
admin
.destroy_all_mailboxes_for_account(account.id().document_id())
.await;
}
test.assert_is_empty().await;
}
pub fn assert_forbidden<T: Debug>(result: Result<T, jmap_client::Error>) {
if !matches!(
result,
Err(jmap_client::Error::Method(MethodError {
p_type: MethodErrorType::Forbidden
})) | Err(jmap_client::Error::Set(SetError {
type_: SetErrorType::BlobNotFound | SetErrorType::Forbidden,
..
}))
) {
panic!("Expected forbidden, got {:?}", result);
}
}
+336
View File
@@ -0,0 +1,336 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use jmap_proto::types::state::State;
use std::str::FromStr;
use store::{ahash::AHashSet, write::BatchBuilder};
use types::{
collection::{Collection, SyncCollection},
id::Id,
};
pub async fn test(test: &TestServer) {
println!("Running Email Changes tests...");
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mut states = vec![State::Initial];
for (changes, expected_changelog) in [
(
vec![
LogAction::Insert(0),
LogAction::Insert(1),
LogAction::Insert(2),
],
vec![vec![vec![0, 1, 2], vec![], vec![]]],
),
(
vec![
LogAction::Move(0, 3),
LogAction::Insert(4),
LogAction::Insert(5),
LogAction::Update(1),
LogAction::Update(2),
],
vec![
vec![vec![1, 2, 3, 4, 5], vec![], vec![]],
vec![vec![3, 4, 5], vec![1, 2], vec![0]],
],
),
(
vec![
LogAction::Delete(1),
LogAction::Insert(6),
LogAction::Insert(7),
LogAction::Update(2),
],
vec![
vec![vec![2, 3, 4, 5, 6, 7], vec![], vec![]],
vec![vec![3, 4, 5, 6, 7], vec![2], vec![0, 1]],
vec![vec![6, 7], vec![2], vec![1]],
],
),
(
vec![
LogAction::Update(4),
LogAction::Update(5),
LogAction::Update(6),
LogAction::Update(7),
],
vec![
vec![vec![2, 3, 4, 5, 6, 7], vec![], vec![]],
vec![vec![3, 4, 5, 6, 7], vec![2], vec![0, 1]],
vec![vec![6, 7], vec![2, 4, 5], vec![1]],
vec![vec![], vec![4, 5, 6, 7], vec![]],
],
),
(
vec![
LogAction::Delete(4),
LogAction::Delete(5),
LogAction::Delete(6),
LogAction::Delete(7),
],
vec![
vec![vec![2, 3], vec![], vec![]],
vec![vec![3], vec![2], vec![0, 1]],
vec![vec![], vec![2], vec![1, 4, 5]],
vec![vec![], vec![], vec![4, 5, 6, 7]],
vec![vec![], vec![], vec![4, 5, 6, 7]],
],
),
(
vec![
LogAction::Insert(8),
LogAction::Insert(9),
LogAction::Insert(10),
LogAction::Update(3),
],
vec![
vec![vec![2, 3, 8, 9, 10], vec![], vec![]],
vec![vec![3, 8, 9, 10], vec![2], vec![0, 1]],
vec![vec![8, 9, 10], vec![2, 3], vec![1, 4, 5]],
vec![vec![8, 9, 10], vec![3], vec![4, 5, 6, 7]],
vec![vec![8, 9, 10], vec![3], vec![4, 5, 6, 7]],
vec![vec![8, 9, 10], vec![3], vec![]],
],
),
(
vec![LogAction::Update(2), LogAction::Update(8)],
vec![
vec![vec![2, 3, 8, 9, 10], vec![], vec![]],
vec![vec![3, 8, 9, 10], vec![2], vec![0, 1]],
vec![vec![8, 9, 10], vec![2, 3], vec![1, 4, 5]],
vec![vec![8, 9, 10], vec![2, 3], vec![4, 5, 6, 7]],
vec![vec![8, 9, 10], vec![2, 3], vec![4, 5, 6, 7]],
vec![vec![8, 9, 10], vec![2, 3], vec![]],
vec![vec![], vec![2, 8], vec![]],
],
),
(
vec![
LogAction::Move(9, 11),
LogAction::Move(10, 12),
LogAction::Delete(8),
],
vec![
vec![vec![2, 3, 11, 12], vec![], vec![]],
vec![vec![3, 11, 12], vec![2], vec![0, 1]],
vec![vec![11, 12], vec![2, 3], vec![1, 4, 5]],
vec![vec![11, 12], vec![2, 3], vec![4, 5, 6, 7]],
vec![vec![11, 12], vec![2, 3], vec![4, 5, 6, 7]],
vec![vec![11, 12], vec![2, 3], vec![]],
vec![vec![11, 12], vec![2], vec![8, 9, 10]],
vec![vec![11, 12], vec![], vec![8, 9, 10]],
],
),
]
.into_iter()
{
let mut batch = BatchBuilder::new();
batch
.with_account_id(account.id().document_id())
.with_collection(Collection::Email);
for change in changes {
match change {
LogAction::Insert(id) => {
batch
.with_document(id as u32)
.log_item_insert(SyncCollection::Email, None);
}
LogAction::Update(id) => {
batch
.with_document(id as u32)
.log_item_update(SyncCollection::Email, None);
}
LogAction::Delete(id) => {
batch
.with_document(id as u32)
.log_item_delete(SyncCollection::Email, None);
}
LogAction::UpdateChild(id) => {
batch.log_container_property_change(SyncCollection::Email, id as u32);
}
LogAction::Move(old_id, new_id) => {
batch
.with_document(old_id as u32)
.log_item_delete(SyncCollection::Email, None)
.with_document(new_id as u32)
.log_item_insert(SyncCollection::Email, None);
}
}
}
server
.core
.storage
.data
.write(batch.build_all())
.await
.unwrap();
let mut new_state = State::Initial;
for (test_num, state) in (states).iter().enumerate() {
let changes = client.email_changes(state.to_string(), None).await.unwrap();
assert_eq!(
expected_changelog[test_num],
[changes.created(), changes.updated(), changes.destroyed()]
.into_iter()
.map(|list| {
let mut list = list
.iter()
.map(|i| Id::from_str(i).unwrap().into())
.collect::<Vec<u64>>();
list.sort_unstable();
list
})
.collect::<Vec<Vec<_>>>(),
"test_num: {}, state: {:?}",
test_num,
state
);
if &State::Initial == state {
new_state = State::parse_str(changes.new_state()).unwrap();
}
for max_changes in 1..=8 {
let mut insertions = expected_changelog[test_num][0]
.iter()
.copied()
.collect::<AHashSet<_>>();
let mut updates = expected_changelog[test_num][1]
.iter()
.copied()
.collect::<AHashSet<_>>();
let mut deletions = expected_changelog[test_num][2]
.iter()
.copied()
.collect::<AHashSet<_>>();
let mut int_state = state.clone();
for _ in 0..100 {
let changes = client
.email_changes(int_state.to_string(), max_changes.into())
.await
.unwrap();
assert!(
changes.created().len()
+ changes.updated().len()
+ changes.destroyed().len()
<= max_changes,
"{} > {}",
changes.created().len()
+ changes.updated().len()
+ changes.destroyed().len(),
max_changes
);
changes.created().iter().for_each(|id| {
assert!(
insertions.remove(&Id::from_str(id).unwrap()),
"{:?} != {}",
insertions,
Id::from_str(id).unwrap()
);
});
changes.updated().iter().for_each(|id| {
assert!(
updates.remove(&Id::from_str(id).unwrap()),
"{:?} != {}",
updates,
Id::from_str(id).unwrap()
);
});
changes.destroyed().iter().for_each(|id| {
assert!(
deletions.remove(&Id::from_str(id).unwrap()),
"{:?} != {}",
deletions,
Id::from_str(id).unwrap()
);
});
int_state = State::parse_str(changes.new_state()).unwrap();
if !changes.has_more_changes() {
break;
}
}
assert_eq!(
insertions.len(),
0,
"test_num: {}, state: {:?}, pending: {:?}",
test_num,
state,
insertions
);
assert_eq!(
updates.len(),
0,
"test_num: {}, state: {:?}, pending: {:?}",
test_num,
state,
updates
);
assert_eq!(
deletions.len(),
0,
"test_num: {}, state: {:?}, pending: {:?}",
test_num,
state,
deletions
);
}
}
states.push(new_state);
}
let changes = client
.email_changes(State::Initial.to_string(), None)
.await
.unwrap();
let mut created = changes
.created()
.iter()
.map(|i| Id::from_str(i).unwrap().into())
.collect::<Vec<u64>>();
created.sort_unstable();
assert_eq!(created, vec![2, 3, 11, 12]);
assert_eq!(changes.updated(), Vec::<String>::new());
assert_eq!(changes.destroyed(), Vec::<String>::new());
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
#[derive(Debug, Clone, Copy)]
pub enum LogAction {
Insert(u64),
Update(u64),
Delete(u64),
UpdateChild(u64),
Move(u64, u64),
}
pub trait ParseState: Sized {
fn parse_str(state: &str) -> Option<Self>;
}
impl ParseState for State {
fn parse_str(state: &str) -> Option<Self> {
State::parse(state)
}
}
+102
View File
@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use jmap_client::mailbox::Role;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Email Copy tests...");
let account = test.account("[email protected]");
let mut client = account.jmap_client().await;
// Create a mailbox on account 1
let ac1_mailbox_id = client
.set_default_account_id(Id::new(1).to_string())
.mailbox_create("Copy Test Ac# 1", None::<String>, Role::None)
.await
.unwrap()
.take_id();
// Insert a message on account 1
let ac1_email_id = client
.email_import(
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
)
.as_bytes()
.to_vec(),
[&ac1_mailbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
// Create a mailbox on account 2
let ac2_mailbox_id = client
.set_default_account_id(Id::new(2).to_string())
.mailbox_create("Copy Test Ac# 2", None::<String>, Role::None)
.await
.unwrap()
.take_id();
// Copy the email and delete it from the first account
let mut request = client.build();
request
.copy_email(Id::new(1).to_string())
.on_success_destroy_original(true)
.create(&ac1_email_id)
.mailbox_id(&ac2_mailbox_id, true)
.keyword("$draft", true)
.received_at(311923920);
let ac2_email_id = request
.send()
.await
.unwrap()
.method_response_by_pos(0)
.unwrap_copy_email()
.unwrap()
.created(&ac1_email_id)
.unwrap()
.take_id();
// Check that the email was copied
let email = client
.email_get(&ac2_email_id, None::<Vec<_>>)
.await
.unwrap()
.unwrap();
assert_eq!(
email.preview().unwrap(),
"I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great."
);
assert_eq!(email.subject().unwrap(), "TPS Report");
assert_eq!(email.mailbox_ids(), &[&ac2_mailbox_id]);
assert_eq!(email.keywords(), &["$draft"]);
assert_eq!(email.received_at().unwrap(), 311923920);
// Check that the email was deleted
assert!(
client
.set_default_account_id(Id::new(1).to_string())
.email_get(&ac1_email_id, None::<Vec<_>>)
.await
.unwrap()
.is_none()
);
// Empty store
account.destroy_all_mailboxes_for_account(1).await;
account.destroy_all_mailboxes_for_account(2).await;
test.assert_is_empty().await;
}
+338
View File
@@ -0,0 +1,338 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{jmap::replace_blob_ids, utils::server::TestServer};
use ::email::mailbox::INBOX_ID;
use jmap_client::email::{self, Header, HeaderForm, import::EmailImportResponse};
use mail_parser::HeaderName;
use registry::schema::prelude::ObjectType;
use std::{fs, path::PathBuf};
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Email Get tests...");
let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_dir.push("resources");
test_dir.push("jmap");
test_dir.push("email_get");
let mailbox_id = Id::from(INBOX_ID).to_string();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
for file_name in fs::read_dir(&test_dir).unwrap() {
let mut file_name = file_name.as_ref().unwrap().path();
if file_name.extension().is_none_or(|e| e != "eml") {
continue;
}
let is_headers_test = file_name.file_name().unwrap() == "headers.eml";
let blob = fs::read(&file_name).unwrap();
let blob_len = blob.len();
// Import email
let mut request = client.build();
let import_request = request
.import_email()
.email(
client
.upload(None, blob, None)
.await
.unwrap()
.take_blob_id(),
)
.mailbox_ids([mailbox_id.clone()])
.keywords(["tag".to_string()])
.received_at((blob_len * 1000000) as i64);
let id = import_request.create_id();
let mut response = request.send_single::<EmailImportResponse>().await.unwrap();
assert_ne!(response.old_state(), Some(response.new_state()));
let email = response.created(&id).unwrap();
let mut request = client.build();
request
.get_email()
.ids([email.id().unwrap()])
.properties([
email::Property::Id,
email::Property::BlobId,
email::Property::ThreadId,
email::Property::MailboxIds,
email::Property::Keywords,
email::Property::Size,
email::Property::ReceivedAt,
email::Property::MessageId,
email::Property::InReplyTo,
email::Property::References,
email::Property::Sender,
email::Property::From,
email::Property::To,
email::Property::Cc,
email::Property::Bcc,
email::Property::ReplyTo,
email::Property::Subject,
email::Property::SentAt,
email::Property::HasAttachment,
email::Property::Preview,
email::Property::BodyValues,
email::Property::TextBody,
email::Property::HtmlBody,
email::Property::Attachments,
email::Property::BodyStructure,
])
.arguments()
.body_properties(if !is_headers_test {
[
email::BodyProperty::PartId,
email::BodyProperty::BlobId,
email::BodyProperty::Size,
email::BodyProperty::Name,
email::BodyProperty::Type,
email::BodyProperty::Charset,
email::BodyProperty::Headers,
email::BodyProperty::Disposition,
email::BodyProperty::Cid,
email::BodyProperty::Language,
email::BodyProperty::Location,
]
} else {
[
email::BodyProperty::PartId,
email::BodyProperty::Size,
email::BodyProperty::Name,
email::BodyProperty::Type,
email::BodyProperty::Charset,
email::BodyProperty::Disposition,
email::BodyProperty::Cid,
email::BodyProperty::Language,
email::BodyProperty::Location,
email::BodyProperty::Header(Header {
name: "X-Custom-Header".into(),
form: HeaderForm::Raw,
all: false,
}),
email::BodyProperty::Header(Header {
name: "X-Custom-Header-2".into(),
form: HeaderForm::Raw,
all: false,
}),
]
})
.fetch_all_body_values(true)
.max_body_value_bytes(100);
let mut result = request
.send_get_email()
.await
.unwrap()
.take_list()
.pop()
.unwrap()
.into_test();
if is_headers_test {
for property in all_headers() {
let mut request = client.build();
request
.get_email()
.ids([email.id().unwrap()])
.properties([property]);
result.headers.extend(
request
.send_get_email()
.await
.unwrap()
.take_list()
.pop()
.unwrap()
.into_test()
.headers,
);
}
}
let result = replace_blob_ids(serde_json::to_string_pretty(&result).unwrap());
file_name.set_extension("json");
if fs::read(&file_name).unwrap() != result.as_bytes() {
file_name.set_extension("failed");
fs::write(&file_name, result.as_bytes()).unwrap();
panic!("Test failed, output saved to {}", file_name.display());
}
}
test.destroy_all_mailboxes(account).await;
test.account("[email protected]")
.registry_destroy_all(ObjectType::SpamTrainingSample)
.await;
test.assert_is_empty().await;
}
pub fn all_headers() -> Vec<email::Property> {
let mut properties = Vec::new();
for header in [
HeaderName::From,
HeaderName::To,
HeaderName::Cc,
HeaderName::Bcc,
HeaderName::Other("X-Address-Single".into()),
HeaderName::Other("X-Address".into()),
HeaderName::Other("X-AddressList-Single".into()),
HeaderName::Other("X-AddressList".into()),
HeaderName::Other("X-AddressesGroup-Single".into()),
HeaderName::Other("X-AddressesGroup".into()),
] {
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Addresses,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Addresses,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::GroupedAddresses,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::GroupedAddresses,
name: header.as_str().to_string(),
all: false,
}));
}
for header in [
HeaderName::ListPost,
HeaderName::ListSubscribe,
HeaderName::ListUnsubscribe,
HeaderName::ListOwner,
HeaderName::Other("X-List-Single".into()),
HeaderName::Other("X-List".into()),
] {
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::URLs,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::URLs,
name: header.as_str().to_string(),
all: false,
}));
}
for header in [
HeaderName::Date,
HeaderName::ResentDate,
HeaderName::Other("X-Date-Single".into()),
HeaderName::Other("X-Date".into()),
] {
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Date,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Date,
name: header.as_str().to_string(),
all: false,
}));
}
for header in [
HeaderName::MessageId,
HeaderName::References,
HeaderName::Other("X-Id-Single".into()),
HeaderName::Other("X-Id".into()),
] {
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::MessageIds,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::MessageIds,
name: header.as_str().to_string(),
all: false,
}));
}
for header in [
HeaderName::Subject,
HeaderName::Keywords,
HeaderName::Other("X-Text-Single".into()),
HeaderName::Other("X-Text".into()),
] {
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Raw,
name: header.as_str().to_string(),
all: false,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Text,
name: header.as_str().to_string(),
all: true,
}));
properties.push(email::Property::Header(Header {
form: HeaderForm::Text,
name: header.as_str().to_string(),
all: false,
}));
}
properties
}
+768
View File
@@ -0,0 +1,768 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use jmap_client::{
Error, Set,
client::Client,
core::{
query::Filter,
set::{SetError, SetErrorType, SetObject, SetRequest},
},
mailbox::{self, Mailbox, Role},
};
use jmap_proto::types::state::State;
use serde::{Deserialize, Serialize};
use store::ahash::AHashMap;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Mailbox tests...");
let account = test.account("[email protected]");
let client = account.jmap_client().await;
// Create test mailboxes
test.destroy_all_mailboxes(account).await;
let id_map = create_test_mailboxes(&client).await;
// Sort by name
assert_eq!(
client
.mailbox_query(
None::<mailbox::query::Filter>,
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
[
"drafts",
"spam2",
"inbox",
"l.1",
"l.2",
"l.3",
"sent",
"spam",
"1.1",
"1.2",
"trash",
"spam1",
"1.1.1.1",
"1.1.1.1.1",
"1.1.1",
"1.2.1"
]
);
// Sort by name as tree
let mut request = client.build();
request
.query_mailbox()
.sort([mailbox::query::Comparator::name()])
.arguments()
.sort_as_tree(true);
assert_eq!(
request
.send_query_mailbox()
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
[
"drafts",
"inbox",
"l.1",
"1.1",
"1.1.1",
"1.1.1.1",
"1.1.1.1.1",
"1.2",
"1.2.1",
"l.2",
"l.3",
"sent",
"spam",
"spam1",
"spam2",
"trash"
]
);
// Sort as tree with filters
let mut request = client.build();
request
.query_mailbox()
.filter(mailbox::query::Filter::name("level"))
.sort([mailbox::query::Comparator::name()])
.arguments()
.sort_as_tree(true);
assert_eq!(
request
.send_query_mailbox()
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
[
"l.1",
"1.1",
"1.1.1",
"1.1.1.1",
"1.1.1.1.1",
"1.2",
"1.2.1",
"l.2",
"l.3"
]
);
// Filter as tree
let mut request = client.build();
request
.query_mailbox()
.filter(mailbox::query::Filter::name("spam"))
.sort([mailbox::query::Comparator::name()])
.arguments()
.filter_as_tree(true)
.sort_as_tree(true);
assert_eq!(
request
.send_query_mailbox()
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["spam", "spam1", "spam2"]
);
let mut request = client.build();
request
.query_mailbox()
.filter(mailbox::query::Filter::name("level"))
.sort([mailbox::query::Comparator::name()])
.arguments()
.filter_as_tree(true)
.sort_as_tree(true);
assert_eq!(
request.send_query_mailbox().await.unwrap().ids(),
Vec::<&str>::new()
);
// Filter by role
assert_eq!(
client
.mailbox_query(
mailbox::query::Filter::role(Role::Inbox).into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["inbox"]
);
assert_eq!(
client
.mailbox_query(
mailbox::query::Filter::has_any_role(true).into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["drafts", "inbox", "sent", "spam", "trash"]
);
// Duplicate role
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["sent"])
.role(Role::Inbox);
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["sent"]),
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
// Duplicate name
let mut request = client.build();
request.set_mailbox().update(&id_map["l.2"]).name("Level 3");
let result = request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["l.2"]);
assert!(
matches!(
result,
Err(Error::Set(SetError {
type_: SetErrorType::AlreadyExists,
..
}))
),
"{result:?}",
);
// Circular relationship
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["l.1"])
.parent_id((&id_map["1.1.1.1.1"]).into());
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["l.1"]),
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["l.1"])
.parent_id((&id_map["l.1"]).into());
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["l.1"]),
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
// Invalid parentId
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["l.1"])
.parent_id(Id::new(u64::MAX).to_string().into());
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["l.1"]),
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
// Obtain state
let state = client
.mailbox_changes(State::Initial.to_string(), 0)
.await
.unwrap()
.new_state()
.to_string();
// Rename and move mailbox
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["1.1.1.1.1"])
.name("Renamed and moved")
.parent_id((&id_map["l.2"]).into());
assert!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["1.1.1.1.1"])
.is_ok()
);
// Verify changes
let state = client.mailbox_changes(state, 0).await.unwrap();
assert_eq!(state.created().len(), 0);
assert_eq!(state.updated().len(), 1);
assert_eq!(state.destroyed().len(), 0);
assert_eq!(state.arguments().updated_properties(), None);
let state = state.new_state().to_string();
// Insert email into Inbox
let mail_id = client
.email_import(
b"From: [email protected]\nSubject: hey\n\ntest".to_vec(),
[&id_map["inbox"]],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
// Inbox's total and unread count should have increased
let inbox = client
.mailbox_get(
&id_map["inbox"],
[
mailbox::Property::TotalEmails,
mailbox::Property::UnreadEmails,
mailbox::Property::TotalThreads,
mailbox::Property::UnreadThreads,
]
.into(),
)
.await
.unwrap()
.unwrap();
assert_eq!(inbox.total_emails(), 1);
assert_eq!(inbox.unread_emails(), 1);
assert_eq!(inbox.total_threads(), 1);
assert_eq!(inbox.unread_threads(), 1);
// Set email to read and fetch properties again
client
.email_set_keyword(&mail_id, "$seen", true)
.await
.unwrap();
let inbox = client
.mailbox_get(
&id_map["inbox"],
[
mailbox::Property::TotalEmails,
mailbox::Property::UnreadEmails,
mailbox::Property::TotalThreads,
mailbox::Property::UnreadThreads,
]
.into(),
)
.await
.unwrap()
.unwrap();
assert_eq!(inbox.total_emails(), 1);
assert_eq!(inbox.unread_emails(), 0);
assert_eq!(inbox.total_threads(), 1);
assert_eq!(inbox.unread_threads(), 0);
// Only email properties must have changed
let prev_state = state.clone();
let state = client.mailbox_changes(state, 0).await.unwrap();
assert_eq!(state.created().len(), 0);
assert_eq!(
state
.updated()
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>(),
&[&id_map["inbox"]]
);
assert_eq!(state.destroyed().len(), 0);
assert_eq!(
state.arguments().updated_properties(),
Some(
&[
mailbox::Property::TotalEmails,
mailbox::Property::UnreadEmails,
mailbox::Property::TotalThreads,
mailbox::Property::UnreadThreads,
][..]
)
);
let state = state.new_state().to_string();
// Use updatedProperties in a query
let mut request = client.build();
let changes_request = request.changes_mailbox(prev_state).max_changes(0);
let properties_ref = changes_request.updated_properties_reference();
let updated_ref = changes_request.updated_reference();
request
.get_mailbox()
.ids_ref(updated_ref)
.properties_ref(properties_ref);
let mut changed_mailboxes = request
.send()
.await
.unwrap()
.unwrap_method_responses()
.pop()
.unwrap()
.unwrap_get_mailbox()
.unwrap()
.take_list();
assert_eq!(changed_mailboxes.len(), 1);
let inbox = changed_mailboxes.pop().unwrap();
assert_eq!(inbox.id().unwrap(), &id_map["inbox"]);
assert_eq!(inbox.total_emails(), 1);
assert_eq!(inbox.unread_emails(), 0);
assert_eq!(inbox.total_threads(), 1);
assert_eq!(inbox.unread_threads(), 0);
assert_eq!(inbox.name(), None);
assert_eq!(inbox.my_rights(), None);
// Move email from Inbox to Trash
client
.email_set_mailboxes(&mail_id, [&id_map["trash"]])
.await
.unwrap();
// E-mail properties of both Inbox and Trash must have changed
let state = client.mailbox_changes(state, 0).await.unwrap();
assert_eq!(state.created().len(), 0);
assert_eq!(state.updated().len(), 2);
assert_eq!(state.destroyed().len(), 0);
let mut folder_ids = vec![&id_map["trash"], &id_map["inbox"]];
let mut updated_ids = state
.updated()
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>();
updated_ids.sort_unstable();
folder_ids.sort_unstable();
assert_eq!(updated_ids, folder_ids);
assert_eq!(
state.arguments().updated_properties(),
Some(
&[
mailbox::Property::TotalEmails,
mailbox::Property::UnreadEmails,
mailbox::Property::TotalThreads,
mailbox::Property::UnreadThreads,
][..]
)
);
// Deleting folders with children is not allowed
let mut request = client.build();
request.set_mailbox().destroy([&id_map["l.1"]]);
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.destroyed(&id_map["l.1"]),
Err(Error::Set(SetError {
type_: SetErrorType::MailboxHasChild,
..
}))
));
// Deleting folders with contents is not allowed (unless remove_emails is true)
let mut request = client.build();
request.set_mailbox().destroy([&id_map["trash"]]);
assert!(matches!(
request
.send_set_mailbox()
.await
.unwrap()
.destroyed(&id_map["trash"]),
Err(Error::Set(SetError {
type_: SetErrorType::MailboxHasEmail,
..
}))
));
// Delete Trash folder and its contents
let mut request = client.build();
request
.set_mailbox()
.destroy([&id_map["trash"]])
.arguments()
.on_destroy_remove_emails(true);
assert!(
request
.send_set_mailbox()
.await
.unwrap()
.destroyed(&id_map["trash"])
.is_ok()
);
// Verify that Trash folder and its contents are gone
assert!(
client
.mailbox_get(&id_map["trash"], None::<Vec<_>>)
.await
.unwrap()
.is_none()
);
assert!(
client
.email_get(&mail_id, None::<Vec<_>>)
.await
.unwrap()
.is_none()
);
// Check search results after changing folder properties
let mut request = client.build();
request
.set_mailbox()
.update(&id_map["drafts"])
.name("Borradores")
.sort_order(100)
.parent_id((&id_map["l.2"]).into())
.role(Role::None);
assert!(
request
.send_set_mailbox()
.await
.unwrap()
.updated(&id_map["drafts"])
.is_ok()
);
assert_eq!(
client
.mailbox_query(
Filter::and([
mailbox::query::Filter::name("Borradores").into(),
mailbox::query::Filter::parent_id((&id_map["l.2"]).into()).into(),
Filter::not([mailbox::query::Filter::has_any_role(true)])
])
.into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["drafts"]
);
assert!(
client
.mailbox_query(
mailbox::query::Filter::name("Drafts").into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.is_empty()
);
assert!(
client
.mailbox_query(
mailbox::query::Filter::role(Role::Drafts).into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.is_empty()
);
assert_eq!(
client
.mailbox_query(
mailbox::query::Filter::parent_id(None::<&str>).into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["inbox", "sent", "spam"]
);
assert_eq!(
client
.mailbox_query(
mailbox::query::Filter::has_any_role(true).into(),
[mailbox::query::Comparator::name()].into()
)
.await
.unwrap()
.ids()
.iter()
.map(|id| id_map.get(id).unwrap())
.collect::<Vec<_>>(),
["inbox", "sent", "spam"]
);
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
async fn create_test_mailboxes(client: &Client) -> AHashMap<String, String> {
let mut mailbox_map = AHashMap::default();
let mut request = client.build();
build_create_query(
request.set_mailbox(),
&mut mailbox_map,
serde_json::from_slice(TEST_MAILBOXES).unwrap(),
None,
);
let mut result = request.send_set_mailbox().await.unwrap();
let mut id_map = AHashMap::with_capacity(mailbox_map.len());
for (create_id, local_id) in mailbox_map {
let server_id = result.created(&create_id).unwrap().take_id();
id_map.insert(local_id.clone(), server_id.clone());
id_map.insert(server_id, local_id);
}
id_map
}
fn build_create_query(
request: &mut SetRequest<Mailbox<Set>>,
mailbox_map: &mut AHashMap<String, String>,
mailboxes: Vec<TestMailbox>,
parent_id: Option<String>,
) {
for mailbox in mailboxes {
let create_mailbox = request
.create()
.name(mailbox.name)
.sort_order(mailbox.order);
if let Some(role) = mailbox.role {
create_mailbox.role(role);
}
if let Some(parent_id) = &parent_id {
create_mailbox.parent_id_ref(parent_id);
}
let create_mailbox_id = create_mailbox.create_id().unwrap();
mailbox_map.insert(create_mailbox_id.clone(), mailbox.id);
if let Some(children) = mailbox.children {
build_create_query(request, mailbox_map, children, create_mailbox_id.into());
}
}
}
#[derive(Serialize, Deserialize)]
struct TestMailbox {
id: String,
name: String,
role: Option<Role>,
order: u32,
children: Option<Vec<TestMailbox>>,
}
const TEST_MAILBOXES: &[u8] = br#"
[
{
"id": "inbox",
"name": "Inbox",
"role": "INBOX",
"order": 5,
"children": [
{
"name": "Level 1",
"id": "l.1",
"order": 4,
"children": [
{
"name": "Sub-Level 1.1",
"id": "1.1",
"order": 3,
"children": [
{
"name": "Z-Sub-Level 1.1.1",
"id": "1.1.1",
"order": 2,
"children": [
{
"name": "X-Sub-Level 1.1.1.1",
"id": "1.1.1.1",
"order": 1,
"children": [
{
"name": "Y-Sub-Level 1.1.1.1.1",
"id": "1.1.1.1.1",
"order": 0
}
]
}
]
}
]
},
{
"name": "Sub-Level 1.2",
"id": "1.2",
"order": 7,
"children": [
{
"name": "Z-Sub-Level 1.2.1",
"id": "1.2.1",
"order": 6
}
]
}
]
},
{
"name": "Level 2",
"id": "l.2",
"order": 8
},
{
"name": "Level 3",
"id": "l.3",
"order": 9
}
]
},
{
"id": "sent",
"name": "Sent",
"role": "SENT",
"order": 15
},
{
"id": "drafts",
"name": "Drafts",
"role": "DRAFTS",
"order": 14
},
{
"id": "trash",
"name": "Trash",
"role": "TRASH",
"order": 13
},
{
"id": "spam",
"name": "Spam",
"role": "JUNK",
"order": 12,
"children": [{
"id": "spam1",
"name": "Work Spam",
"order": 11,
"children": [{
"id": "spam2",
"name": "Friendly Spam",
"order": 10
}]
}]
}
]
"#;
+21
View File
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod acl;
pub mod changes;
pub mod copy;
pub mod get;
pub mod mailbox;
pub mod parse;
pub mod query;
pub mod query_changes;
pub mod search_snippet;
pub mod set;
pub mod sieve_script;
pub mod submission;
pub mod thread_get;
pub mod thread_merge;
pub mod vacation_response;
+229
View File
@@ -0,0 +1,229 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::{mail::get::all_headers, replace_blob_ids},
utils::server::TestServer,
};
use jmap_client::{
email::{self, Header, HeaderForm},
mailbox::Role,
};
use std::{fs, path::PathBuf};
pub async fn test(test: &TestServer) {
println!("Running Email Parse tests...");
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_dir.push("resources");
test_dir.push("jmap");
test_dir.push("email_parse");
let mailbox_id = client
.mailbox_create("JMAP Parse", None::<String>, Role::None)
.await
.unwrap()
.take_id();
// Test parsing an email attachment
for test_name in ["attachment.eml", "attachment_b64.eml"] {
let mut test_file = test_dir.clone();
test_file.push(test_name);
let email = client
.email_import(
fs::read(&test_file).unwrap(),
[mailbox_id.clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
let blob_id = client
.email_get(email.id().unwrap(), Some([email::Property::Attachments]))
.await
.unwrap()
.unwrap()
.attachments()
.unwrap()
.first()
.unwrap()
.blob_id()
.unwrap()
.to_string();
let email = client
.email_parse(
&blob_id,
[
email::Property::Id,
email::Property::BlobId,
email::Property::ThreadId,
email::Property::MailboxIds,
email::Property::Keywords,
email::Property::Size,
email::Property::ReceivedAt,
email::Property::MessageId,
email::Property::InReplyTo,
email::Property::References,
email::Property::Sender,
email::Property::From,
email::Property::To,
email::Property::Cc,
email::Property::Bcc,
email::Property::ReplyTo,
email::Property::Subject,
email::Property::SentAt,
email::Property::HasAttachment,
email::Property::Preview,
email::Property::BodyValues,
email::Property::TextBody,
email::Property::HtmlBody,
email::Property::Attachments,
email::Property::BodyStructure,
]
.into(),
[
email::BodyProperty::PartId,
email::BodyProperty::BlobId,
email::BodyProperty::Size,
email::BodyProperty::Name,
email::BodyProperty::Type,
email::BodyProperty::Charset,
email::BodyProperty::Headers,
email::BodyProperty::Disposition,
email::BodyProperty::Cid,
email::BodyProperty::Language,
email::BodyProperty::Location,
]
.into(),
100.into(),
)
.await
.unwrap();
if !test_name.contains("_b64") {
for parts in [
email.text_body().unwrap(),
email.html_body().unwrap(),
email.attachments().unwrap(),
] {
for part in parts {
let blob_id = part.blob_id().unwrap();
let inner_blob = client.download(blob_id).await.unwrap();
test_file.set_extension(format!("part{}", part.part_id().unwrap()));
//fs::write(&test_file, inner_blob).unwrap();
let expected_inner_blob = fs::read(&test_file).unwrap();
assert_eq!(
inner_blob,
expected_inner_blob,
"file: {}",
test_file.display()
);
}
}
}
test_file.set_extension("json");
let result = replace_blob_ids(serde_json::to_string_pretty(&email.into_test()).unwrap());
if fs::read(&test_file).unwrap() != result.as_bytes() {
test_file.set_extension("failed");
fs::write(&test_file, result.as_bytes()).unwrap();
panic!("Test failed, output saved to {}", test_file.display());
}
}
// Test header parsing on a temporary blob
let mut test_file = test_dir;
test_file.push("headers.eml");
let blob_id = client
.upload(None, fs::read(&test_file).unwrap(), None)
.await
.unwrap()
.take_blob_id();
let mut email = client
.email_parse(
&blob_id,
[
email::Property::Id,
email::Property::MessageId,
email::Property::InReplyTo,
email::Property::References,
email::Property::Sender,
email::Property::From,
email::Property::To,
email::Property::Cc,
email::Property::Bcc,
email::Property::ReplyTo,
email::Property::Subject,
email::Property::SentAt,
email::Property::Preview,
email::Property::TextBody,
email::Property::HtmlBody,
email::Property::Attachments,
]
.into(),
[
email::BodyProperty::Size,
email::BodyProperty::Name,
email::BodyProperty::Type,
email::BodyProperty::Charset,
email::BodyProperty::Disposition,
email::BodyProperty::Cid,
email::BodyProperty::Language,
email::BodyProperty::Location,
email::BodyProperty::Header(Header {
name: "X-Custom-Header".into(),
form: HeaderForm::Raw,
all: false,
}),
email::BodyProperty::Header(Header {
name: "X-Custom-Header-2".into(),
form: HeaderForm::Raw,
all: false,
}),
]
.into(),
100.into(),
)
.await
.unwrap()
.into_test();
for property in all_headers() {
email.headers.extend(
client
.email_parse(&blob_id, [property].into(), [].into(), None)
.await
.unwrap()
.into_test()
.headers,
);
}
test_file.set_extension("json");
let result = replace_blob_ids(serde_json::to_string_pretty(&email).unwrap());
if fs::read(&test_file).unwrap() != result.as_bytes() {
test_file.set_extension("failed");
fs::write(&test_file, result.as_bytes()).unwrap();
panic!("Test failed, output saved to {}", test_file.display());
}
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
+908
View File
@@ -0,0 +1,908 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::store::{deflate_test_resource, query::FIELDS};
use crate::utils::account::Account;
use crate::utils::server::TestServer;
use ::email::{cache::MessageCacheFetch, mailbox::Mailbox};
use ahash::AHashSet;
use common::storage::index::ObjectIndexBuilder;
use jmap_client::{
client::Client,
core::query::{Comparator, Filter},
email,
};
use mail_builder::{
MessageBuilder,
headers::{date::Date, message_id::MessageId, text::Text},
};
use mail_parser::HeaderName;
use std::{collections::hash_map::Entry, str::FromStr, time::Instant};
use store::{
ahash::AHashMap,
write::{BatchBuilder, now},
};
use types::{collection::Collection, id::Id, special_use::SpecialUse};
const MAX_THREADS: usize = 100;
const MAX_MESSAGES: usize = 1000;
const MAX_MESSAGES_PER_THREAD: usize = 100;
pub async fn test(test: &TestServer) {
println!("Running Email Query tests...");
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
if test.is_reset() {
// Add some "virtual" mailbox ids so create doesn't fail
let mut batch = BatchBuilder::new();
let account_id = Id::from_str(client.default_account_id())
.unwrap()
.document_id();
batch
.with_account_id(account_id)
.with_collection(Collection::Mailbox);
for mailbox_id in 1545..3010 {
batch
.with_document(mailbox_id)
.custom(ObjectIndexBuilder::<(), _>::new().with_changes(Mailbox {
name: format!("Mailbox {mailbox_id}"),
role: SpecialUse::None,
parent_id: 0,
sort_order: None,
uid_validity: 0,
subscribers: vec![],
acls: vec![],
}))
.unwrap();
}
server
.core
.storage
.data
.write(batch.build_all())
.await
.unwrap();
// Create test messages
println!("Inserting JMAP Mail query test messages...");
create(test, account).await;
assert_eq!(
test.server
.get_cached_messages(account_id)
.await
.unwrap()
.emails
.items
.iter()
.map(|m| m.thread_id)
.collect::<AHashSet<_>>()
.len(),
MAX_THREADS
);
// Wait for indexing to complete
test.wait_for_tasks().await;
}
let can_stem = !test.server.search_store().is_mysql();
println!("Running JMAP Mail query tests...");
query(&client, can_stem).await;
println!("Running JMAP Mail query options tests...");
query_options(&client).await;
println!("Deleting all messages...");
let mut request = client.build();
let result_ref = request.query_email().result_reference();
request.set_email().destroy_ref(result_ref);
let response = request.send().await.unwrap();
response
.unwrap_method_responses()
.pop()
.unwrap()
.unwrap_set_email()
.unwrap();
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
pub async fn query(client: &Client, can_stem: bool) {
for (filter, sort, expected_results) in [
(
Filter::and(vec![
(email::query::Filter::after(1850)),
(email::query::Filter::from("george")),
]),
vec![
email::query::Comparator::subject(),
email::query::Comparator::sent_at(),
],
vec![
"N01389", "T10115", "N00618", "N03500", "T01587", "T00397", "N01561", "N05250",
"N03973", "N04973", "N04057", "N01940", "N01539", "N01612", "N04484", "N01954",
"N05998", "T02053", "AR00171", "AR00172", "AR00176",
],
),
(
Filter::and(vec![
(email::query::Filter::in_mailbox(Id::new(1768u64).to_string())),
(email::query::Filter::cc("canvas")),
]),
vec![
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec!["T01882", "N04689", "T00925", "N00121"],
),
(
Filter::and(vec![
(email::query::Filter::text(if can_stem { "study" } else { "studies" })),
(email::query::Filter::in_mailbox_other_than(vec![
Id::new(1991).to_string(),
Id::new(1870).to_string(),
Id::new(2011).to_string(),
Id::new(1951).to_string(),
Id::new(1902).to_string(),
Id::new(1808).to_string(),
Id::new(1963).to_string(),
])),
]),
vec![
email::query::Comparator::subject(),
email::query::Comparator::sent_at(),
],
if can_stem {
vec![
/*"T10330", "N01744", "N01743", "N04885", "N02688", "N02122", "A00059", "A00058",
"N02123", "T00651", "T09439", "N05001", "T05848", "T05508",*/
"T09187", "T10330", "N01744", "N01743", "N04885", "N02688", "N02122", "A00059",
"A00057", "A00058", "N02123", "T00651", "T09439", "N05001", "A01072", "A01061",
"AR00050", "T02310", "T05848", "T05508", "P20078", "P20079",
]
} else {
vec!["T10330", "N02122", "N02123", "T09439"]
},
),
(
Filter::and(vec![
(email::query::Filter::has_keyword("N0")).into(),
Filter::not(vec![(email::query::Filter::from("collins"))]),
(email::query::Filter::body("bequeathed")).into(),
]),
vec![
email::query::Comparator::subject(),
email::query::Comparator::sent_at(),
],
vec![
"N02640", "A01020", "N01250", "T03430", "N01800", "N00620", "N05250", "N04630",
"A01040",
],
),
(
email::query::Filter::not_keyword("artist").into(),
vec![
email::query::Comparator::subject(),
email::query::Comparator::sent_at(),
],
vec!["T08626", "T09334", "T09455", "N01737", "T10965"],
),
(
Filter::and(vec![
(email::query::Filter::after(1970)),
(email::query::Filter::before(1972)),
(email::query::Filter::text("colour")),
]),
vec![
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec!["T01745", "P01436", "P01437"],
),
(
Filter::and(vec![(email::query::Filter::text("'cats and dogs'"))]),
vec![email::query::Comparator::from()],
vec!["P77623"],
),
(
Filter::and(vec![
(email::query::Filter::header(
HeaderName::Comments.to_string(),
Some("attributed"),
)),
(email::query::Filter::from("john")),
(email::query::Filter::cc("oil")),
]),
vec![email::query::Comparator::from()],
vec!["T10965"],
),
(
Filter::and(vec![
(email::query::Filter::all_in_thread_have_keyword("N")),
(email::query::Filter::before(1800)),
]),
vec![
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273", "N01453",
"N02984",
],
),
(
Filter::and(vec![
(email::query::Filter::none_in_thread_have_keyword("N")),
(email::query::Filter::after(1995)),
]),
vec![
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"AR00163", "AR00164", "AR00472", "P11481", "AR00066", "AR00178", "P77895",
"P77896", "P77897",
],
),
(
Filter::and(vec![
(email::query::Filter::some_in_thread_have_keyword("Bronze")),
(email::query::Filter::before(1878)),
]),
vec![
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"N04326", "N01610", "N02920", "N01587", "T00167", "T00168", "N01554", "N01535",
"N01536", "N01622", "N01754", "N01594",
],
),
// Sorting tests
(
email::query::Filter::before(1800).into(),
vec![
email::query::Comparator::all_in_thread_have_keyword("N"),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"T09417", "T01882", "T08820", "N04689", "T08891", "T00986", "N00316", "N03544",
"N04296", "N04297", "T08234", "N00112", "T00211", "N01497", "N02639", "N02640",
"T00925", "T11683", "T08269", "D00001", "D00002", "D00046", "N00121", "N00126",
"T08626", "N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273",
"N01453", "N02984",
],
),
(
email::query::Filter::before(1800).into(),
vec![
email::query::Comparator::all_in_thread_have_keyword("N").descending(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"N01496", "N05916", "N01046", "N00675", "N01320", "N01321", "N00273", "N01453",
"N02984", "T09417", "T01882", "T08820", "N04689", "T08891", "T00986", "N00316",
"N03544", "N04296", "N04297", "T08234", "N00112", "T00211", "N01497", "N02639",
"N02640", "T00925", "T11683", "T08269", "D00001", "D00002", "D00046", "N00121",
"N00126", "T08626",
],
),
(
Filter::and(vec![
(email::query::Filter::after(1875)),
(email::query::Filter::before(1878)),
]),
vec![
email::query::Comparator::some_in_thread_have_keyword("Bronze"),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"N01559", "N02123", "N01940", "N03594", "N01494", "N04271", "N04326", "N01610",
"N02920", "N01587", "T00167", "T00168", "N01554", "N01535", "N01536", "N01622",
"N01754", "N01594",
],
),
(
Filter::and(vec![
(email::query::Filter::after(1875)),
(email::query::Filter::before(1878)),
]),
vec![
email::query::Comparator::some_in_thread_have_keyword("Bronze").descending(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"N04326", "N01610", "N02920", "N01587", "T00167", "T00168", "N01554", "N01535",
"N01536", "N01622", "N01754", "N01594", "N01559", "N02123", "N01940", "N03594",
"N01494", "N04271",
],
),
(
Filter::and(vec![
(email::query::Filter::after(1786)),
(email::query::Filter::before(1840)),
(email::query::Filter::has_keyword("T")),
]),
vec![
email::query::Comparator::has_keyword("attributed to"),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"T09417", "T08951", "T01851", "T01852", "T08761", "T08123", "T08756", "T10561",
"T10562", "T10563", "T00986", "T03424", "T03427", "T08234", "T08133", "T06866",
"T08897", "T00996", "T00997", "T01095", "T03393", "T09456", "T00188", "T02362",
"T09065", "T09547", "T10330", "T09187", "T03433", "T08635", "T02366", "T03436",
"T09150", "T01861", "T09759", "T11683", "T02368", "T02369", "T08269", "T01018",
"T10066", "T01710", "T01711", "T05764", "T09455", "T09334", "T10965", "T08626",
],
),
(
Filter::and(vec![
(email::query::Filter::after(1786)),
(email::query::Filter::before(1840)),
(email::query::Filter::has_keyword("T")),
]),
vec![
email::query::Comparator::has_keyword("attributed to").descending(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
vec![
"T09455", "T09334", "T10965", "T08626", "T09417", "T08951", "T01851", "T01852",
"T08761", "T08123", "T08756", "T10561", "T10562", "T10563", "T00986", "T03424",
"T03427", "T08234", "T08133", "T06866", "T08897", "T00996", "T00997", "T01095",
"T03393", "T09456", "T00188", "T02362", "T09065", "T09547", "T10330", "T09187",
"T03433", "T08635", "T02366", "T03436", "T09150", "T01861", "T09759", "T11683",
"T02368", "T02369", "T08269", "T01018", "T10066", "T01710", "T01711", "T05764",
],
),
] {
let mut request = client.build();
let query_request = request
.query_email()
.filter(filter.clone())
.sort(sort.clone())
.calculate_total(true);
query_request.arguments().collapse_threads(false);
let query_result_ref = query_request.result_reference();
request
.get_email()
.ids_ref(query_result_ref)
.properties([email::Property::MessageId]);
let results = request
.send()
.await
.unwrap_or_else(|_| panic!("invalid response for {filter:?}"))
.unwrap_method_responses()
.pop()
.unwrap_or_else(|| panic!("invalid response for {filter:?}"))
.unwrap_get_email()
.unwrap_or_else(|_| panic!("invalid response for {filter:?}"))
.take_list()
.into_iter()
.map(|e| e.message_id().unwrap().first().unwrap().to_string())
.collect::<Vec<_>>();
let mut missing = Vec::new();
let mut extra = Vec::new();
for &expected in &expected_results {
if !results.iter().any(|r| r.as_str() == expected) {
missing.push(expected);
}
}
for result in &results {
if !expected_results.contains(&result.as_str()) {
extra.push(result.as_str());
}
}
assert_eq!(
results, expected_results,
"failed test!\nfilter: {filter:?}\nsort: {sort:?}\nmissing: {missing:?}\nextra: {extra:?}"
);
}
}
pub async fn query_options(client: &Client) {
for (query, expected_results, expected_results_collapsed) in [
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: None,
anchor_offset: 0,
limit: 10,
},
vec![
"N01496", "N01320", "N01321", "N05916", "N00273", "N01453", "N02984", "T08820",
"N00112", "T00211",
],
vec![
"N01496", "N01320", "N05916", "N01453", "T08820", "N01046", "N00675", "T08891",
"T01882", "N04296",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 10,
anchor: None,
anchor_offset: 0,
limit: 10,
},
vec![
"N01046", "N00675", "T08891", "N00126", "T01882", "N04689", "T00925", "N00121",
"N04296", "N04297",
],
vec![
"T08234", "T09417", "N01110", "T08123", "N01039", "T09456", "T08951", "N01273",
"N00373", "T09547",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: -10,
anchor: None,
anchor_offset: 0,
limit: 0,
},
vec![
"T07236", "P11481", "AR00066", "P77895", "P77896", "P77897", "AR00163", "AR00164",
"AR00472", "AR00178",
],
vec![
"P07639", "P07522", "AR00089", "P02949", "T05820", "P11441", "T06971", "P11481",
"AR00163", "AR00164",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: -20,
anchor: None,
anchor_offset: 0,
limit: 10,
},
vec![
"P20079", "AR00024", "AR00182", "P20048", "P20044", "P20045", "P20046", "T06971",
"AR00177", "P77935",
],
vec![
"T00300", "P06033", "T02310", "T02135", "P04006", "P03166", "P01358", "P07133",
"P03138", "T03562",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: -100000,
anchor: None,
anchor_offset: 0,
limit: 1,
},
vec!["N01496"],
vec!["N01496"],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: -1,
anchor: None,
anchor_offset: 0,
limit: 100000,
},
vec!["AR00178"],
vec!["AR00164"],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "N01205").await,
anchor_offset: 0,
limit: 10,
},
vec![
"N01205", "N01976", "T01139", "N01525", "T00176", "N01405", "N02396", "N04885",
"N01526", "N02134",
],
vec![
"N01205", "N01526", "T01455", "N01969", "N05250", "N01781", "N00759", "A00057",
"N03527", "N01558",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "N01205").await,
anchor_offset: 10,
limit: 10,
},
vec![
"N01933", "N03618", "T03904", "N02398", "N02399", "N02688", "T01455", "N03051",
"N01500", "N03411",
],
vec![
"N01559", "N04326", "N06017", "N01553", "N01617", "N01528", "N01539", "T09439",
"N01593", "N03988",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "N01205").await,
anchor_offset: -10,
limit: 10,
},
vec![
"T03614", "N05779", "N04652", "N01534", "A00845", "N03409", "N03410", "N02061",
"N02426", "N00662",
],
vec![
"N00436", "N00443", "N02237", "T03025", "N01722", "N01356", "N01800", "T05475",
"T01587", "N05779",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "N01496").await,
anchor_offset: -10,
limit: 10,
},
vec![
"N01496", "N01320", "N01321", "N05916", "N00273", "N01453", "N02984", "T08820",
"N00112", "T00211",
],
vec![
"N01496", "N01320", "N05916", "N01453", "T08820", "N01046", "N00675", "T08891",
"T01882", "N04296",
],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "AR00164").await,
anchor_offset: 10,
limit: 10,
},
vec![],
vec![],
),
(
EmailQuery {
filter: None,
sort: vec![
email::query::Comparator::subject(),
email::query::Comparator::from(),
email::query::Comparator::sent_at(),
],
position: 0,
anchor: get_anchor(client, "AR00164").await,
anchor_offset: 0,
limit: 0,
},
vec!["AR00164", "AR00472", "AR00178"],
vec!["AR00164"],
),
] {
for (test_num, expected_results) in [expected_results, expected_results_collapsed]
.into_iter()
.enumerate()
{
let mut request = client.build();
let query_request = request
.query_email()
.sort(query.sort.clone())
.position(query.position)
.calculate_total(true);
if query.limit > 0 {
query_request.limit(query.limit);
}
if let Some(filter) = query.filter.as_ref() {
query_request.filter(filter.clone());
}
if let Some(anchor) = query.anchor.as_ref() {
query_request.anchor(anchor);
query_request.anchor_offset(query.anchor_offset);
}
query_request.arguments().collapse_threads(test_num == 1);
if !expected_results.is_empty() {
let query_result_ref = query_request.result_reference();
request
.get_email()
.ids_ref(query_result_ref)
.properties([email::Property::MessageId]);
assert_eq!(
request
.send()
.await
.unwrap()
.unwrap_method_responses()
.pop()
.unwrap()
.unwrap_get_email()
.unwrap()
.take_list()
.into_iter()
.map(|e| e.message_id().unwrap().first().unwrap().to_string())
.collect::<Vec<_>>(),
expected_results,
"{:#?} ({})",
query,
test_num == 1
);
} else {
assert_eq!(
request.send_query_email().await.unwrap().ids(),
Vec::<&str>::new()
);
}
}
}
}
pub async fn create(test: &TestServer, account: &Account) {
let sent_at = now();
let now = Instant::now();
let mut fields = AHashMap::default();
for (field_num, field) in FIELDS.iter().enumerate() {
fields.insert(field.to_string(), field_num);
}
let mut total_messages = 0;
let mut total_threads = 0;
let mut thread_count = AHashMap::default();
let mut artist_count = AHashMap::default();
let mut messages = Vec::new();
let mut chunks = Vec::new();
'outer: for (idx, record) in csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(&deflate_test_resource("artwork_data.csv.gz")[..])
.records()
.enumerate()
{
let record = record.unwrap();
let mut values_str = AHashMap::default();
let mut values_int = AHashMap::default();
for field_name in [
"year",
"acquisitionYear",
"accession_number",
"artist",
"artistRole",
"medium",
"title",
"creditLine",
"inscription",
] {
let field = record.get(fields[field_name]).unwrap();
if field.is_empty()
|| (field_name == "title" && (field.contains('[') || field.contains(']')))
{
continue 'outer;
} else if field_name == "year" || field_name == "acquisitionYear" {
let field = field.parse::<i32>().unwrap_or(0);
if field < 1000 {
continue 'outer;
}
values_int.insert(field_name.to_string(), field);
} else {
values_str.insert(field_name.to_string(), field.to_string());
}
}
let val = artist_count
.entry(values_str["artist"].clone())
.or_insert(0);
if *val == 3 {
continue;
}
*val += 1;
match thread_count.entry(values_int["year"]) {
Entry::Occupied(mut e) => {
let messages_per_thread = e.get_mut();
if *messages_per_thread == MAX_MESSAGES_PER_THREAD {
continue;
}
*messages_per_thread += 1;
}
Entry::Vacant(e) => {
if total_threads == MAX_THREADS {
continue;
}
total_threads += 1;
e.insert(1);
}
}
total_messages += 1;
let mut keywords = Vec::new();
for keyword in [
values_str["medium"].to_string(),
values_str["artistRole"].to_string(),
values_str["accession_number"][0..1].to_string(),
format!(
"N{}",
&values_str["accession_number"][values_str["accession_number"].len() - 1..]
),
] {
if keyword == "attributed to"
|| keyword == "T"
|| keyword == "N0"
|| keyword == "N"
|| keyword == "artist"
|| keyword == "Bronze"
{
keywords.push(keyword);
}
}
let message = MessageBuilder::new()
.from((values_str["artist"].as_str(), "[email protected]"))
.cc((values_str["medium"].as_str(), "[email protected]"))
.subject(format!("Year {}", values_int["year"]))
.date(Date::new(sent_at as i64 + idx as i64))
.message_id(values_str["accession_number"].as_str())
.header("References", MessageId::new(values_int["year"].to_string()))
.header("Comments", Text::new(values_str["artistRole"].as_str()))
.text_body(format!(
"{}\n{}\n",
values_str["creditLine"], values_str["inscription"]
))
.attachment("text/plain", "details.txt", values_str["title"].as_bytes())
.write_to_vec()
.unwrap();
messages.push((
message,
[
Id::new(values_int["year"] as u64).to_string(),
Id::new((values_int["acquisitionYear"] + 1000) as u64).to_string(),
],
keywords,
values_int["year"] as i64,
));
if messages.len() == 100 {
chunks.push(messages);
messages = Vec::new();
}
if total_messages == MAX_MESSAGES {
break;
}
}
if !messages.is_empty() {
chunks.push(messages);
}
let mut tasks = Vec::new();
for chunk in chunks {
let client = account.jmap_client().await;
tasks.push(tokio::spawn(async move {
for (raw_message, mailbox_ids, keywords, sent_at) in chunk {
client
.email_import(raw_message, mailbox_ids, keywords.into(), Some(sent_at))
.await
.unwrap();
}
}));
}
for task in tasks {
task.await.unwrap();
}
test.wait_for_tasks().await;
println!(
"Imported {} messages in {} ms (single thread).",
total_messages,
now.elapsed().as_millis()
);
}
async fn get_anchor(client: &Client, anchor: &str) -> Option<String> {
client
.email_query(
email::query::Filter::header("Message-Id", anchor.into()).into(),
None::<Vec<_>>,
)
.await
.unwrap()
.take_ids()
.pop()
.unwrap()
.into()
}
#[derive(Debug, Clone)]
pub struct EmailQuery {
pub filter: Option<Filter<email::query::Filter>>,
pub sort: Vec<Comparator<email::query::Comparator>>,
pub position: i32,
pub anchor: Option<String>,
pub anchor_offset: i32,
pub limit: usize,
}
+337
View File
@@ -0,0 +1,337 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::mail::changes::{LogAction, ParseState},
utils::server::TestServer,
};
use ::email::message::metadata::MessageData;
use common::storage::index::ObjectIndexBuilder;
use jmap_client::{
core::query::{Comparator, Filter},
email,
mailbox::Role,
};
use jmap_proto::types::state::State;
use std::str::FromStr;
use store::{
ValueKey,
ahash::{AHashMap, AHashSet},
write::{AlignedBytes, Archive, BatchBuilder},
};
use types::{
collection::{Collection, SyncCollection},
id::Id,
};
pub async fn test(test: &TestServer) {
println!("Running Email QueryChanges tests...");
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mailbox1_id = client
.mailbox_create("JMAP Changes 1", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let mailbox2_id = client
.mailbox_create("JMAP Changes 2", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let mut states = vec![State::Initial];
let mut id_map = AHashMap::default();
let mut updated_ids = AHashSet::default();
let mut removed_ids = AHashSet::default();
let mut type1_ids = AHashSet::default();
let mut thread_id_map: AHashMap<u32, Id> = AHashMap::default();
let mut thread_id = 100;
for (change_num, change) in [
LogAction::Insert(0),
LogAction::Insert(1),
LogAction::Insert(2),
LogAction::Move(0, 3),
LogAction::Insert(4),
LogAction::Insert(5),
LogAction::Update(1),
LogAction::Update(2),
LogAction::Delete(1),
LogAction::Insert(6),
LogAction::Insert(7),
LogAction::Update(2),
LogAction::Update(4),
LogAction::Update(5),
LogAction::Update(6),
LogAction::Update(7),
LogAction::Delete(4),
LogAction::Delete(5),
LogAction::Delete(6),
LogAction::Insert(8),
LogAction::Insert(9),
LogAction::Insert(10),
LogAction::Update(3),
LogAction::Update(2),
LogAction::Update(8),
LogAction::Move(9, 11),
LogAction::Move(10, 12),
LogAction::Delete(8),
]
.iter()
.enumerate()
{
match &change {
LogAction::Insert(id) => {
let jmap_id = Id::from_str(
client
.email_import(
format!(
"From: test_{}\nSubject: test_{}\n\ntest",
if change_num % 2 == 0 { 1 } else { 2 },
*id
)
.into_bytes(),
[if change_num % 2 == 0 {
&mailbox1_id
} else {
&mailbox2_id
}],
[if change_num % 2 == 0 { "1" } else { "2" }].into(),
Some(*id as i64),
)
.await
.unwrap()
.id()
.unwrap(),
)
.unwrap();
id_map.insert(*id, jmap_id);
if change_num % 2 == 0 {
type1_ids.insert(jmap_id);
}
thread_id_map.entry(jmap_id.prefix_id()).or_insert(jmap_id);
}
LogAction::Update(id) => {
let id = *id_map.get(id).unwrap();
let mut batch = BatchBuilder::new();
batch
.with_document(id.document_id())
.log_item_update(SyncCollection::Email, id.prefix_id().into());
server.store().write(batch.build_all()).await.unwrap();
updated_ids.insert(id);
}
LogAction::Delete(id) => {
let id = *id_map.get(id).unwrap();
client.email_destroy(&id.to_string()).await.unwrap();
removed_ids.insert(id);
}
LogAction::Move(from, to) => {
let id = *id_map.get(from).unwrap();
let new_id = Id::from_parts(thread_id, id.document_id());
//let new_thread_id = store::rand::random::<u32>();
let old_message_ = server
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account.id().document_id(),
Collection::Email,
id.document_id(),
))
.await
.unwrap()
.unwrap();
let old_message = old_message_.to_unarchived::<MessageData>().unwrap();
let mut new_message = old_message.deserialize::<MessageData>().unwrap();
new_message.thread_id = thread_id;
server
.core
.storage
.data
.write(
BatchBuilder::new()
.with_account_id(account.id().document_id())
.with_collection(Collection::Email)
.with_document(id.document_id())
.custom(
ObjectIndexBuilder::new()
.with_current(old_message)
.with_changes(new_message),
)
.unwrap()
.build_all(),
)
.await
.unwrap();
id_map.insert(*to, new_id);
if type1_ids.contains(&id) {
type1_ids.insert(new_id);
}
removed_ids.insert(id);
thread_id_map.insert(new_id.prefix_id(), new_id);
thread_id += 1;
}
LogAction::UpdateChild(_) => unreachable!(),
}
let mut new_state = State::Initial;
for state in &states {
for (test_num, query) in vec![
QueryChanges {
filter: None,
sort: vec![email::query::Comparator::received_at()],
since_query_state: state.clone(),
max_changes: 0,
up_to_id: None,
collapse_threads: false,
},
QueryChanges {
filter: Some(email::query::Filter::from("test_1").into()),
sort: vec![email::query::Comparator::received_at()],
since_query_state: state.clone(),
max_changes: 0,
up_to_id: None,
collapse_threads: false,
},
QueryChanges {
filter: Some(email::query::Filter::in_mailbox(&mailbox1_id).into()),
sort: vec![email::query::Comparator::received_at()],
since_query_state: state.clone(),
max_changes: 0,
up_to_id: None,
collapse_threads: false,
},
QueryChanges {
filter: None,
sort: vec![email::query::Comparator::received_at()],
since_query_state: state.clone(),
max_changes: 0,
up_to_id: id_map
.get(&7)
.map(|id| id.to_string().into())
.unwrap_or(None),
collapse_threads: false,
},
QueryChanges {
filter: None,
sort: vec![email::query::Comparator::received_at()],
since_query_state: state.clone(),
max_changes: 0,
up_to_id: None,
collapse_threads: true,
},
]
.into_iter()
.enumerate()
{
if (test_num == 3 || test_num == 4) && query.up_to_id.is_none() {
continue;
}
if test_num == 4 && !query.collapse_threads {
continue;
}
let mut request = client.build();
let query_request = request
.query_email_changes(query.since_query_state.to_string())
.sort(query.sort);
if let Some(filter) = query.filter {
query_request.filter(filter);
}
if let Some(up_to_id) = query.up_to_id {
query_request.up_to_id(up_to_id);
}
if query.collapse_threads {
query_request.arguments().collapse_threads(true);
}
let changes = request.send_query_email_changes().await.unwrap();
if test_num == 0 || test_num == 1 {
// Immutable filters should not return modified ids, only deletions.
for id in changes.removed() {
let id = Id::from_str(id).unwrap();
assert!(
removed_ids.contains(&id),
"{:?} (id: {:?})",
changes,
id_map.iter().find(|(_, v)| **v == id).map(|(k, _)| k)
);
}
}
if test_num == 1 || test_num == 2 {
// Only type 1 results should be added to the list.
for item in changes.added() {
let id = Id::from_str(item.id()).unwrap();
assert!(
type1_ids.contains(&id),
"{:?} (id: {:?})",
changes,
id_map.iter().find(|(_, v)| **v == id).map(|(k, _)| k)
);
}
}
if test_num == 3 {
// Only ids up to 7 should be added to the list.
for item in changes.added() {
let item_id = Id::from_str(item.id()).unwrap();
let id = id_map.iter().find(|(_, v)| **v == item_id).unwrap().0;
assert!(id <= &7, "{:?} (id: {})", changes, id);
}
}
if test_num == 4 {
// With collapse_threads, only first email per thread should be added.
let mut seen_threads = AHashSet::new();
for item in changes.added() {
let item_id = Id::from_str(item.id()).unwrap();
let thread_id = item_id.prefix_id();
assert!(
seen_threads.insert(thread_id),
"Thread {} appears multiple times with collapse_threads: {:?}",
thread_id,
changes
);
// Verify this is the first email in this thread
assert_eq!(
thread_id_map.get(&thread_id),
Some(&item_id),
"Expected first email in thread {}, got {:?}",
thread_id,
item_id
);
}
}
if let State::Initial = state {
new_state = State::parse_str(changes.new_query_state()).unwrap();
}
}
}
states.push(new_state);
}
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
#[derive(Debug, Clone)]
pub struct QueryChanges {
pub filter: Option<Filter<email::query::Filter>>,
pub sort: Vec<Comparator<email::query::Comparator>>,
pub since_query_state: State,
pub max_changes: usize,
pub up_to_id: Option<String>,
pub collapse_threads: bool,
}
+174
View File
@@ -0,0 +1,174 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use email::mailbox::INBOX_ID;
use jmap_client::{core::query, email::query::Filter};
use std::{fs, path::PathBuf};
use store::ahash::AHashMap;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running SearchSnippet tests...");
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mailbox_id = Id::from(INBOX_ID).to_string();
let mut email_ids = AHashMap::default();
let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_dir.push("resources");
test_dir.push("jmap");
test_dir.push("email_snippet");
// Import test messages
for email_name in [
"html",
"subpart",
"mixed",
"text_plain",
"text_plain_chinese",
] {
let mut file_name = test_dir.clone();
file_name.push(format!("{}.eml", email_name));
let email_id = client
.email_import(
fs::read(&file_name).unwrap(),
[&mailbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
email_ids.insert(email_name, email_id);
}
test.wait_for_tasks().await;
let can_stem = test.server.search_store().internal_fts().is_some();
// Run tests
for (filter, email_name, snippet_subject, snippet_preview) in [
(
query::Filter::or(vec![
query::Filter::or(vec![Filter::subject("friend"), Filter::subject("help")]),
query::Filter::or(vec![Filter::body("secret"), Filter::body("call")]),
]),
"text_plain",
Some("<mark>Help</mark> a <mark>friend</mark> from Abidjan Côte d'Ivoire"),
Some(concat!(
"d'Ivoire. He <mark>secretly</mark> <mark>called</mark> me on his bedside ",
"and told me that he has a sum of $7.5M (Seven Million five Hundred Thousand",
" Dollars) left in a suspense account in a local bank here in Abidjan Côte ",
"d'Ivoire, that he used my name a"
)),
),
(
Filter::text("côte").into(),
"text_plain",
Some("Help a friend from Abidjan <mark>Côte</mark> d'Ivoire"),
Some(concat!(
"in Abidjan <mark>Côte</mark> d'Ivoire. He secretly called me on ",
"his bedside and told me that he has a sum of $7.5M (Seven ",
"Million five Hundred Thousand Dollars) left in a suspense ",
"account in a local bank here in Abidjan <mark>Côte</mark> d'Ivoire, that "
)),
),
(
Filter::text("\"your country\"").into(),
"text_plain",
None,
Some(concat!(
"over to <mark>your</mark> <mark>country</mark> to further my education and ",
"to secure a residential permit for me in <mark>your</mark> <mark>country",
"</mark>. Moreover, I am willing to offer you 30 percent of the total sum as ",
"compensation for your effort inp",
)),
),
(
Filter::text("overseas").into(),
"text_plain",
None,
Some("nominated account <mark>overseas</mark>. "),
),
(
Filter::text("孫子兵法").into(),
"text_plain_chinese",
Some("<mark>孫</mark><mark>子</mark><mark>兵法</mark>"),
Some(concat!(
"&lt;&quot;<mark>孫</mark><mark>子</mark><mark>兵法</mark>&quot;&gt; ",
"<mark>孫</mark><mark>子</mark>曰:兵者,國之大事,死生之地,存亡之道,",
"不可不察也。 <mark>孫</mark><mark>子</mark>曰:凡用兵之法,馳車千駟"
)),
),
(
Filter::text("cia").into(),
"subpart",
None,
Some("shouldn't the <mark>CIA</mark> have something like that? Bill"),
),
(
Filter::text("frösche").into(),
"html",
Some("Die Hasen und die <mark>Frösche</mark>"),
Some(concat!(
"und die <mark>Frösche</mark> Die Hasen klagten einst über ihre mißliche Lage; ",
"&quot;wir leben&quot;, sprach ein Redner, &quot;in steter Furcht vor Menschen und ",
"Tieren, eine Beute der Hunde, der Adler, ja fast aller Raubtiere! ",
"Unsere stete Angst ist är"
)),
),
(
Filter::text(if can_stem {
"es:galería vasto biblioteca"
} else {
"es:galería vastos biblioteca"
})
.into(),
"mixed",
Some("<mark>Biblioteca</mark> de Babel"),
Some(concat!(
"llaman la *<mark>Biblioteca</mark>*) se compone de un número indefinido, y tal ",
"vez infinito, de <mark>galerías</mark> hexagonales, con <mark>vastos</mark> ",
"pozos de ventilación en el medio, cercados por barandas bajísimas. Desde ",
"cualquier hexágono se "
)),
),
] {
let mut request = client.build();
let result_ref = request
.query_email()
.filter(filter.clone())
.result_reference();
request
.get_search_snippet()
.filter(filter)
.email_ids_ref(result_ref);
let response = request
.send()
.await
.unwrap()
.unwrap_method_responses()
.pop()
.unwrap()
.unwrap_get_search_snippet()
.unwrap();
let snippet = response
.snippet(email_ids.get(email_name).unwrap())
.unwrap_or_else(|| panic!("No snippet for {}", email_name));
assert_eq!(snippet_subject, snippet.subject());
assert_eq!(snippet_preview, snippet.preview());
assert!(
snippet.preview().map_or(0, |p| p.len()) <= 255,
"len: {}",
snippet.preview().map_or(0, |p| p.len())
);
}
// Destroy test data
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
+327
View File
@@ -0,0 +1,327 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::{find_values, replace_blob_ids, replace_boundaries, replace_values},
utils::server::TestServer,
};
use ::email::mailbox::INBOX_ID;
use ahash::AHashSet;
use jmap_client::{
Error, Set,
client::Client,
core::set::{SetError, SetErrorType},
email::{self, Email},
mailbox::Role,
};
use registry::schema::prelude::ObjectType;
use std::{fs, path::PathBuf};
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Email Set tests...");
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mailbox_id = Id::from(INBOX_ID).to_string();
create(&client, &mailbox_id).await;
update(&client, &mailbox_id).await;
test.destroy_all_mailboxes(account).await;
test.account("[email protected]")
.registry_destroy_all(ObjectType::SpamTrainingSample)
.await;
test.assert_is_empty().await;
}
async fn create(client: &Client, mailbox_id: &str) {
let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_dir.push("resources");
test_dir.push("jmap");
test_dir.push("email_set");
for file_name in fs::read_dir(&test_dir).unwrap() {
let mut file_name = file_name.as_ref().unwrap().path();
if file_name.extension().is_none_or(|e| e != "json") {
continue;
}
println!("Creating email from {:?}", file_name);
// Upload blobs
let mut json_request = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
let blob_values = find_values(&json_request, "\"blobId\"");
if !blob_values.is_empty() {
let mut blob_ids = Vec::with_capacity(blob_values.len());
for blob_value in &blob_values {
let blob_value = blob_value.replace("\\r", "\r").replace("\\n", "\n");
blob_ids.push(
client
.upload(None, blob_value.into_bytes(), None)
.await
.unwrap()
.take_blob_id(),
);
}
json_request = replace_values(json_request, &blob_values, &blob_ids);
}
// Create message and obtain its blobId
let mut request = client.build();
let mut create_item =
serde_json::from_slice::<Email<Set>>(json_request.as_bytes()).unwrap();
create_item.mailbox_ids([mailbox_id]);
let create_id = request.set_email().create_item(create_item);
let created_email = request
.send_set_email()
.await
.unwrap()
.created(&create_id)
.unwrap();
// Download raw message
let raw_message = client
.download(created_email.blob_id().unwrap())
.await
.unwrap();
// Fetch message
let mut request = client.build();
request
.get_email()
.ids([created_email.id().unwrap()])
.properties([
email::Property::Id,
email::Property::BlobId,
email::Property::ThreadId,
email::Property::MailboxIds,
email::Property::Keywords,
email::Property::ReceivedAt,
email::Property::MessageId,
email::Property::InReplyTo,
email::Property::References,
email::Property::Sender,
email::Property::From,
email::Property::To,
email::Property::Cc,
email::Property::Bcc,
email::Property::ReplyTo,
email::Property::Subject,
email::Property::SentAt,
email::Property::HasAttachment,
email::Property::Preview,
email::Property::BodyValues,
email::Property::TextBody,
email::Property::HtmlBody,
email::Property::Attachments,
email::Property::BodyStructure,
])
.arguments()
.body_properties([
email::BodyProperty::PartId,
email::BodyProperty::BlobId,
email::BodyProperty::Size,
email::BodyProperty::Name,
email::BodyProperty::Type,
email::BodyProperty::Charset,
email::BodyProperty::Headers,
email::BodyProperty::Disposition,
email::BodyProperty::Cid,
email::BodyProperty::Language,
email::BodyProperty::Location,
])
.fetch_all_body_values(true)
.max_body_value_bytes(100);
let email = request
.send_get_email()
.await
.unwrap()
.pop()
.unwrap()
.into_test();
// Compare raw message
file_name.set_extension("eml");
let result = replace_boundaries(String::from_utf8(raw_message).unwrap());
if fs::read(&file_name).unwrap() != result.as_bytes() {
file_name.set_extension("eml_failed");
fs::write(&file_name, result.as_bytes()).unwrap();
panic!("Test failed, output saved to {}", file_name.display());
}
// Compare response
file_name.set_extension("jmap");
let result = replace_blob_ids(replace_boundaries(
serde_json::to_string_pretty(&email).unwrap(),
));
if fs::read(&file_name).unwrap() != result.as_bytes() {
file_name.set_extension("jmap_failed");
fs::write(&file_name, result.as_bytes()).unwrap();
panic!("Test failed, output saved to {}", file_name.display());
}
}
}
async fn update(client: &Client, root_mailbox_id: &str) {
// Obtain all messageIds previously created
let mailbox = client
.email_query(
email::query::Filter::in_mailbox(root_mailbox_id).into(),
None::<Vec<_>>,
)
.await
.unwrap();
// Create two test mailboxes
let test_mailbox1_id = client
.mailbox_create("Test 1", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let test_mailbox2_id = client
.mailbox_create("Test 2", None::<String>, Role::None)
.await
.unwrap()
.take_id();
// Set keywords and mailboxes
let mut request = client.build();
request
.set_email()
.update(mailbox.id(0))
.mailbox_ids([&test_mailbox1_id, &test_mailbox2_id])
.keywords(["test1", "test2"]);
request
.send_set_email()
.await
.unwrap()
.updated(mailbox.id(0))
.unwrap();
assert_email_properties(
client,
mailbox.id(0),
&[&test_mailbox1_id, &test_mailbox2_id],
&["test1", "test2"],
)
.await;
// Patch keywords and mailboxes
let mut request = client.build();
request
.set_email()
.update(mailbox.id(0))
.mailbox_id(&test_mailbox1_id, false)
.keyword("test1", true)
.keyword("test2", false)
.keyword("test3", true);
request
.send_set_email()
.await
.unwrap()
.updated(mailbox.id(0))
.unwrap();
assert_email_properties(
client,
mailbox.id(0),
&[&test_mailbox2_id],
&["test1", "test3"],
)
.await;
// Orphan messages should not be permitted
let mut request = client.build();
request
.set_email()
.update(mailbox.id(0))
.mailbox_id(&test_mailbox2_id, false);
assert!(matches!(
request
.send_set_email()
.await
.unwrap()
.updated(mailbox.id(0)),
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
// Updating and destroying the same item should not be allowed
let mut request = client.build();
let set_email_request = request.set_email();
set_email_request
.update(mailbox.id(0))
.mailbox_id(&test_mailbox2_id, false);
set_email_request.destroy([mailbox.id(0)]);
assert!(matches!(
request
.send_set_email()
.await
.unwrap()
.updated(mailbox.id(0)),
Err(Error::Set(SetError {
type_: SetErrorType::WillDestroy,
..
}))
));
// Delete some messages
let mut request = client.build();
request.set_email().destroy([mailbox.id(1), mailbox.id(2)]);
assert_eq!(
request
.send_set_email()
.await
.unwrap()
.destroyed_ids()
.unwrap()
.count(),
2
);
let mut request = client.build();
request.get_email().ids([mailbox.id(1), mailbox.id(2)]);
assert_eq!(request.send_get_email().await.unwrap().not_found().len(), 2);
// Destroy test mailboxes
client
.mailbox_destroy(&test_mailbox1_id, true)
.await
.unwrap();
client
.mailbox_destroy(&test_mailbox2_id, true)
.await
.unwrap();
}
pub async fn assert_email_properties(
client: &Client,
message_id: &str,
mailbox_ids: &[&str],
keywords: &[&str],
) {
let result = client
.email_get(
message_id,
[email::Property::MailboxIds, email::Property::Keywords].into(),
)
.await
.unwrap()
.unwrap();
assert_eq!(
mailbox_ids.iter().copied().collect::<AHashSet<_>>(),
result
.mailbox_ids()
.iter()
.copied()
.collect::<AHashSet<_>>()
);
assert_eq!(
keywords.iter().copied().collect::<AHashSet<_>>(),
result.keywords().iter().copied().collect::<AHashSet<_>>()
);
}
+581
View File
@@ -0,0 +1,581 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::mail::submission::{MockMessage, assert_message_delivery, spawn_mock_smtp_server},
utils::{dns::DnsCache, server::TestServer, smtp::SmtpConnection},
};
use jmap_client::{
Error,
core::set::{SetError, SetErrorType},
email, mailbox,
sieve::query::{Comparator, Filter},
};
use registry::schema::{prelude::ObjectType, structs::SieveUserScript};
use std::{
fs,
path::PathBuf,
time::{Duration, Instant},
};
pub async fn test(test: &TestServer) {
println!("Running Sieve tests...");
// Create a global script
let admin = test.account("[email protected]");
admin
.registry_create_object(SieveUserScript {
contents: "require \"reject\";\nreject \"Rejected from a global script.\";\nstop;\n"
.into(),
description: None,
is_active: true,
name: "common".into(),
})
.await;
admin.reload_settings().await;
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
// Validate scripts
client
.sieve_script_validate(get_script("validate_ok"))
.await
.unwrap();
assert!(matches!(
client
.sieve_script_validate(get_script("validate_error"))
.await,
Err(Error::Set(SetError {
type_: SetErrorType::InvalidScript,
..
}))
));
// Create 5 Sieve scripts, all deactivated.
let mut script_ids = Vec::new();
for i in 0..5 {
script_ids.push(
client
.sieve_script_create(
format!("script_{}", i + 1),
format!("require \"fileinto\"; fileinto \"{}\";", i + 1).into_bytes(),
false,
)
.await
.unwrap()
.take_id(),
);
}
let response = client
.sieve_script_query(Filter::is_active(false).into(), [Comparator::name()].into())
.await
.unwrap();
assert_eq!(response.ids().len(), 5);
for (pos, id) in response.ids().iter().enumerate() {
let script = client
.sieve_script_get(id, None::<Vec<_>>)
.await
.unwrap()
.unwrap();
assert_eq!(script.name().unwrap(), format!("script_{}", pos + 1));
assert_eq!(
String::from_utf8(client.download(script.blob_id().unwrap()).await.unwrap()).unwrap(),
format!("require \"fileinto\"; fileinto \"{}\";", pos + 1)
);
}
// Activate last script twice and then the first script
for _ in 0..2 {
client
.sieve_script_activate(script_ids.last().unwrap())
.await
.unwrap();
assert_eq!(
client
.sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into())
.await
.unwrap()
.ids(),
vec![script_ids.last().unwrap().to_string()]
);
}
client
.sieve_script_activate(script_ids.first().unwrap())
.await
.unwrap();
assert_eq!(
client
.sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into())
.await
.unwrap()
.ids(),
vec![script_ids.first().unwrap().to_string()]
);
// Destroying an active script should not work
assert!(matches!(
client
.sieve_script_destroy(script_ids.first().unwrap())
.await,
Err(Error::Set(SetError {
type_: SetErrorType::ScriptIsActive,
..
}))
));
// Deactivate all scripts
client.sieve_script_deactivate().await.unwrap();
assert_eq!(
client
.sieve_script_query(Filter::is_active(true).into(), [Comparator::name()].into())
.await
.unwrap()
.ids(),
Vec::<String>::new()
);
// Connect to LMTP service
let mut lmtp = SmtpConnection::connect().await;
// Run mailbox, fileinto, flags tests
client
.sieve_script_create("test_mailbox", get_script("test_mailbox"), true)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
// Make sure all folders were created
let mailbox_names = "My/Nested/Mailbox/with/multiple/levels/Folder"
.split('/')
.collect::<Vec<_>>();
let mut mailbox_ids = Vec::new();
for &mailbox in &mailbox_names {
let mut response = client
.mailbox_query(mailbox::query::Filter::name(mailbox).into(), None::<Vec<_>>)
.await
.unwrap();
assert!(
!response.ids().is_empty(),
"Mailbox {} was not created.",
mailbox
);
mailbox_ids.extend(response.take_ids());
}
assert_eq!(mailbox_ids.len(), mailbox_names.len());
// Make sure the message was delivered to the right folders
let message_ids = client
.email_query(None::<email::query::Filter>, None::<Vec<_>>)
.await
.unwrap()
.take_ids();
assert_eq!(message_ids.len(), 1, "too many messages {:?}", message_ids);
let email = client
.email_get(
message_ids.last().unwrap(),
[email::Property::MailboxIds, email::Property::Keywords].into(),
)
.await
.unwrap()
.unwrap();
assert_eq!(
email.keywords().len(),
2,
"Expected 2 keywords, found {:?}.",
email.keywords()
);
for keyword in ["$important", "$seen"] {
if !email.keywords().contains(&keyword) {
panic!("Keyword {} not found in {:?}.", keyword, email.keywords());
}
}
assert_eq!(
email.mailbox_ids().len(),
3,
"Expected 3 mailbox ids, found {:?}.",
email.mailbox_ids()
);
let drafts_id = client
.mailbox_query(
mailbox::query::Filter::name("Drafts").into(),
None::<Vec<_>>,
)
.await
.unwrap()
.take_ids()
.pop()
.expect("Drafts mailbox not found.");
assert!(
email.mailbox_ids().contains(&drafts_id.as_str()),
"Drafts mailbox {} not found in {:?}.",
drafts_id,
email.mailbox_ids()
);
for mailbox_pos in [mailbox_ids.len() - 1, mailbox_ids.len() - 2] {
if !email
.mailbox_ids()
.contains(&mailbox_ids[mailbox_pos].as_str())
{
panic!(
"Mailbox {} ({}) not found in {:?}.",
mailbox_names[mailbox_pos],
mailbox_ids[mailbox_pos],
email.keywords()
);
}
}
// Run discard and duplicate tests
client
.sieve_script_create(
"test_discard_reject",
get_script("test_discard_reject"),
true,
)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"Bcc: Undisclosed recipients;\r\n",
"Message-ID: <[email protected]>\r\n",
"Subject: Holidays\r\n",
"\r\n",
"Remember to file your TPS reports before ",
"going on holidays."
),
)
.await;
assert_eq!(
client
.email_query(None::<email::query::Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids()
.len(),
1,
"Discard failed."
);
// Let one sec duplicate ids expire
tokio::time::sleep(Duration::from_millis(1100)).await;
// Start mock SMTP server
let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server();
server.ipv4_add(
"localhost",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
// Run reject and duplicate check tests
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"Bcc: Undisclosed recipients;\r\n",
"Message-ID: <[email protected]>\r\n",
"Subject: Holidays\r\n",
"\r\n",
"Remember to file your T.P.S. reports before ",
"going on holidays."
),
)
.await;
assert_eq!(
client
.email_query(None::<email::query::Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids()
.len(),
1,
"Reject failed."
);
assert_message_delivery(
&mut smtp_rx,
MockMessage::new("<>", ["<[email protected]>"], "@No soup for you"),
)
.await;
// Run include tests
client
.sieve_script_create("test_include_this", get_script("test_include_this"), false)
.await
.unwrap();
client
.sieve_script_create("test_include", get_script("test_include"), true)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"Bcc: Undisclosed recipients;\r\n",
"Message-ID: <[email protected]>\r\n",
"Subject: Holidays\r\n",
"\r\n",
"Remember to file your T.P.S. reports before ",
"going on holidays."
),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<>",
["<[email protected]>"],
"@Rejected from an included script",
),
)
.await;
client
.sieve_script_create(
"Test Script",
concat!(
"require \"reject\";\n",
"reject \"Rejected from a mixed-case included script.\";\n",
"stop;\n"
)
.as_bytes()
.to_vec(),
false,
)
.await
.unwrap();
client
.sieve_script_create("test_include_case", get_script("test_include_case"), true)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"Bcc: Undisclosed recipients;\r\n",
"Message-ID: <[email protected]>\r\n",
"Subject: Holidays\r\n",
"\r\n",
"Remember to file your T.P.S. reports before ",
"going on holidays."
),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<>",
["<[email protected]>"],
"@Rejected from a mixed-case included script",
),
)
.await;
// Run include global tests
client
.sieve_script_create(
"test_include_global",
get_script("test_include_global"),
true,
)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"Bcc: Undisclosed recipients;\r\n",
"Message-ID: <[email protected]>\r\n",
"Subject: Holidays\r\n",
"\r\n",
"Remember to file your T.P.S. reports before ",
"going on holidays."
),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<>",
["<[email protected]>"],
"@Rejected from a global script",
),
)
.await;
// Run enclose + redirect tests
client
.sieve_script_create(
"test_redirect_enclose",
get_script("test_redirect_enclose"),
true,
)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<[email protected]>",
["<[email protected]>"],
"@Attached you'll find",
),
)
.await;
assert_eq!(
client
.email_query(None::<email::query::Filter>, None::<Vec<_>>)
.await
.unwrap()
.ids()
.len(),
1,
"Redirected message was stored."
);
// Run notify + editheader + notify + fcc tests
client
.sieve_script_create("test_notify_fcc", get_script("test_notify_fcc"), true)
.await
.unwrap();
smtp_settings.lock().do_stop = true;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Urgently I need those TPS Reports\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<[email protected]>",
["<[email protected]>"],
"@It's TPS-o-clock",
),
)
.await;
let mut request = client.build();
request.get_email().properties([
email::Property::MailboxIds,
email::Property::Keywords,
email::Property::Subject,
]);
let emails = request.send_get_email().await.unwrap().take_list();
assert_eq!(
emails.len(),
3,
"Two new messages were expected: {:#?}.",
emails
);
'outer: for (subject, folder, keywords) in [
("It's TPS-o-clock", "Notifications", ""),
(
"Urgently I need those **censored** Reports",
"Inbox",
"$seen",
),
] {
for email in &emails {
if email.subject().unwrap().eq(subject) {
if !keywords.is_empty() && !email.keywords().contains(&keywords) {
panic!("Keyword {:?} not found in: {:#?}", keywords, email);
}
let mailbox_id = client
.mailbox_query(
mailbox::query::Filter::name(folder.to_string()).into(),
None::<Vec<_>>,
)
.await
.unwrap()
.take_ids()
.pop()
.unwrap_or_else(|| panic!("Mailbox {:?} not found", folder));
if !email.mailbox_ids().contains(&mailbox_id.as_str()) {
panic!(
"Mailbox {:?} ({}) not found in: {:#?}",
folder, mailbox_id, email
);
}
continue 'outer;
}
}
panic!("Email {:?} not found in: {:#?}", subject, emails);
}
// Remove test data
client.sieve_script_deactivate().await.unwrap();
let mut request = client.build();
request.query_sieve_script();
for id in request.send_query_sieve_script().await.unwrap().take_ids() {
client.sieve_script_destroy(&id).await.unwrap();
}
test.destroy_all_mailboxes(account).await;
admin
.registry_destroy_all(ObjectType::SieveUserScript)
.await;
test.assert_is_empty().await;
}
fn get_script(name: &str) -> Vec<u8> {
let mut script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
script_path.push("resources");
script_path.push("jmap");
script_path.push("sieve");
script_path.push(format!("{}.sieve", name));
fs::read(script_path).unwrap()
}
+762
View File
@@ -0,0 +1,762 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::mail::set::assert_email_properties,
utils::{dns::DnsCache, server::TestServer},
};
use ahash::AHashMap;
use common::auth::{AccountCache, EmailAddress};
use jmap_client::{
Error,
client::Client,
core::{
response::IdentityGetResponse,
set::{SetError, SetErrorType, SetObject},
},
email_submission::{Address, Delivered, DeliveryStatus, Displayed, UndoStatus, query::Filter},
mailbox::Role,
};
use mail_parser::DateTime;
use std::{
sync::Arc,
time::{Duration, Instant},
};
use store::{parking_lot::Mutex, write::BatchBuilder};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::TcpListener,
sync::mpsc,
};
use types::{collection::Collection, field::PrincipalField, id::Id};
#[derive(Default, Debug, PartialEq, Eq)]
pub struct MockMessage {
pub mail_from: String,
pub rcpt_to: Vec<String>,
pub message: String,
}
impl MockMessage {
pub fn new<T, U>(mail_from: T, rcpt_to: U, message: T) -> Self
where
T: Into<String>,
U: IntoIterator<Item = T>,
{
Self {
mail_from: mail_from.into(),
rcpt_to: rcpt_to.into_iter().map(|s| s.into()).collect(),
message: message.into(),
}
}
}
#[derive(Default)]
pub struct MockSMTPSettings {
pub fail_mail_from: bool,
pub fail_rcpt_to: bool,
pub fail_message: bool,
pub do_stop: bool,
}
#[allow(clippy::disallowed_types)]
pub async fn test(test: &TestServer) {
println!("Running E-mail submissions tests...");
// Start mock SMTP server
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server();
server.ipv4_add(
"localhost",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + std::time::Duration::from_secs(10),
);
// Test automatic identity creation
for (identity_id, email) in [(2u64, "[email protected]"), (1u64, "[email protected]")] {
let identity = client
.identity_get(&Id::from(identity_id).to_string(), None)
.await
.unwrap()
.unwrap();
assert_eq!(identity.email().unwrap(), email);
assert_eq!(identity.name().unwrap(), "John Doe");
}
// Users should be allowed to create identities only
// using email addresses associated to their principal
let iid1 = client
.identity_create("John Doe", "[email protected]")
.await
.unwrap()
.take_id();
let iid2 = client
.identity_create("John Doe (secondary)", "[email protected]")
.await
.unwrap()
.take_id();
assert!(matches!(
client
.identity_create("John the Spammer", "[email protected]")
.await,
Err(jmap_client::Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
client.identity_destroy(&iid1).await.unwrap();
client.identity_destroy(&iid2).await.unwrap();
// Create an identity without using a valid address should fail
match client
.identity_create("John Doe", "[email protected]")
.await
.unwrap_err()
{
Error::Set(err) => assert_eq!(err.error(), &SetErrorType::InvalidProperties),
err => panic!("Unexpected error: {:?}", err),
}
// Create an identity
let identity_id = client
.identity_create("John Doe (manually created)", "[email protected]")
.await
.unwrap()
.take_id();
// Create test mailboxes
let mailbox_id = client
.mailbox_create("JMAP EmailSubmission", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let mailbox_id_2 = client
.mailbox_create("JMAP EmailSubmission 2", None::<String>, Role::None)
.await
.unwrap()
.take_id();
// Import an email without any recipients
let email_id = client
.email_import(
b"From: [email protected]\nSubject: hey\n\ntest".to_vec(),
[&mailbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
// Submission without a valid emailId or identityId should fail
assert!(matches!(
client
.email_submission_create(Id::new(123456).to_string(), &identity_id)
.await,
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
assert!(matches!(
client
.email_submission_create(&email_id, Id::new(123456).to_string())
.await,
Err(Error::Set(SetError {
type_: SetErrorType::InvalidProperties,
..
}))
));
// Submissions of e-mails without any recipients should fail
assert!(matches!(
client
.email_submission_create(&email_id, &identity_id)
.await,
Err(Error::Set(SetError {
type_: SetErrorType::NoRecipients,
..
}))
));
// Submissions with an envelope that does not match
// the identity from address should fail
assert!(matches!(
client
.email_submission_create_envelope(
&email_id,
&identity_id,
"[email protected]",
Vec::<Address>::new(),
)
.await,
Err(Error::Set(SetError {
type_: SetErrorType::ForbiddenFrom,
..
}))
));
// Submit a valid message submission
let email_body = concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Bcc: [email protected]\r\n",
"Subject: hey\r\n\r\n",
"test"
);
let email_id = client
.email_import(
email_body.as_bytes().to_vec(),
[&mailbox_id],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_id();
client
.email_submission_create(&email_id, &identity_id)
.await
.unwrap();
// Confirm that the message has been delivered
let email_body = email_body.replace("Bcc: [email protected]\r\n", "");
assert_message_delivery(
&mut smtp_rx,
MockMessage::new(
"<[email protected]>",
["<[email protected]>", "<[email protected]>"],
&email_body,
),
)
.await;
// Manually add recipients to the envelope and confirm submission
let email_submission_id = client
.email_submission_create_envelope(
&email_id,
&identity_id,
"[email protected]",
[
"[email protected]", // Should be de-duplicated
"[email protected]",
"[email protected] ",
" james@other_domain.com ", // Should be sanitized
" [email protected] ",
],
)
.await
.unwrap()
.take_id();
for _ in 0..3 {
let mut message = expect_message_delivery(&mut smtp_rx).await;
assert_eq!(message.mail_from, "<[email protected]>");
let rcpt_to = message.rcpt_to.pop().unwrap();
assert!(
[
"<james@other_domain.com>",
"<[email protected]>",
"<[email protected]>",
]
.contains(&rcpt_to.as_str())
);
assert!(
message.message.contains(&email_body),
"Got [{}], Expected[{}]",
message.message,
email_body
);
}
// Confirm that the email submission status was updated
tokio::time::sleep(Duration::from_millis(100)).await;
let email_submission = client
.email_submission_get(&email_submission_id, None)
.await
.unwrap()
.unwrap();
assert_eq!(email_submission.undo_status().unwrap(), &UndoStatus::Final);
assert_eq!(
email_submission.delivery_status().unwrap(),
&AHashMap::from_iter([
(
"[email protected]".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
(
"[email protected]".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
(
"james@other_domain.com".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
])
);
// SMTP rejects some of the recipients
let email_submission_id = client
.email_submission_create_envelope(
&email_id,
&identity_id,
"[email protected]",
[
"[email protected]",
"delay@other_domain.com",
"[email protected]",
"[email protected]",
],
)
.await
.unwrap()
.take_id();
assert_message_delivery(
&mut smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], &email_body),
)
.await;
expect_nothing(&mut smtp_rx).await;
// Verify SMTP replies
tokio::time::sleep(Duration::from_millis(100)).await;
let email_submission = client
.email_submission_get(&email_submission_id, None)
.await
.unwrap()
.unwrap();
assert_eq!(
email_submission.undo_status().unwrap(),
&UndoStatus::Pending
);
assert_eq!(
email_submission.delivery_status().unwrap(),
&AHashMap::from_iter([
(
"[email protected]".to_string(),
DeliveryStatus::new(
"550 5.1.2 Mailbox does not exist.",
Delivered::No,
Displayed::Unknown
)
),
(
"delay@other_domain.com".to_string(),
DeliveryStatus::new(
"Code: 451, Enhanced code: 4.5.3, Message: Try again later.",
Delivered::Queued,
Displayed::Unknown
)
),
(
"[email protected]".to_string(),
DeliveryStatus::new(
"Code: 550, Enhanced code: 0.0.0, Message: I refuse to accept that recipient.",
Delivered::No,
Displayed::Unknown
)
),
(
"[email protected]".to_string(),
DeliveryStatus::new(
"Code: 250, Enhanced code: 0.0.0, Message: OK",
Delivered::Yes,
Displayed::Unknown
)
),
])
);
// Cancel submission
client
.email_submission_change_status(&email_submission_id, UndoStatus::Canceled)
.await
.unwrap();
let email_submission = client
.email_submission_get(&email_submission_id, None)
.await
.unwrap()
.unwrap();
assert_eq!(
email_submission.undo_status().unwrap(),
&UndoStatus::Canceled
);
assert_eq!(
email_submission.delivery_status().unwrap(),
&AHashMap::from_iter([
(
"[email protected]".to_string(),
DeliveryStatus::new(
"550 5.1.2 Mailbox does not exist.",
Delivered::No,
Displayed::Unknown
)
),
(
"delay@other_domain.com".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
(
"[email protected]".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
(
"[email protected]".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Unknown, Displayed::Unknown)
),
])
);
// Confirm that the sendAt property is updated when using FUTURERELEASE
let hold_until_date = "2079-11-20T05:00:00Z";
let hold_until = DateTime::parse_rfc3339(hold_until_date)
.unwrap()
.to_timestamp();
let email_submission_id = client
.email_submission_create_envelope(
&email_id,
&identity_id,
Address::new("[email protected]")
.parameter("HOLDUNTIL", Some(hold_until_date.to_string())),
["[email protected]"],
)
.await
.unwrap()
.take_id();
tokio::time::sleep(Duration::from_millis(100)).await;
let email_submission = client
.email_submission_get(&email_submission_id, None)
.await
.unwrap()
.unwrap();
assert_eq!(email_submission.send_at().unwrap(), hold_until);
assert_eq!(
email_submission.undo_status().unwrap(),
&UndoStatus::Pending
);
assert_eq!(
email_submission.delivery_status().unwrap(),
&AHashMap::from_iter([(
"[email protected]".to_string(),
DeliveryStatus::new("250 2.1.5 Queued", Delivered::Queued, Displayed::Unknown)
),])
);
// Confirm that the query undoStatus filter agrees with EmailSubmission/get
assert!(
client
.email_submission_query(
Filter::undo_status(UndoStatus::Pending).into(),
None::<Vec<_>>
)
.await
.unwrap()
.take_ids()
.contains(&email_submission_id)
);
assert!(
!client
.email_submission_query(
Filter::undo_status(UndoStatus::Final).into(),
None::<Vec<_>>
)
.await
.unwrap()
.take_ids()
.contains(&email_submission_id)
);
// Verify onSuccessUpdateEmail action
let mut request = client.build();
let set_request = request.set_email_submission();
let create_id = set_request
.create()
.email_id(&email_id)
.identity_id(&identity_id)
.create_id()
.unwrap();
set_request
.arguments()
.on_success_update_email(&create_id)
.keyword("$draft", true)
.mailbox_id(&mailbox_id, false)
.mailbox_id(&mailbox_id_2, true);
request.send().await.unwrap().unwrap_method_responses();
assert_email_properties(&client, &email_id, &[&mailbox_id_2], &["$draft"]).await;
// Verify onSuccessDestroyEmail action
let mut request = client.build();
let set_request = request.set_email_submission();
let create_id = set_request
.create()
.email_id(&email_id)
.identity_id(&identity_id)
.create_id()
.unwrap();
set_request.arguments().on_success_destroy_email(&create_id);
request.send().await.unwrap().unwrap_method_responses();
assert!(
client
.email_get(&email_id, None::<Vec<_>>)
.await
.unwrap()
.is_none()
);
smtp_settings.lock().do_stop = true;
// Identities are created and destroyed as the account's addresses change
let account_id = account.id().document_id();
let account_cache = test.server.inner.cache.accounts.get(&account_id).unwrap();
let mut updated_cache = AccountCache::clone(&account_cache);
updated_cache.addresses = updated_cache
.addresses
.iter()
.cloned()
.chain([EmailAddress {
local_part: "jdoe.temp".into(),
domain_id: account_cache.addresses.first().unwrap().domain_id,
}])
.collect();
test.server
.inner
.cache
.accounts
.insert(account_id, Arc::new(updated_cache));
assert!(
identity_id_by_email(&client, "[email protected]")
.await
.is_some(),
"Identity was not created for the new address"
);
test.server
.inner
.cache
.accounts
.insert(account_id, account_cache);
assert!(
identity_id_by_email(&client, "[email protected]")
.await
.is_none(),
"Identity was not destroyed for the removed address"
);
assert!(
identity_id_by_email(&client, "[email protected]")
.await
.is_some(),
"Identity for a valid address was destroyed"
);
// Destroy the created mailbox, identity and all submissions
for identity_id in [
identity_id,
Id::from(1u64).to_string(),
Id::from(2u64).to_string(),
] {
client.identity_destroy(&identity_id).await.unwrap();
}
for id in client
.email_submission_query(None::<Filter>, None::<Vec<_>>)
.await
.unwrap()
.take_ids()
{
let _ = client
.email_submission_change_status(&id, UndoStatus::Canceled)
.await;
client.email_submission_destroy(&id).await.unwrap();
}
test.destroy_all_mailboxes(account).await;
let mut batch = BatchBuilder::new();
batch
.with_account_id(account.id().document_id())
.with_collection(Collection::Principal)
.with_document(0)
.clear(PrincipalField::IdentityAddresses);
test.server.commit_batch(batch).await.unwrap();
test.assert_is_empty().await;
}
pub fn spawn_mock_smtp_server() -> (mpsc::Receiver<MockMessage>, Arc<Mutex<MockSMTPSettings>>) {
// Create channels
let (event_tx, event_rx) = mpsc::channel::<MockMessage>(100);
let _settings = Arc::new(Mutex::new(MockSMTPSettings::default()));
let settings = _settings.clone();
// Start mock SMTP server
tokio::spawn(async move {
let listener = TcpListener::bind("127.0.0.1:9999")
.await
.unwrap_or_else(|e| {
panic!("Failed to bind mock SMTP server to 127.0.0.1:9999: {}", e);
});
while let Ok((mut stream, _)) = listener.accept().await {
let (rx, mut tx) = stream.split();
let mut rx = BufReader::new(rx);
let mut buf = String::with_capacity(128);
let mut message = MockMessage::default();
tx.write_all(b"220 [127.0.0.1] Clueless host service ready\r\n")
.await
.unwrap();
while rx.read_line(&mut buf).await.is_ok() {
//print!("-> {}", buf);
if buf.starts_with("EHLO") {
tx.write_all(b"250 Hi there, but I have no extensions to offer :-(\r\n")
.await
.unwrap();
} else if buf.starts_with("MAIL FROM") {
if settings.lock().fail_mail_from {
tx.write_all("552-I do not\r\n552 like that MAIL FROM.\r\n".as_bytes())
.await
.unwrap();
} else {
message.mail_from = buf.split_once(':').unwrap().1.trim().to_string();
tx.write_all(b"250 OK\r\n").await.unwrap();
}
} else if buf.starts_with("RCPT TO") {
if buf.contains("fail@") {
tx.write_all(
"550-I refuse to\r\n550 accept that recipient.\r\n".as_bytes(),
)
.await
.unwrap();
} else if buf.contains("delay@") {
tx.write_all("451 4.5.3 Try again later.\r\n".as_bytes())
.await
.unwrap();
} else {
message
.rcpt_to
.push(buf.split(':').nth(1).unwrap().trim().to_string());
tx.write_all(b"250 OK\r\n").await.unwrap();
}
} else if buf.starts_with("DATA") {
if settings.lock().fail_message {
tx.write_all(
"503-Thank you but I am\r\n503 saving myself for dessert.\r\n"
.as_bytes(),
)
.await
.unwrap();
} else if !message.mail_from.is_empty() && !message.rcpt_to.is_empty() {
tx.write_all(b"354 Start feeding me now some quality content please\r\n")
.await
.unwrap();
buf.clear();
while rx.read_line(&mut buf).await.is_ok() {
if buf.starts_with('.') && buf.len() < 4 {
message.message = message.message.trim().to_string();
break;
} else {
message.message += buf.as_str();
buf.clear();
}
}
tx.write_all(b"250 Great success!\r\n").await.unwrap();
message.rcpt_to.sort_unstable();
event_tx.send(message).await.unwrap();
message = MockMessage::default();
} else {
tx.write_all("554 You forgot to tell me a few things.\r\n".as_bytes())
.await
.unwrap();
}
} else if buf.starts_with("QUIT") {
tx.write_all("250 Arrivederci!\r\n".as_bytes())
.await
.unwrap();
break;
} else if buf.starts_with("RSET") {
tx.write_all("250 Your wish is my command.\r\n".as_bytes())
.await
.unwrap();
message = MockMessage::default();
} else {
println!("Unknown command: {}", buf.trim());
}
buf.clear();
}
if settings.lock().do_stop {
//println!("Mock SMTP server stopped.");
break;
}
}
});
(event_rx, _settings)
}
pub async fn expect_message_delivery(event_rx: &mut mpsc::Receiver<MockMessage>) -> MockMessage {
match tokio::time::timeout(Duration::from_millis(3000), event_rx.recv()).await {
Ok(Some(message)) => {
//println!("Got message [{}]", message.message);
message
}
result => {
panic!("Timeout waiting for message, got: {:?}", result);
}
}
}
pub async fn assert_message_delivery(
event_rx: &mut mpsc::Receiver<MockMessage>,
expected_message: MockMessage,
) {
let message = expect_message_delivery(event_rx).await;
assert_eq!(message.mail_from, expected_message.mail_from);
assert_eq!(message.rcpt_to, expected_message.rcpt_to);
if let Some(needle) = expected_message.message.strip_prefix('@') {
assert!(
message.message.contains(needle),
"[{}] needle = {:?}",
message.message,
needle
);
} else {
assert!(
message.message.contains(&expected_message.message),
"Got [{}], Expected[{}]",
message.message,
expected_message.message
);
}
}
pub async fn expect_nothing(event_rx: &mut mpsc::Receiver<MockMessage>) {
match tokio::time::timeout(Duration::from_millis(500), event_rx.recv()).await {
Err(_) => {}
message => {
panic!("Received a message when expecting nothing: {:?}", message);
}
}
}
async fn identity_id_by_email(client: &Client, email: &str) -> Option<String> {
let mut request = client.build();
request.get_identity();
request
.send_single::<IdentityGetResponse>()
.await
.unwrap()
.take_list()
.into_iter()
.find(|identity| identity.email() == Some(email))
.map(|mut identity| identity.take_id())
}
+52
View File
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use jmap_client::mailbox::Role;
pub async fn test(test: &TestServer) {
println!("Running Email Thread tests...");
let account = test.account("[email protected]");
let client = account.jmap_client().await;
let mailbox_id = client
.mailbox_create("JMAP Get", None::<String>, Role::None)
.await
.unwrap()
.take_id();
let mut expected_result = vec!["".to_string(); 5];
let mut thread_id = "".to_string();
for num in [5, 3, 1, 2, 4] {
let mut email = client
.email_import(
format!("Subject: test\nReferences: <1234>\n\n{}", num).into_bytes(),
[&mailbox_id],
None::<Vec<String>>,
Some(10000i64 + num as i64),
)
.await
.unwrap();
thread_id = email.thread_id().unwrap().to_string();
expected_result[num - 1] = email.take_id();
}
test.wait_for_tasks().await;
assert_eq!(
client
.thread_get(&thread_id)
.await
.unwrap()
.unwrap()
.email_ids(),
expected_result
);
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
+864
View File
@@ -0,0 +1,864 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
store::deflate_test_resource,
utils::server::{DestroyAllMailboxes, TestServer},
};
use ::email::{
cache::MessageCacheFetch,
mailbox::INBOX_ID,
message::ingest::{EmailIngest, IngestEmail, IngestSource},
};
use common::auth::AccessToken;
use jmap_client::{email, mailbox::Role};
use mail_parser::{MessageParser, mailbox::mbox::MessageIterator};
use std::{io::Cursor, str::FromStr, time::Duration};
use store::{
ahash::AHashSet,
rand::{self, RngExt},
};
use types::id::Id;
pub async fn test(test: &TestServer) {
test_single_thread(test).await;
test_multi_thread(test).await;
}
async fn test_single_thread(test_server: &TestServer) {
println!("Running Email Merge Threads tests...");
let account = test_server.account("[email protected]");
let mut client = account.jmap_client().await;
let mut account_ids = Vec::new();
for name in [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
] {
account_ids.push(test_server.account(name).id_string());
}
for (test_group_num, test) in [test_1(), test_2(), test_3()].iter().enumerate() {
let mut messages = Vec::new();
let mut total_messages = 0;
let mut messages_per_thread =
build_messages(test, &mut messages, &mut total_messages, None, 0);
messages_per_thread.sort_unstable();
let mut mailbox_ids = Vec::with_capacity(6);
for account_id in &account_ids {
mailbox_ids.push(
client
.set_default_account_id(*account_id)
.mailbox_create("Thread nightmare", None::<String>, Role::None)
.await
.unwrap()
.take_id(),
);
}
for message in &messages {
client
.set_default_account_id(account_ids[0])
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[0].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
for message in messages.iter().rev() {
client
.set_default_account_id(account_ids[1])
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[1].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
for chunk in messages.chunks(5) {
client.set_default_account_id(account_ids[2]);
for message in chunk {
client
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[2].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
client.set_default_account_id(account_ids[3]);
for message in chunk.iter().rev() {
client
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[3].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
}
for chunk in messages.chunks(5).rev() {
client.set_default_account_id(account_ids[4]);
for message in chunk {
client
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[4].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
client.set_default_account_id(account_ids[5]);
for message in chunk.iter().rev() {
client
.email_import(
message.to_string().into_bytes(),
[mailbox_ids[5].clone()],
None::<Vec<String>>,
None,
)
.await
.unwrap();
}
}
test_server.wait_for_tasks().await;
for test_num in 0..=5 {
let result = client
.set_default_account_id(account_ids[test_num])
.email_query(
email::query::Filter::in_mailbox(mailbox_ids[test_num].clone()).into(),
None::<Vec<_>>,
)
.await
.unwrap();
assert_eq!(
result.ids().len(),
total_messages,
"test# {}/{}",
test_group_num,
test_num
);
let thread_ids: AHashSet<u32> = result
.ids()
.iter()
.map(|id| Id::from_str(id).unwrap().prefix_id())
.collect();
let mut messages_per_thread_db = Vec::new();
for thread_id in thread_ids {
messages_per_thread_db.push(
client
.thread_get(&Id::new(thread_id as u64).to_string())
.await
.unwrap()
.unwrap()
.email_ids()
.len(),
);
}
messages_per_thread_db.sort_unstable();
assert_eq!(messages_per_thread_db, messages_per_thread);
println!("passed test# {}/{}", test_group_num, test_num);
}
for account_id in &account_ids {
client
.set_default_account_id(*account_id)
.destroy_all_mailboxes()
.await;
}
test_server.wait_for_tasks().await;
test_server.assert_is_empty().await;
}
test_server.assert_is_empty().await;
}
#[allow(dead_code)]
async fn test_multi_thread(test: &TestServer) {
println!("Running Email Merge Threads tests (multi-threaded)...");
let mut handles = vec![];
let account = test.account("[email protected]");
let account_id = account.id().document_id();
let mailbox_id = INBOX_ID;
for message in MessageIterator::new(Cursor::new(deflate_test_resource("mailbox.gz")))
.collect::<Vec<_>>()
.into_iter()
{
let message = message.unwrap();
let server = test.server.clone();
handles.push(tokio::task::spawn(async move {
let mut retry_count = 0;
loop {
match server
.email_ingest(IngestEmail {
raw_message: message.contents(),
message: MessageParser::new().parse(message.contents()),
blob_hash: None,
access_token: &AccessToken::from_id_maybe_invalid(account_id),
mailbox_ids: vec![mailbox_id],
keywords: vec![],
received_at: None,
source: IngestSource::Smtp {
deliver_to: "[email protected]",
is_sender_authenticated: true,
is_spam: false,
},
session_id: 0,
})
.await
{
Ok(_) => break,
Err(err) => {
if err.is_assertion_failure() && retry_count < 10 {
//println!("Retrying ingest for {}...", message.from());
let backoff = rand::rng().random_range(50..=300);
tokio::time::sleep(Duration::from_millis(backoff)).await;
retry_count += 1;
continue;
}
panic!("Failed to ingest message: {:?}", err);
}
}
}
}));
}
// Wait for all tasks to complete
let messages = handles.len();
println!("Waiting for {} tasks to complete...", messages);
for handle in handles {
handle.await.expect("Task panicked");
}
assert_eq!(
messages,
test.server
.get_cached_messages(account_id)
.await
.unwrap()
.emails
.items
.len(),
);
println!("Deleting all messages...");
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
fn build_message(message: usize, in_reply_to: Option<usize>, thread_num: usize) -> String {
if let Some(in_reply_to) = in_reply_to {
format!(
"Message-ID: <{}>\nReferences: <{}>\nSubject: re: T{}\n\nreply\n",
message, in_reply_to, thread_num
)
} else {
format!(
"Message-ID: <{}>\nSubject: T{}\n\nmsg\n",
message, thread_num
)
}
}
fn build_messages(
three: &ThreadTest,
messages: &mut Vec<String>,
total_messages: &mut usize,
in_reply_to: Option<usize>,
thread_num: usize,
) -> Vec<usize> {
let mut messages_per_thread = Vec::new();
match three {
ThreadTest::Message => {
*total_messages += 1;
messages.push(build_message(*total_messages, in_reply_to, thread_num));
}
ThreadTest::MessageWithReplies(replies) => {
*total_messages += 1;
messages.push(build_message(*total_messages, in_reply_to, thread_num));
let in_reply_to = Some(*total_messages);
for reply in replies {
build_messages(reply, messages, total_messages, in_reply_to, thread_num);
}
}
ThreadTest::Root(items) => {
for (thread_num, item) in items.iter().enumerate() {
let count_start = *total_messages;
build_messages(item, messages, total_messages, None, thread_num);
messages_per_thread.push(*total_messages - count_start);
}
}
}
messages_per_thread
}
pub fn build_thread_test_messages() -> Vec<String> {
let mut messages = Vec::new();
let mut total_messages = 0;
build_messages(&test_3(), &mut messages, &mut total_messages, None, 0);
messages
}
pub enum ThreadTest {
Message,
MessageWithReplies(Vec<ThreadTest>),
Root(Vec<ThreadTest>),
}
fn test_1() -> ThreadTest {
ThreadTest::Root(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
]),
]),
]),
]),
])
}
fn test_2() -> ThreadTest {
ThreadTest::Root(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(
vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
]),
ThreadTest::Message,
]),
]),
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
],
)]),
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
]),
]),
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
]),
]),
ThreadTest::Message,
ThreadTest::Message,
])]),
]),
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
ThreadTest::Message,
]),
]),
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]),
])
}
fn test_3() -> ThreadTest {
ThreadTest::Root(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(
vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
])],
)]),
ThreadTest::Message,
ThreadTest::Message,
])]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
]),
]),
]),
]),
]),
ThreadTest::Message,
ThreadTest::Message,
])]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
])]),
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message, ThreadTest::Message]),
ThreadTest::Message,
ThreadTest::Message,
])]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(
vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
],
)]),
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(
vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
],
)]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
])]),
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![ThreadTest::Message]),
ThreadTest::Message,
]),
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
]),
]),
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![
ThreadTest::Message,
ThreadTest::MessageWithReplies(vec![ThreadTest::MessageWithReplies(
vec![ThreadTest::Message, ThreadTest::Message],
)]),
ThreadTest::Message,
]),
ThreadTest::Message,
]),
]),
]),
]),
])
}
+166
View File
@@ -0,0 +1,166 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::mail::submission::{
MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server,
},
utils::{dns::DnsCache, server::TestServer, smtp::SmtpConnection},
};
use chrono::{TimeDelta, Utc};
use std::time::Instant;
pub async fn test(test: &TestServer) {
println!("Running Vacation Response tests...");
// Create test account
let server = test.server.clone();
let account = test.account("[email protected]");
let client = account.jmap_client().await;
// Start mock SMTP server
let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server();
server.ipv4_add(
"localhost",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + std::time::Duration::from_secs(10),
);
// Let people know that we'll be down in Kokomo
client
.vacation_response_enable(
"Off the Florida Keys there's a place called Kokomo",
"That's where you wanna go to get away from it all".into(),
"That's where <b>you wanna go</b> to get away from it all".into(),
)
.await
.unwrap();
// Connect to LMTP service
let mut lmtp = SmtpConnection::connect().await;
// Send a message
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
// Await vacation response
assert_message_delivery(
&mut smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Kokomo"),
)
.await;
// Further messages from the same recipient should not
// trigger a vacation response
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: TPS Report -- friendly reminder\r\n",
"\r\n",
"Listen, are you gonna have those TPS reports for us this afternoon?",
),
)
.await;
expect_nothing(&mut smtp_rx).await;
// Messages from MAILER-DAEMON should not
// trigger a vacation response
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Delivery Failure\r\n",
"\r\n",
"I tried so hard and got so far but in the end it wasn't delivered.",
),
)
.await;
expect_nothing(&mut smtp_rx).await;
// Vacation responses should honor the configured date ranges
client
.vacation_response_set_dates(
(Utc::now() + TimeDelta::try_days(1).unwrap_or_default())
.timestamp()
.into(),
None,
)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: When were you going on holidays?\r\n",
"\r\n",
"I'm asking because Bill really wants those TPS reports.",
),
)
.await;
expect_nothing(&mut smtp_rx).await;
client
.vacation_response_set_dates(
(Utc::now() - TimeDelta::try_days(1).unwrap_or_default())
.timestamp()
.into(),
None,
)
.await
.unwrap();
smtp_settings.lock().do_stop = true;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: When were you going on holidays?\r\n",
"\r\n",
"I'm asking because Bill really wants those TPS reports.",
),
)
.await;
lmtp.quit().await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Kokomo"),
)
.await;
// Remove test data
client.vacation_response_disable().await.unwrap();
client.sieve_script_deactivate().await.unwrap();
let mut request = client.build();
request.query_sieve_script();
for id in request.send_query_sieve_script().await.unwrap().take_ids() {
client.sieve_script_destroy(&id).await.unwrap();
}
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;
}
+285
View File
@@ -0,0 +1,285 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use registry::{
schema::{
enums::{MtaProtocol, Permission},
properties::Property,
structs::{
CalendarAlarm, Expression, ExpressionMatch, Imap, Jmap, MtaExtensions,
MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageAuth, Sharing,
},
},
types::list::List,
};
pub mod calendar;
pub mod compliance;
pub mod contacts;
pub mod core;
pub mod files;
pub mod mail;
pub mod principal;
#[tokio::test(flavor = "multi_thread")]
pub async fn jmap_tests() {
let mut test = TestServerBuilder::new("jmap_tests")
.await
.with_default_listeners()
.await
.build()
.await;
// Create admin account
let admin = test.create_admin_account("[email protected]").await;
// Create test users
for (name, secret, description, aliases) in [
(
"[email protected]",
"12345 + extra safety",
"John Doe",
&["[email protected]"][..],
),
(
"[email protected]",
"abcde + extra safety",
"Jane Smith",
&["[email protected]"],
),
(
"[email protected]",
"098765 + extra safety",
"Bill Foobar",
&["[email protected]"],
),
(
"[email protected]",
"aabbcc + extra safety",
"Robert Foobar",
&[][..],
),
] {
let account = admin
.create_user_account(
name,
secret,
description,
aliases,
vec![Permission::UnlimitedRequests, Permission::UnlimitedUploads],
)
.await;
test.insert_account(account);
}
// Create test group
test.insert_account(
admin
.create_group_account("[email protected]", "Sales Group", &[])
.await,
);
// Add test settings
admin
.registry_create_object(Imap {
allow_plain_text_auth: true,
..Default::default()
})
.await;
admin
.registry_update_setting(
Jmap {
set_max_objects: 100_000,
get_max_results: 100_000,
event_source_throttle: 500u64.into(),
push_throttle: 500u64.into(),
websocket_throttle: 500u64.into(),
push_attempt_wait: 500u64.into(),
..Default::default()
},
&[
Property::SetMaxObjects,
Property::GetMaxResults,
Property::EventSourceThrottle,
Property::PushThrottle,
Property::WebsocketThrottle,
Property::PushAttemptWait,
],
)
.await;
admin
.registry_create_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(CalendarAlarm {
min_trigger_interval: 1000u64.into(),
..Default::default()
})
.await;
admin
.registry_create_object(Sharing {
allow_directory_queries: true,
..Default::default()
})
.await;
admin
.registry_create_object(MtaOutboundStrategy {
route: Expression {
match_: List::from_iter([
ExpressionMatch {
if_: "rcpt_domain == 'example.com'".into(),
then: "'local'".into(),
},
ExpressionMatch {
if_: concat!(
"contains(['remote.org', 'foobar.com', ",
"'test.com', 'other_domain.com'], rcpt_domain)"
)
.into(),
then: "'mock-smtp'".into(),
},
]),
else_: "'mx'".to_string(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
address: "127.0.0.1".into(),
port: 9999,
allow_invalid_certs: true,
implicit_tls: false,
name: "mock-smtp".into(),
protocol: MtaProtocol::Smtp,
..Default::default()
}))
.await;
admin
.registry_create_object(MtaExtensions {
future_release: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "!is_empty(authenticated_as)".into(),
then: "99999999d".into(),
}]),
else_: "false".to_string(),
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.insert_account(admin);
mail::get::test(&test).await;
mail::set::test(&test).await;
mail::parse::test(&test).await;
mail::query::test(&test).await;
mail::search_snippet::test(&test).await;
mail::changes::test(&test).await;
mail::query_changes::test(&test).await;
mail::copy::test(&test).await;
mail::thread_get::test(&test).await;
mail::thread_merge::test(&test).await;
mail::mailbox::test(&test).await;
mail::acl::test(&test).await;
mail::sieve_script::test(&test).await;
mail::vacation_response::test(&test).await;
mail::submission::test(&test).await;
core::event_source::test(&test).await;
core::websocket::test(&test).await;
core::push_subscription::test(&test).await;
core::blob::test(&test).await;
contacts::addressbook::test(&test).await;
contacts::contact::test(&test).await;
contacts::acl::test(&test).await;
files::node::test(&test).await;
files::acl::test(&test).await;
calendar::calendars::test(&test).await;
calendar::event::test(&test).await;
calendar::instance::test(&test).await;
calendar::notification::test(&test).await;
calendar::alarm::test(&test).await;
calendar::identity::test(&test).await;
calendar::acl::test(&test).await;
principal::get::test(&test).await;
principal::availability::test(&test).await;
compliance::test(&test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
pub fn find_values(string: &str, name: &str) -> Vec<String> {
let mut last_pos = 0;
let mut values = Vec::new();
while let Some(pos) = string[last_pos..].find(name) {
let mut value = string[last_pos + pos + name.len()..]
.split('"')
.nth(1)
.unwrap();
if value.ends_with('\\') {
value = &value[..value.len() - 1];
}
values.push(value.to_string());
last_pos += pos + name.len();
}
values
}
pub fn replace_values(mut string: String, find: &[String], replace: &[String]) -> String {
for (find, replace) in find.iter().zip(replace.iter()) {
string = string.replace(find, replace);
}
string
}
pub fn replace_boundaries(string: String) -> String {
let values = find_values(&string, "boundary=");
if !values.is_empty() {
replace_values(
string,
&values,
&(0..values.len())
.map(|i| format!("boundary_{}", i))
.collect::<Vec<_>>(),
)
} else {
string
}
}
pub fn replace_blob_ids(string: String) -> String {
let values = find_values(&string, "blobId\":");
if !values.is_empty() {
replace_values(
string,
&values,
&(0..values.len())
.map(|i| format!("blob_{}", i))
.collect::<Vec<_>>(),
)
} else {
string
}
}
+266
View File
@@ -0,0 +1,266 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
jmap::calendar::event::*,
utils::{
jmap::{IntoJmapSet, JmapUtils},
server::TestServer,
},
};
use calcard::jscalendar::JSCalendarProperty;
use jmap_proto::request::method::MethodObject;
use serde_json::json;
use types::id::Id;
pub async fn test(test: &TestServer) {
println!("Running Principal Availability tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let john_id = john.id_string().to_string();
let jane_id = jane.id_string().to_string();
// Create test calendars
let response = john
.jmap_create(
MethodObject::Calendar,
[json!({
"name": "Test Calendar",
"includeInAvailability": "all"
})],
Vec::<(&str, &str)>::new(),
)
.await;
let calendar1_id = response.created(0).id().to_string();
// Create test events
let event_1 = test_jscalendar_1().with_property(
JSCalendarProperty::<Id>::CalendarIds,
[calendar1_id.as_str()].into_jmap_set(),
);
let event_2 = test_jscalendar_2().with_property(
JSCalendarProperty::<Id>::CalendarIds,
[calendar1_id.as_str()].into_jmap_set(),
);
let event_3 = test_jscalendar_3()
.with_property(
JSCalendarProperty::<Id>::CalendarIds,
[calendar1_id.as_str()].into_jmap_set(),
)
.with_property(
JSCalendarProperty::<Id>::Participants,
json!({
"3f5bc8c0-c722-5345-b7d9-5a899db08a30": {
"calendarAddress": "mailto:[email protected]",
"@type": "Participant",
"roles": {
"attendee": true,
"chair": true
},
"participationStatus": "accepted"
}
}),
);
let response = john
.jmap_create(
MethodObject::CalendarEvent,
[event_1, event_2, event_3],
Vec::<(&str, &str)>::new(),
)
.await;
let _event_1_id = response.created(0).id().to_string();
let _event_2_id = response.created(1).id().to_string();
let event_3_id = response.created(2).id().to_string();
// Jane should not have access to John's availability
let response = jane
.jmap_method_calls(json!([[
"Principal/getAvailability",
{
"accountId": &jane_id,
"id": &john_id,
"utcStart": "2006-01-01T00:00:00Z",
"utcEnd": "2006-01-08T00:00:00Z",
},
"0"
]]))
.await;
response.list_array().assert_is_equal(json!([]));
// Grant Jane free/busy access
john.jmap_update(
MethodObject::Calendar,
[(
&calendar1_id,
json!({
"shareWith": {
&jane_id : {
"mayReadFreeBusy": true,
}
}
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&calendar1_id);
// Jane should see John's availability now
let response = jane
.jmap_method_calls(json!([[
"Principal/getAvailability",
{
"accountId": &jane_id,
"id": &john_id,
"utcStart": "2006-01-01T00:00:00Z",
"utcEnd": "2006-01-08T00:00:00Z",
},
"0"
]]))
.await;
response.list_array().assert_is_equal(json!([
{
"utcStart": "2006-01-02T15:00:00Z",
"utcEnd": "2006-01-02T16:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-02T17:00:00Z",
"utcEnd": "2006-01-02T18:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-03T17:00:00Z",
"utcEnd": "2006-01-03T18:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-04T15:00:00Z",
"utcEnd": "2006-01-04T16:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-04T19:00:00Z",
"utcEnd": "2006-01-04T20:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-05T17:00:00Z",
"utcEnd": "2006-01-05T18:00:00Z",
"busyStatus": "confirmed",
"event": null
},
{
"utcStart": "2006-01-06T19:00:00Z",
"utcEnd": "2006-01-06T20:00:00Z",
"busyStatus": "confirmed",
"event": null
}
]));
// Update availability to none
john.jmap_update(
MethodObject::Calendar,
[(
&calendar1_id,
json!({
"includeInAvailability": "none"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&calendar1_id);
// Jane should not see any events now
let response = jane
.jmap_method_calls(json!([[
"Principal/getAvailability",
{
"accountId": &jane_id,
"id": &john_id,
"utcStart": "2006-01-01T00:00:00Z",
"utcEnd": "2006-01-08T00:00:00Z",
},
"0"
]]))
.await;
response.list_array().assert_is_equal(json!([]));
// Update availability to attending
john.jmap_update(
MethodObject::Calendar,
[(
&calendar1_id,
json!({
"includeInAvailability": "attending"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&calendar1_id);
// Jane should only see events where John is attending
let response = jane
.jmap_method_calls(json!([[
"Principal/getAvailability",
{
"accountId": &jane_id,
"id": &john_id,
"utcStart": "2006-01-01T00:00:00Z",
"utcEnd": "2006-01-08T00:00:00Z",
},
"0"
]]))
.await;
response.list_array().assert_is_equal(json!([
{
"utcStart": "2006-01-04T15:00:00Z",
"utcEnd": "2006-01-04T16:00:00Z",
"busyStatus": "confirmed",
"event": null
}
]));
// Update attending event to not attending
john.jmap_update(
MethodObject::CalendarEvent,
[(
&event_3_id,
json!({
"participants/3f5bc8c0-c722-5345-b7d9-5a899db08a30/participationStatus": "declined"
}),
)],
Vec::<(&str, &str)>::new(),
)
.await
.updated(&event_3_id);
// Jane should not see any events now
let response = jane
.jmap_method_calls(json!([[
"Principal/getAvailability",
{
"accountId": &jane_id,
"id": &john_id,
"utcStart": "2006-01-01T00:00:00Z",
"utcEnd": "2006-01-08T00:00:00Z",
},
"0"
]]))
.await;
response.list_array().assert_is_equal(json!([]));
// Cleanup
john.destroy_all_calendars().await;
test.assert_is_empty().await;
}
+471
View File
@@ -0,0 +1,471 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use jmap_proto::{object::principal::PrincipalProperty, request::method::MethodObject};
use serde_json::json;
use crate::utils::{jmap::JmapUtils, server::TestServer};
pub async fn test(test: &TestServer) {
println!("Running Principal get/query tests...");
let john = test.account("[email protected]");
let jane = test.account("[email protected]");
let bill = test.account("[email protected]");
let sales = test.account("[email protected]");
let john_id = john.id_string();
let jane_id = jane.id_string();
let bill_id = bill.id_string();
let sales_id = sales.id_string();
// Validate session object capabilities
let response = john.jmap_session_object().await.into_inner();
let application_server_key =
response["capabilities"]["urn:ietf:params:jmap:webpush-vapid"]["applicationServerKey"]
.clone();
response.assert_is_equal(json!({
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 4,
"maxSizeRequest": 10000000,
"maxConcurrentRequests": 4,
"maxCallsInRequest": 16,
"maxObjectsInGet": 100000,
"maxObjectsInSet": 100000,
"collationAlgorithms": [
"i;ascii-numeric",
"i;ascii-casemap",
"i;unicode-casemap"
]
},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:calendars": {},
"urn:ietf:params:jmap:calendars:parse": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:contacts:parse": {},
"urn:ietf:params:jmap:emailpush": {},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {},
"urn:ietf:params:jmap:principals:availability": {},
"urn:ietf:params:jmap:submission": {},
"urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:sieve": {
"implementation": "Stalwart v1.0.0"
},
"urn:ietf:params:jmap:blob": {},
"urn:ietf:params:jmap:quota": {},
"urn:ietf:params:jmap:webpush-vapid": {
"applicationServerKey": application_server_key
},
"urn:ietf:params:jmap:websocket": {
"url": "wss://127.0.0.1:8899/jmap/ws",
"supportsPush": true
}
},
"accounts": {
john_id: {
"name": "[email protected]",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": 10,
"maxSizeMailboxName": 255,
"maxSizeAttachmentsPerEmail": 50000000,
"emailQuerySortOptions": [
"receivedAt",
"size",
"from",
"to",
"subject",
"sentAt",
"hasKeyword",
"allInThreadHaveKeyword",
"someInThreadHaveKeyword"
],
"mayCreateTopLevelMailbox": true
},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 2592000,
"submissionExtensions": {
"FUTURERELEASE": [],
"SIZE": [],
"DSN": [],
"DELIVERYBY": [],
"MT-PRIORITY": [
"MIXER"
],
"REQUIRETLS": []
}
},
"urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:contacts": {
"maxAddressBooksPerCard": null,
"mayCreateAddressBook": true
},
"urn:ietf:params:jmap:contacts:parse": {},
"urn:ietf:params:jmap:emailpush": {},
"urn:ietf:params:jmap:calendars": {
"maxCalendarsPerEvent": null,
"minDateTime": "0001-01-01T00:00:00Z",
"maxDateTime": "9999-12-31T23:59:59Z",
"maxExpandedQueryDuration": "P52W1D",
"maxParticipantsPerEvent": 20,
"mayCreateCalendar": true
},
"urn:ietf:params:jmap:calendars:parse": {},
"urn:ietf:params:jmap:websocket": {},
"urn:ietf:params:jmap:sieve": {
"maxSizeScriptName": 512,
"maxSizeScript": 102400,
"maxNumberScripts": 100,
"maxNumberRedirects": 1,
"sieveExtensions": [
"body",
"comparator-elbonia",
"comparator-i;ascii-casemap",
"comparator-i;ascii-numeric",
"comparator-i;octet",
"convert",
"copy",
"date",
"duplicate",
"editheader",
"enclose",
"encoded-character",
"enotify",
"envelope",
"envelope-deliverby",
"envelope-dsn",
"environment",
"ereject",
"extlists",
"extracttext",
"fcc",
"fileinto",
"foreverypart",
"ihave",
"imap4flags",
"imapsieve",
"include",
"index",
"mailbox",
"mailboxid",
"mboxmetadata",
"mime",
"redirect-deliverby",
"redirect-dsn",
"regex",
"reject",
"relational",
"replace",
"servermetadata",
"spamtest",
"spamtestplus",
"special-use",
"subaddress",
"vacation",
"vacation-seconds",
"variables",
"virustest"
],
"notificationMethods": [
"mailto"
],
"externalLists": null
},
"urn:ietf:params:jmap:blob": {
"maxSizeBlobSet": 7499488,
"maxDataSources": 16,
"supportedTypeNames": [
"Email",
"Thread",
"SieveScript"
],
"supportedDigestAlgorithms": [
"sha",
"sha-256",
"sha-512"
]
},
"urn:ietf:params:jmap:quota": {},
"urn:ietf:params:jmap:principals": {
"currentUserPrincipalId": john_id
},
"urn:ietf:params:jmap:principals:availability": {
"maxAvailabilityDuration": "P52W1D",
},
"urn:ietf:params:jmap:filenode": {
"maxFileNodeDepth": null,
"maxSizeFileNodeName": 255,
"forbiddenNameChars": "/<>:\"\\|?*",
"forbiddenNodeNames": [
".",
"..",
"CON",
"PRN",
"AUX",
"NUL",
"COM0",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT0",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9"
],
"fileNodeQuerySortOptions": [
"name",
"size",
"nodeType"
],
"mayCreateTopLevelFileNode": true,
"caseInsensitiveNames": false,
"webTrashUrl": null,
"webUrlTemplate": null,
"webWriteUrlTemplate": null
},
"urn:ietf:params:jmap:mail:share": {},
"urn:stalwart:jmap": {}
}
}
},
"primaryAccounts": {
"urn:ietf:params:jmap:mail": john_id,
"urn:ietf:params:jmap:submission": john_id,
"urn:ietf:params:jmap:vacationresponse": john_id,
"urn:ietf:params:jmap:contacts": john_id,
"urn:ietf:params:jmap:contacts:parse": john_id,
"urn:ietf:params:jmap:emailpush": john_id,
"urn:ietf:params:jmap:calendars": john_id,
"urn:ietf:params:jmap:calendars:parse": john_id,
"urn:ietf:params:jmap:websocket": john_id,
"urn:ietf:params:jmap:sieve": john_id,
"urn:ietf:params:jmap:blob": john_id,
"urn:ietf:params:jmap:quota": john_id,
"urn:ietf:params:jmap:principals": john_id,
"urn:ietf:params:jmap:principals:availability": john_id,
"urn:ietf:params:jmap:filenode": john_id,
"urn:ietf:params:jmap:mail:share": john_id,
"urn:stalwart:jmap": john_id
},
"username": "[email protected]",
"apiUrl": "https://127.0.0.1:8899/jmap/",
"downloadUrl":
"https://127.0.0.1:8899/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl":
"https://127.0.0.1:8899/jmap/upload/{accountId}/",
"eventSourceUrl":
"https://127.0.0.1:8899/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"state": response.text_field("state")
}));
// Obtain principal ids for Jane, Bill and the sales group
let response = john
.jmap_query(
MethodObject::Principal,
[("email", "[email protected]")],
["name"],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.ids().collect::<Vec<_>>(), [john_id]);
let response = john
.jmap_query(
MethodObject::Principal,
[("name", "[email protected]")],
["name"],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.ids().collect::<Vec<_>>(), [bill_id]);
let response = john
.jmap_query(
MethodObject::Principal,
[("accountIds", [jane_id])],
["name"],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.ids().collect::<Vec<_>>(), [jane_id]);
let response = john
.jmap_query(
MethodObject::Principal,
[("text", "sales group")],
["name"],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(response.ids().collect::<Vec<_>>(), [sales_id]);
// Validate principal contents
let response = john
.jmap_get(
MethodObject::Principal,
[
PrincipalProperty::Id,
PrincipalProperty::Type,
PrincipalProperty::Email,
PrincipalProperty::Description,
PrincipalProperty::Name,
PrincipalProperty::Timezone,
PrincipalProperty::Capabilities,
PrincipalProperty::Accounts,
],
[john_id, jane_id, bill_id, sales_id],
)
.await;
let list = response.list();
assert_eq!(list.len(), 4);
list[0].assert_is_equal(json!({
"id": john_id,
"type": "individual",
"email": "[email protected]",
"description": "John Doe",
"name": "[email protected]",
"timezone": null,
"capabilities": {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {}
},
"accounts": {
john_id: {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {
"accountId": john_id,
"mayGetAvailability": true,
"mayShareWith": true,
"calendarAddress": "mailto:[email protected]"
},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {},
"urn:ietf:params:jmap:principals:owner": {
"accountIdForPrincipal": john_id,
"principalId": john_id
}
}
}
}));
list[1].assert_is_equal(json!({
"id": jane_id,
"type": "individual",
"email": "[email protected]",
"description": "Jane Smith",
"name": "[email protected]",
"timezone": null,
"capabilities": {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {}
},
"accounts": {
jane_id: {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {
"accountId": jane_id,
"mayGetAvailability": true,
"mayShareWith": true,
"calendarAddress": "mailto:[email protected]"
},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {},
"urn:ietf:params:jmap:principals:owner": {
"accountIdForPrincipal": jane_id,
"principalId": jane_id
}
}
}
}));
list[2].assert_is_equal(json!({
"id": bill_id,
"type": "individual",
"email": "[email protected]",
"description": "Bill Foobar",
"name": "[email protected]",
"timezone": null,
"capabilities": {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {}
},
"accounts": {
bill_id: {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {
"accountId": bill_id,
"mayGetAvailability": true,
"mayShareWith": true,
"calendarAddress": "mailto:[email protected]"
},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {},
"urn:ietf:params:jmap:principals:owner": {
"accountIdForPrincipal": bill_id,
"principalId": bill_id
}
}
}
}));
list[3].assert_is_equal(json!({
"id": sales_id,
"type": "group",
"email": "[email protected]",
"description": "Sales Group",
"name": "[email protected]",
"timezone": null,
"capabilities": {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {}
},
"accounts": {
sales_id: {
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:calendars": {
"accountId": sales_id,
"mayGetAvailability": true,
"mayShareWith": true,
"calendarAddress": "mailto:[email protected]"
},
"urn:ietf:params:jmap:filenode": {},
"urn:ietf:params:jmap:principals": {},
"urn:ietf:params:jmap:principals:owner": {
"accountIdForPrincipal": sales_id,
"principalId": sales_id
}
}
}
}));
}
+8
View File
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod availability;
pub mod get;