From 0117681a0228522e67505a46dfb332629ba249ad Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Tue, 21 Apr 2026 19:27:38 -0700 Subject: [PATCH 1/5] feat(invitations): card-style invite email with kind-0 team profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the team-invitation email so it mirrors the accept-invite page: centred Synvya wordmark, "Team Invitation" header, white card with the team's avatar and display name, inviter + recipient + expiry, and a prominent Accept Invitation button. - New api/src/nostr_profile.rs fetches kind-0 metadata (display_name, picture) from BUNKER_RELAYS with a 3s timeout; returns None on any failure so the email still sends with the DB handle as fallback. - Prefer inviter email over the pubkey-prefix fallback when the admin has no display_name/username, so recipients see "staging@…" instead of "f7ebfcf1…". - Team display name + avatar in the email now match what the accept page shows, because both derive from the same kind-0 record. - HTML-escape all interpolated values and restrict avatar URLs to http/https. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/src/api/http/teams.rs | 58 +++++++++- api/src/email_service.rs | 234 ++++++++++++++++++++++++++++++-------- api/src/lib.rs | 1 + api/src/nostr_profile.rs | 143 +++++++++++++++++++++++ 4 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 api/src/nostr_profile.rs diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index a7cdca03..87b794f1 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -659,12 +659,62 @@ 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 +728,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..8e57248e 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}; @@ -33,12 +34,20 @@ pub trait EmailSender: Send + Sync { async fn send_claim_email(&self, to_email: &str, claim_url: &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>; @@ -169,35 +178,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", ) } @@ -362,9 +451,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 +463,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 +475,13 @@ 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, }); @@ -609,14 +702,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 } @@ -787,14 +900,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 } @@ -887,16 +1020,27 @@ impl EmailService { self.inner.send_claim_email(to_email, claim_url).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..95db1f3f --- /dev/null +++ b/api/src/nostr_profile.rs @@ -0,0 +1,143 @@ +// 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()); + } +} From 02c637e69009d602f0eebaebfcca595934a47918 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Tue, 21 Apr 2026 19:40:33 -0700 Subject: [PATCH 2/5] style: apply cargo fmt Fixes formatting in teams.rs, email_service.rs, and nostr_profile.rs flagged by the `cargo fmt --all -- --check` CI step on synvya-staging. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/src/api/http/teams.rs | 3 +-- api/src/email_service.rs | 5 ++++- api/src/nostr_profile.rs | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index 87b794f1..57eaa92a 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -703,8 +703,7 @@ pub async fn invite_user( // 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(); + let relays = keycast_core::types::authorization::Authorization::get_bunker_relays(); crate::nostr_profile::fetch_profile_metadata(pk, &relays).await } else { None diff --git a/api/src/email_service.rs b/api/src/email_service.rs index 8e57248e..cc9e66b0 100644 --- a/api/src/email_service.rs +++ b/api/src/email_service.rs @@ -481,7 +481,10 @@ impl EmailSender for DevEmailSender { 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_display_name), + subject: format!( + "You've been invited to join {} on Synvya", + team_display_name + ), verification_url: Some(invite_url.to_string()), reset_url: None, }); diff --git a/api/src/nostr_profile.rs b/api/src/nostr_profile.rs index 95db1f3f..efcec289 100644 --- a/api/src/nostr_profile.rs +++ b/api/src/nostr_profile.rs @@ -43,7 +43,11 @@ pub async fn fetch_profile_metadata( let public_key = match PublicKey::from_hex(pubkey_hex) { Ok(pk) => pk, Err(e) => { - tracing::warn!("fetch_profile_metadata: invalid pubkey {}: {}", pubkey_hex, e); + tracing::warn!( + "fetch_profile_metadata: invalid pubkey {}: {}", + pubkey_hex, + e + ); return None; } }; From 20646c564310456f14f3b6cec4272b27a0a5dd75 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Tue, 21 Apr 2026 19:51:43 -0700 Subject: [PATCH 3/5] feat(email): unify verify / reset / claim email layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the three remaining transactional emails (verification, password reset, account claim) with the visual language of the new team-invite email: centred Synvya wordmark header, centred heading + intro, rounded pill button, copy-link fallback, muted footer note. Done via a small shared helper `basic_email_html` that takes heading, intro, CTA label, CTA URL, and footer note. Keeps the emails minimal — no card, no stylised subject — since these flows have no entity to present and overly stylised reset/verify emails can read as phishing. All interpolated values are HTML-escaped. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/src/email_service.rs | 130 ++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/api/src/email_service.rs b/api/src/email_service.rs index 8e57248e..6fc9d257 100644 --- a/api/src/email_service.rs +++ b/api/src/email_service.rs @@ -76,30 +76,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.", ) } @@ -111,29 +137,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.", ) } @@ -145,29 +154,12 @@ fn password_reset_text(reset_url: &str) -> String { } fn claim_email_html(claim_url: &str) -> String { - 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 + 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.", ) } From 304f509feb469f3bfa9e7443d7cbc9ad889b4fd6 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Tue, 21 Apr 2026 20:18:00 -0700 Subject: [PATCH 4/5] feat(email): card layout for claim email using preloaded kind-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claim emails now render the same card layout as team invites when the preloaded account has kind-0 metadata: avatar, display name (restaurant name), and "Claim your login for {email}". Falls back to the plain single-CTA layout when kind-0 is unavailable. - Extend send_claim_email signature with account_display_name and account_picture Options; threaded through all three EmailSender impls and the EmailService facade. - claim_email_html: branches to card layout when either field is set; otherwise delegates to basic_email_html. HTML-escapes interpolated values; avatar URL restricted to http/https via safe_http_url. - admin.rs batch claim handler: best-effort kind-0 fetch from BUNKER_RELAYS for each user_pubkey (3s timeout, handled entirely in fetch_profile_metadata) before sending. Silent fallback on failure so batch throughput degrades gracefully. Password-reset and verification emails intentionally left on the plain layout — no entity to present, and over-stylised reset mail reads as phishing. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/src/api/http/admin.rs | 14 ++++- api/src/email_service.rs | 126 +++++++++++++++++++++++++++++++++----- api/tests/aws_ses_test.rs | 7 ++- 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index ee01431e..0999699e 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -729,7 +729,19 @@ 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/email_service.rs b/api/src/email_service.rs index 6fc9d257..33bee532 100644 --- a/api/src/email_service.rs +++ b/api/src/email_service.rs @@ -31,7 +31,17 @@ 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. /// @@ -153,13 +163,71 @@ fn password_reset_text(reset_url: &str) -> String { ) } -fn claim_email_html(claim_url: &str) -> String { - 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.", +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#" + + +
+
+ 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. +

+
+ + +"# ) } @@ -410,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); @@ -683,9 +759,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 @@ -881,9 +963,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 @@ -1008,8 +1096,16 @@ 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)] 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()); From 0907e5feb844888cc11db9d2067bcfe11d406627 Mon Sep 17 00:00:00 2001 From: Alejandro Gil Date: Tue, 21 Apr 2026 20:39:29 -0700 Subject: [PATCH 5/5] chore(fmt): apply cargo fmt to admin.rs claim-email call site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse a two-line statement onto one line to satisfy rustfmt's width heuristics — the preceding line ran one char longer than cargo fmt's breakpoint, leaving the two lines inconsistent. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/src/api/http/admin.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index 0999699e..c988b4ca 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -733,8 +733,7 @@ pub async fn batch_create_claim_tokens( // 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 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());