Push subscription improvements + CalendarAlert implementation (closes #1248)

This commit is contained in:
mdecimus
2025-10-19 18:43:47 +02:00
parent 496d866052
commit a4720e1efe
55 changed files with 2196 additions and 1226 deletions

View File

@@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
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;
use crate::jmap::{IntoJmapSet, JMAPTest, JmapUtils};
pub async fn test(params: &mut JMAPTest) {
println!("Running Calendar Alarm tests...");
let account = params.account("jdoe@example.com");
let account_id = account.id_string();
let client = account.client();
let client_ws = account.client_owned().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 {
stream_tx.send(change.unwrap()).await.unwrap();
}
});
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;
params.assert_is_empty().await;
}

View File

@@ -5,6 +5,7 @@
*/
pub mod acl;
pub mod alarm;
pub mod calendars;
pub mod event;
pub mod identity;

View File

@@ -7,7 +7,11 @@
use crate::jmap::{JMAPTest, mail::delivery::SmtpConnection};
use email::mailbox::INBOX_ID;
use futures::StreamExt;
use jmap_client::{TypeState, event_source::Changes, mailbox::Role};
use jmap_client::{
DataType,
event_source::{Changes, PushNotification},
mailbox::Role,
};
use std::time::Duration;
use store::ahash::AHashSet;
use tokio::sync::mpsc;
@@ -29,7 +33,13 @@ pub async fn test(params: &mut JMAPTest) {
tokio::spawn(async move {
while let Some(change) = changes.next().await {
if let Err(_err) = event_tx.send(change.unwrap()).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;
}
@@ -44,7 +54,7 @@ pub async fn test(params: &mut JMAPTest) {
.await
.unwrap()
.take_id();
assert_state(&mut event_rx, account.id_string(), &[TypeState::Mailbox]).await;
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 {
@@ -53,7 +63,7 @@ pub async fn test(params: &mut JMAPTest) {
.await
.unwrap();
}
assert_state(&mut event_rx, account.id_string(), &[TypeState::Mailbox]).await;
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
@@ -77,10 +87,10 @@ pub async fn test(params: &mut JMAPTest) {
&mut event_rx,
account.id_string(),
&[
TypeState::EmailDelivery,
TypeState::Email,
TypeState::Thread,
TypeState::Mailbox,
DataType::EmailDelivery,
DataType::Email,
DataType::Thread,
DataType::Mailbox,
],
)
.await;
@@ -88,7 +98,7 @@ pub async fn test(params: &mut JMAPTest) {
// Destroy mailbox
client.mailbox_destroy(&mailbox_id, true).await.unwrap();
assert_state(&mut event_rx, account.id_string(), &[TypeState::Mailbox]).await;
assert_state(&mut event_rx, account.id_string(), &[DataType::Mailbox]).await;
// Destroy Inbox
client
@@ -98,7 +108,7 @@ pub async fn test(params: &mut JMAPTest) {
assert_state(
&mut event_rx,
account.id_string(),
&[TypeState::Email, TypeState::Thread, TypeState::Mailbox],
&[DataType::Email, DataType::Thread, DataType::Mailbox],
)
.await;
assert_ping(&mut event_rx).await;
@@ -111,7 +121,7 @@ pub async fn test(params: &mut JMAPTest) {
async fn assert_state(
event_rx: &mut mpsc::Receiver<Changes>,
account_id: &str,
state: &[TypeState],
state: &[DataType],
) {
match tokio::time::timeout(Duration::from_millis(700), event_rx.recv()).await {
Ok(Some(changes)) => {
@@ -120,8 +130,8 @@ async fn assert_state(
.changes(account_id)
.unwrap()
.map(|x| x.0)
.collect::<AHashSet<&TypeState>>(),
state.iter().collect::<AHashSet<&TypeState>>()
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}
result => {

View File

@@ -12,7 +12,7 @@ use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body};
use hyper::{StatusCode, body, header::CONTENT_ENCODING, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use jmap_client::{mailbox::Role, push_subscription::Keys};
use jmap_proto::response::status::StateChangeResponse;
use jmap_proto::{response::status::PushObject, types::state::State};
use services::state_manager::ece::ece_encrypt;
use std::{
sync::{
@@ -24,7 +24,7 @@ use std::{
use store::ahash::AHashSet;
use tokio::sync::mpsc;
use types::{id::Id, type_state::DataType};
use utils::config::Config;
use utils::{config::Config, map::vec_map::VecMap};
const SERVER: &str = r#"
[server]
@@ -125,7 +125,7 @@ pub async fn test(params: &mut JMAPTest) {
// Receive states just for the requested types
client
.push_subscription_update_types(&push_id, [jmap_client::TypeState::Email].into())
.push_subscription_update_types(&push_id, [jmap_client::DataType::Email].into())
.await
.unwrap();
client
@@ -224,15 +224,15 @@ pub struct PushServer {
#[derive(serde::Deserialize, Debug)]
#[serde(untagged)]
enum PushMessage {
StateChange(StateChangeResponse),
PushObject(PushObject),
Verification(PushVerification),
}
impl PushMessage {
pub fn unwrap_state_change(self) -> StateChangeResponse {
pub fn unwrap_state_change(self) -> VecMap<Id, VecMap<DataType, State>> {
match self {
PushMessage::StateChange(state_change) => state_change,
_ => panic!("Expected StateChange"),
PushMessage::PushObject(PushObject::StateChange { changed }) => changed,
_ => panic!("Expected PushObject"),
}
}
@@ -348,7 +348,6 @@ async fn assert_state(event_rx: &mut mpsc::Receiver<PushMessage>, id: &Id, state
expect_push(event_rx)
.await
.unwrap_state_change()
.changed
.get(id)
.unwrap()
.iter()

View File

@@ -8,7 +8,7 @@ use crate::jmap::JMAPTest;
use ahash::AHashSet;
use futures::StreamExt;
use jmap_client::{
TypeState,
DataType, PushObject,
client_ws::WebSocketMessage,
core::{
response::{Response, TaggedMethodResponse},
@@ -66,7 +66,7 @@ pub async fn test(params: &mut JMAPTest) {
.mailbox_update_sort_order(&mailbox_id, 1)
.await
.unwrap();
assert_state(&mut stream_rx, account.id_string(), &[TypeState::Mailbox]).await;
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 {
@@ -76,7 +76,7 @@ pub async fn test(params: &mut JMAPTest) {
.unwrap();
}
tokio::time::sleep(Duration::from_millis(500)).await;
assert_state(&mut stream_rx, account.id_string(), &[TypeState::Mailbox]).await;
assert_state(&mut stream_rx, account.id_string(), &[DataType::Mailbox]).await;
expect_nothing(&mut stream_rx).await;
// Disable push notifications
@@ -117,18 +117,18 @@ async fn expect_response(
async fn assert_state(
stream_rx: &mut mpsc::Receiver<WebSocketMessage>,
id: &str,
state: &[TypeState],
state: &[DataType],
) {
match tokio::time::timeout(Duration::from_millis(700), stream_rx.recv()).await {
Ok(Some(message)) => match message {
WebSocketMessage::StateChange(changes) => {
WebSocketMessage::PushNotification(PushObject::StateChange { changed }) => {
assert_eq!(
changes
.changes(id)
changed
.get(id)
.unwrap()
.map(|x| x.0)
.collect::<AHashSet<&TypeState>>(),
state.iter().collect::<AHashSet<&TypeState>>()
.keys()
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}
_ => panic!("Expected state change, got: {:?}", message),

View File

@@ -78,11 +78,12 @@ async fn jmap_tests() {
)
.await;
/*server::webhooks::test(&mut params).await;
mail::query::test(&mut params, delete).await;
server::webhooks::test(&mut params).await;
mail::get::test(&mut params).await;
mail::set::test(&mut params).await;
mail::parse::test(&mut params).await;
mail::query::test(&mut params, delete).await;
mail::search_snippet::test(&mut params).await;
mail::changes::test(&mut params).await;
mail::query_changes::test(&mut params).await;
@@ -92,20 +93,20 @@ async fn jmap_tests() {
mail::mailbox::test(&mut params).await;
mail::delivery::test(&mut params).await;
mail::acl::test(&mut params).await;
auth::limits::test(&mut params).await;
auth::oauth::test(&mut params).await;
core::event_source::test(&mut params).await;
core::push_subscription::test(&mut params).await;
mail::sieve_script::test(&mut params).await;
mail::vacation_response::test(&mut params).await;
mail::submission::test(&mut params).await;
core::websocket::test(&mut params).await;
auth::quota::test(&mut params).await;
mail::crypto::test(&mut params).await;
core::event_source::test(&mut params).await;
core::websocket::test(&mut params).await;
core::push_subscription::test(&mut params).await;
core::blob::test(&mut params).await;
auth::limits::test(&mut params).await;
auth::oauth::test(&mut params).await;
auth::quota::test(&mut params).await;
auth::permissions::test(&params).await;
server::purge::test(&mut params).await;
server::enterprise::test(&mut params).await;*/
contacts::addressbook::test(&mut params).await;
contacts::contact::test(&mut params).await;
@@ -117,12 +118,17 @@ async fn jmap_tests() {
calendar::calendars::test(&mut params).await;
calendar::event::test(&mut params).await;
calendar::notification::test(&mut params).await;
calendar::alarm::test(&mut params).await;
calendar::identity::test(&mut params).await;
calendar::acl::test(&mut params).await;
principal::get::test(&mut params).await;
principal::availability::test(&mut params).await;
server::purge::test(&mut params).await;
server::enterprise::test(&mut params).await;
if delete {
params.temp_dir.delete();
}
@@ -1691,6 +1697,9 @@ enable = true
[sharing]
allow-directory-query = true
[calendar.alarms]
minimum-interval = "1s"
[tracer.console]
type = "console"
level = "{LEVEL}"