fraiseql_functions/outbound/mod.rs
1//! Per-user outbound-send policy — the sender-identity rule for any paired
2//! outbound email.
3//!
4//! Sending stays per-user (a banked design input): a paired outbound email — a
5//! sequence step, a follow-up, an auto-acknowledgement — goes *from the connected
6//! user's verified address*, never from a shared or default mailbox. This module
7//! encodes that rule as a pure, fail-loud policy: [`resolve_sender_identity`]
8//! reads the connected user's verified sending address out of the host
9//! [`auth_context`](crate::HostContext::auth_context) and refuses, loudly, when
10//! there is none.
11//!
12//! The identity is host-owned by construction — it comes from the authenticated
13//! context the server populates, not from guest input — so a function cannot
14//! choose to send from another address. That is the enforcement: the only sending
15//! identity a function can obtain is its own connected user's, and the absence of
16//! one is an error rather than a silent fall-back to a shared mailbox.
17//!
18//! A first-class `send_email` host op that injects the bound `from` and a concrete
19//! SMTP / provider transport are a planned hardening follow-up on this policy (see
20//! `docs/architecture/native-runtime-ergonomics.md`); this module ships the
21//! enforceable rule that op will call, and the reference workload
22//! `examples/native-functions/follow-up-email.ts` mirrors it in `TypeScript`.
23
24use std::{future::Future, pin::Pin};
25
26use serde_json::Value;
27
28/// An owned, `Send` boxed future — the object-safe async return used to keep
29/// [`SenderIdentityResolver`] dyn-dispatchable without adding a new dyn-dispatch
30/// trait-macro (the workspace ratchet).
31pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
32
33/// The connected user's verified sending identity — the only `from` a paired
34/// outbound email may use.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SenderIdentity {
37 /// The verified sending address (the message `from`).
38 pub address: String,
39 /// The user's display name, if the auth context carries one.
40 pub display_name: Option<String>,
41}
42
43/// A refusal to send: the per-user policy could not be satisfied.
44///
45/// The policy fails loud rather than fabricating a sender, so a misconfiguration
46/// can never cause a send from the wrong — or a shared — mailbox.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct SendPolicyError {
49 /// Human-readable reason the send is refused.
50 pub message: String,
51 /// Whether a retry might succeed. A **permanent** refusal (`false`, the
52 /// default) — no verified identity, an ambiguous subject — will not succeed
53 /// on retry and should be dead-lettered immediately. A **transient** refusal
54 /// (`true`) — the identity store is momentarily unavailable — is eligible for
55 /// retry. The `send_email` host op maps this onto the error status durable
56 /// dispatch classifies by (permanent → 403, transient → 503).
57 pub retryable: bool,
58}
59
60impl SendPolicyError {
61 /// Build a **permanent** refusal from a reason (the default: a retry will not
62 /// help — e.g. no verified sending identity).
63 #[must_use]
64 pub fn new(message: impl Into<String>) -> Self {
65 Self {
66 message: message.into(),
67 retryable: false,
68 }
69 }
70
71 /// Build a **transient** refusal from a reason (a retry may succeed — e.g. the
72 /// identity store is momentarily unavailable).
73 #[must_use]
74 pub fn transient(message: impl Into<String>) -> Self {
75 Self {
76 message: message.into(),
77 retryable: true,
78 }
79 }
80}
81
82impl std::fmt::Display for SendPolicyError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "{}", self.message)
85 }
86}
87
88impl std::error::Error for SendPolicyError {}
89
90/// Resolve the connected user's verified sending identity from a host auth
91/// context.
92///
93/// The address is taken from the `email` field the host populates from the
94/// authenticated identity. It must be a non-empty, plausibly-addressable string
95/// (containing an `@`); anything else — a missing, blank, or malformed value —
96/// is a refusal, because a paired outbound email must be sent from the connected
97/// user's address and never from a shared or default mailbox.
98///
99/// # Errors
100///
101/// Returns [`SendPolicyError`] when the auth context carries no usable verified
102/// sending address.
103pub fn resolve_sender_identity(auth_context: &Value) -> Result<SenderIdentity, SendPolicyError> {
104 let address = auth_context
105 .get("email")
106 .and_then(Value::as_str)
107 .map(str::trim)
108 .filter(|value| value.contains('@') && !value.contains(char::is_whitespace))
109 .ok_or_else(|| {
110 SendPolicyError::new(
111 "refusing to send: the authenticated user has no verified sending address; a \
112 paired outbound email must be sent from the connected user's address, never a \
113 shared or default mailbox",
114 )
115 })?;
116
117 let display_name = auth_context
118 .get("display_name")
119 .and_then(Value::as_str)
120 .map(str::trim)
121 .filter(|value| !value.is_empty())
122 .map(ToString::to_string);
123
124 Ok(SenderIdentity {
125 address: address.to_string(),
126 display_name,
127 })
128}
129
130/// The injectable seam the `send_email` host op calls to obtain a host-owned
131/// `from` (DESIGN §4.2).
132///
133/// One implementation per deployment, object-safe so the server can inject an
134/// `Arc<dyn SenderIdentityResolver>` into the functions host.
135/// The default [`LoginEmailSender`] is the degenerate case — the sending address
136/// *is* the connected user's login email, read from the auth context with no DB.
137/// A DB-backed implementation (in the server) resolves `sub → verified
138/// from-address + mailbox` on the shared identity primitive, cached and
139/// fail-closed. Either way a refusal is a [`SendPolicyError`], never a silent
140/// fall-back to a shared mailbox.
141pub trait SenderIdentityResolver: Send + Sync {
142 /// Resolve the sending identity for `auth_context` — the host-owned
143 /// authenticated context, never guest input.
144 ///
145 /// The future resolves to [`SendPolicyError`] when no verified sending
146 /// identity is available.
147 fn resolve_sender<'a>(
148 &'a self,
149 auth_context: &'a Value,
150 ) -> BoxFuture<'a, Result<SenderIdentity, SendPolicyError>>;
151}
152
153/// The degenerate [`SenderIdentityResolver`]: the sending address is the
154/// connected user's login email, read from the host auth context (no DB).
155///
156/// This subsumes the pure [`resolve_sender_identity`] policy as a trait
157/// implementation (DESIGN §4.1) — the seam works with no `[identity.sender]`
158/// configured, and a DB-backed resolver replaces it verbatim where the sending
159/// mailbox differs from the login email.
160#[derive(Debug, Default, Clone, Copy)]
161pub struct LoginEmailSender;
162
163impl SenderIdentityResolver for LoginEmailSender {
164 fn resolve_sender<'a>(
165 &'a self,
166 auth_context: &'a Value,
167 ) -> BoxFuture<'a, Result<SenderIdentity, SendPolicyError>> {
168 let result = resolve_sender_identity(auth_context);
169 Box::pin(async move { result })
170 }
171}
172
173/// A guest's request to send an email via the `send_email` host op.
174///
175/// The `from` is **not** part of this request: it is host-owned and injected by
176/// the op from the resolved [`SenderIdentity`], so a guest can never send from
177/// another address. A `from` field in the guest's JSON is silently ignored (it
178/// maps to no field here), which is the enforcement — the guest cannot override
179/// the sender.
180#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub struct SendEmailRequest {
182 /// The recipient address.
183 pub to: String,
184 /// The message subject.
185 pub subject: String,
186 /// The plain-text body, if any. At least one of `text`/`html` should be set.
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub text: Option<String>,
189 /// The HTML body, if any.
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub html: Option<String>,
192 /// An optional `Reply-To` address.
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub reply_to: Option<String>,
195}
196
197/// The result of a successful [`send_email`](crate::HostContext::send_email).
198#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
199pub struct SendEmailResponse {
200 /// The relay/provider message id, when one was returned.
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub message_id: Option<String>,
203 /// Always `true` on a returned response — the relay accepted the message.
204 pub accepted: bool,
205}
206
207/// The per-dispatch context a [`send`](EmailTransport::send) carries beyond the
208/// sender and the request: the correlation send-id and the tenant scope.
209///
210/// The `send_id` is the host's per-dispatch idempotency token (see
211/// [`idempotency_token`](crate::HostContext::idempotency_token)); the transport
212/// uses it two ways — as the VERP `bounces+<send-id>@…` Return-Path that
213/// correlates an inbound bounce/challenge/reply back to this send, and as an
214/// exactly-once key so a durable retry of an already-sent dispatch does not
215/// double-send. The `tenant` scopes the send-status and suppression rows the
216/// transport reads/writes. Both are `Option` because a zero-config deployment (no
217/// HMAC secret, no tenant) sends without correlation or tenant scoping.
218///
219/// A named struct rather than positional `Option<&str>` parameters: `send_id` and
220/// `tenant` are both optional strings and would be trivially transposable at the
221/// call site.
222#[derive(Debug, Clone, Copy, Default)]
223pub struct SendContext<'a> {
224 /// The per-dispatch VERP send-id / exactly-once key. `None` → the transport
225 /// sends with no VERP Return-Path and no exactly-once dedup.
226 pub send_id: Option<&'a str>,
227 /// The tenant the send is scoped to (RLS stamp on send-status / suppression
228 /// rows). `None` → single-tenant.
229 pub tenant: Option<&'a str>,
230}
231
232/// The transport seam the `send_email` host op relays through, once the op has
233/// resolved the host-owned `from` from the [`SenderIdentityResolver`].
234///
235/// Object-safe so the server can inject an `Arc<dyn EmailTransport>` into the
236/// functions host; the concrete per-connected-account SMTP transport lives in
237/// `fraiseql-server` (the runtime-SMTP owner), mirroring the resolver split.
238///
239/// The returned [`FraiseQLError`](fraiseql_error::FraiseQLError) carries the status
240/// that durable dispatch classifies by: a **4xx** (e.g. `Validation`/403) is a
241/// permanent failure routed straight to the dead-letter queue, a **5xx** (e.g.
242/// `ServiceUnavailable`) is transient and retried.
243pub trait EmailTransport: Send + Sync {
244 /// Send `request` from the resolved verified `sender` identity, within the
245 /// per-dispatch [`SendContext`] (correlation send-id + tenant scope).
246 fn send<'a>(
247 &'a self,
248 sender: &'a SenderIdentity,
249 request: &'a SendEmailRequest,
250 context: SendContext<'a>,
251 ) -> BoxFuture<'a, fraiseql_error::Result<SendEmailResponse>>;
252}
253
254#[cfg(test)]
255mod tests;