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:
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
use aes_gcm::{Aes128Gcm, Key, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use p256::{
|
||||
PublicKey,
|
||||
ecdh::EphemeralSecret,
|
||||
elliptic_curve::{rand_core::OsRng, sec1::ToEncodedPoint},
|
||||
};
|
||||
use sha2::Sha256;
|
||||
use store::rand::RngExt;
|
||||
|
||||
/*
|
||||
|
||||
From https://github.com/mozilla/rust-ece (MPL-2.0 license)
|
||||
Adapted to use 'aes-gcm' and 'p256' crates instead of 'openssl'.
|
||||
|
||||
*/
|
||||
|
||||
const ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX: &str = "WebPush: info\0";
|
||||
const ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH: usize = 144;
|
||||
const ECE_WEBPUSH_IKM_LENGTH: usize = 32;
|
||||
const ECE_WEBPUSH_PUBLIC_KEY_LENGTH: usize = 65;
|
||||
const ECE_WEBPUSH_DEFAULT_RS: u32 = 4096;
|
||||
const ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE: usize = 128;
|
||||
|
||||
const ECE_AES128GCM_PAD_SIZE: usize = 1;
|
||||
const ECE_AES128GCM_KEY_INFO: &str = "Content-Encoding: aes128gcm\0";
|
||||
const ECE_AES128GCM_NONCE_INFO: &str = "Content-Encoding: nonce\0";
|
||||
const ECE_AES128GCM_HEADER_LENGTH: usize = 21;
|
||||
const ECE_AES_KEY_LENGTH: usize = 16;
|
||||
|
||||
const ECE_NONCE_LENGTH: usize = 12;
|
||||
const ECE_TAG_LENGTH: usize = 16;
|
||||
|
||||
pub(crate) const WEBPUSH_MAX_BODY_SIZE: usize = 4096;
|
||||
|
||||
pub(crate) const ECE_WEBPUSH_MAX_PLAINTEXT_SIZE: usize = {
|
||||
let single_record = ECE_WEBPUSH_DEFAULT_RS as usize - ECE_TAG_LENGTH;
|
||||
let wire_budget = WEBPUSH_MAX_BODY_SIZE
|
||||
- (ECE_AES128GCM_HEADER_LENGTH + ECE_WEBPUSH_PUBLIC_KEY_LENGTH)
|
||||
- ECE_TAG_LENGTH;
|
||||
let budget = if wire_budget < single_record {
|
||||
wire_budget
|
||||
} else {
|
||||
single_record
|
||||
};
|
||||
|
||||
budget / ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE * ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE
|
||||
- ECE_AES128GCM_PAD_SIZE
|
||||
};
|
||||
|
||||
pub fn ece_encrypt(
|
||||
p256dh: &[u8],
|
||||
client_auth_secret: &[u8],
|
||||
mut data: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let salt = store::rand::rng().random::<[u8; 16]>();
|
||||
let server_secret = EphemeralSecret::random(&mut OsRng);
|
||||
let server_public_key = server_secret.public_key();
|
||||
let server_public_key_bytes = server_public_key.to_encoded_point(false);
|
||||
|
||||
let client_public_key = PublicKey::from_sec1_bytes(p256dh).map_err(|e| e.to_string())?;
|
||||
let shared_secret = server_secret.diffie_hellman(&client_public_key);
|
||||
|
||||
let ikm_info = generate_info(p256dh, server_public_key_bytes.as_bytes());
|
||||
let ikm = hkdf_sha256(
|
||||
client_auth_secret,
|
||||
&shared_secret.raw_secret_bytes()[..],
|
||||
&ikm_info,
|
||||
ECE_WEBPUSH_IKM_LENGTH,
|
||||
)?;
|
||||
let key = hkdf_sha256(
|
||||
&salt,
|
||||
&ikm,
|
||||
ECE_AES128GCM_KEY_INFO.as_bytes(),
|
||||
ECE_AES_KEY_LENGTH,
|
||||
)?;
|
||||
let nonce = hkdf_sha256(
|
||||
&salt,
|
||||
&ikm,
|
||||
ECE_AES128GCM_NONCE_INFO.as_bytes(),
|
||||
ECE_NONCE_LENGTH,
|
||||
)?;
|
||||
|
||||
// Calculate pad length
|
||||
let mut pad_length = ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE
|
||||
- (data.len() % ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE);
|
||||
if pad_length < ECE_AES128GCM_PAD_SIZE {
|
||||
pad_length += ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
// Split into records
|
||||
let rs = ECE_WEBPUSH_DEFAULT_RS as usize - ECE_TAG_LENGTH;
|
||||
let mut min_num_records = data.len() / (rs - 1);
|
||||
if !data.len().is_multiple_of(rs - 1) {
|
||||
min_num_records += 1;
|
||||
}
|
||||
let mut pad_length = std::cmp::max(pad_length, min_num_records);
|
||||
let total_size = data.len() + pad_length;
|
||||
let mut num_records = total_size / rs;
|
||||
let size_of_final_record = total_size % rs;
|
||||
if size_of_final_record > 0 {
|
||||
num_records += 1;
|
||||
}
|
||||
let data_per_record = data.len() / num_records;
|
||||
let mut extra_data = data.len() % num_records;
|
||||
if size_of_final_record > 0 && data_per_record > size_of_final_record - 1 {
|
||||
extra_data += data_per_record - (size_of_final_record - 1)
|
||||
}
|
||||
let mut sequence_number = 0;
|
||||
let mut plain_text =
|
||||
Vec::with_capacity(data_per_record + ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE);
|
||||
|
||||
// Write header
|
||||
let key_id = server_public_key_bytes.as_bytes();
|
||||
debug_assert_eq!(key_id.len(), ECE_WEBPUSH_PUBLIC_KEY_LENGTH);
|
||||
let mut output = Vec::with_capacity(
|
||||
ECE_AES128GCM_HEADER_LENGTH + key_id.len() + total_size + num_records * ECE_TAG_LENGTH,
|
||||
);
|
||||
output.extend_from_slice(&salt);
|
||||
output.extend_from_slice(&ECE_WEBPUSH_DEFAULT_RS.to_be_bytes());
|
||||
output.push(key_id.len() as u8);
|
||||
output.extend_from_slice(key_id);
|
||||
|
||||
loop {
|
||||
let records_remaining = num_records - sequence_number;
|
||||
if records_remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let mut data_share = data_per_record;
|
||||
if data_share > data.len() {
|
||||
data_share = data.len();
|
||||
} else if extra_data > 0 {
|
||||
let mut extra_share = extra_data / (records_remaining - 1);
|
||||
if !extra_data.is_multiple_of(records_remaining - 1) {
|
||||
extra_share += 1;
|
||||
}
|
||||
data_share += extra_share;
|
||||
extra_data -= extra_share;
|
||||
}
|
||||
|
||||
let cur_data = &data[0..data_share];
|
||||
data = &data[data_share..];
|
||||
let padding = std::cmp::min(pad_length, rs - data_share);
|
||||
pad_length -= padding;
|
||||
let cur_sequence_number = sequence_number;
|
||||
sequence_number += 1;
|
||||
|
||||
let padded_plaintext_len = cur_data.len() + padding;
|
||||
|
||||
plain_text.extend_from_slice(cur_data);
|
||||
plain_text.push(if sequence_number == num_records { 2 } else { 1 });
|
||||
plain_text.resize(padded_plaintext_len, 0);
|
||||
|
||||
output.extend_from_slice(&aes_gcm_128_encrypt(
|
||||
&key,
|
||||
&generate_iv(&nonce, cur_sequence_number),
|
||||
&plain_text,
|
||||
)?);
|
||||
plain_text.clear();
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn hkdf_sha256(salt: &[u8], secret: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>, String> {
|
||||
let (_, hk) = Hkdf::<Sha256>::extract(Some(salt), secret);
|
||||
let mut okm = vec![0u8; len];
|
||||
hk.expand(info, &mut okm).map_err(|e| e.to_string())?;
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
fn aes_gcm_128_encrypt(key: &[u8], nonce: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let key: &Key<Aes128Gcm> = key
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid AES-GCM key length".to_string())?;
|
||||
let nonce: &Nonce<_> = nonce
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid AES-GCM nonce length".to_string())?;
|
||||
|
||||
<Aes128Gcm as aes_gcm::KeyInit>::new(key)
|
||||
.encrypt(nonce, data)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn generate_info(
|
||||
client_public_key: &[u8],
|
||||
server_public_key: &[u8],
|
||||
) -> [u8; ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH] {
|
||||
let mut info = [0u8; ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH];
|
||||
let prefix = ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX.as_bytes();
|
||||
let mut offset = prefix.len();
|
||||
info[0..offset].copy_from_slice(prefix);
|
||||
info[offset..offset + ECE_WEBPUSH_PUBLIC_KEY_LENGTH].copy_from_slice(client_public_key);
|
||||
offset += ECE_WEBPUSH_PUBLIC_KEY_LENGTH;
|
||||
info[offset..].copy_from_slice(server_public_key);
|
||||
info
|
||||
}
|
||||
|
||||
pub fn generate_iv(nonce: &[u8], counter: usize) -> [u8; ECE_NONCE_LENGTH] {
|
||||
let mut iv = [0u8; ECE_NONCE_LENGTH];
|
||||
let offset = ECE_NONCE_LENGTH - 8;
|
||||
iv[0..offset].copy_from_slice(&nonce[0..offset]);
|
||||
let mask = u64::from_be_bytes((&nonce[offset..]).try_into().unwrap());
|
||||
iv[offset..].copy_from_slice(&(mask ^ (counter as u64)).to_be_bytes());
|
||||
iv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encrypt_len(plaintext_len: usize) -> usize {
|
||||
let secret = EphemeralSecret::random(&mut OsRng);
|
||||
let p256dh = secret.public_key().to_encoded_point(false);
|
||||
|
||||
ece_encrypt(p256dh.as_bytes(), &[0u8; 16], &vec![b'a'; plaintext_len])
|
||||
.expect("encryption failed")
|
||||
.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_plaintext_stays_within_a_single_record() {
|
||||
let header = ECE_AES128GCM_HEADER_LENGTH + ECE_WEBPUSH_PUBLIC_KEY_LENGTH;
|
||||
let limit = WEBPUSH_MAX_BODY_SIZE;
|
||||
|
||||
let padded = (ECE_WEBPUSH_MAX_PLAINTEXT_SIZE + ECE_AES128GCM_PAD_SIZE)
|
||||
.next_multiple_of(ECE_WEBPUSH_DEFAULT_PADDING_BLOCK_SIZE);
|
||||
|
||||
let len = encrypt_len(ECE_WEBPUSH_MAX_PLAINTEXT_SIZE);
|
||||
assert!(
|
||||
len <= limit,
|
||||
"{len} exceeds the {limit} octet payload limit"
|
||||
);
|
||||
assert_eq!(
|
||||
len,
|
||||
header + padded + ECE_TAG_LENGTH,
|
||||
"expected exactly one authentication tag, i.e. a single record"
|
||||
);
|
||||
|
||||
let over = encrypt_len(ECE_WEBPUSH_MAX_PLAINTEXT_SIZE + 1);
|
||||
assert!(
|
||||
over > limit,
|
||||
"{ECE_WEBPUSH_MAX_PLAINTEXT_SIZE} is not the largest single-record plaintext"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use aho_corasick::AhoCorasick;
|
||||
use common::{MessageStoreCache, Server};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
message::{
|
||||
body::ToBodyPart,
|
||||
headers::{HeaderToValue, IntoForm},
|
||||
metadata::{
|
||||
ArchivedMessageMetadata, ArchivedMessageMetadataContents, ArchivedMessageMetadataPart,
|
||||
ArchivedMetadataPartType, MESSAGE_HAS_ATTACHMENT, MESSAGE_RECEIVED_MASK, MessageData,
|
||||
MessageMetadata, MetadataHeaderName,
|
||||
},
|
||||
},
|
||||
push::EmailPush,
|
||||
};
|
||||
use jmap_proto::{
|
||||
method::query::Filter,
|
||||
object::{
|
||||
email::{EmailFilter, EmailProperty, EmailValue, HeaderForm},
|
||||
push_subscription::EmailPushProperty,
|
||||
},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Map, Property, Value};
|
||||
use mail_parser::{HeaderName, HeaderValue};
|
||||
use std::iter::Peekable;
|
||||
use store::{
|
||||
ValueKey,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
blob::{BlobClass, BlobId},
|
||||
blob_hash::BlobHash,
|
||||
collection::Collection,
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
const BODY_PROPERTIES: &[EmailProperty] = &[
|
||||
EmailProperty::PartId,
|
||||
EmailProperty::BlobId,
|
||||
EmailProperty::Size,
|
||||
EmailProperty::Name,
|
||||
EmailProperty::Type,
|
||||
EmailProperty::Charset,
|
||||
EmailProperty::Disposition,
|
||||
EmailProperty::Cid,
|
||||
EmailProperty::Language,
|
||||
EmailProperty::Location,
|
||||
];
|
||||
|
||||
pub async fn build_email_push_object(
|
||||
server: &Server,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
config: &EmailPush,
|
||||
max_size: usize,
|
||||
) -> trc::Result<Option<(Value<'static, EmailProperty, EmailValue>, usize)>> {
|
||||
let properties = &config.properties;
|
||||
let Some(data) = server
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
document_id,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = data
|
||||
.deserialize::<MessageData>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let Some(metadata_archive) = server
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let metadata = metadata_archive
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
let (Some(contents), Some(root_part)) = (
|
||||
metadata.contents.first(),
|
||||
metadata.contents.first().and_then(|c| c.parts.first()),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let blob_hash = BlobHash::from(&metadata.blob_hash);
|
||||
let needs_body = properties.iter().any(|property| {
|
||||
matches!(
|
||||
property,
|
||||
EmailPushProperty::TextBody
|
||||
| EmailPushProperty::HtmlBody
|
||||
| EmailPushProperty::Attachments
|
||||
| EmailPushProperty::BodyStructure
|
||||
)
|
||||
}) || config.filter.iter().any(|filter| {
|
||||
matches!(
|
||||
filter,
|
||||
Filter::Property(EmailFilter::Body(_) | EmailFilter::Text(_))
|
||||
)
|
||||
});
|
||||
|
||||
let raw_body;
|
||||
let mut raw_message = ChainedBytes::new(metadata.raw_headers.as_ref());
|
||||
if needs_body {
|
||||
let Some(blob) = server
|
||||
.blob_store()
|
||||
.get_blob(blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
raw_body = blob;
|
||||
raw_message.append(
|
||||
raw_body
|
||||
.get(metadata.blob_body_offset.to_native() as usize..)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
if !config.filter.is_empty() {
|
||||
let cache = if config.filter.iter().any(|filter| {
|
||||
matches!(
|
||||
filter,
|
||||
Filter::Property(
|
||||
EmailFilter::AllInThreadHaveKeyword(_)
|
||||
| EmailFilter::SomeInThreadHaveKeyword(_)
|
||||
| EmailFilter::NoneInThreadHaveKeyword(_)
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Some(
|
||||
server
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !eval_filter_node(
|
||||
&mut config.filter.iter().peekable(),
|
||||
&FilterContext {
|
||||
data: &data,
|
||||
document_id,
|
||||
cache: cache.as_deref(),
|
||||
metadata,
|
||||
contents,
|
||||
root_part,
|
||||
raw_message: &raw_message,
|
||||
},
|
||||
)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let blob_id = BlobId {
|
||||
hash: blob_hash,
|
||||
class: BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::Email.into(),
|
||||
document_id,
|
||||
},
|
||||
section: None,
|
||||
};
|
||||
let blob_body_offset =
|
||||
metadata.blob_body_offset.to_native() as isize - root_part.offset_body.to_native() as isize;
|
||||
|
||||
let id = Id::from_parts(data.thread_id, document_id);
|
||||
let mut email = Map::with_capacity(properties.len());
|
||||
let mut used = 0;
|
||||
|
||||
for property in properties {
|
||||
let key = EmailProperty::from(property);
|
||||
let value: Value<'static, EmailProperty, EmailValue> = match property {
|
||||
EmailPushProperty::Id => id.into(),
|
||||
EmailPushProperty::ThreadId => Id::from(id.prefix_id()).into(),
|
||||
EmailPushProperty::BlobId => blob_id.clone().into(),
|
||||
EmailPushProperty::MailboxIds => {
|
||||
let mut mailbox_ids = Map::with_capacity(data.mailboxes.len());
|
||||
for mailbox in data.mailboxes.iter() {
|
||||
mailbox_ids.insert_unchecked(
|
||||
EmailProperty::IdValue(Id::from(mailbox.mailbox_id)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
Value::Object(mailbox_ids)
|
||||
}
|
||||
EmailPushProperty::Keywords => {
|
||||
let mut keywords = Map::with_capacity(2);
|
||||
for keyword in data.keywords.iter() {
|
||||
keywords.insert_unchecked(EmailProperty::Keyword(keyword.clone()), true);
|
||||
}
|
||||
Value::Object(keywords)
|
||||
}
|
||||
EmailPushProperty::Size => data.size.into(),
|
||||
EmailPushProperty::ReceivedAt => EmailValue::Date(UTCDate::from_timestamp(
|
||||
(metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64,
|
||||
))
|
||||
.into(),
|
||||
EmailPushProperty::Preview => {
|
||||
if metadata.preview.is_empty() {
|
||||
continue;
|
||||
}
|
||||
metadata.preview.to_string().into()
|
||||
}
|
||||
EmailPushProperty::HasAttachment => {
|
||||
((metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0).into()
|
||||
}
|
||||
EmailPushProperty::Subject
|
||||
| EmailPushProperty::SentAt
|
||||
| EmailPushProperty::MessageId
|
||||
| EmailPushProperty::InReplyTo
|
||||
| EmailPushProperty::References
|
||||
| EmailPushProperty::Sender
|
||||
| EmailPushProperty::From
|
||||
| EmailPushProperty::To
|
||||
| EmailPushProperty::Cc
|
||||
| EmailPushProperty::Bcc
|
||||
| EmailPushProperty::ReplyTo => {
|
||||
let (header_name, form) = match property {
|
||||
EmailPushProperty::Subject => (MetadataHeaderName::Subject, HeaderForm::Text),
|
||||
EmailPushProperty::SentAt => (MetadataHeaderName::Date, HeaderForm::Date),
|
||||
EmailPushProperty::MessageId => {
|
||||
(MetadataHeaderName::MessageId, HeaderForm::MessageIds)
|
||||
}
|
||||
EmailPushProperty::InReplyTo => {
|
||||
(MetadataHeaderName::InReplyTo, HeaderForm::MessageIds)
|
||||
}
|
||||
EmailPushProperty::References => {
|
||||
(MetadataHeaderName::References, HeaderForm::MessageIds)
|
||||
}
|
||||
EmailPushProperty::Sender => {
|
||||
(MetadataHeaderName::Sender, HeaderForm::Addresses)
|
||||
}
|
||||
EmailPushProperty::From => (MetadataHeaderName::From, HeaderForm::Addresses),
|
||||
EmailPushProperty::To => (MetadataHeaderName::To, HeaderForm::Addresses),
|
||||
EmailPushProperty::Cc => (MetadataHeaderName::Cc, HeaderForm::Addresses),
|
||||
EmailPushProperty::Bcc => (MetadataHeaderName::Bcc, HeaderForm::Addresses),
|
||||
EmailPushProperty::ReplyTo => {
|
||||
(MetadataHeaderName::ReplyTo, HeaderForm::Addresses)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
root_part
|
||||
.header_value(&header_name)
|
||||
.map(|value| HeaderValue::from(value).into_form(&form))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
EmailPushProperty::Header(_) => root_part.header_to_value(&key, &raw_message),
|
||||
EmailPushProperty::Headers => root_part.headers_to_value(&raw_message),
|
||||
EmailPushProperty::TextBody
|
||||
| EmailPushProperty::HtmlBody
|
||||
| EmailPushProperty::Attachments => {
|
||||
let parts = match property {
|
||||
EmailPushProperty::TextBody => &contents.text_body,
|
||||
EmailPushProperty::HtmlBody => &contents.html_body,
|
||||
EmailPushProperty::Attachments => &contents.attachments,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
parts
|
||||
.iter()
|
||||
.map(|part_id| {
|
||||
contents.to_body_part(
|
||||
u16::from(part_id) as u32,
|
||||
BODY_PROPERTIES,
|
||||
&raw_message,
|
||||
&blob_id,
|
||||
blob_body_offset,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
}
|
||||
EmailPushProperty::BodyStructure => {
|
||||
contents.to_body_part(0, BODY_PROPERTIES, &raw_message, &blob_id, blob_body_offset)
|
||||
}
|
||||
EmailPushProperty::BodyValues => Value::Object(Map::with_capacity(0)),
|
||||
};
|
||||
|
||||
let entry_size = key.to_cow().len() + estimate_value_size(&value) + 4;
|
||||
if used + entry_size < max_size {
|
||||
used += entry_size;
|
||||
email.insert_unchecked(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some((email.into(), used)))
|
||||
}
|
||||
|
||||
fn estimate_value_size(value: &Value<'_, EmailProperty, EmailValue>) -> usize {
|
||||
match value {
|
||||
Value::Null => 4,
|
||||
Value::Bool(_) => 5,
|
||||
Value::Number(_) => 12,
|
||||
Value::Str(text) => text.len() + 2,
|
||||
Value::Element(_) => 40,
|
||||
Value::Array(values) => {
|
||||
2 + values
|
||||
.iter()
|
||||
.map(|value| estimate_value_size(value) + 1)
|
||||
.sum::<usize>()
|
||||
}
|
||||
Value::Object(map) => {
|
||||
2 + map
|
||||
.iter()
|
||||
.map(|(_, value)| estimate_value_size(value) + 24)
|
||||
.sum::<usize>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterContext<'a> {
|
||||
data: &'a MessageData,
|
||||
document_id: u32,
|
||||
cache: Option<&'a MessageStoreCache>,
|
||||
metadata: &'a ArchivedMessageMetadata,
|
||||
contents: &'a ArchivedMessageMetadataContents,
|
||||
root_part: &'a ArchivedMessageMetadataPart,
|
||||
raw_message: &'a ChainedBytes<'a>,
|
||||
}
|
||||
|
||||
fn eval_filter_node<'a, I>(tokens: &mut Peekable<I>, context: &FilterContext) -> Option<bool>
|
||||
where
|
||||
I: Iterator<Item = &'a Filter<EmailFilter>>,
|
||||
{
|
||||
match tokens.next()? {
|
||||
operator @ (Filter::And | Filter::Or | Filter::Not) => {
|
||||
let mut all = true;
|
||||
let mut any = false;
|
||||
while let Some(token) = tokens.peek() {
|
||||
if matches!(token, Filter::Close) {
|
||||
tokens.next();
|
||||
break;
|
||||
}
|
||||
if let Some(result) = eval_filter_node(tokens, context) {
|
||||
all &= result;
|
||||
any |= result;
|
||||
}
|
||||
}
|
||||
Some(match operator {
|
||||
Filter::And => all,
|
||||
Filter::Or => any,
|
||||
Filter::Not => !any,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
}
|
||||
Filter::Property(condition) => Some(eval_filter_condition(condition, context)),
|
||||
Filter::Close => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_filter_condition(condition: &EmailFilter, context: &FilterContext) -> bool {
|
||||
match condition {
|
||||
EmailFilter::InMailbox(id) => {
|
||||
let mailbox_id = id.document_id();
|
||||
context
|
||||
.data
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mailbox| mailbox.mailbox_id == mailbox_id)
|
||||
}
|
||||
EmailFilter::InMailboxOtherThan(ids) => context
|
||||
.data
|
||||
.mailboxes
|
||||
.iter()
|
||||
.any(|mailbox| ids.iter().all(|id| id.document_id() != mailbox.mailbox_id)),
|
||||
EmailFilter::Before(date) => received_at(context) < date.timestamp(),
|
||||
EmailFilter::After(date) => received_at(context) >= date.timestamp(),
|
||||
EmailFilter::MinSize(size) => context.data.size >= *size,
|
||||
EmailFilter::MaxSize(size) => context.data.size < *size,
|
||||
EmailFilter::HasKeyword(keyword) => {
|
||||
context.data.keywords.iter().any(|value| value == keyword)
|
||||
}
|
||||
EmailFilter::NotKeyword(keyword) => {
|
||||
!context.data.keywords.iter().any(|value| value == keyword)
|
||||
}
|
||||
EmailFilter::AllInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| {
|
||||
cache
|
||||
.in_thread(context.data.thread_id)
|
||||
.all(|message| cache.has_keyword(message, keyword))
|
||||
}),
|
||||
EmailFilter::SomeInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| {
|
||||
cache
|
||||
.in_thread(context.data.thread_id)
|
||||
.any(|message| cache.has_keyword(message, keyword))
|
||||
}),
|
||||
EmailFilter::NoneInThreadHaveKeyword(keyword) => context.cache.is_some_and(|cache| {
|
||||
!cache
|
||||
.in_thread(context.data.thread_id)
|
||||
.any(|message| cache.has_keyword(message, keyword))
|
||||
}),
|
||||
EmailFilter::HasAttachment(value) => {
|
||||
((context.metadata.rcvd_attach.to_native() & MESSAGE_HAS_ATTACHMENT) != 0) == *value
|
||||
}
|
||||
EmailFilter::From(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::From,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
EmailFilter::To(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::To,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
EmailFilter::Cc(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Cc,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
EmailFilter::Bcc(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Bcc,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
EmailFilter::Subject(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Subject,
|
||||
&HeaderForm::Text,
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
EmailFilter::Body(text) => {
|
||||
ascii_matcher(text).is_some_and(|matcher| body_matches(context, &matcher))
|
||||
}
|
||||
EmailFilter::Text(text) => ascii_matcher(text).is_some_and(|matcher| {
|
||||
header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::From,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
) || header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::To,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
) || header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Cc,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
) || header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Bcc,
|
||||
&HeaderForm::Addresses,
|
||||
&matcher,
|
||||
) || header_matches(
|
||||
context,
|
||||
&MetadataHeaderName::Subject,
|
||||
&HeaderForm::Text,
|
||||
&matcher,
|
||||
) || body_matches(context, &matcher)
|
||||
}),
|
||||
EmailFilter::Header(parts) => {
|
||||
let Some(name) = parts.first() else {
|
||||
return false;
|
||||
};
|
||||
let header_name = MetadataHeaderName::from(
|
||||
HeaderName::parse(name.as_str())
|
||||
.unwrap_or_else(|| HeaderName::Other(name.as_str().into())),
|
||||
);
|
||||
match (context.root_part.header_value(&header_name), parts.get(1)) {
|
||||
(Some(value), Some(expected)) => ascii_matcher(expected).is_some_and(|matcher| {
|
||||
value_contains(
|
||||
&HeaderValue::from(value).into_form(&HeaderForm::Raw),
|
||||
&matcher,
|
||||
)
|
||||
}),
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
}
|
||||
}
|
||||
EmailFilter::SentBefore(date) => {
|
||||
sent_at(context).is_some_and(|sent_at| sent_at < date.timestamp())
|
||||
}
|
||||
EmailFilter::SentAfter(date) => {
|
||||
sent_at(context).is_some_and(|sent_at| sent_at >= date.timestamp())
|
||||
}
|
||||
EmailFilter::InThread(id) => context.data.thread_id == id.document_id(),
|
||||
EmailFilter::Id(ids) => ids.iter().any(|id| id.document_id() == context.document_id),
|
||||
EmailFilter::_T(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn received_at(context: &FilterContext) -> i64 {
|
||||
(context.metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK) as i64
|
||||
}
|
||||
|
||||
fn sent_at(context: &FilterContext) -> Option<i64> {
|
||||
match context
|
||||
.root_part
|
||||
.header_value(&MetadataHeaderName::Date)
|
||||
.map(HeaderValue::from)
|
||||
{
|
||||
Some(HeaderValue::DateTime(datetime)) => Some(datetime.to_timestamp()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ascii_matcher(needle: &str) -> Option<AhoCorasick> {
|
||||
AhoCorasick::builder()
|
||||
.ascii_case_insensitive(true)
|
||||
.build([needle])
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn header_matches(
|
||||
context: &FilterContext,
|
||||
name: &MetadataHeaderName,
|
||||
form: &HeaderForm,
|
||||
matcher: &AhoCorasick,
|
||||
) -> bool {
|
||||
context
|
||||
.root_part
|
||||
.header_value(name)
|
||||
.is_some_and(|value| value_contains(&HeaderValue::from(value).into_form(form), matcher))
|
||||
}
|
||||
|
||||
fn value_contains(value: &Value<'_, EmailProperty, EmailValue>, matcher: &AhoCorasick) -> bool {
|
||||
match value {
|
||||
Value::Str(text) => matcher.is_match(text.as_ref()),
|
||||
Value::Array(values) => values.iter().any(|value| value_contains(value, matcher)),
|
||||
Value::Object(map) => map.iter().any(|(_, value)| value_contains(value, matcher)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn body_matches(context: &FilterContext, matcher: &AhoCorasick) -> bool {
|
||||
context
|
||||
.contents
|
||||
.text_body
|
||||
.iter()
|
||||
.chain(context.contents.html_body.iter())
|
||||
.any(|part_id| {
|
||||
context
|
||||
.contents
|
||||
.parts
|
||||
.as_ref()
|
||||
.get(u16::from(part_id) as usize)
|
||||
.is_some_and(|part| {
|
||||
matches!(
|
||||
part.body,
|
||||
ArchivedMetadataPartType::Text | ArchivedMetadataPartType::Html
|
||||
) && matcher.is_match(part.decode_contents(context.raw_message).as_str())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
Event,
|
||||
ece::{ECE_WEBPUSH_MAX_PLAINTEXT_SIZE, WEBPUSH_MAX_BODY_SIZE, ece_encrypt},
|
||||
email_push::build_email_push_object,
|
||||
};
|
||||
use crate::state_manager::PushRegistration;
|
||||
use calcard::jscalendar::JSCalendarDateTime;
|
||||
use common::{Server, ipc::PushNotification, network::webpush::Vapid};
|
||||
use email::push::{PushSubscription, Urgency};
|
||||
use jmap_proto::{
|
||||
object::email::{EmailProperty, EmailValue},
|
||||
response::status::PushObject,
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::Value;
|
||||
use reqwest::{
|
||||
Client, Url,
|
||||
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE},
|
||||
redirect::Policy,
|
||||
};
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
use tokio::sync::mpsc;
|
||||
use trc::PushSubscriptionEvent;
|
||||
use types::{id::Id, type_state::DataType};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
const MAX_ERROR_RESPONSE_LEN: usize = 1024;
|
||||
const MAX_REDIRECTS: usize = 4;
|
||||
const PUSH_OBJECT_OVERHEAD: usize = 128;
|
||||
|
||||
#[derive(Default)]
|
||||
struct EmailPushObject {
|
||||
emails: Vec<Value<'static, EmailProperty, EmailValue>>,
|
||||
change_id: Option<u64>,
|
||||
urgency: Urgency,
|
||||
used: usize,
|
||||
}
|
||||
|
||||
impl PushRegistration {
|
||||
pub fn send(
|
||||
&mut self,
|
||||
id: Id,
|
||||
push_tx: mpsc::Sender<Event>,
|
||||
push_timeout: Duration,
|
||||
server: Server,
|
||||
) {
|
||||
let subscription = self.server.clone();
|
||||
let push_client = self.client.clone();
|
||||
let notifications = std::mem::take(&mut self.notifications);
|
||||
|
||||
self.in_flight = true;
|
||||
self.last_request = Instant::now();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut changed: VecMap<Id, VecMap<DataType, State>> = VecMap::new();
|
||||
let mut email_pushes: VecMap<Id, EmailPushObject> = VecMap::new();
|
||||
|
||||
let mut failed_state_change = false;
|
||||
let mut failed_email_pushes = Vec::new();
|
||||
let mut failed_calendar_alerts = Vec::new();
|
||||
|
||||
for notification in ¬ifications {
|
||||
match notification {
|
||||
PushNotification::StateChange(state_change) => {
|
||||
for type_state in state_change.types {
|
||||
changed
|
||||
.get_mut_or_insert(state_change.account_id.into())
|
||||
.set(type_state, State::Exact(state_change.change_id));
|
||||
}
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
let payload = PushObject::CalendarAlert {
|
||||
account_id: calendar_alert.account_id.into(),
|
||||
calendar_event_id: calendar_alert.event_id.into(),
|
||||
uid: calendar_alert.uid.clone(),
|
||||
recurrence_id: calendar_alert.recurrence_id.map(|timestamp| {
|
||||
JSCalendarDateTime::new(timestamp, true).to_rfc3339()
|
||||
}),
|
||||
alert_id: calendar_alert.alert_id.clone(),
|
||||
};
|
||||
if !http_request(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&payload).unwrap().into_bytes(),
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
Urgency::Normal,
|
||||
)
|
||||
.await
|
||||
{
|
||||
failed_calendar_alerts
|
||||
.push((calendar_alert.account_id, calendar_alert.event_id));
|
||||
}
|
||||
}
|
||||
PushNotification::EmailPush(email_push) => {
|
||||
if let Some(config) = subscription
|
||||
.email_push
|
||||
.iter()
|
||||
.find(|config| config.account_id == email_push.account_id)
|
||||
{
|
||||
let emails =
|
||||
email_pushes.get_mut_or_insert(Id::from(email_push.account_id));
|
||||
let remaining = server
|
||||
.core
|
||||
.jmap
|
||||
.push_max_size
|
||||
.min(if subscription.keys.is_some() {
|
||||
ECE_WEBPUSH_MAX_PLAINTEXT_SIZE
|
||||
} else {
|
||||
WEBPUSH_MAX_BODY_SIZE
|
||||
})
|
||||
.saturating_sub(PUSH_OBJECT_OVERHEAD)
|
||||
.saturating_sub(emails.used);
|
||||
|
||||
match build_email_push_object(
|
||||
&server,
|
||||
email_push.account_id,
|
||||
email_push.email_id,
|
||||
config,
|
||||
remaining,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some((object, used))) => {
|
||||
emails.urgency = config.urgency;
|
||||
if emails
|
||||
.change_id
|
||||
.is_none_or(|change_id| email_push.change_id > change_id)
|
||||
{
|
||||
emails.change_id = Some(email_push.change_id);
|
||||
}
|
||||
emails.used += used;
|
||||
emails.emails.push(object);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.details(
|
||||
"Failed to build EmailPush notification object."
|
||||
)
|
||||
);
|
||||
failed_email_pushes.push(email_push.account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed.is_empty() {
|
||||
failed_state_change = !http_request(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&PushObject::StateChange { changed })
|
||||
.unwrap()
|
||||
.into_bytes(),
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
Urgency::Normal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
for (account_id, email_push) in email_pushes {
|
||||
if email_push.emails.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = PushObject::EmailPush {
|
||||
account_id,
|
||||
emails: email_push.emails,
|
||||
state: email_push.change_id.map(State::Exact),
|
||||
};
|
||||
|
||||
if !http_request(
|
||||
&push_client,
|
||||
&subscription,
|
||||
serde_json::to_string(&payload).unwrap().into_bytes(),
|
||||
push_timeout,
|
||||
server.core.jmap.vapid.as_ref(),
|
||||
email_push.urgency,
|
||||
)
|
||||
.await
|
||||
{
|
||||
failed_email_pushes.push(account_id.document_id());
|
||||
}
|
||||
}
|
||||
|
||||
let result = if !failed_state_change
|
||||
&& failed_email_pushes.is_empty()
|
||||
&& failed_calendar_alerts.is_empty()
|
||||
{
|
||||
Event::DeliverySuccess { id }
|
||||
} else {
|
||||
let mut failed_notifications = Vec::with_capacity(
|
||||
failed_state_change as usize
|
||||
+ failed_email_pushes.len()
|
||||
+ failed_calendar_alerts.len(),
|
||||
);
|
||||
|
||||
for notification in notifications {
|
||||
match ¬ification {
|
||||
PushNotification::StateChange(_) => {
|
||||
if failed_state_change {
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
}
|
||||
PushNotification::EmailPush(email_push) => {
|
||||
if failed_email_pushes.contains(&email_push.account_id) {
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
}
|
||||
PushNotification::CalendarAlert(calendar_alert) => {
|
||||
if failed_calendar_alerts
|
||||
.contains(&(calendar_alert.account_id, calendar_alert.event_id))
|
||||
{
|
||||
failed_notifications.push(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Event::DeliveryFailure {
|
||||
id,
|
||||
notifications: failed_notifications,
|
||||
}
|
||||
};
|
||||
|
||||
push_tx.send(result).await.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_push_client() -> Client {
|
||||
utils::http::http_client_builder(cfg!(feature = "test_mode"))
|
||||
.redirect(Policy::custom(|attempt| match attempt.previous().last() {
|
||||
Some(previous) if is_same_organization(previous, attempt.url()) => {
|
||||
if attempt.previous().len() > MAX_REDIRECTS {
|
||||
attempt.error("Too many redirects.")
|
||||
} else {
|
||||
attempt.follow()
|
||||
}
|
||||
}
|
||||
_ => attempt.stop(),
|
||||
}))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn http_request(
|
||||
push_client: &Client,
|
||||
details: &PushSubscription,
|
||||
mut body: Vec<u8>,
|
||||
push_timeout: Duration,
|
||||
vapid: Option<&Vapid>,
|
||||
urgency: Urgency,
|
||||
) -> bool {
|
||||
let mut client = push_client
|
||||
.post(details.url.as_str())
|
||||
.timeout(push_timeout)
|
||||
.header("TTL", "86400")
|
||||
.header("Urgency", urgency.as_str());
|
||||
|
||||
if let Some(authorization) = vapid.and_then(|vapid| vapid.authorization(&details.url, now())) {
|
||||
client = client.header(AUTHORIZATION, authorization);
|
||||
}
|
||||
|
||||
let mut content_type = "application/json";
|
||||
if let Some(keys) = &details.keys {
|
||||
match ece_encrypt(&keys.p256dh, &keys.auth, &body) {
|
||||
Ok(body_) => {
|
||||
body = body_;
|
||||
content_type = "application/octet-stream";
|
||||
client = client.header(CONTENT_ENCODING, "aes128gcm");
|
||||
}
|
||||
Err(err) => {
|
||||
// Do not reattempt if encryption fails.
|
||||
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "Failed to encrypt push subscription",
|
||||
Url = details.url.to_string(),
|
||||
Reason = err
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match client
|
||||
.header(CONTENT_TYPE, content_type)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Success),
|
||||
Url = details.url.to_string()
|
||||
);
|
||||
|
||||
true
|
||||
} else {
|
||||
let mut reason = response.text().await.unwrap_or_default();
|
||||
reason.truncate(reason.ceil_char_boundary(MAX_ERROR_RESPONSE_LEN));
|
||||
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "HTTP POST failed",
|
||||
Url = details.url.to_string(),
|
||||
Code = status.as_u16(),
|
||||
Reason = reason,
|
||||
);
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "HTTP POST failed",
|
||||
Url = details.url.to_string(),
|
||||
Reason = err.to_string()
|
||||
);
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_same_organization(previous: &Url, next: &Url) -> bool {
|
||||
if previous.scheme() == next.scheme()
|
||||
&& let (Some(previous_host), Some(next_host)) = (previous.host_str(), next.host_str())
|
||||
{
|
||||
if is_ip_literal(previous_host) || is_ip_literal(next_host) {
|
||||
previous_host == next_host
|
||||
} else {
|
||||
match (psl::domain_str(previous_host), psl::domain_str(next_host)) {
|
||||
(Some(previous_domain), Some(next_domain)) => previous_domain == next_domain,
|
||||
_ => previous_host == next_host,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ip_literal(host: &str) -> bool {
|
||||
host.strip_prefix('[')
|
||||
.and_then(|host| host.strip_suffix(']'))
|
||||
.unwrap_or(host)
|
||||
.parse::<IpAddr>()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_same_organization;
|
||||
use reqwest::Url;
|
||||
|
||||
#[test]
|
||||
fn same_organization_redirects() {
|
||||
for (previous, next, expected) in [
|
||||
(
|
||||
"https://push.example.org/a",
|
||||
"https://push.example.org/b",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"https://push.example.org/a",
|
||||
"https://push2.example.org/b",
|
||||
true,
|
||||
),
|
||||
("https://push.example.org/a", "https://example.org/b", true),
|
||||
(
|
||||
"https://push.example.org/a",
|
||||
"https://push.example.org:8443/b",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"https://push.example.org/a",
|
||||
"https://push.evil.org/b",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://push.example.org/a",
|
||||
"http://push.example.org/b",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://push.example.co.uk/a",
|
||||
"https://evil.co.uk/b",
|
||||
false,
|
||||
),
|
||||
("https://1.2.3.4/a", "https://1.2.3.4/b", true),
|
||||
("https://1.2.3.4/a", "https://5.6.7.8/b", false),
|
||||
("https://1.2.3.4/a", "https://5.6.3.4/b", false),
|
||||
("https://1.2.3.4/a", "https://127.0.0.1/b", false),
|
||||
("https://[2606:4700::1111]/a", "https://[::1]/b", false),
|
||||
(
|
||||
"https://[2606:4700::1111]/a",
|
||||
"https://[2606:4700::1111]/b",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let previous = Url::parse(previous).unwrap();
|
||||
let next = Url::parse(next).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
is_same_organization(&previous, &next),
|
||||
expected,
|
||||
"{previous} -> {next}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Event, PURGE_EVERY, SEND_TIMEOUT, push::spawn_push_manager};
|
||||
use crate::state_manager::IpcSubscriber;
|
||||
use common::{
|
||||
Inner,
|
||||
ipc::{BroadcastEvent, PushEvent},
|
||||
};
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use store::ahash::AHashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use trc::ServerEvent;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Subscriber {
|
||||
ipc: Vec<IpcSubscriber>,
|
||||
is_push: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_or_default)]
|
||||
pub fn spawn_push_router(inner: Arc<Inner>, mut change_rx: mpsc::Receiver<PushEvent>) {
|
||||
let push_tx = spawn_push_manager(inner.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut subscribers: AHashMap<u32, Subscriber> = AHashMap::default();
|
||||
let mut last_purge = Instant::now();
|
||||
|
||||
while let Some(event) = change_rx.recv().await {
|
||||
let mut purge_needed = last_purge.elapsed() >= PURGE_EVERY;
|
||||
|
||||
match event {
|
||||
PushEvent::Stop => {
|
||||
if push_tx.send(Event::Reset).await.is_err() {
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending push reset.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
PushEvent::Subscribe {
|
||||
account_ids,
|
||||
types,
|
||||
tx,
|
||||
} => {
|
||||
for account_id in account_ids {
|
||||
subscribers
|
||||
.entry(account_id)
|
||||
.or_default()
|
||||
.ipc
|
||||
.push(IpcSubscriber {
|
||||
types,
|
||||
tx: tx.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PushEvent::PushServerRegister { activate, expired } => {
|
||||
for account_id in activate {
|
||||
subscribers.entry(account_id).or_default().is_push = true;
|
||||
}
|
||||
|
||||
for account_id in expired {
|
||||
let mut remove_account = false;
|
||||
if let Some(subscriber_list) = subscribers.get_mut(&account_id) {
|
||||
subscriber_list.is_push = false;
|
||||
remove_account = subscriber_list.ipc.is_empty();
|
||||
}
|
||||
if remove_account {
|
||||
subscribers.remove(&account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PushEvent::Publish {
|
||||
notification,
|
||||
broadcast,
|
||||
} => {
|
||||
// Publish event to cluster
|
||||
if broadcast
|
||||
&& let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone()
|
||||
&& broadcast_tx
|
||||
.send(BroadcastEvent::PushNotification(notification.clone()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::ThreadError),
|
||||
Details = "Error sending broadcast event.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
|
||||
let account_id = notification.account_id();
|
||||
if let Some(subscribers) = subscribers.get(&account_id) {
|
||||
for subscriber in &subscribers.ipc {
|
||||
if let Some(notification) = notification.filter_types(&subscriber.types)
|
||||
{
|
||||
if subscriber.is_valid() {
|
||||
let subscriber_tx = subscriber.tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Timeout after 500ms in case there is a blocked client
|
||||
if subscriber_tx
|
||||
.send_timeout(notification, SEND_TIMEOUT)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details =
|
||||
"Error sending state change to subscriber.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
purge_needed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if subscribers.is_push
|
||||
&& push_tx.send(Event::Push { notification }).await.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending push updates.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PushEvent::PushServerUpdate {
|
||||
account_id,
|
||||
broadcast,
|
||||
} => {
|
||||
// Publish event to cluster
|
||||
if broadcast
|
||||
&& let Some(broadcast_tx) = &inner.ipc.broadcast_tx.clone()
|
||||
&& broadcast_tx
|
||||
.send(BroadcastEvent::PushServerUpdate(account_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::ThreadError),
|
||||
Details = "Error sending broadcast event.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
|
||||
// Notify push manager
|
||||
if push_tx.send(Event::Update { account_id }).await.is_err() {
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending push updates.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if purge_needed {
|
||||
let mut remove_account_ids = Vec::new();
|
||||
|
||||
for (account_id, subscribers) in &mut subscribers {
|
||||
subscribers.ipc.retain(|subscriber| subscriber.is_valid());
|
||||
|
||||
if subscribers.ipc.is_empty() && !subscribers.is_push {
|
||||
remove_account_ids.push(*account_id);
|
||||
}
|
||||
}
|
||||
|
||||
for remove_account_id in remove_account_ids {
|
||||
subscribers.remove(&remove_account_id);
|
||||
}
|
||||
|
||||
last_purge = Instant::now();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod ece;
|
||||
pub mod email_push;
|
||||
pub mod http;
|
||||
pub mod manager;
|
||||
pub mod push;
|
||||
|
||||
use common::ipc::PushNotification;
|
||||
use email::push::PushSubscription;
|
||||
use reqwest::Client;
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use types::{id::Id, type_state::DataType};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
const PURGE_EVERY: Duration = Duration::from_secs(3600);
|
||||
const SEND_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IpcSubscriber {
|
||||
types: Bitmap<DataType>,
|
||||
tx: mpsc::Sender<PushNotification>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PushRegistration {
|
||||
server: Arc<PushSubscription>,
|
||||
member_account_ids: Vec<u32>,
|
||||
num_attempts: u32,
|
||||
last_request: Instant,
|
||||
notifications: Vec<PushNotification>,
|
||||
in_flight: bool,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
Push {
|
||||
notification: PushNotification,
|
||||
},
|
||||
Update {
|
||||
account_id: u32,
|
||||
},
|
||||
DeliverySuccess {
|
||||
id: Id,
|
||||
},
|
||||
DeliveryFailure {
|
||||
id: Id,
|
||||
notifications: Vec<PushNotification>,
|
||||
},
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl IpcSubscriber {
|
||||
fn is_valid(&self) -> bool {
|
||||
!self.tx.is_closed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
Event,
|
||||
http::{build_push_client, http_request},
|
||||
};
|
||||
use crate::state_manager::PushRegistration;
|
||||
use common::{
|
||||
BuildServer, IPC_CHANNEL_BUFFER, Inner, LONG_1Y_SLUMBER, Server,
|
||||
auth::BuildAccessToken,
|
||||
ipc::{PushEvent, PushNotification},
|
||||
};
|
||||
use email::push::{PushSubscription, PushSubscriptions, Urgency};
|
||||
use std::{
|
||||
collections::hash_map::Entry,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use store::{
|
||||
ValueKey,
|
||||
ahash::{AHashMap, AHashSet},
|
||||
write::{AlignedBytes, Archive, now},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use trc::{AddContext, PushSubscriptionEvent, ServerEvent};
|
||||
use types::{collection::Collection, field::PrincipalField, id::Id};
|
||||
|
||||
pub fn spawn_push_manager(inner: Arc<Inner>) -> mpsc::Sender<Event> {
|
||||
let (push_tx_, mut push_rx) = mpsc::channel::<Event>(IPC_CHANNEL_BUFFER);
|
||||
let push_tx = push_tx_.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut push_servers: AHashMap<Id, PushRegistration> = AHashMap::default();
|
||||
let mut account_push_ids: AHashMap<u32, AHashSet<Id>> = AHashMap::default();
|
||||
let mut last_verify: AHashMap<u32, Instant> = AHashMap::default();
|
||||
let mut last_retry = Instant::now();
|
||||
let mut retry_timeout = LONG_1Y_SLUMBER;
|
||||
let mut retry_ids = AHashSet::default();
|
||||
let push_client = build_push_client();
|
||||
|
||||
// Load active subscriptions on startup
|
||||
{
|
||||
let server = inner.build_server();
|
||||
|
||||
if server.core.network.roles.push_notifications {
|
||||
match server
|
||||
.document_ids(
|
||||
u32::MAX,
|
||||
Collection::Principal,
|
||||
PrincipalField::PushSubscriptions,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(account_ids) => {
|
||||
for account_id in account_ids {
|
||||
if server.core.jmap.push_total_shards <= 1
|
||||
|| account_id % server.core.jmap.push_total_shards
|
||||
== server.registry().cluster_push_shard()
|
||||
{
|
||||
// Load push subscriptions for account
|
||||
let (subscriptions, member_account_ids) =
|
||||
match load_push_subscriptions(&server, account_id).await {
|
||||
Ok(subscriptions) => subscriptions,
|
||||
Err(err) => {
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let current_time = now();
|
||||
for subscription in subscriptions
|
||||
.subscriptions
|
||||
.into_iter()
|
||||
.filter(|s| s.verified && s.expires > current_time)
|
||||
{
|
||||
let id = Id::from_parts(subscription.id, account_id);
|
||||
let subscription = Arc::new(subscription);
|
||||
|
||||
for account_id in &member_account_ids {
|
||||
account_push_ids.entry(*account_id).or_default().insert(id);
|
||||
}
|
||||
push_servers.insert(
|
||||
id,
|
||||
PushRegistration {
|
||||
member_account_ids: member_account_ids.clone(),
|
||||
num_attempts: 0,
|
||||
last_request: Instant::now()
|
||||
- (server.core.jmap.push_throttle
|
||||
+ Duration::from_millis(1)),
|
||||
notifications: Vec::new(),
|
||||
server: subscription.clone(),
|
||||
in_flight: false,
|
||||
client: push_client.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to push events
|
||||
if !account_push_ids.is_empty()
|
||||
&& server
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.clone()
|
||||
.send(PushEvent::PushServerRegister {
|
||||
activate: account_push_ids.keys().copied().collect(),
|
||||
expired: vec![],
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending state change.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// Wait for the next event or timeout
|
||||
let event_or_timeout = tokio::time::timeout(retry_timeout, push_rx.recv()).await;
|
||||
|
||||
// Load settings
|
||||
let server = inner.build_server();
|
||||
let push_attempt_interval = server.core.jmap.push_attempt_interval;
|
||||
let push_attempts_max = server.core.jmap.push_attempts_max;
|
||||
let push_retry_interval = server.core.jmap.push_retry_interval;
|
||||
let push_timeout = server.core.jmap.push_timeout;
|
||||
let push_verify_timeout = server.core.jmap.push_verify_timeout;
|
||||
let push_throttle = server.core.jmap.push_throttle;
|
||||
|
||||
match event_or_timeout {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Update { account_id } => {
|
||||
if server.core.jmap.push_total_shards > 1
|
||||
&& account_id % server.core.jmap.push_total_shards
|
||||
!= server.registry().cluster_push_shard()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load push subscriptions for account
|
||||
let (subscriptions, member_account_ids) =
|
||||
match load_push_subscriptions(&server, account_id).await {
|
||||
Ok(subscriptions) => subscriptions,
|
||||
Err(err) => {
|
||||
trc::error!(err.caused_by(trc::location!()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let old_account_push_ids = account_push_ids
|
||||
.remove(&account_id)
|
||||
.filter(|v| !v.is_empty());
|
||||
|
||||
// Process subscriptions
|
||||
let current_time = now();
|
||||
let mut newest_unverified: Option<Arc<PushSubscription>> = None;
|
||||
for subscription in subscriptions
|
||||
.subscriptions
|
||||
.into_iter()
|
||||
.filter(|s| s.expires > current_time)
|
||||
{
|
||||
let id = Id::from_parts(subscription.id, account_id);
|
||||
let subscription = Arc::new(subscription);
|
||||
|
||||
if subscription.verified {
|
||||
for account_id in &member_account_ids {
|
||||
account_push_ids.entry(*account_id).or_default().insert(id);
|
||||
}
|
||||
|
||||
match push_servers.entry(id) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
// Update existing subscription
|
||||
let entry = entry.get_mut();
|
||||
entry.server = subscription.clone();
|
||||
entry.member_account_ids = member_account_ids.clone();
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(PushRegistration {
|
||||
member_account_ids: member_account_ids.clone(),
|
||||
num_attempts: 0,
|
||||
last_request: Instant::now()
|
||||
- (push_throttle + Duration::from_millis(1)),
|
||||
notifications: Vec::new(),
|
||||
server: subscription.clone(),
|
||||
in_flight: false,
|
||||
client: push_client.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match &newest_unverified {
|
||||
Some(existing) if existing.id >= subscription.id => {}
|
||||
_ => newest_unverified = Some(subscription),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(subscription) = newest_unverified {
|
||||
let current_time = Instant::now();
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
if subscription.url.contains("skip_checks") {
|
||||
last_verify.insert(
|
||||
account_id,
|
||||
current_time - (push_verify_timeout + Duration::from_millis(1)),
|
||||
);
|
||||
}
|
||||
|
||||
if last_verify
|
||||
.get(&account_id)
|
||||
.map(|last_verify| {
|
||||
current_time - *last_verify > push_verify_timeout
|
||||
})
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let core = server.core.clone();
|
||||
let push_client = push_client.clone();
|
||||
tokio::spawn(async move {
|
||||
http_request(
|
||||
&push_client,
|
||||
&subscription,
|
||||
format!(
|
||||
concat!(
|
||||
"{{\"@type\":\"PushVerification\",",
|
||||
"\"pushSubscriptionId\":\"{}\",",
|
||||
"\"verificationCode\":\"{}\"}}"
|
||||
),
|
||||
Id::from(subscription.id),
|
||||
subscription.verification_code
|
||||
)
|
||||
.into_bytes(),
|
||||
push_timeout,
|
||||
core.jmap.vapid.as_ref(),
|
||||
Urgency::Normal,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
last_verify.insert(account_id, current_time);
|
||||
} else {
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "Failed to verify push subscription",
|
||||
Url = subscription.url.clone(),
|
||||
AccountId = account_id,
|
||||
Reason = "Too many requests"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update subscriptions
|
||||
let mut remove_push_ids = AHashSet::new();
|
||||
let mut active_account_ids = Vec::new();
|
||||
let mut inactive_account_ids = Vec::new();
|
||||
match (old_account_push_ids, account_push_ids.get(&account_id)) {
|
||||
(Some(old), Some(current)) if &old != current => {
|
||||
for id in old.difference(current) {
|
||||
remove_push_ids.insert(*id);
|
||||
}
|
||||
active_account_ids = member_account_ids;
|
||||
}
|
||||
(Some(old), None) => {
|
||||
remove_push_ids = old;
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
active_account_ids = member_account_ids;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Update push server registrations
|
||||
if !remove_push_ids.is_empty() {
|
||||
for id in remove_push_ids {
|
||||
if let Some(subscription) = push_servers.remove(&id) {
|
||||
for account_id in &subscription.member_account_ids {
|
||||
if let Some(ids) = account_push_ids.get_mut(account_id) {
|
||||
ids.remove(&id);
|
||||
if ids.is_empty() {
|
||||
account_push_ids.remove(account_id);
|
||||
inactive_account_ids.push(*account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!active_account_ids.is_empty() || !inactive_account_ids.is_empty())
|
||||
&& server
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.clone()
|
||||
.send(PushEvent::PushServerRegister {
|
||||
activate: active_account_ids,
|
||||
expired: inactive_account_ids,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending state change.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Push { notification } => {
|
||||
let account_id = notification.account_id();
|
||||
if let Some(ids) = account_push_ids.get_mut(&account_id) {
|
||||
let current_time = now();
|
||||
let mut remove_ids = Vec::new();
|
||||
|
||||
for id in ids.iter() {
|
||||
if let Some(subscription) = push_servers.get_mut(id) {
|
||||
if subscription.server.expires > current_time {
|
||||
if let Some(mut notification) =
|
||||
notification.filter_types(&subscription.server.types)
|
||||
{
|
||||
if let PushNotification::EmailPush(email_push) =
|
||||
¬ification
|
||||
&& !subscription
|
||||
.server
|
||||
.email_push
|
||||
.iter()
|
||||
.any(|ep| ep.account_id == account_id)
|
||||
{
|
||||
notification = PushNotification::StateChange(
|
||||
email_push.to_state_change(),
|
||||
);
|
||||
}
|
||||
|
||||
subscription.notifications.push(notification);
|
||||
let last_request = subscription.last_request.elapsed();
|
||||
|
||||
if !subscription.in_flight
|
||||
&& ((subscription.num_attempts == 0
|
||||
&& last_request > push_throttle)
|
||||
|| ((1..push_attempts_max)
|
||||
.contains(&subscription.num_attempts)
|
||||
&& last_request > push_attempt_interval))
|
||||
{
|
||||
subscription.send(
|
||||
*id,
|
||||
push_tx.clone(),
|
||||
push_timeout,
|
||||
server.clone(),
|
||||
);
|
||||
retry_ids.remove(id);
|
||||
} else {
|
||||
retry_ids.insert(*id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
push_servers.remove(id);
|
||||
}
|
||||
} else {
|
||||
remove_ids.push(*id);
|
||||
}
|
||||
}
|
||||
|
||||
if !remove_ids.is_empty() {
|
||||
for remove_id in remove_ids {
|
||||
ids.remove(&remove_id);
|
||||
}
|
||||
if ids.is_empty() {
|
||||
account_push_ids.remove(&account_id);
|
||||
if server
|
||||
.inner
|
||||
.ipc
|
||||
.push_tx
|
||||
.clone()
|
||||
.send(PushEvent::PushServerRegister {
|
||||
activate: vec![],
|
||||
expired: vec![account_id],
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
trc::event!(
|
||||
Server(ServerEvent::ThreadError),
|
||||
Details = "Error sending state change.",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Reset => {
|
||||
push_servers.clear();
|
||||
account_push_ids.clear();
|
||||
}
|
||||
Event::DeliverySuccess { id } => {
|
||||
if let Some(subscription) = push_servers.get_mut(&id) {
|
||||
subscription.num_attempts = 0;
|
||||
subscription.in_flight = false;
|
||||
retry_ids.remove(&id);
|
||||
}
|
||||
}
|
||||
Event::DeliveryFailure { id, notifications } => {
|
||||
if let Some(subscription) = push_servers.get_mut(&id) {
|
||||
subscription.last_request = Instant::now();
|
||||
subscription.num_attempts += 1;
|
||||
subscription.notifications.extend(notifications);
|
||||
subscription.in_flight = false;
|
||||
retry_ids.insert(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
break;
|
||||
}
|
||||
Err(_) => (),
|
||||
}
|
||||
|
||||
retry_timeout = if !retry_ids.is_empty() {
|
||||
let last_retry_elapsed = last_retry.elapsed();
|
||||
|
||||
if last_retry_elapsed >= push_retry_interval {
|
||||
let mut remove_ids = Vec::with_capacity(retry_ids.len());
|
||||
|
||||
for retry_id in &retry_ids {
|
||||
if let Some(subscription) = push_servers.get_mut(retry_id) {
|
||||
let last_request = subscription.last_request.elapsed();
|
||||
|
||||
if !subscription.in_flight
|
||||
&& ((subscription.num_attempts == 0
|
||||
&& last_request >= push_throttle)
|
||||
|| (subscription.num_attempts > 0
|
||||
&& last_request >= push_attempt_interval))
|
||||
{
|
||||
if subscription.num_attempts < push_attempts_max {
|
||||
subscription.send(
|
||||
*retry_id,
|
||||
push_tx.clone(),
|
||||
push_timeout,
|
||||
server.clone(),
|
||||
);
|
||||
} else {
|
||||
trc::event!(
|
||||
PushSubscription(PushSubscriptionEvent::Error),
|
||||
Details = "Failed to deliver push subscription",
|
||||
Url = subscription.server.url.clone(),
|
||||
Reason = "Too many failed attempts"
|
||||
);
|
||||
|
||||
subscription.notifications.clear();
|
||||
subscription.num_attempts = 0;
|
||||
}
|
||||
remove_ids.push(*retry_id);
|
||||
}
|
||||
} else {
|
||||
remove_ids.push(*retry_id);
|
||||
}
|
||||
}
|
||||
|
||||
if remove_ids.len() < retry_ids.len() {
|
||||
for remove_id in remove_ids {
|
||||
retry_ids.remove(&remove_id);
|
||||
}
|
||||
last_retry = Instant::now();
|
||||
push_retry_interval
|
||||
} else {
|
||||
retry_ids.clear();
|
||||
LONG_1Y_SLUMBER
|
||||
}
|
||||
} else {
|
||||
push_retry_interval - last_retry_elapsed
|
||||
}
|
||||
} else {
|
||||
LONG_1Y_SLUMBER
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
push_tx_
|
||||
}
|
||||
|
||||
async fn load_push_subscriptions(
|
||||
server: &Server,
|
||||
account_id: u32,
|
||||
) -> trc::Result<(PushSubscriptions, Vec<u32>)> {
|
||||
let member_of = server
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.member_ids()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(push_subscriptions) = server
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Principal,
|
||||
0,
|
||||
PrincipalField::PushSubscriptions,
|
||||
))
|
||||
.await?
|
||||
{
|
||||
push_subscriptions
|
||||
.deserialize::<PushSubscriptions>()
|
||||
.map(|push_subscriptions| (push_subscriptions, member_of))
|
||||
.caused_by(trc::location!())
|
||||
} else {
|
||||
Ok((PushSubscriptions::default(), member_of))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user