Skip to main content

cloud/envoy/
messaging.rs

1//! `messaging.*` verb signatures — outbound channel catalog (R409-T12).
2//!
3//! Three verbs for outbound message dispatch. Exemplar providers:
4//! - Email: Resend, SendGrid, AWS SES
5//! - SMS: Twilio, Vonage
6//! - Webhook: plain HTTP (any URL)
7//!
8//! - `messaging.email.send`       — send a transactional email
9//! - `messaging.sms.send`         — send an SMS message
10//! - `messaging.webhook.dispatch` — POST a JSON payload to a URL
11//!
12//! The webhook verb is intentionally simple — it covers the "notify this
13//! URL" pattern used by CI integrations, chat bots, and alert routing without
14//! prescribing a specific event schema.
15
16use serde::{Deserialize, Serialize};
17
18use super::{InternalVerb, VerbCategory};
19
20// ── messaging.email.send ──────────────────────────────────────────────────
21
22/// Marker type for `messaging.email.send`.
23pub struct MessagingEmailSend;
24
25/// Request body for `messaging.email.send`.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
28pub struct MessagingEmailSendInput {
29    /// Sender address, e.g. `"yah <noreply@yah.dev>"` or `"noreply@yah.dev"`.
30    pub from: String,
31    /// Recipient addresses. At least one required.
32    pub to: Vec<String>,
33    /// Email subject line.
34    pub subject: String,
35    /// HTML body. At least one of `html` or `text` should be provided.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub html: Option<String>,
38    /// Plain-text body fallback.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub text: Option<String>,
41    /// Reply-to address. `None` defaults to the sender.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub reply_to: Option<String>,
44}
45
46/// Response body for `messaging.email.send`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
49pub struct MessagingEmailSendOutput {
50    /// Provider-issued message ID.
51    pub id: String,
52}
53
54impl InternalVerb for MessagingEmailSend {
55    type Input = MessagingEmailSendInput;
56    type Output = MessagingEmailSendOutput;
57    const ID: &'static str = "messaging.email.send";
58    const CATEGORY: VerbCategory = VerbCategory::Messaging;
59}
60
61// ── messaging.sms.send ────────────────────────────────────────────────────
62
63/// Marker type for `messaging.sms.send`.
64pub struct MessagingSmsSend;
65
66/// Request body for `messaging.sms.send`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
69pub struct MessagingSmsSendInput {
70    /// Sender phone number (E.164 format, e.g. `"+15555550100"`) or
71    /// alphanumeric sender ID where supported.
72    pub from: String,
73    /// Recipient phone number in E.164 format, e.g. `"+15555550101"`.
74    pub to: String,
75    /// Message body. Providers may split long messages into multiple SMS
76    /// segments.
77    pub body: String,
78}
79
80/// Response body for `messaging.sms.send`.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
83pub struct MessagingSmsSendOutput {
84    /// Provider-issued message SID or ID.
85    pub id: String,
86    /// Delivery status at send time: `"queued"`, `"sent"`, `"failed"`,
87    /// `"unknown"`. Async delivery confirmation is out of scope.
88    pub status: String,
89}
90
91impl InternalVerb for MessagingSmsSend {
92    type Input = MessagingSmsSendInput;
93    type Output = MessagingSmsSendOutput;
94    const ID: &'static str = "messaging.sms.send";
95    const CATEGORY: VerbCategory = VerbCategory::Messaging;
96}
97
98// ── messaging.webhook.dispatch ────────────────────────────────────────────
99
100/// Marker type for `messaging.webhook.dispatch`.
101pub struct MessagingWebhookDispatch;
102
103/// Request body for `messaging.webhook.dispatch`.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
106pub struct MessagingWebhookDispatchInput {
107    /// Target URL. Must be HTTPS in production.
108    pub url: String,
109    /// JSON payload to POST.
110    pub payload: serde_json::Value,
111    /// Additional HTTP headers to include (e.g. `Authorization`,
112    /// `X-Custom-Header`). `None` sends no extra headers.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub headers: Option<std::collections::BTreeMap<String, String>>,
115}
116
117/// Response body for `messaging.webhook.dispatch`.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
120pub struct MessagingWebhookDispatchOutput {
121    /// HTTP status code returned by the target server.
122    pub status_code: u16,
123    /// `true` when `status_code` is in the 2xx range.
124    pub ok: bool,
125}
126
127impl InternalVerb for MessagingWebhookDispatch {
128    type Input = MessagingWebhookDispatchInput;
129    type Output = MessagingWebhookDispatchOutput;
130    const ID: &'static str = "messaging.webhook.dispatch";
131    const CATEGORY: VerbCategory = VerbCategory::Messaging;
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn verb_ids_match_canonical_namespace() {
140        for id in [
141            MessagingEmailSend::ID,
142            MessagingSmsSend::ID,
143            MessagingWebhookDispatch::ID,
144        ] {
145            assert!(id.starts_with("messaging."), "{id}");
146        }
147    }
148
149    #[test]
150    fn verbs_are_under_messaging_category() {
151        assert_eq!(MessagingEmailSend::CATEGORY, VerbCategory::Messaging);
152        assert_eq!(MessagingSmsSend::CATEGORY, VerbCategory::Messaging);
153        assert_eq!(MessagingWebhookDispatch::CATEGORY, VerbCategory::Messaging);
154    }
155
156    #[test]
157    fn email_send_optional_fields_omitted() {
158        let input = MessagingEmailSendInput {
159            from: "noreply@yah.dev".into(),
160            to: vec!["user@example.com".into()],
161            subject: "Hello".into(),
162            html: Some("<p>Hi</p>".into()),
163            text: None,
164            reply_to: None,
165        };
166        let wire = serde_json::to_value(&input).unwrap();
167        assert!(!wire.as_object().unwrap().contains_key("text"));
168        assert!(!wire.as_object().unwrap().contains_key("reply_to"));
169        assert_eq!(wire["html"], "<p>Hi</p>");
170    }
171
172    #[test]
173    fn email_send_to_is_vec() {
174        let wire = r#"{"from":"a@b.com","to":["x@y.com","z@w.com"],"subject":"S"}"#;
175        let parsed: MessagingEmailSendInput = serde_json::from_str(wire).unwrap();
176        assert_eq!(parsed.to.len(), 2);
177    }
178
179    #[test]
180    fn sms_send_round_trips() {
181        let input = MessagingSmsSendInput {
182            from: "+15550100".into(),
183            to: "+15550101".into(),
184            body: "Hello".into(),
185        };
186        let wire = serde_json::to_string(&input).unwrap();
187        let back: MessagingSmsSendInput = serde_json::from_str(&wire).unwrap();
188        assert_eq!(back.to, "+15550101");
189    }
190
191    #[test]
192    fn webhook_dispatch_payload_accepts_arbitrary_json() {
193        let wire = r#"{"url":"https://hook.example.com","payload":{"event":"deploy","sha":"abc"}}"#;
194        let parsed: MessagingWebhookDispatchInput = serde_json::from_str(wire).unwrap();
195        assert_eq!(parsed.payload["event"], "deploy");
196        assert!(parsed.headers.is_none());
197    }
198
199    #[test]
200    fn webhook_dispatch_output_ok_reflects_status() {
201        let ok = MessagingWebhookDispatchOutput {
202            status_code: 200,
203            ok: true,
204        };
205        let err = MessagingWebhookDispatchOutput {
206            status_code: 503,
207            ok: false,
208        };
209        assert_eq!(serde_json::to_value(&ok).unwrap()["ok"], true);
210        assert_eq!(serde_json::to_value(&err).unwrap()["status_code"], 503);
211    }
212
213    #[cfg(feature = "json-schema")]
214    #[test]
215    fn verbs_emit_schemas_via_for_verb() {
216        use super::super::VerbDescriptor;
217
218        let email = VerbDescriptor::for_verb::<MessagingEmailSend>();
219        assert_eq!(email.id, "messaging.email.send");
220        assert!(email.input_schema.to_string().contains("subject"));
221
222        let sms = VerbDescriptor::for_verb::<MessagingSmsSend>();
223        assert_eq!(sms.id, "messaging.sms.send");
224
225        let webhook = VerbDescriptor::for_verb::<MessagingWebhookDispatch>();
226        assert_eq!(webhook.id, "messaging.webhook.dispatch");
227        assert!(webhook.output_schema.to_string().contains("status_code"));
228    }
229}