Skip to main content

heddle_api/v2/
notifications.rs

1//! Portable notification delivery gates. Hosts must run these before either
2//! settings replacement or capability-authorized unsubscribe, then independently
3//! check authorization, destination verification and expected_version. These
4//! gates complement remaining stored-settings validation: hosts must also check
5//! timezone, selectors, duplicate overrides and input budgets.
6
7use crate::heddle::api::common::{CallFailureCode, ErrorReason};
8use crate::heddle::api::v1alpha2::{
9    NotificationPreferences, NotificationRule, SetNotificationPreferencesRequest,
10    UnsubscribeNotificationsRequest,
11    notification_rule::{Channel, Delivery},
12};
13
14pub const MAX_EFFECTIVE_DELIVERIES: usize = 4096;
15pub const MAX_NOTIFICATION_PREFERENCES_BYTES: usize = 1024 * 1024;
16pub const LOCKED_EMAIL_KINDS: &[&str] = &["account_security", "security_surface"];
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
19pub enum NotificationValidationError {
20    #[error("unknown channel or unspecified/unknown delivery")]
21    InvalidRule,
22    #[error("DIGEST requires the email channel")]
23    DigestRequiresEmail,
24    #[error("digest interval must be zero, one hour, one day or one week with zero nanos")]
25    InvalidDigestInterval,
26    #[error("security/recovery email must remain IMMEDIATE")]
27    LockedEmail,
28    #[error("unsubscribe requires DISABLED delivery")]
29    UnsubscribeDelivery,
30    #[error("notification preferences exceed the read projection bound")]
31    ProjectionTooLarge,
32}
33
34impl NotificationValidationError {
35    pub const fn code(self) -> CallFailureCode {
36        match self {
37            Self::LockedEmail => CallFailureCode::FailedPrecondition,
38            Self::ProjectionTooLarge => CallFailureCode::ResourceExhausted,
39            _ => CallFailureCode::InvalidArgument,
40        }
41    }
42
43    pub const fn reason(self) -> ErrorReason {
44        match self {
45            Self::LockedEmail => ErrorReason::PolicyDenied,
46            Self::ProjectionTooLarge => ErrorReason::QuotaExceeded,
47            _ => ErrorReason::FieldInvalid,
48        }
49    }
50}
51
52/// Check selector/channel semantics. Wildcard OFF rules that include locked
53/// email kinds are rejected rather than silently overriding the user's request.
54/// After enum validation, the email lock takes precedence over email-only DIGEST.
55pub fn validate_notification_rule(
56    rule: &NotificationRule,
57) -> Result<(), NotificationValidationError> {
58    let channel =
59        Channel::try_from(rule.channel).map_err(|_| NotificationValidationError::InvalidRule)?;
60    let delivery =
61        Delivery::try_from(rule.delivery).map_err(|_| NotificationValidationError::InvalidRule)?;
62    if delivery == Delivery::Unspecified {
63        return Err(NotificationValidationError::InvalidRule);
64    }
65    let matches_locked = rule.kind.is_empty()
66        || rule.kind == "*"
67        || LOCKED_EMAIL_KINDS.contains(&rule.kind.as_str());
68    if matches_locked
69        && matches!(channel, Channel::Email | Channel::Unspecified)
70        && delivery != Delivery::Immediate
71    {
72        return Err(NotificationValidationError::LockedEmail);
73    }
74    if delivery == Delivery::Digest && channel != Channel::Email {
75        return Err(NotificationValidationError::DigestRequiresEmail);
76    }
77    Ok(())
78}
79
80pub fn validate_notification_preferences_write(
81    request: &SetNotificationPreferencesRequest,
82) -> Result<(), NotificationValidationError> {
83    let preferences = request
84        .preferences
85        .as_ref()
86        .ok_or(NotificationValidationError::InvalidRule)?;
87    if let Some(interval) = &preferences.digest_interval {
88        validate_digest_interval(interval)?;
89    }
90    for digest_override in &preferences.digest_overrides {
91        let interval = digest_override
92            .digest_interval
93            .as_ref()
94            .ok_or(NotificationValidationError::InvalidDigestInterval)?;
95        validate_digest_interval(interval)?;
96    }
97    for rule in &preferences.rules {
98        validate_notification_rule(rule)?;
99    }
100    Ok(())
101}
102
103fn validate_digest_interval(
104    interval: &prost_types::Duration,
105) -> Result<(), NotificationValidationError> {
106    if interval.nanos != 0 || !matches!(interval.seconds, 0 | 3600 | 86400 | 604800) {
107        return Err(NotificationValidationError::InvalidDigestInterval);
108    }
109    Ok(())
110}
111
112pub fn validate_unsubscribe_notifications(
113    request: &UnsubscribeNotificationsRequest,
114) -> Result<(), NotificationValidationError> {
115    for rule in &request.rules {
116        validate_notification_rule(rule)?;
117        if rule.delivery != Delivery::Disabled as i32 {
118            return Err(NotificationValidationError::UnsubscribeDelivery);
119        }
120    }
121    Ok(())
122}
123
124/// Validate the documented bound before emitting a preferences read. Stored
125/// rules and string lengths count toward the byte cap, not only resolved cells.
126pub fn validate_notification_preferences_bound(
127    preferences: &NotificationPreferences,
128) -> Result<(), NotificationValidationError> {
129    use prost::Message;
130    if preferences.effective_delivery.len() > MAX_EFFECTIVE_DELIVERIES
131        || preferences.encoded_len() > MAX_NOTIFICATION_PREFERENCES_BYTES
132    {
133        return Err(NotificationValidationError::ProjectionTooLarge);
134    }
135    Ok(())
136}