Skip to main content

bark_apns/
message.rs

1use crate::{
2    crypto::Encryption,
3    error::{Error, Result},
4};
5use serde::Serialize;
6
7const DEFAULT_TITLE: &str = "Notification";
8const CATEGORY: &str = "myNotificationCategory";
9const ENCRYPTED_TITLE: &str = "Bark";
10const ENCRYPTED_BODY: &str = "Encrypted Message";
11
12/// Bark interruption level for a notification.
13///
14/// These values match Bark's documented `level` parameter. The level is also
15/// serialized into APNs as `aps.interruption-level` for direct delivery.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
17pub enum InterruptionLevel {
18    /// Important alert. Bark uses this with critical notification sound handling.
19    #[serde(rename = "critical")]
20    Critical,
21    /// Time-sensitive notification, available on iOS 15 and later.
22    #[serde(rename = "timeSensitive")]
23    TimeSensitive,
24    /// Default active notification behavior.
25    #[serde(rename = "active")]
26    Active,
27    /// Adds the notification to Notification Center without actively alerting.
28    #[serde(rename = "passive")]
29    Passive,
30}
31
32impl InterruptionLevel {
33    /// Returns the `level` string recognized by Bark.
34    pub const fn as_bark_str(self) -> &'static str {
35        match self {
36            Self::Critical => "critical",
37            Self::TimeSensitive => "timeSensitive",
38            Self::Active => "active",
39            Self::Passive => "passive",
40        }
41    }
42}
43
44/// Builder for a Bark-compatible push message.
45///
46/// `Message` serializes to the JSON payload sent to APNs. For normal pushes it
47/// writes both APNs `aps` fields and Bark custom fields. For encrypted pushes it
48/// encrypts Bark request fields into `ciphertext` and keeps only the APNs
49/// placeholder alert plus `ciphertext`/`iv` in clear text.
50///
51/// Start with [`Message::new`] and chain the fields that should be present:
52///
53/// ```
54/// use bark_apns::{InterruptionLevel, Message};
55///
56/// let message = Message::new()
57///     .title("Build")
58///     .body("finished")
59///     .group("ci")
60///     .level(InterruptionLevel::TimeSensitive);
61/// ```
62#[derive(Clone, Debug)]
63pub struct Message {
64    /// Push title shown by iOS and stored by Bark.
65    title: String,
66    /// Optional push subtitle shown by iOS.
67    subtitle: Option<String>,
68    /// Plain-text fallback body.
69    body: String,
70    /// Optional Markdown body rendered by Bark before display.
71    markdown: Option<String>,
72    /// Optional image URL downloaded and attached by Bark.
73    image: Option<String>,
74    /// APNs and Bark interruption level.
75    level: InterruptionLevel,
76    /// Critical alert volume in Bark's 0-10 scale.
77    volume: Option<u8>,
78    /// App badge value.
79    badge: Option<u64>,
80    /// Bark automatic copy flag.
81    auto_copy: Option<bool>,
82    /// Explicit text copied by Bark when automatic copy runs.
83    copy: Option<String>,
84    /// Bark sound name, without requiring a `.caf` suffix from callers.
85    sound: Option<String>,
86    /// Bark call-style repeated ringing flag.
87    call: bool,
88    /// Optional custom notification icon URL.
89    icon: Option<String>,
90    /// Bark group name and APNs thread id for normal pushes.
91    group: Option<String>,
92    /// Bark archive preference override.
93    archive: Option<bool>,
94    /// Optional archive retention time in seconds.
95    ttl: Option<u64>,
96    /// URL opened when the notification is tapped.
97    url: Option<String>,
98    /// Bark action parameter, such as `alert`.
99    action: Option<String>,
100    /// Bark message id and APNs collapse id.
101    id: Option<String>,
102    /// Whether this message is a Bark delete request.
103    delete: bool,
104    /// Optional encryption settings for encrypted Bark pushes.
105    encryption: Option<Encryption>,
106}
107
108impl Message {
109    /// Creates an empty message builder.
110    ///
111    /// The default title is `"Notification"`, the default level is
112    /// [`InterruptionLevel::Active`], and no sound is set. A normal notification
113    /// must eventually contain either [`Message::body`] or [`Message::markdown`].
114    /// Delete messages created with [`Message::delete`] do not require content.
115    pub fn new() -> Self {
116        Self {
117            title: DEFAULT_TITLE.to_owned(),
118            body: String::new(),
119            subtitle: None,
120            markdown: None,
121            image: None,
122            level: InterruptionLevel::Active,
123            volume: None,
124            badge: None,
125            auto_copy: None,
126            copy: None,
127            sound: None,
128            call: false,
129            icon: None,
130            group: None,
131            archive: None,
132            ttl: None,
133            url: None,
134            action: None,
135            id: None,
136            delete: false,
137            encryption: None,
138        }
139    }
140
141    /// Sets the push title.
142    ///
143    /// If omitted, Bark receives `"Notification"` as the title.
144    pub fn title<T>(mut self, title: T) -> Self
145    where
146        T: Into<String>,
147    {
148        self.title = title.into();
149        self
150    }
151
152    /// Sets the push subtitle.
153    ///
154    /// Bark maps this to the notification subtitle when the value is present.
155    /// Empty or whitespace-only values are omitted.
156    pub fn subtitle<S>(mut self, subtitle: S) -> Self
157    where
158        S: Into<String>,
159    {
160        self.subtitle = non_empty(subtitle.into());
161        self
162    }
163
164    /// Sets the plain-text push body.
165    ///
166    /// Bark uses this as the notification body unless `markdown` is also
167    /// provided. For encrypted pushes, this value is included inside the
168    /// encrypted Bark JSON.
169    pub fn body<B>(mut self, body: B) -> Self
170    where
171        B: Into<String>,
172    {
173        self.body = body.into();
174        self
175    }
176
177    /// Sets the Markdown body.
178    ///
179    /// Bark's notification service extension renders this value and treats it as
180    /// taking precedence over `body` for display. Empty or whitespace-only values
181    /// are omitted.
182    ///
183    /// Bark parses Markdown with Apple's Swift Markdown package and its own
184    /// `MarkdownParser`. The parser handles paragraphs, headings, block quotes,
185    /// strong emphasis, emphasis, strikethrough, inline code, fenced or indented
186    /// code blocks, links, images, ordered lists, unordered lists, nested list
187    /// indentation, task-list checkboxes, soft breaks, and hard line breaks.
188    ///
189    /// For push notification display, Bark's notification service extension uses
190    /// the parsed attributed string's plain `.string` value as the notification
191    /// body and collapses repeated blank lines. That means visual styles such as
192    /// bold, italic, strikethrough, link color, code font, and quote color are
193    /// not preserved in the notification banner itself; the rendered text,
194    /// headings, list markers, checkbox symbols, code text, link text, and image
195    /// alt text remain.
196    pub fn markdown<M>(mut self, markdown: M) -> Self
197    where
198        M: Into<String>,
199    {
200        self.markdown = non_empty(markdown.into());
201        self
202    }
203
204    /// Sets a push image URL.
205    ///
206    /// Bark downloads this image in the notification service extension and
207    /// attaches it to the notification when possible. Empty or whitespace-only
208    /// values are omitted.
209    pub fn image<I>(mut self, image: I) -> Self
210    where
211        I: Into<String>,
212    {
213        self.image = non_empty(image.into());
214        self
215    }
216
217    /// Sets the notification interruption level.
218    ///
219    /// Defaults to [`InterruptionLevel::Active`]. Critical notifications may
220    /// require the Bark app and APNs topic to have the appropriate entitlement.
221    pub fn level(mut self, level: InterruptionLevel) -> Self {
222        self.level = level;
223        self
224    }
225
226    /// Sets the critical alert volume from `0` to `10`.
227    ///
228    /// Bark uses this value for `level=critical`. Values larger than `10` are
229    /// clamped to `10`.
230    pub fn volume(mut self, volume: u8) -> Self {
231        self.volume = Some(volume.min(10));
232        self
233    }
234
235    /// Sets the app badge number.
236    ///
237    /// The value is serialized into APNs `aps.badge` and Bark's `badge`
238    /// parameter.
239    pub fn badge(mut self, badge: u64) -> Self {
240        self.badge = Some(badge);
241        self
242    }
243
244    /// Sets Bark's automatic copy flag.
245    ///
246    /// Bark interprets `"1"` as enabled. On newer iOS versions the user may
247    /// still need to long-press or expand the notification to copy.
248    pub fn auto_copy(mut self, auto_copy: bool) -> Self {
249        self.auto_copy = Some(auto_copy);
250        self
251    }
252
253    /// Sets the text Bark should copy when automatic copy runs.
254    ///
255    /// If omitted, Bark copies the notification body. Empty or whitespace-only
256    /// values are omitted.
257    pub fn copy<C>(mut self, copy: C) -> Self
258    where
259        C: Into<String>,
260    {
261        self.copy = non_empty(copy.into());
262        self
263    }
264
265    /// Sets the Bark sound name.
266    ///
267    /// This crate does not set a default sound, matching Bark's request
268    /// semantics. For normal pushes, APNs receives `aps.sound`; if the value does
269    /// not end in `.caf`, the APNs sound name is suffixed with `.caf`. For
270    /// encrypted pushes, the sound is kept inside the encrypted Bark JSON and is
271    /// not exposed in clear text.
272    pub fn sound<S>(mut self, sound: S) -> Self
273    where
274        S: Into<String>,
275    {
276        self.sound = non_empty(sound.into());
277        self
278    }
279
280    /// Enables or disables Bark's repeated call-style ringing.
281    ///
282    /// Bark expects `"1"` to enable this behavior. Passing `false` omits the
283    /// field.
284    pub fn call(mut self, call: bool) -> Self {
285        self.call = call;
286        self
287    }
288
289    /// Sets a custom icon URL.
290    ///
291    /// Bark's notification service extension downloads and caches the icon, then
292    /// uses it to replace the default Bark icon when iOS supports the feature.
293    /// Empty or whitespace-only values are omitted.
294    pub fn icon<I>(mut self, icon: I) -> Self
295    where
296        I: Into<String>,
297    {
298        self.icon = non_empty(icon.into());
299        self
300    }
301
302    /// Sets the Bark group name.
303    ///
304    /// The group is used for Notification Center grouping and for Bark's history
305    /// grouping. Normal pushes also set APNs `aps.thread-id`.
306    pub fn group<G>(mut self, group: G) -> Self
307    where
308        G: Into<String>,
309    {
310        self.group = non_empty(group.into());
311        self
312    }
313
314    /// Sets Bark's archive flag.
315    ///
316    /// `true` serializes `"1"` and asks Bark to save the push to history.
317    /// `false` serializes `"0"` and asks Bark not to archive it. If omitted,
318    /// Bark uses the app's own archive setting.
319    pub fn archive(mut self, archive: bool) -> Self {
320        self.archive = Some(archive);
321        self
322    }
323
324    /// Sets the archive time-to-live in seconds.
325    ///
326    /// Bark applies this only to messages saved to history.
327    pub fn ttl(mut self, ttl: u64) -> Self {
328        self.ttl = Some(ttl);
329        self
330    }
331
332    /// Sets the URL opened when the notification is tapped.
333    ///
334    /// Bark supports URL schemes and universal links. Empty or whitespace-only
335    /// values are omitted.
336    pub fn url<U>(mut self, url: U) -> Self
337    where
338        U: Into<String>,
339    {
340        self.url = non_empty(url.into());
341        self
342    }
343
344    /// Sets Bark's `action` parameter.
345    ///
346    /// Bark documents `action=alert` as showing an action popup when the user
347    /// opens the app from the notification.
348    pub fn action<A>(mut self, action: A) -> Self
349    where
350        A: Into<String>,
351    {
352        self.action = non_empty(action.into());
353        self
354    }
355
356    /// Sets the Bark message id.
357    ///
358    /// The same id can update a delivered notification. It is also required for
359    /// delete messages. APNs collapse ids must be at most 64 bytes, so this crate
360    /// validates the id before sending.
361    pub fn id<I>(mut self, id: I) -> Self
362    where
363        I: Into<String>,
364    {
365        self.id = non_empty(id.into());
366        self
367    }
368
369    /// Marks this message as a Bark delete request.
370    ///
371    /// Delete requests serialize as a background APNs payload with `delete=1`
372    /// and require [`Message::id`]. They do not require body or markdown
373    /// content.
374    pub fn delete(mut self) -> Self {
375        self.delete = true;
376        self
377    }
378
379    /// Encrypts Bark request fields before sending.
380    ///
381    /// The APNs payload will contain the placeholder alert plus top-level
382    /// `ciphertext` and, for CBC/GCM, `iv`. Fields such as `title`, `body`,
383    /// `markdown`, `sound`, `group`, and `badge` are placed in the encrypted JSON
384    /// rather than clear-text APNs custom fields.
385    pub fn encryption(mut self, encryption: Encryption) -> Self {
386        self.encryption = Some(encryption);
387        self
388    }
389
390    pub(crate) fn is_delete(&self) -> bool {
391        self.delete
392    }
393
394    pub(crate) fn id_value(&self) -> Option<&str> {
395        self.id.as_deref()
396    }
397
398    pub(crate) fn payload_bytes(&self) -> Result<Vec<u8>> {
399        Ok(serde_json::to_vec(&self.payload_value()?)?)
400    }
401
402    pub(crate) fn validate_headers(&self) -> Result<()> {
403        if let Some(id) = &self.id {
404            let actual = id.len();
405            if actual > 64 {
406                return Err(Error::InvalidCollapseId { actual });
407            }
408        }
409
410        if self.delete && self.id.is_none() {
411            return Err(Error::MissingMessageIdForDelete);
412        }
413
414        Ok(())
415    }
416
417    fn payload_value(&self) -> Result<serde_json::Value> {
418        self.validate_headers()?;
419
420        if self.delete {
421            return Ok(serde_json::to_value(DeletePayload {
422                aps: DeleteAps {
423                    content_available: 1,
424                },
425                delete: "1",
426                id: self.id.as_deref().expect("validated delete id"),
427            })?);
428        }
429
430        self.validate_content()?;
431
432        if let Some(encryption) = &self.encryption {
433            let fields = BarkPlaintextFields::from_message(self);
434            let plaintext = serde_json::to_vec(&fields)?;
435            let ciphertext = encryption.encrypt_bark_json(&plaintext)?;
436            return Ok(serde_json::to_value(EncryptedNotificationPayload {
437                aps: ApsPayload::encrypted(),
438                ciphertext,
439                iv: encryption.apns_iv(),
440            })?);
441        }
442
443        Ok(serde_json::to_value(NotificationPayload {
444            aps: ApsPayload::plain(self),
445            fields: BarkFields::from_message(self),
446        })?)
447    }
448
449    fn validate_content(&self) -> Result<()> {
450        if self.body.trim().is_empty() && self.markdown.is_none() {
451            return Err(Error::EmptyMessage);
452        }
453
454        Ok(())
455    }
456}
457
458impl Default for Message {
459    fn default() -> Self {
460        Self::new()
461    }
462}
463
464#[derive(Debug, Serialize)]
465struct NotificationPayload {
466    aps: ApsPayload,
467    #[serde(flatten)]
468    fields: BarkFields,
469}
470
471#[derive(Debug, Serialize)]
472struct EncryptedNotificationPayload<'a> {
473    aps: ApsPayload,
474    ciphertext: String,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    iv: Option<&'a str>,
477}
478
479#[derive(Debug, Serialize)]
480struct DeletePayload<'a> {
481    aps: DeleteAps,
482    delete: &'static str,
483    id: &'a str,
484}
485
486#[derive(Debug, Serialize)]
487struct DeleteAps {
488    #[serde(rename = "content-available")]
489    content_available: u8,
490}
491
492#[derive(Debug, Serialize)]
493struct ApsPayload {
494    #[serde(rename = "mutable-content")]
495    mutable_content: u8,
496    category: &'static str,
497    #[serde(rename = "interruption-level")]
498    interruption_level: InterruptionLevel,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    badge: Option<u64>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    sound: Option<String>,
503    #[serde(rename = "thread-id", skip_serializing_if = "Option::is_none")]
504    thread_id: Option<String>,
505    alert: AlertPayload,
506}
507
508impl ApsPayload {
509    fn plain(message: &Message) -> Self {
510        Self {
511            mutable_content: 1,
512            category: CATEGORY,
513            interruption_level: message.level,
514            badge: message.badge,
515            sound: message.sound.as_deref().map(apns_sound_name),
516            thread_id: message.group.clone(),
517            alert: AlertPayload {
518                title: message.title.clone(),
519                subtitle: message.subtitle.clone(),
520                body: message.body.clone(),
521            },
522        }
523    }
524
525    fn encrypted() -> Self {
526        Self {
527            mutable_content: 1,
528            category: CATEGORY,
529            interruption_level: InterruptionLevel::Active,
530            badge: None,
531            sound: None,
532            thread_id: None,
533            alert: AlertPayload {
534                title: ENCRYPTED_TITLE.to_owned(),
535                subtitle: None,
536                body: ENCRYPTED_BODY.to_owned(),
537            },
538        }
539    }
540}
541
542#[derive(Debug, Serialize)]
543struct AlertPayload {
544    title: String,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    subtitle: Option<String>,
547    body: String,
548}
549
550#[derive(Debug, Default, Serialize)]
551struct BarkFields {
552    #[serde(skip_serializing_if = "Option::is_none")]
553    level: Option<&'static str>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    volume: Option<String>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    badge: Option<String>,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    call: Option<&'static str>,
560    #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
561    auto_copy: Option<&'static str>,
562    #[serde(skip_serializing_if = "Option::is_none")]
563    copy: Option<String>,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    sound: Option<String>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    icon: Option<String>,
568    #[serde(skip_serializing_if = "Option::is_none")]
569    image: Option<String>,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    group: Option<String>,
572    #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
573    archive: Option<&'static str>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    ttl: Option<String>,
576    #[serde(skip_serializing_if = "Option::is_none")]
577    url: Option<String>,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    action: Option<String>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    markdown: Option<String>,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    id: Option<String>,
584}
585
586impl BarkFields {
587    fn from_message(message: &Message) -> Self {
588        Self {
589            level: Some(message.level.as_bark_str()),
590            volume: message.volume.map(|volume| volume.to_string()),
591            badge: message.badge.map(|badge| badge.to_string()),
592            call: message.call.then_some("1"),
593            auto_copy: message
594                .auto_copy
595                .map(|auto_copy| if auto_copy { "1" } else { "0" }),
596            copy: message.copy.clone(),
597            sound: message.sound.clone(),
598            icon: message.icon.clone(),
599            image: message.image.clone(),
600            group: message.group.clone(),
601            archive: message
602                .archive
603                .map(|archive| if archive { "1" } else { "0" }),
604            ttl: message.ttl.map(|ttl| ttl.to_string()),
605            url: message.url.clone(),
606            action: message.action.clone(),
607            markdown: message.markdown.clone(),
608            id: message.id.clone(),
609        }
610    }
611}
612
613#[derive(Debug, Default, Serialize)]
614struct BarkPlaintextFields {
615    title: String,
616    #[serde(skip_serializing_if = "Option::is_none")]
617    subtitle: Option<String>,
618    body: String,
619    #[serde(skip_serializing_if = "Option::is_none")]
620    markdown: Option<String>,
621    #[serde(skip_serializing_if = "Option::is_none")]
622    level: Option<&'static str>,
623    #[serde(skip_serializing_if = "Option::is_none")]
624    volume: Option<String>,
625    #[serde(skip_serializing_if = "Option::is_none")]
626    badge: Option<String>,
627    #[serde(skip_serializing_if = "Option::is_none")]
628    call: Option<&'static str>,
629    #[serde(rename = "autocopy", skip_serializing_if = "Option::is_none")]
630    auto_copy: Option<&'static str>,
631    #[serde(skip_serializing_if = "Option::is_none")]
632    copy: Option<String>,
633    #[serde(skip_serializing_if = "Option::is_none")]
634    sound: Option<String>,
635    #[serde(skip_serializing_if = "Option::is_none")]
636    icon: Option<String>,
637    #[serde(skip_serializing_if = "Option::is_none")]
638    image: Option<String>,
639    #[serde(skip_serializing_if = "Option::is_none")]
640    group: Option<String>,
641    #[serde(rename = "isarchive", skip_serializing_if = "Option::is_none")]
642    archive: Option<&'static str>,
643    #[serde(skip_serializing_if = "Option::is_none")]
644    ttl: Option<String>,
645    #[serde(skip_serializing_if = "Option::is_none")]
646    url: Option<String>,
647    #[serde(skip_serializing_if = "Option::is_none")]
648    action: Option<String>,
649    #[serde(skip_serializing_if = "Option::is_none")]
650    id: Option<String>,
651}
652
653impl BarkPlaintextFields {
654    fn from_message(message: &Message) -> Self {
655        let fields = BarkFields::from_message(message);
656        Self {
657            title: message.title.clone(),
658            subtitle: message.subtitle.clone(),
659            body: message.body.clone(),
660            markdown: fields.markdown,
661            level: fields.level,
662            volume: fields.volume,
663            badge: fields.badge,
664            call: fields.call,
665            auto_copy: fields.auto_copy,
666            copy: fields.copy,
667            sound: fields.sound,
668            icon: fields.icon,
669            image: fields.image,
670            group: fields.group,
671            archive: fields.archive,
672            ttl: fields.ttl,
673            url: fields.url,
674            action: fields.action,
675            id: fields.id,
676        }
677    }
678}
679
680fn non_empty(value: String) -> Option<String> {
681    if value.trim().is_empty() {
682        None
683    } else {
684        Some(value)
685    }
686}
687
688fn apns_sound_name(sound: &str) -> String {
689    if sound.ends_with(".caf") {
690        sound.to_owned()
691    } else {
692        format!("{sound}.caf")
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use serde_json::json;
699
700    use super::*;
701    use crate::crypto::{EncryptionAlgorithm, EncryptionMode};
702
703    fn payload(message: Message) -> serde_json::Value {
704        message.payload_value().unwrap()
705    }
706
707    #[test]
708    fn serializes_markdown_without_manual_escaping() {
709        let payload = payload(
710            Message::new()
711                .title("build")
712                .body("fallback")
713                .markdown("## ok\nquoted: \"yes\"")
714                .group(String::from("ci")),
715        );
716
717        assert_eq!(payload["markdown"], "## ok\nquoted: \"yes\"");
718        assert_eq!(payload["group"], "ci");
719        assert_eq!(payload["aps"]["alert"]["body"], "fallback");
720    }
721
722    #[test]
723    fn sound_is_only_serialized_when_explicitly_set() {
724        let default_payload = payload(Message::new().body("quiet"));
725        let sound_payload = payload(Message::new().body("loud").sound("birdsong"));
726
727        assert!(default_payload["aps"].get("sound").is_none());
728        assert_eq!(sound_payload["aps"]["sound"], "birdsong.caf");
729        assert_eq!(sound_payload["sound"], "birdsong");
730    }
731
732    #[test]
733    fn serializes_delete_as_background_payload() {
734        let payload = payload(Message::new().id("deploy-42").delete());
735
736        assert_eq!(
737            payload,
738            json!({
739                "aps": { "content-available": 1 },
740                "delete": "1",
741                "id": "deploy-42"
742            })
743        );
744    }
745
746    #[test]
747    fn encrypted_payload_keeps_bark_fields_out_of_apns_user_info() {
748        let encryption = Encryption::with_iv(
749            EncryptionAlgorithm::AES128,
750            EncryptionMode::CBC,
751            "1234567890123456",
752            "1111111111111111",
753        )
754        .unwrap();
755
756        let payload = payload(
757            Message::new()
758                .title("secret title")
759                .body("secret body")
760                .markdown("**secret**")
761                .badge(7)
762                .group("ops")
763                .sound("birdsong")
764                .encryption(encryption),
765        );
766
767        assert_eq!(payload["aps"]["alert"]["title"], ENCRYPTED_TITLE);
768        assert_eq!(payload["aps"]["alert"]["body"], ENCRYPTED_BODY);
769        assert!(payload["aps"].get("sound").is_none());
770        assert_eq!(payload["iv"], "1111111111111111");
771        assert!(payload.get("ciphertext").is_some());
772        assert!(payload.get("group").is_none());
773        assert!(payload.get("markdown").is_none());
774        assert!(payload.get("badge").is_none());
775    }
776
777    #[test]
778    fn delete_requires_id() {
779        let err = Message::new().delete().payload_bytes().unwrap_err();
780
781        assert!(matches!(err, Error::MissingMessageIdForDelete));
782    }
783
784    #[test]
785    fn normal_message_requires_body_or_markdown() {
786        let err = Message::new().title("empty").payload_bytes().unwrap_err();
787
788        assert!(matches!(err, Error::EmptyMessage));
789    }
790}