Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 77 additions & 51 deletions crates/outer/src/auth/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,59 +137,16 @@ impl EmailSender {
}

/// Send one sign-in code. The code itself goes in the body; failures
/// name the reason, never the token.
/// name the reason, never the token. The test sender captures the bare
/// code (existing sign-in tests verify with it), while [`send_text`]
/// captures subject plus body.
pub async fn send_code(&self, to: &str, code: &str) -> Result<(), EmailError> {
let subject = format!("Your Outer sign-in code is {code}");
let text = format!(
"Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email."
);
match self {
Self::Cloudflare {
client,
api_base,
account_id,
api_token,
from,
} => {
let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send");
let subject = format!("Your Outer sign-in code is {code}");
let text = format!(
"Your Outer sign-in code is {code}. It expires in 10 minutes.\n\nIf you didn't ask for this, ignore this email."
);
let response = client
.post(&url)
.bearer_auth(api_token)
.json(&serde_json::json!({
"to": to,
"from": { "address": from.address, "name": from.name },
"subject": subject,
"text": text,
}))
.send()
.await
.map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?;
let status = response.status();
let body: serde_json::Value = response.json().await.map_err(|err| {
EmailError::Retryable(format!("email API returned an unreadable body: {err}"))
})?;
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| error_code(&body) == Some(10004)
{
return Err(EmailError::Retryable(
"email provider is rate limited, try again shortly".to_string(),
));
}
if !status.is_success()
|| body.get("success").and_then(|v| v.as_bool()) != Some(true)
{
let detail = first_error(&body).unwrap_or_else(|| status.to_string());
return Err(EmailError::Permanent(format!(
"email provider refused the message ({detail})"
)));
}
if permanent_bounce(&body, to) {
return Err(EmailError::Permanent(format!(
"email to {to} permanently bounced"
)));
}
Ok(())
}
Self::Cloudflare { .. } => self.post(to, &subject, &text).await,
Self::Log => {
tracing::info!(email = %to, code = %code, "sign-in code (localhost log sender)");
Ok(())
Expand All @@ -204,6 +161,75 @@ impl EmailSender {
}
}
}

/// Send one free-form message (collaborator invites). Same transport,
/// same failure contract as [`send_code`].
pub async fn send_text(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> {
match self {
Self::Cloudflare { .. } => self.post(to, subject, text).await,
Self::Log => {
tracing::info!(email = %to, subject = %subject, "outbound email (localhost log sender)");
Ok(())
}
#[cfg(test)]
Self::Capture { codes } => {
codes
.lock()
.unwrap()
.insert(to.to_string(), format!("{subject}\n\n{text}"));
Ok(())
}
}
}

/// The Cloudflare Email Sending POST behind [`send_code`] and
/// [`send_text`].
async fn post(&self, to: &str, subject: &str, text: &str) -> Result<(), EmailError> {
let Self::Cloudflare {
client,
api_base,
account_id,
api_token,
from,
} = self
else {
unreachable!("post is only called on the Cloudflare sender");
};
let url = format!("{api_base}/client/v4/accounts/{account_id}/email/sending/send");
let response = client
.post(&url)
.bearer_auth(api_token)
.json(&serde_json::json!({
"to": to,
"from": { "address": from.address, "name": from.name },
"subject": subject,
"text": text,
}))
.send()
.await
.map_err(|err| EmailError::Retryable(scrubbed(&err.to_string())))?;
let status = response.status();
let body: serde_json::Value = response.json().await.map_err(|err| {
EmailError::Retryable(format!("email API returned an unreadable body: {err}"))
})?;
if status == reqwest::StatusCode::TOO_MANY_REQUESTS || error_code(&body) == Some(10004) {
return Err(EmailError::Retryable(
"email provider is rate limited, try again shortly".to_string(),
));
}
if !status.is_success() || body.get("success").and_then(|v| v.as_bool()) != Some(true) {
let detail = first_error(&body).unwrap_or_else(|| status.to_string());
return Err(EmailError::Permanent(format!(
"email provider refused the message ({detail})"
)));
}
if permanent_bounce(&body, to) {
return Err(EmailError::Permanent(format!(
"email to {to} permanently bounced"
)));
}
Ok(())
}
}

/// The recipient counts as permanently bounced when it appears in
Expand Down
13 changes: 12 additions & 1 deletion crates/outer/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ pub fn normalize_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}

fn valid_email(email: &str) -> bool {
pub(crate) fn valid_email(email: &str) -> bool {
let parts = email.split('@').collect::<Vec<_>>();
parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') && !parts[1].is_empty()
}
Expand Down Expand Up @@ -493,6 +493,17 @@ pub async fn verify_code_core(
});
}
}
// A collaborator invite for this address links here: the row's
// userId names the new account (spec §7.4). Invites to addresses
// that already had accounts linked in `collaborators::add`.
sqlx::query(
"UPDATE app_collaborator SET userId = ?1 WHERE email = ?2 AND userId IS NULL",
)
.bind(&id)
.bind(&email)
.execute(&mut *tx)
.await
.map_err(db)?;
// Only people who joined with an invite get codes to pass on
// (owner, 2026-10-24): not admins, not allowlisted emails, and
// not app collaborators, who are admitted through the allowlist.
Expand Down
Loading
Loading