diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index ee01431e..c988b4ca 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -729,7 +729,18 @@ pub async fn batch_create_claim_tokens( // Send email if requested if let (Some(email), Some(svc)) = (&req.delivery_email, &email_service) { - if let Err(e) = svc.send_claim_email(email, &claim_url).await { + // Best-effort kind-0 fetch for the preloaded account so the email + // can show the restaurant's display name + logo on the claim card. + // Falls back to the plain layout on any failure. + let relays = keycast_core::types::authorization::Authorization::get_bunker_relays(); + let profile = crate::nostr_profile::fetch_profile_metadata(&user_pubkey, &relays).await; + let account_display_name = profile.as_ref().and_then(|p| p.display_name.as_deref()); + let account_picture = profile.as_ref().and_then(|p| p.picture.as_deref()); + + if let Err(e) = svc + .send_claim_email(email, &claim_url, account_display_name, account_picture) + .await + { tracing::warn!( "Failed to send claim email for vine_id={} to {}: {}", vine_id, diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index a7cdca03..57eaa92a 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -659,12 +659,61 @@ pub async fn invite_user( ) .await?; - // Resolve inviter display name + // Resolve inviter display name (used for the list response regardless of email outcome) let inviter_name = resolve_display_name(&pool, &user_pubkey_hex, tenant_id).await; + // For the email body we prefer a real display name → email → pubkey prefix. + // The admin's email is the most useful fallback when no kind-0 display name exists. + let inviter_real_display: Option = + sqlx::query_as::<_, (Option, Option)>( + "SELECT display_name, username FROM users WHERE pubkey = $1 AND tenant_id = $2", + ) + .bind(&user_pubkey_hex) + .bind(tenant_id) + .fetch_optional(&pool) + .await + .ok() + .flatten() + .and_then(|(dn, un)| { + dn.filter(|s| !s.is_empty()) + .or_else(|| un.filter(|s| !s.is_empty())) + }); + let inviter_email_opt = user_repo.get_email(&user_pubkey_hex, tenant_id).await.ok(); + let inviter_label = inviter_real_display + .clone() + .or_else(|| inviter_email_opt.clone()) + .unwrap_or_else(|| inviter_name.clone()); + // Resolve team name let team = team_repo.find(tenant_id, team_id).await?; + // Resolve team's primary stored key (used for kind-0 profile lookup). + let team_key_pubkey: Option = sqlx::query_scalar( + "SELECT pubkey FROM stored_keys + WHERE tenant_id = $1 AND team_id = $2 + ORDER BY id ASC + LIMIT 1", + ) + .bind(tenant_id) + .bind(team_id) + .fetch_optional(&pool) + .await + .map_err(|e| ApiError::internal(format!("Failed to load team key: {}", e)))?; + + // Best-effort kind-0 fetch so the email can show the team's real display + // name and avatar (same as the accept page). Falls back to the DB handle. + let profile = if let Some(ref pk) = team_key_pubkey { + let relays = keycast_core::types::authorization::Authorization::get_bunker_relays(); + crate::nostr_profile::fetch_profile_metadata(pk, &relays).await + } else { + None + }; + let team_display_name = profile + .as_ref() + .and_then(|p| p.display_name.clone()) + .unwrap_or_else(|| team.name.clone()); + let team_picture = profile.as_ref().and_then(|p| p.picture.clone()); + // Build invite URL let invite_base_url = std::env::var("INVITE_BASE_URL") .or_else(|_| std::env::var("BASE_URL")) @@ -678,9 +727,11 @@ pub async fn invite_user( if let Err(e) = email_service .send_team_invite_email( &email, - &team.name, - &inviter_name, + &team_display_name, + team_picture.as_deref(), + &inviter_label, role_str, + invitation.expires_at, &invite_url, ) .await diff --git a/api/src/email_service.rs b/api/src/email_service.rs index 34b2c840..e7de730d 100644 --- a/api/src/email_service.rs +++ b/api/src/email_service.rs @@ -2,6 +2,7 @@ // ABOUTME: Supports SendGrid for production, AWS SES (behind `aws` feature), and DevEmailSender for local development/testing use async_trait::async_trait; +use chrono::{DateTime, Utc}; use serde::Serialize; use std::env; use std::sync::{Arc, Mutex}; @@ -30,15 +31,33 @@ pub trait EmailSender: Send + Sync { ) -> Result<(), String>; /// Send a claim link email for a preloaded account. - async fn send_claim_email(&self, to_email: &str, claim_url: &str) -> Result<(), String>; + /// + /// `account_display_name` and `account_picture` come from the preloaded + /// pubkey's kind-0 metadata when available; when both are `None` the email + /// falls back to the plain single-CTA layout. + async fn send_claim_email( + &self, + to_email: &str, + claim_url: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, + ) -> Result<(), String>; /// Send a team invitation email. + /// + /// `team_display_name` should be the kind-0 display name when available, + /// falling back to the DB team handle. `team_picture` is an optional kind-0 + /// avatar URL (http/https only; callers are responsible for validating). + /// `inviter_label` is the admin's email (preferred) or display name. + #[allow(clippy::too_many_arguments)] async fn send_team_invite_email( &self, to_email: &str, - team_name: &str, - inviter_name: &str, + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, role: &str, + expires_at: DateTime, invite_url: &str, ) -> Result<(), String>; @@ -67,30 +86,56 @@ fn password_reset_base_url(default: &str) -> String { // Shared email templates (used by SendGrid, SES, and any future providers) // --------------------------------------------------------------------------- -fn verification_email_html(verification_url: &str) -> String { +/// Shared layout for single-call-to-action transactional emails (verification, +/// password reset, claim). Keeps the same wordmark / button / footer treatment +/// as the team invitation card, minus the card itself — there's no subject +/// entity to present in these flows, so stylising further risks looking like +/// phishing on what are already high-trust emails. +fn basic_email_html( + heading: &str, + intro: &str, + cta_label: &str, + cta_url: &str, + footer_note: &str, +) -> String { + let heading_esc = html_escape(heading); + let intro_esc = html_escape(intro); + let cta_label_esc = html_escape(cta_label); + let url_esc = html_escape(cta_url); + let footer_esc = html_escape(footer_note); + format!( - r#" - - -

Verify your Synvya email

-

Thanks for signing up! Please verify your email address by clicking the button below:

- -

- Or copy and paste this link into your browser:
- {} -

-

- If you didn't sign up for Synvya, you can safely ignore this email. -

- - - "#, - verification_url, verification_url, verification_url + r#" + + +
+
+ Synvya +
+

{heading_esc}

+

{intro_esc}

+ +

+ Or copy and paste this link into your browser:
+ {url_esc} +

+

{footer_esc}

+
+ + +"# + ) +} + +fn verification_email_html(verification_url: &str) -> String { + basic_email_html( + "Verify your Synvya email", + "Thanks for signing up! Confirm your email address to finish creating your account.", + "Verify Email Address", + verification_url, + "If you didn't sign up for Synvya, you can safely ignore this email.", ) } @@ -102,29 +147,12 @@ fn verification_email_text(verification_url: &str) -> String { } fn password_reset_html(reset_url: &str) -> String { - format!( - r#" - - -

Reset your Synvya password

-

We received a request to reset your password. Click the button below to set a new password:

- -

- Or copy and paste this link into your browser:
- {} -

-

- This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email. -

- - - "#, - reset_url, reset_url, reset_url + basic_email_html( + "Reset your Synvya password", + "We received a request to reset your password. Click the button below to set a new one.", + "Reset Password", + reset_url, + "This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.", ) } @@ -135,30 +163,71 @@ fn password_reset_text(reset_url: &str) -> String { ) } -fn claim_email_html(claim_url: &str) -> String { +fn claim_email_html( + claim_url: &str, + recipient_email: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, +) -> String { + // Without a display name or picture there's nothing to show in a card — + // fall back to the shared plain layout. + if account_display_name.is_none() && account_picture.is_none() { + return basic_email_html( + "Your Synvya account is ready", + "Your Synvya account is ready. Click the button below to claim it and set up your login.", + "Claim Your Account", + claim_url, + "This link will expire in 7 days. If you didn't request this, you can safely ignore this email.", + ); + } + + let name_esc = html_escape(account_display_name.unwrap_or("")); + let recipient_esc = html_escape(recipient_email); + let url_esc = html_escape(claim_url); + + let avatar_html = account_picture + .and_then(safe_http_url) + .map(|url| { + format!( + r#""# + ) + }) + .unwrap_or_default(); + + let name_html = if name_esc.is_empty() { + String::new() + } else { + format!( + r#"
{name_esc}
"# + ) + }; + format!( - r#" - - -

Your Synvya account is ready!

-

Your Synvya account is ready. Click the button below to claim it and set up your login:

- -

- Or copy and paste this link into your browser:
- {} -

-

- This link will expire in 7 days. If you didn't request this, you can safely ignore this email. -

- - - "#, - claim_url, claim_url, claim_url + r#" + + +
+
+ Synvya +
+

Your Synvya account is ready

+
+ {avatar_html}{name_html}
Claim your login for {recipient_esc}.
+
+ +

+ Or copy and paste this link into your browser:
+ {url_esc} +

+

+ This link will expire in 7 days. If you didn't request this, you can safely ignore this email. +

+
+ + +"# ) } @@ -169,35 +238,115 @@ fn claim_email_text(claim_url: &str) -> String { ) } -fn team_invite_html(team_name: &str, inviter_name: &str, role: &str, invite_url: &str) -> String { +/// Minimal HTML-attribute/text escape for values interpolated into the template. +/// Handles the five chars that matter in body text and double-quoted attributes. +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// Keep only http/https URLs; reject anything else to avoid `javascript:` etc. +/// Returned string is already HTML-attribute-safe. +fn safe_http_url(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + Some(html_escape(trimmed)) + } else { + None + } +} + +fn title_case_role(role: &str) -> String { + let mut chars = role.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +fn team_invite_html( + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, + recipient_email: &str, + role: &str, + expires_at: DateTime, + invite_url: &str, +) -> String { + let team_name_esc = html_escape(team_display_name); + let inviter_esc = html_escape(inviter_label); + let recipient_esc = html_escape(recipient_email); + let role_esc = html_escape(&title_case_role(role)); + let url_esc = html_escape(invite_url); + // "Apr 28, 2026" — date only, TZ-ambiguous time would confuse recipients. + let expires_fmt = expires_at.format("%b %-d, %Y").to_string(); + + let avatar_html = team_picture + .and_then(safe_http_url) + .map(|url| { + format!( + r#""# + ) + }) + .unwrap_or_default(); + format!( - r#" - - -

You're invited to join {team_name}

-

{inviter_name} has invited you to join {team_name} as a {role} on Synvya.

- -

- Or copy and paste this link into your browser:
- {invite_url} -

-

- This invitation expires in 7 days. If you didn't expect this email, you can safely ignore it. -

- - - "#, + r#" + + +
+
+ Synvya +
+

Team Invitation

+
+ {avatar_html}
{team_name_esc}
+
{inviter_esc} invited you to join as {role_esc}.
+
Sent to {recipient_esc}. Expires {expires_fmt}.
+
+ +

+ Or copy and paste this link into your browser:
+ {url_esc} +

+

+ This invitation expires in 7 days. If you didn't expect this email, you can safely ignore it. +

+
+ + +"# ) } -fn team_invite_text(team_name: &str, inviter_name: &str, role: &str, invite_url: &str) -> String { +fn team_invite_text( + team_display_name: &str, + inviter_label: &str, + recipient_email: &str, + role: &str, + expires_at: DateTime, + invite_url: &str, +) -> String { + let role_tc = title_case_role(role); + let expires_fmt = expires_at.format("%b %-d, %Y").to_string(); format!( - "{inviter_name} has invited you to join {team_name} as a {role} on Synvya.\n\nAccept the invitation:\n{invite_url}\n\nThis invitation expires in 7 days. If you didn't expect this email, you can safely ignore it.", + "Team Invitation — {team_display_name}\n\n\ + {inviter_label} invited you to join as {role_tc}.\n\ + Sent to {recipient_email}. Expires {expires_fmt}.\n\n\ + Accept the invitation:\n{invite_url}\n\n\ + This invitation expires in 7 days. If you didn't expect this email, you can safely ignore it.\n", ) } @@ -329,13 +478,21 @@ impl EmailSender for DevEmailSender { Ok(()) } - async fn send_claim_email(&self, to_email: &str, claim_url: &str) -> Result<(), String> { + async fn send_claim_email( + &self, + to_email: &str, + claim_url: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, + ) -> Result<(), String> { tracing::info!(""); tracing::info!("=================================================="); tracing::info!(" SYNVYA CLAIM EMAIL"); tracing::info!("=================================================="); tracing::info!(" To: {}", to_email); tracing::info!(" Subject: Your Synvya account is ready to claim"); + tracing::info!(" Display name: {:?}", account_display_name); + tracing::info!(" Picture: {:?}", account_picture); tracing::info!(""); tracing::info!(" Claim link:"); tracing::info!(" {}", claim_url); @@ -362,9 +519,11 @@ impl EmailSender for DevEmailSender { async fn send_team_invite_email( &self, to_email: &str, - team_name: &str, - inviter_name: &str, + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, role: &str, + expires_at: DateTime, invite_url: &str, ) -> Result<(), String> { tracing::info!(""); @@ -372,8 +531,10 @@ impl EmailSender for DevEmailSender { tracing::info!(" TEAM INVITATION EMAIL"); tracing::info!("=================================================="); tracing::info!(" To: {}", to_email); - tracing::info!(" Team: {} (as {})", team_name, role); - tracing::info!(" Invited by: {}", inviter_name); + tracing::info!(" Team: {} (as {})", team_display_name, role); + tracing::info!(" Invited by: {}", inviter_label); + tracing::info!(" Picture: {:?}", team_picture); + tracing::info!(" Expires: {}", expires_at); tracing::info!(""); tracing::info!(" Accept link:"); tracing::info!(" {}", invite_url); @@ -382,13 +543,16 @@ impl EmailSender for DevEmailSender { eprintln!( "\n\x1b[35m[DEV EMAIL]\x1b[0m Team invite for {} to join {}: \x1b[4m{}\x1b[0m\n", - to_email, team_name, invite_url + to_email, team_display_name, invite_url ); if let Ok(mut captured) = self.captured.lock() { captured.push(CapturedEmail { to: to_email.to_string(), - subject: format!("You've been invited to join {} on Synvya", team_name), + subject: format!( + "You've been invited to join {} on Synvya", + team_display_name + ), verification_url: Some(invite_url.to_string()), reset_url: None, }); @@ -598,9 +762,15 @@ impl EmailSender for SendGridEmailSender { self.send_email(to_email, subject, &html, &text).await } - async fn send_claim_email(&self, to_email: &str, claim_url: &str) -> Result<(), String> { + async fn send_claim_email( + &self, + to_email: &str, + claim_url: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, + ) -> Result<(), String> { let subject = "Your Synvya account is ready to claim"; - let html = claim_email_html(claim_url); + let html = claim_email_html(claim_url, to_email, account_display_name, account_picture); let text = claim_email_text(claim_url); self.send_email(to_email, subject, &html, &text).await @@ -609,14 +779,34 @@ impl EmailSender for SendGridEmailSender { async fn send_team_invite_email( &self, to_email: &str, - team_name: &str, - inviter_name: &str, + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, role: &str, + expires_at: DateTime, invite_url: &str, ) -> Result<(), String> { - let subject = format!("You've been invited to join {} on Synvya", team_name); - let html = team_invite_html(team_name, inviter_name, role, invite_url); - let text = team_invite_text(team_name, inviter_name, role, invite_url); + let subject = format!( + "You've been invited to join {} on Synvya", + team_display_name + ); + let html = team_invite_html( + team_display_name, + team_picture, + inviter_label, + to_email, + role, + expires_at, + invite_url, + ); + let text = team_invite_text( + team_display_name, + inviter_label, + to_email, + role, + expires_at, + invite_url, + ); self.send_email(to_email, &subject, &html, &text).await } @@ -776,9 +966,15 @@ impl EmailSender for SesEmailSender { self.send_email(to_email, subject, &html, &text).await } - async fn send_claim_email(&self, to_email: &str, claim_url: &str) -> Result<(), String> { + async fn send_claim_email( + &self, + to_email: &str, + claim_url: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, + ) -> Result<(), String> { let subject = "Your Synvya account is ready to claim"; - let html = claim_email_html(claim_url); + let html = claim_email_html(claim_url, to_email, account_display_name, account_picture); let text = claim_email_text(claim_url); self.send_email(to_email, subject, &html, &text).await @@ -787,14 +983,34 @@ impl EmailSender for SesEmailSender { async fn send_team_invite_email( &self, to_email: &str, - team_name: &str, - inviter_name: &str, + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, role: &str, + expires_at: DateTime, invite_url: &str, ) -> Result<(), String> { - let subject = format!("You've been invited to join {} on Synvya", team_name); - let html = team_invite_html(team_name, inviter_name, role, invite_url); - let text = team_invite_text(team_name, inviter_name, role, invite_url); + let subject = format!( + "You've been invited to join {} on Synvya", + team_display_name + ); + let html = team_invite_html( + team_display_name, + team_picture, + inviter_label, + to_email, + role, + expires_at, + invite_url, + ); + let text = team_invite_text( + team_display_name, + inviter_label, + to_email, + role, + expires_at, + invite_url, + ); self.send_email(to_email, &subject, &html, &text).await } @@ -883,20 +1099,39 @@ impl EmailService { .await } - pub async fn send_claim_email(&self, to_email: &str, claim_url: &str) -> Result<(), String> { - self.inner.send_claim_email(to_email, claim_url).await + pub async fn send_claim_email( + &self, + to_email: &str, + claim_url: &str, + account_display_name: Option<&str>, + account_picture: Option<&str>, + ) -> Result<(), String> { + self.inner + .send_claim_email(to_email, claim_url, account_display_name, account_picture) + .await } + #[allow(clippy::too_many_arguments)] pub async fn send_team_invite_email( &self, to_email: &str, - team_name: &str, - inviter_name: &str, + team_display_name: &str, + team_picture: Option<&str>, + inviter_label: &str, role: &str, + expires_at: DateTime, invite_url: &str, ) -> Result<(), String> { self.inner - .send_team_invite_email(to_email, team_name, inviter_name, role, invite_url) + .send_team_invite_email( + to_email, + team_display_name, + team_picture, + inviter_label, + role, + expires_at, + invite_url, + ) .await } } diff --git a/api/src/lib.rs b/api/src/lib.rs index 605bf61e..d6016ec6 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -5,6 +5,7 @@ pub mod divine_names; pub mod email_service; pub mod handlers; pub mod nip98; +pub mod nostr_profile; pub mod redis; pub mod state; pub mod ucan_auth; diff --git a/api/src/nostr_profile.rs b/api/src/nostr_profile.rs new file mode 100644 index 00000000..efcec289 --- /dev/null +++ b/api/src/nostr_profile.rs @@ -0,0 +1,147 @@ +// ABOUTME: Fetch NIP-01 kind-0 metadata (name, picture) for a pubkey from configured relays. +// ABOUTME: Best-effort with a short timeout; callers fall back to local data on failure. + +use nostr_sdk::prelude::*; +use serde::Deserialize; +use std::time::Duration; + +/// Overall deadline for the relay round-trip. Kept short because this runs +/// inline in the invite-email HTTP handler. +const FETCH_TIMEOUT: Duration = Duration::from_secs(3); + +/// Subset of kind-0 metadata we use in emails. +#[derive(Debug, Clone, Default)] +pub struct ProfileMetadata { + /// Preferred human-readable name (`display_name` → `name`). + pub display_name: Option, + /// Avatar URL (`picture`). + pub picture: Option, +} + +#[derive(Debug, Deserialize)] +struct Kind0Content { + #[serde(default)] + name: Option, + #[serde(default)] + display_name: Option, + #[serde(default)] + picture: Option, +} + +/// Fetch kind-0 metadata for `pubkey_hex` from the given relays. +/// +/// Returns `None` on any failure (bad pubkey, no relays reachable, timeout, +/// no event, malformed JSON). Callers should fall back to local data. +pub async fn fetch_profile_metadata( + pubkey_hex: &str, + relays: &[String], +) -> Option { + if relays.is_empty() { + return None; + } + + let public_key = match PublicKey::from_hex(pubkey_hex) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + "fetch_profile_metadata: invalid pubkey {}: {}", + pubkey_hex, + e + ); + return None; + } + }; + + let client = Client::default(); + for relay in relays { + if let Err(e) = client.add_relay(relay.as_str()).await { + tracing::debug!("fetch_profile_metadata: add_relay {} failed: {}", relay, e); + } + } + client.connect().await; + + let filter = Filter::new() + .author(public_key) + .kind(Kind::Metadata) + .limit(1); + + let result = match client.fetch_events(filter, FETCH_TIMEOUT).await { + Ok(events) => events + .into_iter() + .next() + .and_then(|ev| parse_kind0(&ev.content)), + Err(e) => { + tracing::warn!( + "fetch_profile_metadata: fetch_events failed for {}: {}", + pubkey_hex, + e + ); + None + } + }; + + // Best-effort teardown so sockets don't linger. + client.shutdown().await; + + result +} + +fn parse_kind0(content: &str) -> Option { + let parsed: Kind0Content = serde_json::from_str(content).ok()?; + let display_name = parsed + .display_name + .and_then(non_empty) + .or_else(|| parsed.name.and_then(non_empty)); + let picture = parsed.picture.and_then(non_empty); + if display_name.is_none() && picture.is_none() { + return None; + } + Some(ProfileMetadata { + display_name, + picture, + }) +} + +fn non_empty(s: String) -> Option { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_display_name_preferred_over_name() { + let m = parse_kind0(r#"{"name":"short","display_name":"Long Name"}"#).unwrap(); + assert_eq!(m.display_name.as_deref(), Some("Long Name")); + assert_eq!(m.picture, None); + } + + #[test] + fn falls_back_to_name_when_display_name_missing() { + let m = parse_kind0(r#"{"name":"only"}"#).unwrap(); + assert_eq!(m.display_name.as_deref(), Some("only")); + } + + #[test] + fn ignores_empty_strings() { + let m = parse_kind0(r#"{"name":"","display_name":" ","picture":""}"#); + assert!(m.is_none()); + } + + #[test] + fn extracts_picture() { + let m = parse_kind0(r#"{"name":"a","picture":"https://example.com/x.png"}"#).unwrap(); + assert_eq!(m.picture.as_deref(), Some("https://example.com/x.png")); + } + + #[test] + fn returns_none_on_bad_json() { + assert!(parse_kind0("not json").is_none()); + } +} diff --git a/api/tests/aws_ses_test.rs b/api/tests/aws_ses_test.rs index 9d3f8d52..195cbb97 100644 --- a/api/tests/aws_ses_test.rs +++ b/api/tests/aws_ses_test.rs @@ -77,7 +77,12 @@ async fn test_ses_send_claim_email() { let sender = SesEmailSender::new().await.expect("SES init failed"); let result = sender - .send_claim_email(&recipient, "https://example.com/claim?token=abc123") + .send_claim_email( + &recipient, + "https://example.com/claim?token=abc123", + None, + None, + ) .await; assert!(result.is_ok(), "Claim email failed: {:?}", result.err());