fraiseql-server 2.11.0

HTTP server for FraiseQL v2 GraphQL engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Per-connected-account SMTP send transport for the `send_email` host op.
//!
//! Consumes the `[mailbox.<name>.smtp]` halves: one pooled `lettre` transport per
//! connected account, keyed by the account's verified sending address. The op
//! resolves the host-owned `from` (the #539 sender seam), then this transport
//! routes the send to the account whose address matches — never falling back to a
//! different mailbox. Secrets (the SMTP password) are read server-side from the
//! account's `password_env`, never from the DB row or guest input.
//!
//! Failures are classified onto the error status durable dispatch reads: a
//! permanent SMTP error (5xx, unknown account, malformed recipient) is a 4xx
//! `FraiseQLError` (dead-lettered), a transient one (connection refused, timeout,
//! greylisting) is a 5xx (retried).

use std::{collections::HashMap, future::Future, pin::Pin, time::Duration};

use fraiseql_error::{FraiseQLError, Result};
use fraiseql_functions::{
    EmailTransport, SendContext, SendEmailRequest, SendEmailResponse, SenderIdentity,
};
use lettre::{
    Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
    address::Envelope,
    message::{Mailbox, MultiPart, SinglePart},
    transport::smtp::authentication::Credentials,
};
use tracing::warn;

use super::{
    config::{MailboxSmtpConfig, SmtpTlsMode},
    tracking::{SendTracker, SentRecord},
};

/// Mail-appropriate backoff floor for a transient SMTP failure (greylisting).
///
/// Greylisters tempfail a first delivery and accept a retry a few minutes later, so
/// a seconds-scale policy backoff would exhaust its attempts before the greylist
/// lifts. Five minutes is a common greylist window; the durable dispatcher waits at
/// least this long between retries of a transient send.
const GREYLISTING_BACKOFF_SECS: u64 = 300;

/// Build the `send_email` transport from the SMTP halves of the configured mailboxes.
///
/// Returns an `Arc<dyn EmailTransport>` ready to attach via
/// [`BeforeMutationHooks::with_email`](crate::subsystems::BeforeMutationHooks::with_email).
/// Returns `None` when no `[mailbox.<name>.smtp]` account was successfully built —
/// the caller then leaves `send_email` unconfigured (fail-loud). `get_env` resolves
/// each account's password env (in production, [`std::env::var`]).
///
/// When both `tracker` and `address_hash_key` are `Some`, the transport enforces
/// the delivery-feedback loop (suppression check before send, exactly-once skip,
/// `Sent` record); otherwise it sends without correlation.
#[must_use]
pub fn build_email_transport<S: std::hash::BuildHasher>(
    mailboxes: &HashMap<String, super::MailboxConfig, S>,
    get_env: impl Fn(&str) -> Option<String>,
    tracker: Option<std::sync::Arc<dyn SendTracker>>,
    address_hash_key: Option<std::sync::Arc<[u8]>>,
) -> Option<std::sync::Arc<dyn EmailTransport>> {
    let accounts = mailboxes
        .iter()
        .filter_map(|(name, mailbox)| mailbox.smtp.as_ref().map(|smtp| (name.as_str(), smtp)));
    let mut transport = SmtpMailboxTransport::build(accounts, get_env)?;
    // Attach the delivery-feedback store when present; the address-hash key (which
    // needs the server HMAC secret) additionally enables the suppression check.
    if let Some(tracker) = tracker {
        transport = transport.with_tracker(tracker, address_hash_key);
    }
    Some(std::sync::Arc::new(transport) as std::sync::Arc<dyn EmailTransport>)
}

/// One connected SMTP account: a pooled `lettre` transport, keyed in the parent
/// map by its verified sending address.
struct SmtpAccount {
    transport:       AsyncSmtpTransport<Tokio1Executor>,
    /// The VERP Return-Path local part (`bounces` by default) — the envelope
    /// `MAIL FROM` becomes `<local_part>+<send-id>@<domain>`.
    verp_local_part: String,
    /// The VERP Return-Path domain (the sending address's own domain by default,
    /// for SPF/DMARC alignment).
    verp_domain:     String,
}

impl SmtpAccount {
    /// Build the VERP envelope sender `<local_part>+<send-id>@<domain>` for a send.
    ///
    /// Returns a permanent [`FraiseQLError::Validation`] if the resolved
    /// Return-Path is not a valid address (a misconfigured `return_path`).
    fn verp_from(&self, send_id: &str) -> Result<Address> {
        Address::new(format!("{}+{send_id}", self.verp_local_part), &self.verp_domain).map_err(
            |error| FraiseQLError::Validation {
                message: format!(
                    "invalid VERP Return-Path {}+{send_id}@{}: {error}",
                    self.verp_local_part, self.verp_domain
                ),
                path:    None,
            },
        )
    }
}

