Skip to main content

baukit_push/
port.rs

1//! The provider-neutral push delivery port.
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    pin::Pin,
6    time::Duration,
7};
8
9use thiserror::Error;
10
11use crate::{DeviceToken, PushValidationError};
12
13/// Longest provider ticket ID accepted, in bytes.
14pub const MAX_PUSH_TICKET_ID_LENGTH: usize = 128;
15
16/// One notification addressed to a single device token.
17///
18/// `data` travels alongside the visible notification and reaches the app when
19/// the user opens it. Keys are ordered so the serialized payload is stable,
20/// which keeps request bodies comparable in tests and logs.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct PushMessage {
23    /// Provider-issued device token the notification is delivered to.
24    pub token: DeviceToken,
25    /// Notification title shown on the device.
26    pub title: String,
27    /// Notification body shown on the device.
28    pub body: String,
29    /// Optional key-value payload delivered with the notification.
30    pub data: BTreeMap<String, String>,
31    /// Provider channel the notification belongs to, when the platform has channels.
32    ///
33    /// Android reads this to pick the notification channel. Leave it `None` for
34    /// the provider default.
35    pub channel_id: Option<String>,
36}
37
38impl PushMessage {
39    /// Creates a message for a token with a title and body and no extra data.
40    pub fn new(token: DeviceToken, title: impl Into<String>, body: impl Into<String>) -> Self {
41        Self {
42            token,
43            title: title.into(),
44            body: body.into(),
45            data: BTreeMap::new(),
46            channel_id: None,
47        }
48    }
49
50    /// Adds one key-value pair to the notification payload.
51    #[must_use]
52    pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
53        self.data.insert(key.into(), value.into());
54        self
55    }
56
57    /// Sets the provider channel the notification belongs to.
58    #[must_use]
59    pub fn with_channel_id(mut self, channel_id: impl Into<String>) -> Self {
60        self.channel_id = Some(channel_id.into());
61        self
62    }
63}
64
65/// Why a provider rejected one notification.
66///
67/// The variants cover the vocabulary push providers actually return. Anything
68/// outside it becomes [`PushRejection::Other`] with the provider's own string,
69/// so a new provider code is visible in logs instead of being silently dropped.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum PushRejection {
72    /// The token no longer belongs to an installed app.
73    ///
74    /// This is permanent. Delete the token; see [`PushOutcome::is_token_dead`].
75    DeviceNotRegistered,
76    /// The serialized notification exceeded the provider's size limit.
77    MessageTooBig,
78    /// Too many notifications were sent to this token in too short a window.
79    MessageRateExceeded,
80    /// The provider credential was missing, malformed, or rejected.
81    InvalidCredentials,
82    /// The upstream platform (APNs, FCM) refused the notification.
83    ProviderError,
84    /// A provider code outside the known vocabulary.
85    Other(String),
86}
87
88impl PushRejection {
89    /// Parses a provider error code into a rejection.
90    #[must_use]
91    pub fn from_code(code: &str) -> Self {
92        match code {
93            "DeviceNotRegistered" => Self::DeviceNotRegistered,
94            "MessageTooBig" => Self::MessageTooBig,
95            "MessageRateExceeded" => Self::MessageRateExceeded,
96            "InvalidCredentials" => Self::InvalidCredentials,
97            "MismatchSenderId" | "ProviderError" => Self::ProviderError,
98            other => Self::Other(other.to_owned()),
99        }
100    }
101
102    /// Returns whether resending the same notification can succeed.
103    ///
104    /// Only [`PushRejection::MessageRateExceeded`] and
105    /// [`PushRejection::ProviderError`] are worth another attempt. A dead
106    /// token, an oversized payload, and a rejected credential all fail the same
107    /// way again.
108    #[must_use]
109    pub const fn is_retryable(&self) -> bool {
110        matches!(self, Self::MessageRateExceeded | Self::ProviderError)
111    }
112}
113
114/// What happened to one notification, keyed by the token it was addressed to.
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct PushOutcome {
117    /// The device token the notification was addressed to.
118    pub token: DeviceToken,
119    /// The delivery state the provider reported.
120    pub status: PushDeliveryStatus,
121}
122
123impl PushOutcome {
124    /// Returns whether the token should be deleted from the caller's store.
125    ///
126    /// True only for [`PushRejection::DeviceNotRegistered`]. Every other
127    /// rejection describes the notification, not the token.
128    #[must_use]
129    pub const fn is_token_dead(&self) -> bool {
130        matches!(
131            &self.status,
132            PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered)
133        )
134    }
135}
136
137/// The ID a provider issued for one accepted notification.
138///
139/// Poll the provider's receipt for it through [`PushReceiptSource`].
140#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct PushTicketId(String);
142
143impl PushTicketId {
144    /// Validates a ticket ID: 1 to [`MAX_PUSH_TICKET_ID_LENGTH`] bytes of visible ASCII.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`PushValidationError::TicketId`] for any other value.
149    pub fn new(id: impl Into<String>) -> Result<Self, PushValidationError> {
150        let id = id.into();
151        let valid = !id.is_empty()
152            && id.len() <= MAX_PUSH_TICKET_ID_LENGTH
153            && id.bytes().all(|byte| byte.is_ascii_graphic());
154        if valid {
155            Ok(Self(id))
156        } else {
157            Err(PushValidationError::TicketId)
158        }
159    }
160
161    /// Returns the ID for a provider request or a store query.
162    #[must_use]
163    pub fn as_str(&self) -> &str {
164        &self.0
165    }
166}
167
168/// The delivery state a provider reported for one notification.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub enum PushDeliveryStatus {
171    /// The provider took the notification but has not reported an outcome yet.
172    ///
173    /// Providers confirm delivery asynchronously. Treat this as neither success
174    /// nor failure and do not resend on it. Record the ticket with
175    /// [`PendingReceiptStore::record_accepted`](crate::PendingReceiptStore::record_accepted)
176    /// to poll its receipt later.
177    Accepted(PushTicketId),
178    /// The provider handed the notification to the device platform.
179    Delivered,
180    /// The provider refused the notification.
181    Rejected(PushRejection),
182}
183
184/// The settled receipt for one accepted notification.
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub enum PushReceipt {
187    /// The provider handed the notification to the device platform.
188    Delivered,
189    /// The provider refused the notification after accepting it.
190    Rejected(PushRejection),
191}
192
193impl PushReceipt {
194    /// Returns whether the token the notification went to should be deleted.
195    ///
196    /// True only for [`PushRejection::DeviceNotRegistered`].
197    #[must_use]
198    pub const fn is_token_dead(&self) -> bool {
199        matches!(self, Self::Rejected(PushRejection::DeviceNotRegistered))
200    }
201}
202
203impl From<PushReceipt> for PushDeliveryStatus {
204    fn from(receipt: PushReceipt) -> Self {
205        match receipt {
206            PushReceipt::Delivered => Self::Delivered,
207            PushReceipt::Rejected(rejection) => Self::Rejected(rejection),
208        }
209    }
210}
211
212/// A failure that prevented the whole batch from being processed.
213///
214/// Per-notification refusals are not errors; they arrive as
215/// [`PushDeliveryStatus::Rejected`] inside a successful result.
216#[derive(Clone, Debug, Error, Eq, PartialEq)]
217pub enum PushError {
218    /// The request never reached the provider, or the provider is failing.
219    #[error("push transport failed: {class:?}")]
220    Transport {
221        /// How the caller should react, including any provider-requested delay.
222        class: baukit_http::RetryClass,
223    },
224    /// The provider answered with a body that does not match its contract.
225    #[error("push provider returned an invalid response: {0}")]
226    InvalidResponse(String),
227}
228
229impl PushError {
230    /// Returns whether sending the same batch again can succeed.
231    #[must_use]
232    pub const fn is_retryable(&self) -> bool {
233        match self {
234            Self::Transport { class } => class.is_retryable(),
235            Self::InvalidResponse(_) => false,
236        }
237    }
238
239    /// Returns the wait the provider asked for, if it named one.
240    #[must_use]
241    pub const fn retry_after(&self) -> Option<Duration> {
242        match self {
243            Self::Transport { class } => class.retry_after(),
244            Self::InvalidResponse(_) => None,
245        }
246    }
247}
248
249/// The future returned by [`PushSender::send`].
250pub type PushFuture<'a> =
251    Pin<Box<dyn Future<Output = Result<Vec<PushOutcome>, PushError>> + Send + 'a>>;
252
253/// Outbound port for delivering notifications to devices.
254///
255/// Implementations split a batch into provider-sized chunks themselves. The
256/// returned outcomes cover every message in the batch, in no guaranteed order;
257/// match them to messages by token.
258pub trait PushSender: Send + Sync {
259    /// Delivers a batch of notifications and reports the outcome of each.
260    fn send<'a>(&'a self, batch: Vec<PushMessage>) -> PushFuture<'a>;
261}
262
263/// The future returned by [`PushReceiptSource::receipts`].
264pub type PushReceiptFuture<'a> = Pin<
265    Box<dyn Future<Output = Result<HashMap<PushTicketId, PushReceipt>, PushError>> + Send + 'a>,
266>;
267
268/// Outbound port for reading the receipts of accepted notifications.
269///
270/// Implementations split the IDs into provider-sized requests themselves.
271pub trait PushReceiptSource: Send + Sync {
272    /// Fetches the settled receipts for the given tickets.
273    ///
274    /// A ticket the provider has not settled yet is absent from the map.
275    fn receipts<'a>(&'a self, tickets: Vec<PushTicketId>) -> PushReceiptFuture<'a>;
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn token() -> DeviceToken {
283        DeviceToken::new("t").expect("valid test token")
284    }
285
286    #[test]
287    fn known_provider_codes_map_onto_the_neutral_vocabulary() {
288        assert_eq!(
289            PushRejection::from_code("DeviceNotRegistered"),
290            PushRejection::DeviceNotRegistered
291        );
292        assert_eq!(
293            PushRejection::from_code("MessageTooBig"),
294            PushRejection::MessageTooBig
295        );
296        assert_eq!(
297            PushRejection::from_code("MessageRateExceeded"),
298            PushRejection::MessageRateExceeded
299        );
300        assert_eq!(
301            PushRejection::from_code("InvalidCredentials"),
302            PushRejection::InvalidCredentials
303        );
304        assert_eq!(
305            PushRejection::from_code("MismatchSenderId"),
306            PushRejection::ProviderError
307        );
308    }
309
310    #[test]
311    fn an_unknown_code_is_preserved_verbatim() {
312        assert_eq!(
313            PushRejection::from_code("SomeNewExpoCode"),
314            PushRejection::Other("SomeNewExpoCode".to_owned())
315        );
316    }
317
318    #[test]
319    fn only_rate_limits_and_provider_faults_are_worth_resending() {
320        assert!(PushRejection::MessageRateExceeded.is_retryable());
321        assert!(PushRejection::ProviderError.is_retryable());
322        assert!(!PushRejection::DeviceNotRegistered.is_retryable());
323        assert!(!PushRejection::MessageTooBig.is_retryable());
324        assert!(!PushRejection::InvalidCredentials.is_retryable());
325        assert!(!PushRejection::Other("x".to_owned()).is_retryable());
326    }
327
328    #[test]
329    fn only_an_unregistered_device_marks_a_token_dead() {
330        let dead = PushOutcome {
331            token: token(),
332            status: PushDeliveryStatus::Rejected(PushRejection::DeviceNotRegistered),
333        };
334        assert!(dead.is_token_dead());
335        for status in [
336            PushDeliveryStatus::Accepted(PushTicketId::new("ticket").expect("valid ticket")),
337            PushDeliveryStatus::Delivered,
338            PushDeliveryStatus::Rejected(PushRejection::MessageTooBig),
339        ] {
340            let outcome = PushOutcome {
341                token: token(),
342                status,
343            };
344            assert!(!outcome.is_token_dead());
345        }
346    }
347
348    #[test]
349    fn an_outcome_never_shows_its_token_in_debug_output() {
350        let outcome = PushOutcome {
351            token: DeviceToken::new("ExponentPushToken[secret]").expect("valid token"),
352            status: PushDeliveryStatus::Delivered,
353        };
354        let message = PushMessage::new(outcome.token.clone(), "Title", "Body");
355        assert!(!format!("{outcome:?} {message:?}").contains("secret"));
356    }
357
358    #[test]
359    fn transport_errors_carry_the_providers_retry_delay() {
360        let error = PushError::Transport {
361            class: baukit_http::RetryClass::RetryAfter(Duration::from_secs(30)),
362        };
363        assert!(error.is_retryable());
364        assert_eq!(error.retry_after(), Some(Duration::from_secs(30)));
365
366        let invalid = PushError::InvalidResponse("no data field".to_owned());
367        assert!(!invalid.is_retryable());
368        assert_eq!(invalid.retry_after(), None);
369    }
370
371    #[test]
372    fn a_message_builds_with_data_and_a_channel() {
373        let message = PushMessage::new(token(), "Title", "Body")
374            .with_data("session_id", "abc")
375            .with_channel_id("reminders");
376        assert_eq!(message.data["session_id"], "abc");
377        assert_eq!(message.channel_id.as_deref(), Some("reminders"));
378    }
379
380    #[test]
381    fn a_ticket_id_accepts_visible_ascii_up_to_the_limit() {
382        assert!(PushTicketId::new("019285a1-7c3e-7d2a-9f1b-3c4d5e6f7a8b").is_ok());
383        assert!(PushTicketId::new("x".repeat(MAX_PUSH_TICKET_ID_LENGTH)).is_ok());
384        for invalid in [
385            String::new(),
386            "x".repeat(MAX_PUSH_TICKET_ID_LENGTH + 1),
387            "with space".to_owned(),
388        ] {
389            assert_eq!(
390                PushTicketId::new(invalid),
391                Err(PushValidationError::TicketId)
392            );
393        }
394    }
395
396    #[test]
397    fn only_an_unregistered_device_receipt_marks_a_token_dead() {
398        assert!(PushReceipt::Rejected(PushRejection::DeviceNotRegistered).is_token_dead());
399        assert!(!PushReceipt::Rejected(PushRejection::MessageTooBig).is_token_dead());
400        assert!(!PushReceipt::Delivered.is_token_dead());
401        assert_eq!(
402            PushDeliveryStatus::from(PushReceipt::Delivered),
403            PushDeliveryStatus::Delivered
404        );
405    }
406}