1use crate::heddle::api::common::{CallFailureCode, ErrorReason};
8use crate::heddle::api::v1alpha2::{
9 EffectiveDelivery, NotificationPreferences, NotificationRule,
10 SetNotificationPreferencesRequest, SpoolRef, UnsubscribeNotificationsRequest,
11 effective_delivery::Source,
12 notification_rule::{Channel, Delivery},
13};
14
15pub const MAX_EFFECTIVE_DELIVERIES: usize = 4096;
16pub const MAX_NOTIFICATION_PREFERENCES_BYTES: usize = 1024 * 1024;
17pub const LOCKED_EMAIL_KINDS: &[&str] = &["account_security", "security_surface"];
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
20pub enum NotificationValidationError {
21 #[error("unknown channel or unspecified/unknown delivery")]
22 InvalidRule,
23 #[error("DIGEST requires the email channel")]
24 DigestRequiresEmail,
25 #[error("digest interval must be zero, one hour, one day or one week with zero nanos")]
26 InvalidDigestInterval,
27 #[error("security/recovery email must remain IMMEDIATE")]
28 LockedEmail,
29 #[error("unsubscribe requires DISABLED delivery")]
30 UnsubscribeDelivery,
31 #[error("notification preferences exceed the read projection bound")]
32 ProjectionTooLarge,
33 #[error("ancestor chain must be complete, acyclic and within the host's 64/128-node bound")]
34 InvalidAncestry,
35 #[error("invalid or unauthorized delivery provenance")]
36 InvalidSource,
37}
38
39impl NotificationValidationError {
40 pub const fn code(self) -> CallFailureCode {
41 match self {
42 Self::LockedEmail | Self::InvalidAncestry => CallFailureCode::FailedPrecondition,
43 Self::ProjectionTooLarge => CallFailureCode::ResourceExhausted,
44 _ => CallFailureCode::InvalidArgument,
45 }
46 }
47
48 pub const fn reason(self) -> ErrorReason {
49 match self {
50 Self::LockedEmail | Self::InvalidAncestry => ErrorReason::PolicyDenied,
51 Self::ProjectionTooLarge => ErrorReason::QuotaExceeded,
52 _ => ErrorReason::FieldInvalid,
53 }
54 }
55}
56
57pub fn validate_notification_rule(
61 rule: &NotificationRule,
62) -> Result<(), NotificationValidationError> {
63 let channel =
64 Channel::try_from(rule.channel).map_err(|_| NotificationValidationError::InvalidRule)?;
65 let delivery =
66 Delivery::try_from(rule.delivery).map_err(|_| NotificationValidationError::InvalidRule)?;
67 if delivery == Delivery::Unspecified {
68 return Err(NotificationValidationError::InvalidRule);
69 }
70 let matches_locked = rule.kind.is_empty()
71 || rule.kind == "*"
72 || LOCKED_EMAIL_KINDS.contains(&rule.kind.as_str());
73 if matches_locked
74 && matches!(channel, Channel::Email | Channel::Unspecified)
75 && delivery != Delivery::Immediate
76 {
77 return Err(NotificationValidationError::LockedEmail);
78 }
79 if delivery == Delivery::Digest && channel != Channel::Email {
80 return Err(NotificationValidationError::DigestRequiresEmail);
81 }
82 Ok(())
83}
84
85pub fn validate_notification_preferences_write(
86 request: &SetNotificationPreferencesRequest,
87) -> Result<(), NotificationValidationError> {
88 let preferences = request
89 .preferences
90 .as_ref()
91 .ok_or(NotificationValidationError::InvalidRule)?;
92 if let Some(interval) = &preferences.digest_interval {
93 validate_digest_interval(interval)?;
94 }
95 for digest_override in &preferences.digest_overrides {
96 let interval = digest_override
97 .digest_interval
98 .as_ref()
99 .ok_or(NotificationValidationError::InvalidDigestInterval)?;
100 validate_digest_interval(interval)?;
101 }
102 for rule in &preferences.rules {
103 validate_notification_rule(rule)?;
104 }
105 Ok(())
106}
107
108pub fn validate_notification_preferences_write_for_system_root(
111 request: &SetNotificationPreferencesRequest,
112 system_root: &SpoolRef,
113) -> Result<(), NotificationValidationError> {
114 validate_notification_preferences_write(request)?;
115 if request.preferences.as_ref().is_some_and(|preferences| {
116 preferences
117 .rules
118 .iter()
119 .any(|rule| rule.spool.as_ref() == Some(system_root))
120 }) {
121 return Err(NotificationValidationError::InvalidRule);
122 }
123 Ok(())
124}
125
126fn validate_digest_interval(
127 interval: &prost_types::Duration,
128) -> Result<(), NotificationValidationError> {
129 if interval.nanos != 0 || !matches!(interval.seconds, 0 | 3600 | 86400 | 604800) {
130 return Err(NotificationValidationError::InvalidDigestInterval);
131 }
132 Ok(())
133}
134
135pub fn validate_unsubscribe_notifications(
136 request: &UnsubscribeNotificationsRequest,
137) -> Result<(), NotificationValidationError> {
138 for rule in &request.rules {
139 validate_notification_rule(rule)?;
140 if rule.delivery != Delivery::Disabled as i32 {
141 return Err(NotificationValidationError::UnsubscribeDelivery);
142 }
143 }
144 Ok(())
145}
146
147pub fn validate_notification_preferences_bound(
150 preferences: &NotificationPreferences,
151) -> Result<(), NotificationValidationError> {
152 use prost::Message;
153 if preferences.effective_delivery.len() > MAX_EFFECTIVE_DELIVERIES
154 || preferences.encoded_len() > MAX_NOTIFICATION_PREFERENCES_BYTES
155 {
156 return Err(NotificationValidationError::ProjectionTooLarge);
157 }
158 Ok(())
159}
160
161pub const MAX_NOTIFICATION_ANCESTORS: usize = 128;
163
164#[derive(Clone, Debug)]
168pub struct NotificationAncestor {
169 pub spool: SpoolRef,
170 pub readable: bool,
171 pub system_root: bool,
172}
173
174fn validate_ancestry(
175 cell: &EffectiveDelivery,
176 ancestors: &[NotificationAncestor],
177 ancestor_limit: usize,
178) -> Result<(), NotificationValidationError> {
179 if !matches!(ancestor_limit, 64 | MAX_NOTIFICATION_ANCESTORS)
180 || ancestors.len() > ancestor_limit
181 || cell.spool.as_ref() != ancestors.first().map(|level| &level.spool)
182 || ancestors.iter().enumerate().any(|(i, level)| {
183 level.spool.id.is_empty()
184 || ancestors[..i]
185 .iter()
186 .any(|other| other.spool == level.spool)
187 || (level.system_root && (i == 0 || i + 1 != ancestors.len()))
188 })
189 {
190 return Err(NotificationValidationError::InvalidAncestry);
191 }
192 if ancestors.first().is_some_and(|level| !level.readable) {
193 return Err(NotificationValidationError::InvalidSource);
194 }
195 Ok(())
196}
197
198pub fn resolve_notification_delivery(
203 rules: &[NotificationRule],
204 cell: &EffectiveDelivery,
205 ancestors: &[NotificationAncestor],
206 ancestor_limit: usize,
207 default_delivery: Delivery,
208 digest_enabled: bool,
209) -> Result<EffectiveDelivery, NotificationValidationError> {
210 validate_ancestry(cell, ancestors, ancestor_limit)?;
211 let channel =
212 Channel::try_from(cell.channel).map_err(|_| NotificationValidationError::InvalidRule)?;
213 if channel == Channel::Unspecified
214 || matches!(cell.kind.as_str(), "" | "*")
215 || !matches!(cell.actor_origin.as_str(), "" | "human" | "agent")
216 || default_delivery == Delivery::Unspecified
217 || (default_delivery == Delivery::Digest && channel != Channel::Email)
218 {
219 return Err(NotificationValidationError::InvalidRule);
220 }
221 for rule in rules {
222 validate_notification_rule(rule)?;
223 if ancestors
224 .iter()
225 .any(|level| level.system_root && rule.spool.as_ref() == Some(&level.spool))
226 {
227 return Err(NotificationValidationError::InvalidRule);
228 }
229 }
230 let mut selected = None;
231 for (i, level) in ancestors
232 .iter()
233 .enumerate()
234 .filter(|(_, level)| !level.system_root)
235 {
236 if let Some(rule) = best_notification_rule(rules, cell, Some(&level.spool)) {
237 let source = if i == 0 {
238 Source::Rule
239 } else {
240 Source::Inherited
241 };
242 let source_spool = (i > 0 && level.readable).then(|| level.spool.clone());
243 selected = Some((rule.delivery, source, source_spool));
244 break;
245 }
246 }
247 if selected.is_none() {
248 selected = best_notification_rule(rules, cell, None).map(|rule| {
249 (
250 rule.delivery,
251 if cell.spool.is_some() {
252 Source::Account
253 } else {
254 Source::Rule
255 },
256 None,
257 )
258 });
259 }
260 let (mut delivery, source, source_spool) =
261 selected.unwrap_or((default_delivery as i32, Source::Default, None));
262 let locked = channel == Channel::Email && LOCKED_EMAIL_KINDS.contains(&cell.kind.as_str());
263 if locked {
264 delivery = Delivery::Immediate as i32;
265 } else if delivery == Delivery::Digest as i32 && !digest_enabled {
266 delivery = Delivery::Disabled as i32;
267 }
268 Ok(EffectiveDelivery {
269 kind: cell.kind.clone(),
270 spool: cell.spool.clone(),
271 actor_origin: cell.actor_origin.clone(),
272 channel: cell.channel,
273 delivery,
274 source: source as i32,
275 locked,
276 source_spool,
277 })
278}
279
280fn best_notification_rule<'a>(
281 rules: &'a [NotificationRule],
282 cell: &EffectiveDelivery,
283 spool: Option<&SpoolRef>,
284) -> Option<&'a NotificationRule> {
285 let mut best: Option<(u8, &NotificationRule)> = None;
286 for rule in rules {
287 let exact_kind = !matches!(rule.kind.as_str(), "" | "*");
288 let exact_channel = rule.channel != Channel::Unspecified as i32;
289 let exact_origin = !matches!(rule.actor_origin.as_str(), "" | "any");
290 if rule.spool.as_ref() != spool
291 || (exact_kind && rule.kind != cell.kind)
292 || (exact_channel && rule.channel != cell.channel)
293 || (exact_origin && rule.actor_origin != cell.actor_origin)
294 {
295 continue;
296 }
297 let score = u8::from(exact_kind) * 4 + u8::from(exact_channel) * 2 + u8::from(exact_origin);
298 if best.is_none_or(|(previous, _)| score > previous) {
299 best = Some((score, rule));
300 }
301 }
302 best.map(|(_, rule)| rule)
303}
304
305pub fn validate_effective_delivery_source(
308 cell: &EffectiveDelivery,
309 ancestors: &[NotificationAncestor],
310 ancestor_limit: usize,
311) -> Result<(), NotificationValidationError> {
312 validate_ancestry(cell, ancestors, ancestor_limit)?;
313 let valid = match Source::try_from(cell.source) {
314 Ok(Source::Rule | Source::Default) => cell.source_spool.is_none(),
315 Ok(Source::Account) => cell.spool.is_some() && cell.source_spool.is_none(),
316 Ok(Source::Inherited) => {
317 cell.spool.is_some()
318 && ancestors.iter().skip(1).any(|level| {
319 !level.system_root
320 && match &cell.source_spool {
321 Some(spool) => level.readable && spool == &level.spool,
322 None => !level.readable,
323 }
324 })
325 }
326 _ => false,
327 };
328 if valid {
329 Ok(())
330 } else {
331 Err(NotificationValidationError::InvalidSource)
332 }
333}
334
335pub fn notification_projection_scopes(
338 request: &crate::heddle::api::v1alpha2::ObserveNotificationsRequest,
339 rules: &[NotificationRule],
340 readable: &[SpoolRef],
341) -> Result<Vec<Option<SpoolRef>>, NotificationValidationError> {
342 if let Some(spool) = &request.effective_delivery_spool {
343 if !request.include_preferences || spool.id.is_empty() || !readable.contains(spool) {
344 return Err(NotificationValidationError::InvalidSource);
345 }
346 return Ok(vec![Some(spool.clone())]);
347 }
348 let mut scopes = vec![None];
349 for rule in rules {
350 if let Some(spool) = &rule.spool
351 && readable.contains(spool)
352 && !scopes.contains(&Some(spool.clone()))
353 {
354 scopes.push(Some(spool.clone()));
355 }
356 }
357 Ok(scopes)
358}
359
360pub fn replace_notification_preferences(
364 stored: &NotificationPreferences,
365 request: &SetNotificationPreferencesRequest,
366 readable: &[SpoolRef],
367) -> Result<NotificationPreferences, NotificationValidationError> {
368 validate_notification_preferences_write(request)?;
369 let mut next = request
370 .preferences
371 .clone()
372 .ok_or(NotificationValidationError::InvalidRule)?;
373 if next.rules.iter().any(|rule| {
374 rule.spool
375 .as_ref()
376 .is_some_and(|spool| !readable.contains(spool))
377 }) || next.digest_overrides.iter().any(|item| {
378 item.spool
379 .as_ref()
380 .is_none_or(|spool| !readable.contains(spool))
381 }) {
382 return Err(NotificationValidationError::InvalidSource);
383 }
384 if !request.clear_unreadable_scopes {
385 next.rules.extend(
386 stored
387 .rules
388 .iter()
389 .filter(|rule| {
390 rule.spool
391 .as_ref()
392 .is_some_and(|spool| !readable.contains(spool))
393 })
394 .cloned(),
395 );
396 next.digest_overrides.extend(
397 stored
398 .digest_overrides
399 .iter()
400 .filter(|item| {
401 item.spool
402 .as_ref()
403 .is_some_and(|spool| !readable.contains(spool))
404 })
405 .cloned(),
406 );
407 }
408 next.effective_delivery.clear();
409 next.next_digest_at = None;
410 for item in &mut next.digest_overrides {
411 item.next_digest_at = None;
412 }
413 Ok(next)
414}
415
416pub fn resolve_invitation_notification_delivery(
420 rules: &[NotificationRule],
421 cell: &EffectiveDelivery,
422 ancestors: &[NotificationAncestor],
423 ancestor_limit: usize,
424 default_delivery: Delivery,
425 digest_enabled: bool,
426) -> Result<EffectiveDelivery, NotificationValidationError> {
427 if !matches!(
428 cell.kind.as_str(),
429 "spool_invitation" | "spool_invitation_declined"
430 ) {
431 return Err(NotificationValidationError::InvalidRule);
432 }
433 if cell.spool.is_some() && ancestors.first().is_some_and(|level| !level.readable) {
434 let account = EffectiveDelivery {
435 spool: None,
436 ..cell.clone()
437 };
438 return resolve_notification_delivery(
439 rules,
440 &account,
441 &[],
442 ancestor_limit,
443 default_delivery,
444 digest_enabled,
445 );
446 }
447 resolve_notification_delivery(
448 rules,
449 cell,
450 ancestors,
451 ancestor_limit,
452 default_delivery,
453 digest_enabled,
454 )
455}