/// The `send_email` transport: routes each send to the connected account whose
/// verified sending address matches the resolved sender identity.
pub struct SmtpMailboxTransport {
    accounts:         HashMap<String, SmtpAccount>,
    /// Optional per-mailbox send-warming counter. `None` → no daily cap is
    /// enforced (see [`with_send_counter`](Self::with_send_counter)).
    counter:          Option<std::sync::Arc<dyn super::warming::SendCounter>>,
    /// Optional delivery-feedback store: suppression check before send +
    /// exactly-once skip + `Sent` record. `None` → no suppression/exactly-once (a
    /// plain send). Paired with `address_hash_key` (see [`with_tracker`](Self::with_tracker)).
    tracker:          Option<std::sync::Arc<dyn SendTracker>>,
    /// The keyed hash of a recipient address for suppression lookups. `None` → no
    /// suppression check even with a tracker (no secret configured).
    address_hash_key: Option<std::sync::Arc<[u8]>>,
}

impl SmtpMailboxTransport {
    /// Build a transport from the SMTP halves of the configured mailboxes.
    ///
    /// Each `[mailbox.<name>.smtp]` account becomes one pooled `lettre` transport,
    /// keyed by its `address`. `get_env` resolves the password env (in production,
    /// [`std::env::var`]). An account whose password env is unset, or whose relay
    /// cannot be built, is skipped with a warning (never relays unauthenticated).
    /// Returns `None` when no account was successfully built — the caller then
    /// leaves `send_email` unconfigured (fail-loud), never a phantom transport.
    #[must_use]
    pub fn build<'a>(
        mailboxes: impl Iterator<Item = (&'a str, &'a MailboxSmtpConfig)>,
        get_env: impl Fn(&str) -> Option<String>,
    ) -> Option<Self> {
        let mut accounts = HashMap::new();
        for (name, cfg) in mailboxes {
            let Some(password) = get_env(&cfg.password_env) else {
                warn!(
                    mailbox = %name,
                    password_env = %cfg.password_env,
                    "SMTP send not enabled for mailbox: password env is unset"
                );
                continue;
            };
            // Return-Path domain should align with the sending domain (SPF/DMARC);
            // a mismatch still sends but silently degrades deliverability, so warn.
            let verp_domain = cfg.return_path_domain().to_string();
            if verp_domain != cfg.sending_domain() {
                warn!(
                    mailbox = %name,
                    sending_domain = %cfg.sending_domain(),
                    return_path_domain = %verp_domain,
                    "VERP Return-Path domain differs from the sending domain — SPF/DMARC \
                     alignment is broken and deliverability of tracked sends may degrade"
                );
            }
            match build_account_transport(cfg, password) {
                Ok(transport) => {
                    accounts.insert(
                        cfg.address.clone(),
                        SmtpAccount {
                            transport,
                            verp_local_part: cfg.return_path_local_part().to_string(),
                            verp_domain,
                        },
                    );
                },
                Err(error) => warn!(
                    mailbox = %name,
                    %error,
                    "SMTP send not enabled for mailbox: relay build failed"
                ),
            }
        }
        if accounts.is_empty() {
            None
        } else {
            Some(Self {
                accounts,
                counter: None,
                tracker: None,
                address_hash_key: None,
            })
        }
    }

    /// Attach a per-mailbox send-warming counter that caps daily volume during a
    /// mailbox's warming period. Without one, no daily cap is enforced.
    #[must_use]
    pub fn with_send_counter(
        mut self,
        counter: std::sync::Arc<dyn super::warming::SendCounter>,
    ) -> Self {
        self.counter = Some(counter);
        self
    }

    /// Attach the delivery-feedback store, and optionally the recipient
    /// address-hash key.
    ///
    /// The store enables the exactly-once skip (whenever a send carries a send-id)
    /// and the `Sent` record after a relay. The `address_hash_key` (a subkey of the
    /// server HMAC secret, see
    /// [`derive_address_hash_key`](fraiseql_observers::derive_address_hash_key))
    /// additionally enables the suppression check — it keys the recipient hash so
    /// the store holds no raw address. Without the key, the suppression check is
    /// skipped (there is no secret to derive the same hash the admin surface writes
    /// with), but exactly-once still applies.
    #[must_use]
    pub fn with_tracker(
        mut self,
        tracker: std::sync::Arc<dyn SendTracker>,
        address_hash_key: Option<std::sync::Arc<[u8]>>,
    ) -> Self {
        self.tracker = Some(tracker);
        self.address_hash_key = address_hash_key;
        self
    }

    /// Number of connected send accounts (for diagnostics/tests).
    #[must_use]
    pub fn account_count(&self) -> usize {
        self.accounts.len()
    }
}

