1use std::sync::Arc;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use lettre::message::header::ContentType;
13use lettre::message::{Attachment, Mailbox, MultiPart, SinglePart};
14use lettre::transport::smtp::authentication::Credentials;
15use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
16use tracing::{debug, warn};
17
18use crate::channels::{ChannelDispatcher, DispatchResult};
19use crate::config::{SmtpEncryption, SmtpSettings};
20use crate::error::SubscriptionError;
21use crate::manager::{ActiveSubscription, PayloadContent};
22
23const DEFAULT_FHIR_MIME: &str = "application/fhir+json";
24const ATTACHMENT_FILENAME: &str = "notification.json";
25
26#[derive(Debug)]
28pub(crate) enum TransportOutcome {
29 Delivered,
30 Permanent(String),
31 Transient(String),
32}
33
34#[async_trait]
38pub(crate) trait EmailTransport: Send + Sync {
39 async fn send_message(&self, msg: Message) -> TransportOutcome;
40}
41
42struct LettreSmtpTransport {
43 inner: AsyncSmtpTransport<Tokio1Executor>,
44}
45
46#[async_trait]
47impl EmailTransport for LettreSmtpTransport {
48 async fn send_message(&self, msg: Message) -> TransportOutcome {
49 match self.inner.send(msg).await {
50 Ok(_) => TransportOutcome::Delivered,
51 Err(e) => classify_smtp_error(&e),
52 }
53 }
54}
55
56fn classify_smtp_error(e: &lettre::transport::smtp::Error) -> TransportOutcome {
57 if e.is_permanent() {
58 TransportOutcome::Permanent(e.to_string())
59 } else {
60 TransportOutcome::Transient(e.to_string())
61 }
62}
63
64pub struct EmailChannel {
72 transport: Arc<dyn EmailTransport>,
73 settings: SmtpSettings,
74}
75
76impl EmailChannel {
77 pub fn new(settings: SmtpSettings) -> Result<Self, SubscriptionError> {
79 let builder = match settings.encryption {
80 SmtpEncryption::None => {
81 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(settings.host.clone())
82 }
83 SmtpEncryption::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(
84 &settings.host,
85 )
86 .map_err(|e| SubscriptionError::Internal(format!("SMTP starttls builder: {e}")))?,
87 SmtpEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&settings.host)
88 .map_err(|e| SubscriptionError::Internal(format!("SMTP tls builder: {e}")))?,
89 };
90
91 let mut builder = builder
92 .port(settings.port)
93 .timeout(Some(Duration::from_secs(settings.timeout_secs)));
94
95 if let (Some(user), Some(pass)) = (&settings.username, &settings.password) {
96 builder = builder.credentials(Credentials::new(user.clone(), pass.clone()));
97 }
98
99 let inner = builder.build();
100 Ok(Self {
101 transport: Arc::new(LettreSmtpTransport { inner }),
102 settings,
103 })
104 }
105
106 #[cfg(test)]
108 pub(crate) fn with_transport(
109 transport: Arc<dyn EmailTransport>,
110 settings: SmtpSettings,
111 ) -> Self {
112 Self {
113 transport,
114 settings,
115 }
116 }
117
118 async fn send(
119 &self,
120 subscription: &ActiveSubscription,
121 bundle: &serde_json::Value,
122 ) -> Result<DispatchResult, SubscriptionError> {
123 let endpoint = subscription.channel.endpoint.as_deref().ok_or_else(|| {
125 SubscriptionError::InvalidEndpoint {
126 message: "email channel requires a mailto: endpoint".to_string(),
127 }
128 })?;
129 let to_mailbox = match parse_mailto(endpoint) {
130 Ok(mb) => mb,
131 Err(msg) => return Ok(DispatchResult::PermanentError(msg)),
132 };
133
134 let payload_mime = subscription
136 .channel
137 .payload_mime_type
138 .as_deref()
139 .unwrap_or(DEFAULT_FHIR_MIME)
140 .to_string();
141
142 if payload_mime == "application/fhir+xml" {
143 return Ok(DispatchResult::PermanentError(
144 "application/fhir+xml payload is not supported for email channel".to_string(),
145 ));
146 }
147
148 if subscription.channel.payload_content == PayloadContent::FullResource
150 && self.settings.encryption == SmtpEncryption::None
151 {
152 return Ok(DispatchResult::PermanentError(
153 "full-resource payload requires encrypted SMTP (STARTTLS or TLS)".to_string(),
154 ));
155 }
156
157 let overrides = HeaderOverrides::from_subscription(&subscription.channel.headers);
159 let from_mailbox = match overrides
160 .from
161 .as_deref()
162 .unwrap_or(&self.settings.from_address)
163 .parse::<Mailbox>()
164 {
165 Ok(mb) => mb,
166 Err(e) => {
167 return Ok(DispatchResult::PermanentError(format!(
168 "invalid From: address: {e}"
169 )));
170 }
171 };
172 let subject = overrides.subject.clone().unwrap_or_else(|| {
173 render_subject(subscription, &self.settings.default_subject, bundle)
174 });
175 let body_text = render_body(subscription, bundle);
176
177 let mut builder = Message::builder()
179 .from(from_mailbox)
180 .to(to_mailbox)
181 .subject(subject);
182
183 if let Some(reply_to) = overrides.reply_to.as_deref() {
184 match reply_to.parse::<Mailbox>() {
185 Ok(mb) => builder = builder.reply_to(mb),
186 Err(e) => {
187 return Ok(DispatchResult::PermanentError(format!(
188 "invalid Reply-To: address: {e}"
189 )));
190 }
191 }
192 }
193 if let Some(cc) = overrides.cc.as_deref() {
194 match cc.parse::<Mailbox>() {
195 Ok(mb) => builder = builder.cc(mb),
196 Err(e) => {
197 return Ok(DispatchResult::PermanentError(format!(
198 "invalid Cc: address: {e}"
199 )));
200 }
201 }
202 }
203
204 let message_result =
205 if include_json_attachment(subscription.channel.payload_content, &payload_mime) {
206 let bundle_bytes = serde_json::to_vec(bundle)
207 .map_err(|e| SubscriptionError::Internal(format!("serialize bundle: {e}")))?;
208 let attachment_ct: ContentType =
209 payload_mime.parse().unwrap_or(ContentType::TEXT_PLAIN);
210 let multipart = MultiPart::mixed()
211 .singlepart(
212 SinglePart::builder()
213 .header(ContentType::TEXT_PLAIN)
214 .body(body_text),
215 )
216 .singlepart(
217 Attachment::new(ATTACHMENT_FILENAME.to_string())
218 .body(bundle_bytes, attachment_ct),
219 );
220 builder.multipart(multipart)
221 } else {
222 builder.header(ContentType::TEXT_PLAIN).body(body_text)
223 };
224
225 let msg = match message_result {
226 Ok(m) => m,
227 Err(e) => {
228 return Ok(DispatchResult::PermanentError(format!(
229 "failed to build email message: {e}"
230 )));
231 }
232 };
233
234 debug!(
235 subscription_id = %subscription.id,
236 endpoint,
237 "Dispatching email notification"
238 );
239
240 match self.transport.send_message(msg).await {
242 TransportOutcome::Delivered => {
243 debug!(subscription_id = %subscription.id, "Email delivered");
244 Ok(DispatchResult::Success)
245 }
246 TransportOutcome::Permanent(msg) => {
247 warn!(
248 subscription_id = %subscription.id,
249 error = %msg,
250 "Email delivery failed (permanent)"
251 );
252 Ok(DispatchResult::PermanentError(msg))
253 }
254 TransportOutcome::Transient(msg) => {
255 warn!(
256 subscription_id = %subscription.id,
257 error = %msg,
258 "Email delivery failed (transient)"
259 );
260 Ok(DispatchResult::RetryableError(msg))
261 }
262 }
263 }
264}
265
266#[async_trait]
267impl ChannelDispatcher for EmailChannel {
268 async fn dispatch(
269 &self,
270 subscription: &ActiveSubscription,
271 notification_bundle: &serde_json::Value,
272 ) -> Result<DispatchResult, SubscriptionError> {
273 self.send(subscription, notification_bundle).await
274 }
275
276 async fn handshake(
277 &self,
278 subscription: &ActiveSubscription,
279 handshake_bundle: &serde_json::Value,
280 ) -> Result<DispatchResult, SubscriptionError> {
281 self.send(subscription, handshake_bundle).await
282 }
283}
284
285fn include_json_attachment(payload: PayloadContent, mime: &str) -> bool {
286 if payload == PayloadContent::Empty {
287 return false;
288 }
289 matches!(mime, "application/fhir+json" | "application/json")
290}
291
292#[derive(Default, Debug)]
293struct HeaderOverrides {
294 from: Option<String>,
295 subject: Option<String>,
296 reply_to: Option<String>,
297 cc: Option<String>,
298}
299
300impl HeaderOverrides {
301 fn from_subscription(headers: &[String]) -> Self {
302 let mut out = Self::default();
303 for entry in headers {
304 let Some((name, value)) = entry.split_once(':') else {
305 continue;
306 };
307 let name = name.trim().to_ascii_lowercase();
308 let value = value.trim().to_string();
309 if value.is_empty() {
310 continue;
311 }
312 match name.as_str() {
313 "from" => out.from = Some(value),
314 "subject" => out.subject = Some(value),
315 "reply-to" => out.reply_to = Some(value),
316 "cc" => out.cc = Some(value),
317 _ => {}
318 }
319 }
320 out
321 }
322}
323
324fn parse_mailto(endpoint: &str) -> Result<Mailbox, String> {
325 let rest = endpoint
326 .strip_prefix("mailto:")
327 .ok_or_else(|| format!("email endpoint must be a mailto: URI: {endpoint}"))?;
328 let address = rest.split('?').next().unwrap_or("").trim();
330 if address.is_empty() {
331 return Err("email endpoint is missing an address".to_string());
332 }
333 address
334 .parse::<Mailbox>()
335 .map_err(|e| format!("invalid email recipient '{address}': {e}"))
336}
337
338fn render_subject(
339 subscription: &ActiveSubscription,
340 default_template: &Option<String>,
341 bundle: &serde_json::Value,
342) -> String {
343 let ntype = extract_notification_type(bundle).unwrap_or("notification");
344 let topic = &subscription.topic_url;
345 let event_num = subscription.events_since_start;
346
347 if let Some(template) = default_template {
348 template
349 .replace("{notification-type}", ntype)
350 .replace("{topic-url}", topic)
351 .replace("{event-number}", &event_num.to_string())
352 } else {
353 format!("[HFS Subscription] {ntype} for {topic} (event {event_num})")
354 }
355}
356
357fn render_body(subscription: &ActiveSubscription, bundle: &serde_json::Value) -> String {
358 let ntype = extract_notification_type(bundle).unwrap_or("notification");
359 let mut body = String::new();
360 body.push_str(&format!("Subscription: {}\n", subscription.id));
361 body.push_str(&format!("Topic: {}\n", subscription.topic_url));
362 body.push_str(&format!("Notification type: {ntype}\n"));
363 body.push_str(&format!(
364 "Event number: {}\n",
365 subscription.events_since_start
366 ));
367 body.push_str(&format!(
368 "Payload content: {}\n",
369 subscription.channel.payload_content.as_fhir_str()
370 ));
371
372 let foci = extract_focus_references(bundle);
373 if !foci.is_empty() {
374 body.push_str("\nFocus references:\n");
375 for f in &foci {
376 body.push_str(&format!(" - {f}\n"));
377 }
378 }
379
380 if subscription.channel.payload_content != PayloadContent::Empty {
381 body.push_str("\nSee attached notification.json for the full FHIR Bundle.\n");
382 }
383
384 body
385}
386
387fn extract_notification_type(bundle: &serde_json::Value) -> Option<&str> {
392 let first = bundle.pointer("/entry/0/resource")?;
393 let resource_type = first.get("resourceType").and_then(|v| v.as_str())?;
394 if resource_type == "Parameters" {
395 first
396 .get("parameter")?
397 .as_array()?
398 .iter()
399 .find(|p| p.get("name").and_then(|v| v.as_str()) == Some("type"))
400 .and_then(|p| p.get("valueCode"))
401 .and_then(|v| v.as_str())
402 } else {
403 first.get("type").and_then(|v| v.as_str())
404 }
405}
406
407fn extract_focus_references(bundle: &serde_json::Value) -> Vec<String> {
408 let Some(first) = bundle.pointer("/entry/0/resource") else {
409 return Vec::new();
410 };
411 let resource_type = first
412 .get("resourceType")
413 .and_then(|v| v.as_str())
414 .unwrap_or("");
415 let mut out = Vec::new();
416 if resource_type == "Parameters" {
417 if let Some(params) = first.get("parameter").and_then(|v| v.as_array()) {
418 for p in params {
419 if p.get("name").and_then(|v| v.as_str()) != Some("notification-event") {
420 continue;
421 }
422 let Some(parts) = p.get("part").and_then(|v| v.as_array()) else {
423 continue;
424 };
425 for part in parts {
426 if part.get("name").and_then(|v| v.as_str()) == Some("focus") {
427 if let Some(r) = part
428 .pointer("/valueReference/reference")
429 .and_then(|v| v.as_str())
430 {
431 out.push(r.to_string());
432 }
433 }
434 }
435 }
436 }
437 } else if let Some(events) = first.get("notificationEvent").and_then(|v| v.as_array()) {
438 for ev in events {
439 if let Some(r) = ev.pointer("/focus/reference").and_then(|v| v.as_str()) {
440 out.push(r.to_string());
441 }
442 }
443 }
444 out
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::manager::{ChannelConfig, ChannelType, SubscriptionStatusCode};
451 use helios_fhir::FhirVersion;
452 use serde_json::json;
453 use std::sync::Mutex;
454
455 struct CapturingTransport {
456 sent: Mutex<Vec<Message>>,
457 response: Mutex<TransportOutcome>,
458 }
459
460 impl CapturingTransport {
461 fn new() -> Arc<Self> {
462 Arc::new(Self {
463 sent: Mutex::new(Vec::new()),
464 response: Mutex::new(TransportOutcome::Delivered),
465 })
466 }
467
468 fn with_response(response: TransportOutcome) -> Arc<Self> {
469 Arc::new(Self {
470 sent: Mutex::new(Vec::new()),
471 response: Mutex::new(response),
472 })
473 }
474
475 fn take_sent(&self) -> Vec<Message> {
476 std::mem::take(&mut self.sent.lock().unwrap())
477 }
478 }
479
480 #[async_trait]
481 impl EmailTransport for CapturingTransport {
482 async fn send_message(&self, msg: Message) -> TransportOutcome {
483 self.sent.lock().unwrap().push(msg);
484 std::mem::replace(
487 &mut *self.response.lock().unwrap(),
488 TransportOutcome::Delivered,
489 )
490 }
491 }
492
493 fn smtp_settings(encryption: SmtpEncryption) -> SmtpSettings {
494 SmtpSettings {
495 host: "localhost".into(),
496 port: 25,
497 username: None,
498 password: None,
499 encryption,
500 from_address: "hfs@example.test".into(),
501 default_subject: None,
502 timeout_secs: 10,
503 }
504 }
505
506 fn sub(
507 endpoint: Option<&str>,
508 payload: PayloadContent,
509 headers: Vec<String>,
510 mime: Option<&str>,
511 ) -> ActiveSubscription {
512 ActiveSubscription {
513 id: "sub-email-1".into(),
514 topic_url: "http://example.org/topic/test".into(),
515 status: SubscriptionStatusCode::Active,
516 channel: ChannelConfig {
517 channel_type: ChannelType::Email,
518 endpoint: endpoint.map(str::to_string),
519 payload_mime_type: mime.map(str::to_string),
520 payload_content: payload,
521 headers,
522 heartbeat_period: None,
523 timeout: None,
524 max_count: None,
525 },
526 filters: vec![],
527 fhir_version: FhirVersion::default(),
528 events_since_start: 3,
529 consecutive_failures: 0,
530 tenant_id: "tenant-a".into(),
531 }
532 }
533
534 fn native_bundle() -> serde_json::Value {
535 json!({
536 "resourceType": "Bundle",
537 "type": "subscription-notification",
538 "entry": [{
539 "resource": {
540 "resourceType": "SubscriptionStatus",
541 "status": "active",
542 "type": "event-notification",
543 "eventsSinceSubscriptionStart": 3,
544 "topic": "http://example.org/topic/test",
545 "notificationEvent": [{
546 "eventNumber": 3,
547 "focus": { "reference": "Encounter/enc-1" }
548 }]
549 }
550 }]
551 })
552 }
553
554 fn raw_body(msg: &Message) -> String {
555 String::from_utf8(msg.formatted()).expect("utf-8 mime body")
556 }
557
558 #[tokio::test]
559 async fn test_missing_endpoint() {
560 let transport = CapturingTransport::new();
561 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
562 let s = sub(None, PayloadContent::IdOnly, vec![], None);
563 let err = channel.dispatch(&s, &native_bundle()).await.unwrap_err();
564 assert!(matches!(err, SubscriptionError::InvalidEndpoint { .. }));
565 }
566
567 #[tokio::test]
568 async fn test_non_mailto_endpoint_permanent_error() {
569 let transport = CapturingTransport::new();
570 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
571 let s = sub(
572 Some("http://not-an-email"),
573 PayloadContent::IdOnly,
574 vec![],
575 None,
576 );
577 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
578 assert!(matches!(result, DispatchResult::PermanentError(_)));
579 }
580
581 #[tokio::test]
582 async fn test_empty_payload_no_attachment() {
583 let transport = CapturingTransport::new();
584 let channel =
585 EmailChannel::with_transport(transport.clone(), smtp_settings(SmtpEncryption::None));
586 let s = sub(
587 Some("mailto:nurse@example.test"),
588 PayloadContent::Empty,
589 vec![],
590 None,
591 );
592 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
593 assert!(matches!(result, DispatchResult::Success));
594 let sent = transport.take_sent();
595 assert_eq!(sent.len(), 1);
596 let raw = raw_body(&sent[0]);
597 assert!(
598 !raw.contains("notification.json"),
599 "attachment must be absent for empty payload"
600 );
601 assert!(raw.contains("Topic: http://example.org/topic/test"));
602 }
603
604 #[tokio::test]
605 async fn test_id_only_payload_has_json_attachment() {
606 let transport = CapturingTransport::new();
607 let channel =
608 EmailChannel::with_transport(transport.clone(), smtp_settings(SmtpEncryption::None));
609 let s = sub(
610 Some("mailto:nurse@example.test"),
611 PayloadContent::IdOnly,
612 vec![],
613 None,
614 );
615 let bundle = native_bundle();
616 let result = channel.dispatch(&s, &bundle).await.unwrap();
617 assert!(matches!(result, DispatchResult::Success));
618 let sent = transport.take_sent();
619 assert_eq!(sent.len(), 1);
620 let raw = raw_body(&sent[0]);
621 assert!(
622 raw.contains("notification.json"),
623 "should include JSON attachment"
624 );
625 assert!(raw.contains("multipart/mixed"));
626 assert!(raw.contains("application/fhir+json"));
627 }
628
629 #[tokio::test]
630 async fn test_full_resource_over_plain_smtp_rejected() {
631 let transport = CapturingTransport::new();
632 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
633 let s = sub(
634 Some("mailto:nurse@example.test"),
635 PayloadContent::FullResource,
636 vec![],
637 None,
638 );
639 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
640 match result {
641 DispatchResult::PermanentError(msg) => {
642 assert!(
643 msg.to_lowercase().contains("encrypt"),
644 "expected encryption error, got: {msg}"
645 );
646 }
647 other => panic!("expected PermanentError, got {other:?}"),
648 }
649 }
650
651 #[tokio::test]
652 async fn test_full_resource_over_starttls_allowed() {
653 let transport = CapturingTransport::new();
654 let channel = EmailChannel::with_transport(
655 transport.clone(),
656 smtp_settings(SmtpEncryption::StartTls),
657 );
658 let s = sub(
659 Some("mailto:nurse@example.test"),
660 PayloadContent::FullResource,
661 vec![],
662 None,
663 );
664 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
665 assert!(matches!(result, DispatchResult::Success));
666 }
667
668 #[tokio::test]
669 async fn test_subscription_overrides_from_and_subject_and_reply_to() {
670 let transport = CapturingTransport::new();
671 let channel =
672 EmailChannel::with_transport(transport.clone(), smtp_settings(SmtpEncryption::None));
673 let s = sub(
674 Some("mailto:nurse@example.test"),
675 PayloadContent::IdOnly,
676 vec![
677 "Subject: Custom Subject".into(),
678 "From: override@example.com".into(),
679 "Reply-To: reply@example.com".into(),
680 ],
681 None,
682 );
683 channel.dispatch(&s, &native_bundle()).await.unwrap();
684 let sent = transport.take_sent();
685 let raw = raw_body(&sent[0]);
686 assert!(raw.contains("Subject: Custom Subject"));
687 assert!(raw.contains("override@example.com"));
688 assert!(raw.contains("reply@example.com"));
689 }
690
691 #[tokio::test]
692 async fn test_server_default_from_when_no_override() {
693 let transport = CapturingTransport::new();
694 let channel =
695 EmailChannel::with_transport(transport.clone(), smtp_settings(SmtpEncryption::None));
696 let s = sub(
697 Some("mailto:nurse@example.test"),
698 PayloadContent::IdOnly,
699 vec![],
700 None,
701 );
702 channel.dispatch(&s, &native_bundle()).await.unwrap();
703 let sent = transport.take_sent();
704 let raw = raw_body(&sent[0]);
705 assert!(raw.contains("hfs@example.test"));
706 }
707
708 #[tokio::test]
709 async fn test_fhir_xml_payload_returns_permanent_error() {
710 let transport = CapturingTransport::new();
711 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
712 let s = sub(
713 Some("mailto:nurse@example.test"),
714 PayloadContent::IdOnly,
715 vec![],
716 Some("application/fhir+xml"),
717 );
718 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
719 assert!(matches!(result, DispatchResult::PermanentError(_)));
720 }
721
722 #[tokio::test]
723 async fn test_handshake_uses_same_transport() {
724 let transport = CapturingTransport::new();
725 let channel =
726 EmailChannel::with_transport(transport.clone(), smtp_settings(SmtpEncryption::None));
727 let s = sub(
728 Some("mailto:nurse@example.test"),
729 PayloadContent::IdOnly,
730 vec![],
731 None,
732 );
733 let result = channel.handshake(&s, &native_bundle()).await.unwrap();
734 assert!(matches!(result, DispatchResult::Success));
735 assert_eq!(transport.take_sent().len(), 1);
736 }
737
738 #[tokio::test]
739 async fn test_transient_transport_error_is_retryable() {
740 let transport = CapturingTransport::with_response(TransportOutcome::Transient(
741 "connection refused".into(),
742 ));
743 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
744 let s = sub(
745 Some("mailto:nurse@example.test"),
746 PayloadContent::IdOnly,
747 vec![],
748 None,
749 );
750 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
751 assert!(matches!(result, DispatchResult::RetryableError(_)));
752 }
753
754 #[tokio::test]
755 async fn test_permanent_transport_error_is_permanent() {
756 let transport = CapturingTransport::with_response(TransportOutcome::Permanent(
757 "550 mailbox not found".into(),
758 ));
759 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
760 let s = sub(
761 Some("mailto:nurse@example.test"),
762 PayloadContent::IdOnly,
763 vec![],
764 None,
765 );
766 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
767 assert!(matches!(result, DispatchResult::PermanentError(_)));
768 }
769
770 #[tokio::test]
771 async fn test_invalid_recipient_after_mailto_is_permanent() {
772 let transport = CapturingTransport::new();
773 let channel = EmailChannel::with_transport(transport, smtp_settings(SmtpEncryption::None));
774 let s = sub(
775 Some("mailto:not-an-email-address"),
776 PayloadContent::IdOnly,
777 vec![],
778 None,
779 );
780 let result = channel.dispatch(&s, &native_bundle()).await.unwrap();
781 assert!(matches!(result, DispatchResult::PermanentError(_)));
782 }
783}