minco_plugin_notifications/
lib.rs1#![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}