impl EmailTransport for SmtpMailboxTransport {
    fn send<'a>(
        &'a self,
        sender: &'a SenderIdentity,
        request: &'a SendEmailRequest,
        context: SendContext<'a>,
    ) -> Pin<Box<dyn Future<Output = Result<SendEmailResponse>> + Send + 'a>> {
        Box::pin(async move {
            // Select the account by the verified sending address — never fall back
            // to a different mailbox (uniform with the read path's fail-closed).
            let Some(account) = self.accounts.get(&sender.address) else {
                return Err(FraiseQLError::Validation {
                    message: format!(
                        "no connected SMTP account for sending address {:?}",
                        sender.address
                    ),
                    path:    None,
                });
            };

            if let Some(tracker) = self.tracker.as_ref() {
                // 1. Suppression: refuse a recipient on the do-not-contact list before anything
                //    else — the biggest deliverability + GDPR lever. A suppressed recipient is
                //    permanent (a retry won't un-suppress), so a 4xx durable dispatch dead-letters
                //    rather than retries. Only with the address-hash key (the same secret the admin
                //    surface hashes with); without it the suppression check is skipped.
                if let Some(key) = self.address_hash_key.as_ref() {
                    let recipient_hash = fraiseql_observers::hash_address(key, &request.to);
                    if let Some(reason) =
                        tracker.suppression_reason(context.tenant, &recipient_hash).await?
                    {
                        return Err(FraiseQLError::Validation {
                            message: format!(
                                "recipient is suppressed ({reason}) — refusing to send"
                            ),
                            path:    None,
                        });
                    }
                }

                // 2. Exactly-once: a durable retry of an already-sent dispatch must not
                //    double-send. If this send-id already completed, skip the relay and return the
                //    recorded response.
                //
                //    ASSUMPTION — one send per dispatch: the send-id is per *dispatch*,
                //    so a single dispatch that sends N distinct emails would share one
                //    send-id and only the first would relay (the rest skip as
                //    "already sent"). The outreach use case is one send per dispatch;
                //    per-recipient send-ids (derive a sub-key per `to`) are a future
                //    refinement if a multi-send dispatch is ever needed.
                if let Some(send_id) = context.send_id {
                    if let Some(recorded) = tracker.recorded_send(context.tenant, send_id).await? {
                        return Ok(SendEmailResponse {
                            message_id: recorded.message_id,
                            accepted:   true,
                        });
                    }
                }
            }

            // 3. Warming cap: refuse when the mailbox is at its daily limit. A daily cap will not
            //    clear on a seconds-scale retry, so this is a permanent failure for this dispatch
            //    (429 → dead-letter → replay next day), not a transient one.
            if let Some(counter) = self.counter.as_ref() {
                if let Some(state) = counter.state(&sender.address).await? {
                    if !state.within_cap() {
                        return Err(FraiseQLError::RateLimited {
                            message:          format!(
                                "sending address {:?} is at its warming daily cap",
                                sender.address
                            ),
                            retry_after_secs: 86_400,
                        });
                    }
                }
            }

            // 4. Set the VERP Return-Path envelope so an inbound bounce/challenge/ reply correlates
            //    back to this send-id. Only when a send-id is present (a signed token); otherwise
            //    the plain header-derived envelope is used.
            let verp_from =
                context.send_id.map(|send_id| account.verp_from(send_id)).transpose()?;
            let message = build_message(sender, request, verp_from)?;

            match account.transport.send(message).await {
                Ok(response) => {
                    let message_id = response.first_line().map(ToString::to_string);
                    // Record the send as `Sent` so the correlation step has a row to
                    // transition and the exactly-once skip fires on a retry. The
                    // message is already sent, so a bookkeeping error is logged, not
                    // surfaced (it must not un-send / trigger a retry).
                    if let (Some(tracker), Some(send_id)) = (self.tracker.as_ref(), context.send_id)
                    {
                        let record = SentRecord {
                            send_id,
                            tenant: context.tenant,
                            recipient: &request.to,
                            sending_address: &sender.address,
                            message_id: message_id.as_deref(),
                        };
                        if let Err(error) = tracker.record_sent(record).await {
                            warn!(%send_id, %error, "failed to record Sent status after relay");
                        }
                    }
                    // Best-effort warming accounting (same must-not-un-send rule).
                    if let Some(counter) = self.counter.as_ref() {
                        if let Err(error) = counter.record_send(&sender.address).await {
                            warn!(address = %sender.address, %error, "failed to record send for warming");
                        }
                    }
                    Ok(SendEmailResponse {
                        message_id,
                        accepted: true,
                    })
                },
                // A permanent SMTP failure (5xx: auth rejected, bad recipient) must
                // NOT retry — map to a 4xx so durable dispatch dead-letters it.
                Err(error) if error.is_permanent() => Err(FraiseQLError::Validation {
                    message: format!("SMTP permanent error: {error}"),
                    path:    None,
                }),
                // Everything else (connection refused, timeout, 4xx greylisting) is
                // transient — a 5xx so durable dispatch retries. Greylisting clears
                // in minutes, not seconds, so the error carries a mail-appropriate
                // backoff floor the durable dispatcher honors (a fast policy backoff
                // would otherwise exhaust before the greylist lifts).
                Err(error) => Err(FraiseQLError::ServiceUnavailable {
                    message:     format!("SMTP transient error: {error}"),
                    retry_after: Some(GREYLISTING_BACKOFF_SECS),
                }),
            }
        })
    }
}

