Skip to main content

tablo_core/auth/
mail.rs

1//! The mail auth sends: the [`AuthMail`] configuration every mailed link shares, the links it
2//! builds, and the headers a page carrying a token answers with.
3
4use std::{future::Future, sync::Arc};
5
6use topcoat::{
7    context::Cx,
8    mail::{Mail, Mailbox},
9    router::{request::uri, response::Response},
10};
11
12use crate::auth::realm::{Realm, current_realm};
13
14/// Where a panel's auth mail comes from and where its links point, installed with
15/// [`Auth::mail`](super::Auth::mail): the sender, and the absolute URL every link starts with.
16///
17/// ```rust
18/// use tablo_core::{Auth, AuthMail, PasswordAuth, PasswordReset};
19/// use topcoat::mail::Mailbox;
20///
21/// # fn main() -> Result<(), topcoat::mail::AddressError> {
22/// let auth = Auth::password()
23///     .mail(AuthMail::new(
24///         Mailbox::named("Example", "no-reply@example.com")?,
25///         "https://admin.example.com",
26///     ))
27///     .password_reset(PasswordReset::new(PasswordAuth));
28/// # let _ = auth;
29/// # Ok(())
30/// # }
31/// ```
32///
33/// The base URL is configuration, not the request's `Host` header, which the client chooses: a
34/// link built from it could point a victim's email at another host. Mounting refuses a base URL
35/// that is not an absolute `http` or `https` URL, or that carries a query or fragment, and a
36/// router without a [`Mailer`](topcoat::mail::Mailer), which the app adds with
37/// `.with(Mailer::new(..))` before mounting the panel.
38pub struct AuthMail {
39    from: Mailbox,
40    base_url: String,
41}
42
43impl AuthMail {
44    /// Mails from `from`, with links that start with `base_url`, such as
45    /// `https://admin.example.com`: the scheme and host the app answers on, before the panel's
46    /// prefix.
47    #[must_use]
48    pub fn new(from: Mailbox, base_url: impl Into<String>) -> Self {
49        Self {
50            from,
51            base_url: base_url.into().trim_end_matches('/').to_string(),
52        }
53    }
54
55    /// Whether the base URL is an absolute `http` or `https` URL without a query or fragment.
56    pub(crate) fn base_url_is_absolute(&self) -> bool {
57        self.base_url.parse::<http::Uri>().is_ok_and(|uri| {
58            matches!(uri.scheme_str(), Some("http" | "https"))
59                && uri.authority().is_some()
60                && uri.query().is_none()
61        }) && !self.base_url.contains('#')
62    }
63
64    pub(crate) fn base_url(&self) -> &str {
65        &self.base_url
66    }
67
68    /// The absolute link to `path` on this app, with `token` in its query.
69    pub(super) fn link(&self, path: &str, token: &topcoat::session::Token) -> String {
70        let query = form_urlencoded::Serializer::new(String::new())
71            .append_pair(TOKEN_FIELD, &token.encode())
72            .finish();
73        format!("{}{path}?{query}", self.base_url)
74    }
75
76    /// A mail from this sender to `address`, titled `subject`, reading `text`.
77    pub(super) fn compose(
78        &self,
79        address: &str,
80        subject: String,
81        text: String,
82    ) -> topcoat::Result<Mail> {
83        Ok(Mail::builder()
84            .from(self.from.clone())
85            .to([Mailbox::new(address)?])
86            .subject(subject)
87            .text(text)
88            .build()?)
89    }
90}
91
92impl std::fmt::Debug for AuthMail {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("AuthMail")
95            .field("from", &self.from.address())
96            .field("base_url", &self.base_url)
97            .finish()
98    }
99}
100
101/// The key a mailed link carries its token under, in its query and in the form it opens.
102pub(super) const TOKEN_FIELD: &str = "token";
103
104/// The token the request's query carries, or empty.
105pub(super) fn token_from_query(cx: &Cx) -> String {
106    uri(cx)
107        .query()
108        .and_then(|query| {
109            form_urlencoded::parse(query.as_bytes())
110                .find(|(key, _)| key == TOKEN_FIELD)
111                .map(|(_, value)| value.into_owned())
112        })
113        .unwrap_or_default()
114}
115
116/// Runs `send` after the response, with the request's panel and its [`AuthMail`], logging a
117/// failure as `what`: waiting on the database or the mail server, or answering differently,
118/// would tell the visitor whether an account exists.
119pub(super) fn after_response<F, Fut>(cx: &Cx, what: &'static str, send: F)
120where
121    F: FnOnce(Cx, Arc<Realm>) -> Fut + Send + 'static,
122    Fut: Future<Output = topcoat::Result<()>> + Send + 'static,
123{
124    let Some(panel) = current_realm(cx).map(Arc::clone) else {
125        return;
126    };
127    let cx = cx.clone();
128    tokio::spawn(async move {
129        let prefix = panel.prefix.clone();
130        if let Err(error) = send(cx, panel).await {
131            tracing::error!(error = %error, panel = %prefix, "{what} not sent");
132        }
133    });
134}
135
136/// `response` with `Referrer-Policy: no-referrer`: a page whose URL or form carries a token
137/// sends no referrer at all, not even to the panel's own origin.
138pub(super) fn private(mut response: Response) -> topcoat::Result<Response> {
139    response.headers_mut().insert(
140        http::header::REFERRER_POLICY,
141        http::HeaderValue::from_static("no-referrer"),
142    );
143    Ok(response)
144}
145
146/// The mistakes in what a panel's auth mails: a feature that mails links without
147/// [`Auth::mail`](super::Auth::mail), a base URL that is not absolute, and a router that cannot
148/// send mail. `has_mail` says whether the router can.
149pub(super) fn declaration_errors(
150    auth: &super::AuthConfig,
151    has_mail: bool,
152) -> Vec<crate::DeclarationErrorKind> {
153    use crate::{AuthMailFault, DeclarationErrorKind};
154
155    let mailing = auth.mailing();
156    if auth.is_disabled() || mailing.is_empty() {
157        return Vec::new();
158    }
159    let Some(mail) = auth.mail.as_ref() else {
160        return mailing
161            .into_iter()
162            .map(|feature| DeclarationErrorKind::AuthMail(AuthMailFault::Unset { feature }))
163            .collect();
164    };
165    let mut faults = Vec::new();
166    if !mail.base_url_is_absolute() {
167        faults.push(AuthMailFault::BaseUrl(mail.base_url().to_string()));
168    }
169    if !has_mail {
170        faults.push(AuthMailFault::MissingMail);
171    }
172    faults
173        .into_iter()
174        .map(DeclarationErrorKind::AuthMail)
175        .collect()
176}