Skip to main content

minco_plugin_notifications/
lib.rs

1//! Provider-neutral notifications, rich outbound mail, and deterministic test adapters.
2#![forbid(unsafe_code)]
3
4pub mod mail;
5pub mod mailpit;
6
7pub use mail::*;
8pub use mailpit::{MailpitTransport, MailpitTransportConfig};
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use minco_core::{
13    CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
14    PluginStability,
15};
16use semver::{Version, VersionReq};
17use serde::{Deserialize, Serialize};
18use std::{collections::BTreeMap, sync::Arc};
19use tokio::sync::RwLock;
20use uuid::Uuid;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum NotificationChannel {
25    Email,
26    Webhook,
27    InApp,
28    DeveloperInbox,
29    Custom(String),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Notification {
34    pub id: Uuid,
35    pub topic: String,
36    pub channel: NotificationChannel,
37    pub recipient: String,
38    pub title: String,
39    pub body: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub link: Option<String>,
42    #[serde(default)]
43    pub metadata: BTreeMap<String, serde_json::Value>,
44    pub created_at: DateTime<Utc>,
45}
46
47impl Notification {
48    pub fn new(
49        topic: impl Into<String>,
50        channel: NotificationChannel,
51        recipient: impl Into<String>,
52        title: impl Into<String>,
53        body: impl Into<String>,
54    ) -> Self {
55        Self {
56            id: Uuid::now_v7(),
57            topic: topic.into(),
58            channel,
59            recipient: recipient.into(),
60            title: title.into(),
61            body: body.into(),
62            link: None,
63            metadata: BTreeMap::new(),
64            created_at: Utc::now(),
65        }
66    }
67}
68
69#[async_trait]
70pub trait NotificationSink: Send + Sync + std::fmt::Debug {
71    async fn send(&self, notification: Notification) -> Result<(), NotificationError>;
72}
73
74#[derive(Clone)]
75pub struct NotificationService(pub Arc<dyn NotificationSink>);
76
77impl std::fmt::Debug for NotificationService {
78    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        formatter.debug_tuple("NotificationService").finish()
80    }
81}
82
83impl NotificationService {
84    pub fn new(sink: Arc<dyn NotificationSink>) -> Self {
85        Self(sink)
86    }
87
88    pub async fn send(&self, notification: Notification) -> Result<(), NotificationError> {
89        self.0.send(notification).await
90    }
91}
92
93#[derive(Debug, Default)]
94pub struct MemoryNotificationSink {
95    notifications: RwLock<Vec<Notification>>,
96}
97
98impl MemoryNotificationSink {
99    pub async fn all(&self) -> Vec<Notification> {
100        self.notifications.read().await.clone()
101    }
102}
103
104#[async_trait]
105impl NotificationSink for MemoryNotificationSink {
106    async fn send(&self, notification: Notification) -> Result<(), NotificationError> {
107        if notification.recipient.trim().is_empty() {
108            return Err(NotificationError::InvalidRecipient);
109        }
110        self.notifications.write().await.push(notification);
111        Ok(())
112    }
113}
114
115#[must_use]
116#[derive(Debug, Clone)]
117pub struct NotificationsPlugin {
118    service: NotificationService,
119    mail_service: Option<MailService>,
120}
121
122impl NotificationsPlugin {
123    pub fn new(sink: Arc<dyn NotificationSink>) -> Self {
124        Self {
125            service: NotificationService::new(sink),
126            mail_service: None,
127        }
128    }
129
130    pub fn with_mail_service(mut self, mail_service: MailService) -> Self {
131        self.mail_service = Some(mail_service);
132        self
133    }
134
135    pub fn with_legacy_mail(
136        mut self,
137        sink: Arc<dyn NotificationSink>,
138        observer: Arc<dyn MailObserver>,
139    ) -> Result<Self, MailError> {
140        self.mail_service = Some(MailService::single(
141            Arc::new(LegacyNotificationMailTransport::new(sink)),
142            observer,
143        )?);
144        Ok(self)
145    }
146
147    pub fn memory() -> (Self, Arc<MemoryNotificationSink>) {
148        let sink = Arc::new(MemoryNotificationSink::default());
149        (Self::new(sink.clone()), sink)
150    }
151
152    pub fn memory_with_mail() -> (
153        Self,
154        Arc<MemoryNotificationSink>,
155        Arc<MemoryMailTransport>,
156        Arc<MemoryMailObserver>,
157    ) {
158        let notification_sink = Arc::new(MemoryNotificationSink::default());
159        let mail_transport = Arc::new(MemoryMailTransport::default());
160        let mail_observer = Arc::new(MemoryMailObserver::default());
161        let mail_service = MailService::single(mail_transport.clone(), mail_observer.clone())
162            .expect("static memory mail transport");
163        (
164            Self::new(notification_sink.clone()).with_mail_service(mail_service),
165            notification_sink,
166            mail_transport,
167            mail_observer,
168        )
169    }
170}
171
172impl Plugin for NotificationsPlugin {
173    fn descriptor(&self) -> PluginDescriptor {
174        let mut descriptor = PluginDescriptor::new(
175            PluginId::new("notifications").expect("static plugin ID"),
176            Version::new(1, 0, 0),
177            "Provider-neutral email, webhook, in-app, and developer notifications",
178        );
179        descriptor.documentation = Some("https://docs.rs/minco-plugin-notifications".into());
180        descriptor.core_compatibility =
181            VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
182        descriptor.stability = PluginStability::Beta;
183        descriptor
184            .data_classes
185            .extend([DataClass::Personal, DataClass::Confidential]);
186        descriptor.provides.push(CapabilityProvision {
187            name: "notifications.send".into(),
188            version: Version::new(1, 0, 0),
189        });
190        if self.mail_service.is_some() {
191            descriptor.provides.push(CapabilityProvision {
192                name: "mail.send".into(),
193                version: Version::new(1, 0, 0),
194            });
195        }
196        descriptor
197    }
198
199    fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
200        context.services().insert(Arc::new(self.service.clone()))?;
201        if let Some(mail_service) = &self.mail_service {
202            context.services().insert(Arc::new(mail_service.clone()))?;
203        }
204        Ok(())
205    }
206}
207
208#[derive(Debug, thiserror::Error)]
209pub enum NotificationError {
210    #[error("notification recipient must not be empty")]
211    InvalidRecipient,
212    #[error("notification delivery failed: {0}")]
213    Delivery(String),
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[tokio::test]
221    async fn memory_sink_keeps_delivery_order() {
222        let sink = MemoryNotificationSink::default();
223        sink.send(Notification::new(
224            "feedback.created",
225            NotificationChannel::DeveloperInbox,
226            "team",
227            "New feedback",
228            "The client reported a problem",
229        ))
230        .await
231        .unwrap();
232        assert_eq!(sink.all().await[0].topic, "feedback.created");
233    }
234
235    #[tokio::test]
236    async fn memory_plugin_exposes_mail_only_when_explicitly_selected() {
237        let (plain, _) = NotificationsPlugin::memory();
238        assert!(
239            plain
240                .descriptor()
241                .provides
242                .iter()
243                .all(|capability| capability.name != "mail.send")
244        );
245
246        let (mail, _, transport, observer) = NotificationsPlugin::memory_with_mail();
247        assert!(
248            mail.descriptor()
249                .provides
250                .iter()
251                .any(|capability| capability.name == "mail.send")
252        );
253        let message = MailMessage::builder("account.welcome", "Welcome")
254            .to(MailAddress::new("person@example.com").unwrap())
255            .text("Welcome")
256            .build()
257            .unwrap();
258        mail.mail_service
259            .as_ref()
260            .unwrap()
261            .send(message)
262            .await
263            .unwrap();
264        transport.assert_sent_count(1).await;
265        assert_eq!(observer.events().await.len(), 3);
266    }
267}