/// Build one account's pooled SMTP transport from its config + resolved password.
fn build_account_transport(
    cfg: &MailboxSmtpConfig,
    password: String,
) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
    let mut builder = match cfg.tls {
        SmtpTlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
            .map_err(|error| FraiseQLError::Configuration {
                message: format!("cannot build STARTTLS relay to {}: {error}", cfg.host),
            })?,
        SmtpTlsMode::Tls => {
            AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|error| {
                FraiseQLError::Configuration {
                    message: format!("cannot build TLS relay to {}: {error}", cfg.host),
                }
            })?
        },
        SmtpTlsMode::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
    };
    builder = builder
        .port(cfg.port)
        .timeout(Some(Duration::from_secs(cfg.timeout_secs)))
        .credentials(Credentials::new(cfg.username.clone(), password));
    Ok(builder.build())
}

/// Build the `lettre` message: host-owned `from`, guest-supplied recipient/body.
///
/// When `verp_from` is `Some`, the SMTP envelope sender (`MAIL FROM`) is overridden
/// to the VERP Return-Path while the header `From` stays the verified sending
/// address — so a bounce/challenge addressed to the Return-Path carries the send-id
/// back for correlation. When `None`, `lettre` derives the envelope from the
/// headers (the plain, uncorrelated path).
fn build_message(
    sender: &SenderIdentity,
    request: &SendEmailRequest,
    verp_from: Option<Address>,
) -> Result<Message> {
    let to_address = request.to.parse::<Address>().map_err(|error| FraiseQLError::Validation {
        message: format!("invalid email address {:?}: {error}", request.to),
        path:    None,
    })?;
    let from = mailbox(&sender.address, sender.display_name.as_deref())?;
    let to = Mailbox::new(None, to_address.clone());

    let mut builder = Message::builder().from(from).to(to).subject(request.subject.clone());
    if let Some(reply_to) = request.reply_to.as_deref() {
        builder = builder.reply_to(mailbox(reply_to, None)?);
    }
    if let Some(verp) = verp_from {
        // Override MAIL FROM only; RCPT TO stays the recipient. `Envelope::new`
        // only errors on an empty recipient list, which cannot happen here.
        let envelope = Envelope::new(Some(verp), vec![to_address]).map_err(|error| {
            FraiseQLError::Validation {
                message: format!("failed to build VERP envelope: {error}"),
                path:    None,
            }
        })?;
        builder = builder.envelope(envelope);
    }

    let built = match (request.text.as_deref(), request.html.as_deref()) {
        (Some(text), Some(html)) => {
            builder.multipart(MultiPart::alternative_plain_html(text.to_owned(), html.to_owned()))
        },
        (Some(text), None) => builder.singlepart(SinglePart::plain(text.to_owned())),
        (None, Some(html)) => builder.singlepart(SinglePart::html(html.to_owned())),
        // No body: a valid, empty plain-text part rather than an error.
        (None, None) => builder.singlepart(SinglePart::plain(String::new())),
    };

    built.map_err(|error| FraiseQLError::Validation {
        message: format!("failed to build email message: {error}"),
        path:    None,
    })
}

/// Parse an address into a `lettre` [`Mailbox`] with an optional display name.
fn mailbox(address: &str, display_name: Option<&str>) -> Result<Mailbox> {
    let parsed = address.parse::<Address>().map_err(|error| FraiseQLError::Validation {
        message: format!("invalid email address {address:?}: {error}"),
        path:    None,
    })?;
    Ok(Mailbox::new(display_name.map(ToOwned::to_owned), parsed))
}

#[cfg(test)]
mod tests;