Skip to main content

helios_subscriptions/channels/
email.rs

1//! Email channel dispatcher.
2//!
3//! Delivers notification bundles via SMTP to subscribers whose `endpoint`
4//! is a `mailto:` URI. The notification bundle is rendered as a short
5//! human-readable summary body with the full FHIR Bundle attached as
6//! `notification.json` (for FHIR JSON payloads).
7
8use 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/// Outcome of a single SMTP send used internally by the email channel.
27#[derive(Debug)]
28pub(crate) enum TransportOutcome {
29    Delivered,
30    Permanent(String),
31    Transient(String),
32}
33
34/// Minimal async transport abstraction over [`lettre::AsyncTransport`] so we
35/// can substitute a capturing stub in unit tests without running a real SMTP
36/// server.
37#[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
64/// Email channel dispatcher.
65///
66/// Endpoints are expected in RFC 6068 `mailto:` form. Subscription headers
67/// with `Subject:`, `From:`, `Reply-To:`, and `Cc:` override the server
68/// defaults on a per-message basis. Full-resource payloads are refused
69/// when the SMTP transport is unencrypted — analogous to the HTTPS
70/// requirement on the rest-hook channel.
71pub struct EmailChannel {
72    transport: Arc<dyn EmailTransport>,
73    settings: SmtpSettings,
74}
75
76impl EmailChannel {
77    /// Build an `EmailChannel` backed by a real SMTP transport.
78    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    /// Build an `EmailChannel` with an injected transport (for testing).
107    #[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        // 1. Resolve recipient from the mailto: endpoint.
124        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        // 2. Payload / MIME policy.
135        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        // 3. Full-resource payloads require encrypted SMTP.
149        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        // 4. Build headers and subject using overrides from the subscription.
158        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        // 5. Assemble the MIME message.
178        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        // 6. Hand off to the transport.
241        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    // RFC 6068 allows query strings like ?subject=...; drop anything after '?'.
329    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
387/// Extract the notification type code from the first entry's resource.
388///
389/// Mirrors `NOTIFICATION_TYPE_JQ` in the external smoke script — handles both
390/// R4 backport (Parameters) and native SubscriptionStatus bundles.
391fn 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            // Replace response with Delivered default after consuming once, so
485            // subsequent sends succeed unless a new response is set.
486            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}