diff --git a/Cargo.lock b/Cargo.lock index 2732a582..ef8641fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -876,7 +876,7 @@ version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" dependencies = [ - "darling", + "darling 0.24.1", "ident_case", "prettyplease 0.3.0", "proc-macro2", @@ -1678,14 +1678,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -1701,13 +1725,24 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core", + "darling_core 0.24.1", "quote", "syn 3.0.6", ] @@ -1860,6 +1895,37 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -2691,6 +2757,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "handlebars" +version = "6.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "hash32" version = "0.2.1" @@ -4543,6 +4625,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -4907,6 +5004,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -6584,6 +6723,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -6670,7 +6810,32 @@ version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ - "darling", + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serial_test" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" +dependencies = [ "proc-macro2", "quote", "syn 3.0.6", @@ -8019,6 +8184,7 @@ dependencies = [ "chrono", "ed25519-dalek", "futures", + "handlebars", "hickory-resolver", "lettre", "rand 0.8.8", @@ -8042,6 +8208,7 @@ version = "0.6.7" dependencies = [ "confique", "serde", + "serial_test", "tranquil-types", ] @@ -8570,6 +8737,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 539e7c19..fe31f3ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ hex = "0.4" hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } hkdf = "0.12" hmac = "0.12" +handlebars = "6.4" http = "1.4" hyper = { version = "1", features = ["server", "http1", "http2"] } hyper-util = { version = "0.1", features = ["server", "server-auto", "server-graceful", "service", "tokio"] } diff --git a/crates/tranquil-api/src/discord_webhook.rs b/crates/tranquil-api/src/discord_webhook.rs index 64de2e0c..6a2dee68 100644 --- a/crates/tranquil-api/src/discord_webhook.rs +++ b/crates/tranquil-api/src/discord_webhook.rs @@ -8,7 +8,7 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::Deserialize; use serde_json::json; use tracing::{debug, info, warn}; -use tranquil_types::Handle; +use tranquil_types::{CommsChannel, Handle}; use tranquil_pds::comms::comms_repo; use tranquil_pds::state::AppState; @@ -203,6 +203,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { user_id, &tranquil_types::Recipient::Discord(discord_user_id), &tranquil_config::get().server.hostname, + CommsChannel::Discord, ) .await; Json(json!({ diff --git a/crates/tranquil-api/src/identity/plc/request.rs b/crates/tranquil-api/src/identity/plc/request.rs index 538ad5af..51629077 100644 --- a/crates/tranquil-api/src/identity/plc/request.rs +++ b/crates/tranquil-api/src/identity/plc/request.rs @@ -15,14 +15,15 @@ pub async fn request_plc_operation_signature( auth.scope.as_deref(), tranquil_pds::oauth::scopes::IdentityAttr::Wildcard, )?; - let user_id = state + let user = state .repos .user - .get_id_by_did(&auth.did) + .get_by_did(&auth.did) .await .log_db_err("fetching user id")? .ok_or(ApiError::AccountNotFound)?; - + let user_id = user.id; + let handle = user.handle; let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await; let display_token = tranquil_pds::util::generate_token_code(); let stored_token = tranquil_pds::util::normalize_token_code(&display_token); @@ -40,9 +41,10 @@ pub async fn request_plc_operation_signature( state.repos.infra.as_ref(), user_id, tranquil_pds::comms::Notice::PlcOperation { + handle: &handle, + hostname, token: &display_token, }, - hostname, ) .await { diff --git a/crates/tranquil-api/src/notification_prefs.rs b/crates/tranquil-api/src/notification_prefs.rs index 306d30ed..efaebf59 100644 --- a/crates/tranquil-api/src/notification_prefs.rs +++ b/crates/tranquil-api/src/notification_prefs.rs @@ -5,6 +5,7 @@ use tracing::info; use tranquil_db_traits::{CommsChannel, CommsMessageType, CommsStatus}; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Active, Auth}; +use tranquil_pds::comms::{NotificationError, VerificationTarget}; use tranquil_pds::state::AppState; use tranquil_types::{Did, Handle}; @@ -151,77 +152,26 @@ pub async fn request_channel_verification( id: &str, handle: Option<&Handle>, ) -> Result { - if channel.verifies_via_bot() { - return Err(ApiError::InvalidRequest( - "This channel verifies through a bot. Please message the bot first so that it's able to reply with useful info".into(), - )); - } let token = tranquil_pds::auth::verification_token::generate_channel_update_token(did, channel, id); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token); - match channel { - CommsChannel::Email => { - let hostname = &tranquil_config::get().server.hostname; - let handle = handle.ok_or_else(|| { - ApiError::InternalError(Some("Email verification requires a handle".into())) - })?; - let new_email = tranquil_types::EmailAddress::new(id)?; - tranquil_pds::comms::comms_repo::enqueue_email_update( - state.repos.infra.as_ref(), - user_id, - &new_email, - handle, - &formatted_token, - hostname, - ) - .await - .log_db_err("enqueue email verification")?; - } - _ => { - let hostname = &tranquil_config::get().server.hostname; - let encoded_token = urlencoding::encode(&formatted_token); - let encoded_id = urlencoding::encode(id); - let verify_link = format!( - "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_id - ); - let prefs = state - .repos - .user - .get_comms_prefs(user_id) - .await - .ok() - .flatten(); - let locale = prefs - .as_ref() - .and_then(|p| p.preferred_locale.as_deref()) - .unwrap_or("en"); - let strings = tranquil_pds::comms::get_strings(locale); - let body = tranquil_pds::comms::format_message( - strings.channel_verification_body, - &[("code", &formatted_token), ("verify_link", &verify_link)], - ); - let subject = tranquil_pds::comms::format_message( - strings.channel_verification_subject, - &[("hostname", hostname)], - ); - let recipient = tranquil_db_traits::Recipient::new(channel, id)?; - state - .repos - .infra - .enqueue_comms( - Some(user_id), - &recipient, - tranquil_db_traits::CommsMessageType::ChannelVerification, - Some(&subject), - &body, - Some(json!({"code": formatted_token})), - ) - .await - .log_db_err("enqueue channel verification")?; - } - } + let fallback_handle = handle.map(|h| h.as_str()).unwrap_or_else(|| did.as_str()); + + tranquil_pds::comms::comms_repo::enqueue_channel_verification( + state.repos.user.as_ref(), + state.repos.infra.as_ref(), + user_id, + &VerificationTarget::resolve(channel, id, Some(fallback_handle))?, + &formatted_token, + &tranquil_config::get().server.hostname, + channel, + ) + .await + .map_err(|e| match e { + NotificationError::RequiresBot => ApiError::VerificationChannelRequiresBot, + _ => ApiError::InternalError(Some(e.to_string())), + })?; Ok(token) } @@ -297,7 +247,14 @@ async fn process_messaging_channel_update( }; if matches!(channel, CommsChannel::Signal) { - request_channel_verification(state, user_id, did, channel, &clean, None).await?; + let handle = state + .repos + .user + .get_handle_by_did(did) + .await + .log_db_err("get handle by did")? + .ok_or(ApiError::AccountNotFound)?; + request_channel_verification(state, user_id, did, channel, &clean, Some(&handle)).await?; } verification_required.push(channel); diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index 30bfe3fd..df65ea40 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -569,14 +569,16 @@ pub async fn request_account_delete( ) -> Result, ApiError> { let session_mfa = require_legacy_session_mfa(&state, &auth).await?; - let user_id = state + let user = state .repos .user - .get_id_by_did(session_mfa.did()) + .get_by_did(session_mfa.did()) .await .ok() .flatten() .ok_or(ApiError::InternalError(None))?; + let user_id = user.id; + let handle = user.handle; let confirmation_token = Uuid::new_v4().to_string(); let expires_at = Utc::now() + Duration::minutes(15); state @@ -591,9 +593,10 @@ pub async fn request_account_delete( state.repos.infra.as_ref(), user_id, tranquil_pds::comms::Notice::AccountDeletion { + hostname, + handle: handle.as_ref(), code: &confirmation_token, }, - hostname, ) .await { diff --git a/crates/tranquil-api/src/server/email.rs b/crates/tranquil-api/src/server/email.rs index a6ea09a9..18d42801 100644 --- a/crates/tranquil-api/src/server/email.rs +++ b/crates/tranquil-api/src/server/email.rs @@ -107,12 +107,23 @@ pub async fn request_email_update( } let hostname = &tranquil_config::get().server.hostname; + let verify_page = format!("https://{hostname}/app/verify?type=email-update"); + let verify_link = format!( + "https://{}/app/verify?type=email-update&token={}", + hostname, + urlencoding::encode(&token) + ); match tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - tranquil_pds::comms::Notice::ShortTokenEmail { token: &token }, - hostname, + tranquil_pds::comms::Notice::ShortTokenEmail { + hostname, + handle: user.handle.as_ref(), + code: &token, + verify_page: &verify_page, + verify_link: &verify_link, + }, ) .await { diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index 2767c5a2..0ad9715a 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -711,8 +711,11 @@ pub async fn request_passkey_recovery( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - tranquil_pds::comms::Notice::PasskeyRecovery { url: &recovery_url }, - hostname, + tranquil_pds::comms::Notice::PasskeyRecovery { + hostname, + handle: user.handle.as_ref(), + url: &recovery_url, + }, ) .await; diff --git a/crates/tranquil-api/src/server/password.rs b/crates/tranquil-api/src/server/password.rs index 0decc0bd..fc790435 100644 --- a/crates/tranquil-api/src/server/password.rs +++ b/crates/tranquil-api/src/server/password.rs @@ -95,9 +95,10 @@ pub async fn request_password_reset( state.repos.infra.as_ref(), user_id, tranquil_pds::comms::Notice::PasswordReset { + hostname, + handle: normalized_handle.as_str(), code: &display_code, }, - hostname, ) .await { diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index 7fa3a09e..b4970035 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -7,6 +7,7 @@ use axum::{ use bcrypt::verify; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::str::FromStr; use tracing::{error, info, warn}; use tranquil_db_traits::{ChannelVerificationStatus, SessionId, TokenFamilyId}; use tranquil_pds::api::error::{ApiError, DbResultExt}; @@ -21,7 +22,7 @@ use tranquil_pds::rate_limit::{ }; use tranquil_pds::state::AppState; use tranquil_pds::types::{AccountState, AtIdentifier, Did, Handle, PlainPassword}; -use tranquil_types::TokenId; +use tranquil_types::{Locale, TokenId}; pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool { !tranquil_config::get() @@ -225,9 +226,10 @@ pub async fn create_session( state.repos.infra.as_ref(), row.id, tranquil_pds::comms::Notice::TwoFactorCode { + hostname, + handle: row.handle.as_ref(), code: code.as_str(), }, - hostname, ) .await { @@ -355,9 +357,13 @@ pub async fn create_session( row.id, tranquil_pds::comms::Notice::LegacyLoginAlert { channel: row.preferred_comms_channel, + hostname, + handle: row.handle.as_ref(), + timestamp: &chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), ip: client_ip, }, - hostname, ) .await { @@ -957,8 +963,10 @@ pub async fn confirm_signup( state.repos.user.as_ref(), state.repos.infra.as_ref(), row.id, - tranquil_pds::comms::Notice::Welcome, - hostname, + tranquil_pds::comms::Notice::Welcome { + hostname, + handle: row.handle.as_ref(), + }, ) .await { @@ -1324,8 +1332,6 @@ pub async fn update_legacy_login_preference( })) } -use tranquil_pds::comms::VALID_LOCALES; - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateLocaleInput { @@ -1337,10 +1343,14 @@ pub async fn update_locale( auth: Auth, Json(input): Json, ) -> Result, ApiError> { - if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) { + if Locale::from_str(&input.preferred_locale).is_err() { return Err(ApiError::InvalidRequest(format!( "Invalid locale. Valid options: {}", - VALID_LOCALES.join(", ") + Locale::ALL + .iter() + .map(|l| l.as_str()) + .collect::>() + .join(", ") ))); } diff --git a/crates/tranquil-api/src/server/verify_token.rs b/crates/tranquil-api/src/server/verify_token.rs index 0626ed1e..4233da82 100644 --- a/crates/tranquil-api/src/server/verify_token.rs +++ b/crates/tranquil-api/src/server/verify_token.rs @@ -211,6 +211,7 @@ async fn notify_channel_verified(state: &AppState, user_id: uuid::Uuid, channel: user_id, &recipient, &tranquil_config::get().server.hostname, + channel, ) .await; } diff --git a/crates/tranquil-api/src/telegram_webhook.rs b/crates/tranquil-api/src/telegram_webhook.rs index 4c9cc0d2..bf59b978 100644 --- a/crates/tranquil-api/src/telegram_webhook.rs +++ b/crates/tranquil-api/src/telegram_webhook.rs @@ -8,6 +8,7 @@ use tracing::{debug, info, warn}; use tranquil_pds::comms::comms_repo; use tranquil_pds::state::AppState; +use tranquil_types::CommsChannel; #[derive(Deserialize)] struct TelegramUpdate { @@ -110,6 +111,7 @@ pub async fn handle_telegram_webhook( user_id, &tranquil_types::Recipient::Telegram(chat_id), &tranquil_config::get().server.hostname, + CommsChannel::Telegram, ) .await; } diff --git a/crates/tranquil-comms/Cargo.toml b/crates/tranquil-comms/Cargo.toml index 1b975b45..0b74a2b9 100644 --- a/crates/tranquil-comms/Cargo.toml +++ b/crates/tranquil-comms/Cargo.toml @@ -13,6 +13,7 @@ async-trait = { workspace = true } base64 = { workspace = true } ed25519-dalek = { workspace = true } futures = { workspace = true } +handlebars = { workspace = true } hickory-resolver = { workspace = true } lettre = { workspace = true } rand = { workspace = true } diff --git a/crates/tranquil-comms/src/email/message.rs b/crates/tranquil-comms/src/email/message.rs index a11e8d97..34289b40 100644 --- a/crates/tranquil-comms/src/email/message.rs +++ b/crates/tranquil-comms/src/email/message.rs @@ -2,10 +2,12 @@ use lettre::Message; use lettre::message::Mailbox; use lettre::message::header::{ContentType, MIME_VERSION_1_0}; use lettre::message::header::{Header, HeaderName, HeaderValue}; +use lettre::message::{MultiPart, SinglePart}; use uuid::Uuid; use super::types::EmailDomain; use crate::sender::SendError; +use crate::templates::html_body_from_metadata; use crate::{CommsMessageType, QueuedComms}; pub(super) fn build( @@ -37,9 +39,18 @@ pub(super) fn build( None => builder, }; - builder - .body(qc.body.clone()) - .map_err(|e| SendError::MessageBuild(e.to_string())) + match html_body_from_metadata(&qc.metadata) { + Some(html) => builder + .multipart( + MultiPart::alternative() + .singlepart(SinglePart::plain(qc.body.clone())) + .singlepart(SinglePart::html(html.to_string())), + ) + .map_err(|e| SendError::MessageBuild(e.to_string())), + None => builder + .body(qc.body.clone()) + .map_err(|e| SendError::MessageBuild(e.to_string())), + } } pub(super) fn recipient_domain(message: &Message) -> Result { @@ -104,6 +115,7 @@ mod tests { use super::*; use crate::{CommsChannel, CommsStatus}; use chrono::Utc; + use serde_json::json; use uuid::Uuid; fn from_mailbox() -> Mailbox { @@ -114,7 +126,12 @@ mod tests { tranquil_types::EmailAddress::new(recipient).unwrap() } - fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms { + fn fixture( + recipient: &str, + subject: Option<&str>, + body: &str, + metadata: Option, + ) -> QueuedComms { QueuedComms { id: Uuid::new_v4(), user_id: None, @@ -124,7 +141,7 @@ mod tests { recipient: recipient.to_string(), subject: subject.map(String::from), body: body.to_string(), - metadata: None, + metadata, attempts: 0, max_attempts: 3, last_error: None, @@ -139,7 +156,7 @@ mod tests { fn build_basic_message() { let msg = build( &from_mailbox(), - &fixture("user@nel.pet", Some("Welcome"), "Hello world."), + &fixture("user@nel.pet", Some("Welcome"), "Hello world.", None), &to("user@nel.pet"), false, ) @@ -154,11 +171,34 @@ mod tests { assert!(raw.contains("Hello world.")); } + #[test] + fn build_multipart_when_html_metadata_present() { + let msg = build( + &from_mailbox(), + &fixture( + "user@nel.pet", + Some("Welcome"), + "Hello plain.", + Some(json!({"html_body": "

Hello html.

"})), + ), + &to("user@jola.dev"), + false, + ) + .unwrap(); + let raw = String::from_utf8(msg.formatted()).unwrap(); + let lower = raw.to_lowercase(); + assert!(lower.contains("multipart/alternative"), "{raw}"); + assert!(lower.contains("text/plain"), "{raw}"); + assert!(lower.contains("text/html"), "{raw}"); + assert!(raw.contains("Hello plain.")); + assert!(raw.contains("

Hello html.

")); + } + #[test] fn utf8_subject_is_encoded() { let msg = build( &from_mailbox(), - &fixture("user@nel.pet", Some("héllo wörld"), "Body"), + &fixture("user@nel.pet", Some("héllo wörld"), "Body", None), &to("user@jola.dev"), false, ) @@ -178,7 +218,12 @@ mod tests { fn subject_crlf_does_not_inject_headers() { let msg = build( &from_mailbox(), - &fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"), + &fixture( + "user@nel.pet", + Some("hi\r\nBcc: evil@nel.pet"), + "body", + None, + ), &to("user@jola.dev"), false, ) @@ -198,7 +243,7 @@ mod tests { fn message_id_uses_from_domain() { let msg = build( &from_mailbox(), - &fixture("user@nel.pet", Some("s"), "b"), + &fixture("user@nel.pet", Some("s"), "b", None), &to("user@jola.dev"), false, ) @@ -218,7 +263,7 @@ mod tests { fn missing_subject_uses_default() { let msg = build( &from_mailbox(), - &fixture("user@nel.pet", None, "Body"), + &fixture("user@nel.pet", None, "Body", None), &to("user@nel.pet"), false, ) @@ -231,7 +276,7 @@ mod tests { fn recipient_domain_extracted() { let msg = build( &from_mailbox(), - &fixture("user@Nel.PET", Some("s"), "b"), + &fixture("user@Nel.PET", Some("s"), "b", None), &to("user@nel.pet"), false, ) @@ -244,7 +289,7 @@ mod tests { fn atmos_category_header_present_when_enabled_and_mapped() { let qc = QueuedComms { comms_type: CommsMessageType::PasswordReset, - ..fixture("user@nel.pet", Some("s"), "b") + ..fixture("user@nel.pet", Some("s"), "b", None) }; let msg = build(&from_mailbox(), &qc, &to("user@jola.dev"), true).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); @@ -255,7 +300,7 @@ mod tests { fn atmos_category_header_absent_when_disabled() { let qc = QueuedComms { comms_type: CommsMessageType::PasswordReset, - ..fixture("user@nel.pet", Some("s"), "b") + ..fixture("user@nel.pet", Some("s"), "b", None) }; let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), false).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); @@ -266,7 +311,7 @@ mod tests { fn atmos_category_header_absent_when_unmapped() { let qc = QueuedComms { comms_type: CommsMessageType::AdminEmail, - ..fixture("user@nel.pet", Some("s"), "b") + ..fixture("user@nel.pet", Some("s"), "b", None) }; let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), true).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); diff --git a/crates/tranquil-comms/src/lib.rs b/crates/tranquil-comms/src/lib.rs index e79c34c1..c6e652ca 100644 --- a/crates/tranquil-comms/src/lib.rs +++ b/crates/tranquil-comms/src/lib.rs @@ -1,13 +1,18 @@ pub mod email; mod locale; mod sender; +mod templates; -pub use email::EmailSender; -pub use locale::{ - DEFAULT_LOCALE, NotificationStrings, VALID_LOCALES, format_message, get_strings, - validate_locale, -}; +pub use email::{EmailSender, SendMode}; +pub use locale::{NotificationStrings, get_strings, validate_locale}; pub use sender::{ CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number, }; +pub use templates::{ + REQUIRED_TEMPLATE_IDS, RenderedTemplate, TemplateError, TemplateId, TemplateStore, + html_body_from_metadata, +}; +pub use templates::{ + init as init_templates, render as render_template, validate as validate_templates, +}; pub use tranquil_db_traits::{CommsChannel, CommsMessageType, CommsStatus, QueuedComms}; diff --git a/crates/tranquil-comms/src/locale.rs b/crates/tranquil-comms/src/locale.rs index d7e342e5..910682c9 100644 --- a/crates/tranquil-comms/src/locale.rs +++ b/crates/tranquil-comms/src/locale.rs @@ -1,12 +1,8 @@ -pub const DEFAULT_LOCALE: &str = "en"; -pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko", "sv", "fi", "fr"]; +use std::str::FromStr; +use tranquil_types::Locale; pub fn validate_locale(locale: &str) -> &str { - if VALID_LOCALES.contains(&locale) { - locale - } else { - DEFAULT_LOCALE - } + Locale::from_str(locale).unwrap_or_default().as_str() } pub struct NotificationStrings { @@ -50,208 +46,201 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings { } static STRINGS_EN: NotificationStrings = NotificationStrings { - welcome_subject: "Welcome to {hostname}", - welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.", - password_reset_subject: "Password Reset - {hostname}", - password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", - email_update_subject: "Confirm your new email - {hostname}", - email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not request this, please ignore this email.", - short_token_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nThis code will expire in 15 minutes.\n\nIf you did not request this, please ignore this email.", - account_deletion_subject: "Account Deletion Request - {hostname}", - account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", - plc_operation_subject: "{hostname} - PLC Operation Token", - plc_operation_body: "Hello @{handle},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {token}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.", - two_factor_code_subject: "Sign-in Verification - {hostname}", - two_factor_code_body: "Hello @{handle},\n\nYour sign-in verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", - passkey_recovery_subject: "Account Recovery - {hostname}", - passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", - signup_verification_subject: "Verify your account - {hostname}", - signup_verification_body: "Welcome! Your verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 30 minutes.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not create an account on {hostname}, please ignore this message.", - legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}", - legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}", - migration_verification_subject: "Verify your email - {hostname}", - migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.", - channel_verified_subject: "Channel verified - {hostname}", - channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.", - channel_verification_subject: "Verify your channel - {hostname}", - channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}", + welcome_subject: "Welcome to {{hostname}}", + welcome_body: "Welcome to {{hostname}}!\n\nYour handle is: @{{handle}}\n\nThank you for joining us.", + password_reset_subject: "Password Reset - {{hostname}}", + password_reset_body: "Hello @{{handle}},\n\nYour password reset code is: {{code}}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", + email_update_subject: "Confirm your new email - {{hostname}}", + email_update_body: "Hello @{{handle}},\n\nYour verification code is:\n{{code}}\n\nCopy the code above and enter it at:\n{{verify_page}}\n\nThis code will expire in 10 minutes.\n\nOr if you like to live dangerously:\n{{{verify_link}}}\n\nIf you did not request this, please ignore this email.", + short_token_body: "Hello @{{handle}},\n\nYour verification code is:\n{{code}}\n\nThis code will expire in 15 minutes.\n\nIf you did not request this, please ignore this email.", + account_deletion_subject: "Account Deletion Request - {{hostname}}", + account_deletion_body: "Hello @{{handle}},\n\nYour account deletion confirmation code is: {{code}}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", + plc_operation_subject: "{{hostname}} - PLC Operation Token", + plc_operation_body: "Hello @{{handle}},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {{token}}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.", + two_factor_code_subject: "Sign-in Verification - {{hostname}}", + two_factor_code_body: "Hello @{{handle}},\n\nYour sign-in verification code is: {{code}}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", + passkey_recovery_subject: "Account Recovery - {{hostname}}", + passkey_recovery_body: "Hello @{{handle}},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{{url}}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", + signup_verification_subject: "Verify your account - {{hostname}}", + signup_verification_body: "Welcome! Your verification code is:\n{{code}}\n\nCopy the code above and enter it at:\n{{verify_page}}\n\nThis code will expire in 30 minutes.\n\nOr if you like to live dangerously:\n{{{verify_link}}}\n\nIf you did not create an account on {{hostname}}, please ignore this message.", + legacy_login_subject: "Security Alert: Legacy Login Detected - {{hostname}}", + legacy_login_body: "Hello @{{handle}},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {{timestamp}}\n- IP Address: {{ip}}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{{hostname}}", + migration_verification_subject: "Verify your email - {{hostname}}", + migration_verification_body: "Welcome to {{hostname}}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{{code}}\n\nCopy the code above and enter it at:\n{{verify_page}}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{{{verify_link}}}\n\nIf you did not migrate your account, please ignore this email.", + channel_verified_subject: "Channel verified - {{hostname}}", + channel_verified_body: "Hello {{handle}},\n\n{{channel}} has been verified as a notification channel for your account on {{hostname}}.", + channel_verification_subject: "Verify your channel - {{hostname}}", + channel_verification_body: "Your verification code is:\n{{code}}\n\nOr verify directly:\n{{{verify_link}}}", }; static STRINGS_ZH: NotificationStrings = NotificationStrings { - welcome_subject: "欢迎加入 {hostname}", - welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。", - password_reset_subject: "密码重置 - {hostname}", - password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。", - email_update_subject: "确认您的新邮箱 - {hostname}", - email_update_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果这不是您的操作,请忽略此邮件。", - short_token_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n此验证码将在15分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", - account_deletion_subject: "账户删除请求 - {hostname}", - account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", - plc_operation_subject: "{hostname} - PLC 操作令牌", - plc_operation_body: "您好 @{handle},\n\n您请求为账户签署 PLC 操作。\n\n您的验证令牌是:{token}\n\n此令牌将在10分钟后过期。\n\n如果这不是您的操作,您可以安全地忽略此消息。", - two_factor_code_subject: "登录验证 - {hostname}", - two_factor_code_body: "您好 @{handle},\n\n您的登录验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", - passkey_recovery_subject: "账户恢复 - {hostname}", - passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。", - signup_verification_subject: "验证您的账户 - {hostname}", - signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。", - legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}", - legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}", - migration_verification_subject: "验证您的邮箱 - {hostname}", - migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。", - channel_verified_subject: "通知渠道已验证 - {hostname}", - channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。", - channel_verification_subject: "验证您的渠道 - {hostname}", - channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}", + welcome_subject: "欢迎加入 {{hostname}}", + welcome_body: "欢迎加入 {{hostname}}!\n\n您的用户名是:@{{handle}}\n\n感谢您的加入。", + password_reset_subject: "密码重置 - {{hostname}}", + password_reset_body: "您好 @{{handle}},\n\n您的密码重置验证码是:{{code}}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。", + email_update_subject: "确认您的新邮箱 - {{hostname}}", + email_update_body: "您好 @{{handle}},\n\n您的验证码是:\n{{code}}\n\n复制上述验证码并在此输入:\n{{verify_page}}\n\n此验证码将在10分钟后过期。\n\n或者直接点击链接:\n{{{verify_link}}}\n\n如果这不是您的操作,请忽略此邮件。", + short_token_body: "您好 @{{handle}},\n\n您的验证码是:\n{{code}}\n\n此验证码将在15分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", + account_deletion_subject: "账户删除请求 - {{hostname}}", + account_deletion_body: "您好 @{{handle}},\n\n您的账户删除确认码是:{{code}}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", + plc_operation_subject: "{{hostname}} - PLC 操作令牌", + plc_operation_body: "您好 @{{handle}},\n\n您请求为账户签署 PLC 操作。\n\n您的验证令牌是:{{token}}\n\n此令牌将在10分钟后过期。\n\n如果这不是您的操作,您可以安全地忽略此消息。", + two_factor_code_subject: "登录验证 - {{hostname}}", + two_factor_code_body: "您好 @{{handle}},\n\n您的登录验证码是:{{code}}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", + passkey_recovery_subject: "账户恢复 - {{hostname}}", + passkey_recovery_body: "您好 @{{handle}},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{{url}}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。", + signup_verification_subject: "验证您的账户 - {{hostname}}", + signup_verification_body: "欢迎!您的验证码是:\n{{code}}\n\n复制上述验证码并在此输入:\n{{verify_page}}\n\n此验证码将在30分钟后过期。\n\n或者直接点击链接:\n{{{verify_link}}}\n\n如果您没有在 {{hostname}} 上创建账户,请忽略此消息。", + legacy_login_subject: "安全提醒:检测到传统应用登录 - {{hostname}}", + legacy_login_body: "您好 @{{handle}},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{{timestamp}}\n- IP 地址:{{ip}}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{{hostname}}", + migration_verification_subject: "验证您的邮箱 - {{hostname}}", + migration_verification_body: "欢迎来到 {{hostname}}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{{code}}\n\n复制上述验证码并在此输入:\n{{verify_page}}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{{{verify_link}}}\n\n如果您没有迁移账户,请忽略此邮件。", + channel_verified_subject: "通知渠道已验证 - {{hostname}}", + channel_verified_body: "您好 {{handle}},\n\n{{channel}} 已被验证为您在 {{hostname}} 上的通知渠道。", + channel_verification_subject: "验证您的渠道 - {{hostname}}", + channel_verification_body: "您的验证码是:\n{{code}}\n\n或直接验证:\n{{{verify_link}}}", }; static STRINGS_JA: NotificationStrings = NotificationStrings { - welcome_subject: "{hostname} へようこそ", - welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。", - password_reset_subject: "パスワードリセット - {hostname}", - password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。", - email_update_subject: "新しいメールアドレスの確認 - {hostname}", - email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nこの操作に心当たりがない場合は、このメールを無視してください。", - short_token_body: "@{handle} 様\n\n確認コードは:\n{code}\n\nこのコードは15分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", - account_deletion_subject: "アカウント削除リクエスト - {hostname}", - account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", - plc_operation_subject: "{hostname} - PLC 操作トークン", - plc_operation_body: "@{handle} 様\n\nアカウントの PLC 操作の署名をリクエストされました。\n\n認証トークンは:{token}\n\nこのトークンは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視しても問題ありません。", - two_factor_code_subject: "ログイン認証 - {hostname}", - two_factor_code_body: "@{handle} 様\n\nログイン認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", - passkey_recovery_subject: "アカウント復旧 - {hostname}", - passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。", - signup_verification_subject: "アカウント認証 - {hostname}", - signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。", - legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}", - legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}", - migration_verification_subject: "メールアドレスの認証 - {hostname}", - migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。", - channel_verified_subject: "通知チャンネル認証完了 - {hostname}", - channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。", - channel_verification_subject: "チャンネルを認証 - {hostname}", - channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}", + welcome_subject: "{{hostname}} へようこそ", + welcome_body: "{{hostname}} へようこそ!\n\nお客様のハンドル:@{{handle}}\n\nご登録ありがとうございます。", + password_reset_subject: "パスワードリセット - {{hostname}}", + password_reset_body: "@{{handle}} 様\n\nパスワードリセットコードは:{{code}}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。", + email_update_subject: "新しいメールアドレスの確認 - {{hostname}}", + email_update_body: "@{{handle}} 様\n\n確認コードは:\n{{code}}\n\n上記のコードをコピーして、こちらで入力してください:\n{{verify_page}}\n\nこのコードは10分後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{{{verify_link}}}\n\nこの操作に心当たりがない場合は、このメールを無視してください。", + short_token_body: "@{{handle}} 様\n\n確認コードは:\n{{code}}\n\nこのコードは15分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", + account_deletion_subject: "アカウント削除リクエスト - {{hostname}}", + account_deletion_body: "@{{handle}} 様\n\nアカウント削除の確認コードは:{{code}}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", + plc_operation_subject: "{{hostname}} - PLC 操作トークン", + plc_operation_body: "@{{handle}} 様\n\nアカウントの PLC 操作の署名をリクエストされました。\n\n認証トークンは:{{token}}\n\nこのトークンは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視しても問題ありません。", + two_factor_code_subject: "ログイン認証 - {{hostname}}", + two_factor_code_body: "@{{handle}} 様\n\nログイン認証コードは:{{code}}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", + passkey_recovery_subject: "アカウント復旧 - {{hostname}}", + passkey_recovery_body: "@{{handle}} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{{url}}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。", + signup_verification_subject: "アカウント認証 - {{hostname}}", + signup_verification_body: "ようこそ!認証コードは:\n{{code}}\n\n上記のコードをコピーして、こちらで入力してください:\n{{verify_page}}\n\nこのコードは30分後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{{{verify_link}}}\n\n{{hostname}} でアカウントを作成していない場合は、このメールを無視してください。", + legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {{hostname}}", + legacy_login_body: "@{{handle}} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{{timestamp}}\n- IP アドレス:{{ip}}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{{hostname}}", + migration_verification_subject: "メールアドレスの認証 - {{hostname}}", + migration_verification_body: "{{hostname}} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{{code}}\n\n上記のコードをコピーして、こちらで入力してください:\n{{verify_page}}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{{{verify_link}}}\n\nアカウントを移行していない場合は、このメールを無視してください。", + channel_verified_subject: "通知チャンネル認証完了 - {{hostname}}", + channel_verified_body: "{{handle}} 様\n\n{{channel}} が {{hostname}} の通知チャンネルとして認証されました。", + channel_verification_subject: "チャンネルを認証 - {{hostname}}", + channel_verification_body: "認証コードは:\n{{code}}\n\n直接認証:\n{{{verify_link}}}", }; static STRINGS_KO: NotificationStrings = NotificationStrings { - welcome_subject: "{hostname}에 오신 것을 환영합니다", - welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.", - password_reset_subject: "비밀번호 재설정 - {hostname}", - password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.", - email_update_subject: "새 이메일 주소 확인 - {hostname}", - email_update_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 10분 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", - short_token_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n이 코드는 15분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", - account_deletion_subject: "계정 삭제 요청 - {hostname}", - account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", - plc_operation_subject: "{hostname} - PLC 작업 토큰", - plc_operation_body: "안녕하세요 @{handle}님,\n\n계정의 PLC 작업 서명을 요청하셨습니다.\n\n인증 토큰은: {token}\n\n이 토큰은 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 안전하게 무시하셔도 됩니다.", - two_factor_code_subject: "로그인 인증 - {hostname}", - two_factor_code_body: "안녕하세요 @{handle}님,\n\n로그인 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", - passkey_recovery_subject: "계정 복구 - {hostname}", - passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.", - signup_verification_subject: "계정 인증 - {hostname}", - signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.", - legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}", - legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림", - migration_verification_subject: "이메일 인증 - {hostname}", - migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.", - channel_verified_subject: "알림 채널 인증 완료 - {hostname}", - channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.", - channel_verification_subject: "채널 인증 - {hostname}", - channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}", + welcome_subject: "{{hostname}}에 오신 것을 환영합니다", + welcome_body: "{{hostname}}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{{handle}}\n\n가입해 주셔서 감사합니다.", + password_reset_subject: "비밀번호 재설정 - {{hostname}}", + password_reset_body: "안녕하세요 @{{handle}}님,\n\n비밀번호 재설정 코드는: {{code}}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.", + email_update_subject: "새 이메일 주소 확인 - {{hostname}}", + email_update_body: "안녕하세요 @{{handle}}님,\n\n인증 코드는:\n{{code}}\n\n위 코드를 복사하여 여기에 입력하세요:\n{{verify_page}}\n\n이 코드는 10분 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{{{verify_link}}}\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", + short_token_body: "안녕하세요 @{{handle}}님,\n\n인증 코드는:\n{{code}}\n\n이 코드는 15분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", + account_deletion_subject: "계정 삭제 요청 - {{hostname}}", + account_deletion_body: "안녕하세요 @{{handle}}님,\n\n계정 삭제 확인 코드는: {{code}}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", + plc_operation_subject: "{{hostname}} - PLC 작업 토큰", + plc_operation_body: "안녕하세요 @{{handle}}님,\n\n계정의 PLC 작업 서명을 요청하셨습니다.\n\n인증 토큰은: {{token}}\n\n이 토큰은 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 안전하게 무시하셔도 됩니다.", + two_factor_code_subject: "로그인 인증 - {{hostname}}", + two_factor_code_body: "안녕하세요 @{{handle}}님,\n\n로그인 인증 코드는: {{code}}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", + passkey_recovery_subject: "계정 복구 - {{hostname}}", + passkey_recovery_body: "안녕하세요 @{{handle}}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{{url}}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.", + signup_verification_subject: "계정 인증 - {{hostname}}", + signup_verification_body: "환영합니다! 인증 코드는:\n{{code}}\n\n위 코드를 복사하여 여기에 입력하세요:\n{{verify_page}}\n\n이 코드는 30분 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{{{verify_link}}}\n\n{{hostname}}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.", + legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {{hostname}}", + legacy_login_body: "안녕하세요 @{{handle}}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {{timestamp}}\n- IP 주소: {{ip}}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{{hostname}} 드림", + migration_verification_subject: "이메일 인증 - {{hostname}}", + migration_verification_body: "{{hostname}}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{{code}}\n\n위 코드를 복사하여 여기에 입력하세요:\n{{verify_page}}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{{{verify_link}}}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.", + channel_verified_subject: "알림 채널 인증 완료 - {{hostname}}", + channel_verified_body: "안녕하세요 {{handle}}님,\n\n{{channel}}이(가) {{hostname}}의 알림 채널로 인증되었습니다.", + channel_verification_subject: "채널 인증 - {{hostname}}", + channel_verification_body: "인증 코드:\n{{code}}\n\n직접 인증:\n{{{verify_link}}}", }; static STRINGS_SV: NotificationStrings = NotificationStrings { - welcome_subject: "Välkommen till {hostname}", - welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.", - password_reset_subject: "Lösenordsåterställning - {hostname}", - password_reset_body: "Hej @{handle},\n\nDin kod för lösenordsåterställning är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", - email_update_subject: "Bekräfta din nya e-post - {hostname}", - email_update_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 10 minuter.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte begärde detta kan du ignorera detta meddelande.", - short_token_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nDenna kod upphör om 15 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", - account_deletion_subject: "Begäran om kontoradering - {hostname}", - account_deletion_body: "Hej @{handle},\n\nDin bekräftelsekod för kontoradering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.", - plc_operation_subject: "{hostname} - PLC-operationstoken", - plc_operation_body: "Hej @{handle},\n\nDu begärde att signera en PLC-operation för ditt konto.\n\nDin verifieringstoken är: {token}\n\nDenna token upphör om 10 minuter.\n\nOm du inte begärde detta kan du säkert ignorera detta meddelande.", - two_factor_code_subject: "Inloggningsverifiering - {hostname}", - two_factor_code_body: "Hej @{handle},\n\nDin inloggningsverifieringskod är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.", - passkey_recovery_subject: "Kontoåterställning - {hostname}", - passkey_recovery_body: "Hej @{handle},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{url}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.", - signup_verification_subject: "Verifiera ditt konto - {hostname}", - signup_verification_body: "Välkommen! Din verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 30 minuter.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte skapade ett konto på {hostname}, ignorera detta meddelande.", - legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}", - legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}", - migration_verification_subject: "Verifiera din e-post - {hostname}", - migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.", - channel_verified_subject: "Aviseringskanal verifierad - {hostname}", - channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.", - channel_verification_subject: "Verifiera din kanal - {hostname}", - channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}", + welcome_subject: "Välkommen till {{hostname}}", + welcome_body: "Välkommen till {{hostname}}!\n\nDitt användarnamn är: @{{handle}}\n\nTack för att du gick med.", + password_reset_subject: "Lösenordsåterställning - {{hostname}}", + password_reset_body: "Hej @{{handle}},\n\nDin kod för lösenordsåterställning är: {{code}}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", + email_update_subject: "Bekräfta din nya e-post - {{hostname}}", + email_update_body: "Hej @{{handle}},\n\nDin verifieringskod är:\n{{code}}\n\nKopiera koden ovan och ange den på:\n{{verify_page}}\n\nDenna kod upphör om 10 minuter.\n\nEller om du gillar att leva farligt:\n{{{verify_link}}}\n\nOm du inte begärde detta kan du ignorera detta meddelande.", + short_token_body: "Hej @{{handle}},\n\nDin verifieringskod är:\n{{code}}\n\nDenna kod upphör om 15 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", + account_deletion_subject: "Begäran om kontoradering - {{hostname}}", + account_deletion_body: "Hej @{{handle}},\n\nDin bekräftelsekod för kontoradering är: {{code}}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.", + plc_operation_subject: "{{hostname}} - PLC-operationstoken", + plc_operation_body: "Hej @{{handle}},\n\nDu begärde att signera en PLC-operation för ditt konto.\n\nDin verifieringstoken är: {{token}}\n\nDenna token upphör om 10 minuter.\n\nOm du inte begärde detta kan du säkert ignorera detta meddelande.", + two_factor_code_subject: "Inloggningsverifiering - {{hostname}}", + two_factor_code_body: "Hej @{{handle}},\n\nDin inloggningsverifieringskod är: {{code}}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.", + passkey_recovery_subject: "Kontoåterställning - {{hostname}}", + passkey_recovery_body: "Hej @{{handle}},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{{url}}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.", + signup_verification_subject: "Verifiera ditt konto - {{hostname}}", + signup_verification_body: "Välkommen! Din verifieringskod är:\n{{code}}\n\nKopiera koden ovan och ange den på:\n{{verify_page}}\n\nDenna kod upphör om 30 minuter.\n\nEller om du gillar att leva farligt:\n{{{verify_link}}}\n\nOm du inte skapade ett konto på {{hostname}}, ignorera detta meddelande.", + legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {{hostname}}", + legacy_login_body: "Hej @{{handle}},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {{timestamp}}\n- IP-adress: {{ip}}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{{hostname}}", + migration_verification_subject: "Verifiera din e-post - {{hostname}}", + migration_verification_body: "Välkommen till {{hostname}}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{{code}}\n\nKopiera koden ovan och ange den på:\n{{verify_page}}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{{{verify_link}}}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.", + channel_verified_subject: "Aviseringskanal verifierad - {{hostname}}", + channel_verified_body: "Hej {{handle}},\n\n{{channel}} har verifierats som aviseringskanal för ditt konto på {{hostname}}.", + channel_verification_subject: "Verifiera din kanal - {{hostname}}", + channel_verification_body: "Din verifieringskod är:\n{{code}}\n\nEller verifiera direkt:\n{{{verify_link}}}", }; static STRINGS_FI: NotificationStrings = NotificationStrings { - welcome_subject: "Tervetuloa palveluun {hostname}", - welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.", - password_reset_subject: "Salasanan palautus - {hostname}", - password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", - email_update_subject: "Vahvista uusi sähköpostisi - {hostname}", - email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 10 minuutissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", - short_token_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nTämä koodi vanhenee 15 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", - account_deletion_subject: "Tilin poistopyyntö - {hostname}", - account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.", - plc_operation_subject: "{hostname} - PLC-toimintotunniste", - plc_operation_body: "Hei @{handle},\n\nPyysit allekirjoittamaan PLC-toiminnon tilillesi.\n\nVahvistustunnisteesi on: {token}\n\nTämä tunniste vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit turvallisesti jättää tämän viestin huomiotta.", - two_factor_code_subject: "Kirjautumisen vahvistus - {hostname}", - two_factor_code_body: "Hei @{handle},\n\nKirjautumisen vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.", - passkey_recovery_subject: "Tilin palautus - {hostname}", - passkey_recovery_body: "Hei @{handle},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{url}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.", - signup_verification_subject: "Vahvista tilisi - {hostname}", - signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 30 minuutissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et luonut tiliä palveluun {hostname}, jätä tämä viesti huomiotta.", - legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}", - legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}", - migration_verification_subject: "Vahvista sähköpostisi - {hostname}", - migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.", - channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}", - channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.", - channel_verification_subject: "Vahvista kanavasi - {hostname}", - channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}", + welcome_subject: "Tervetuloa palveluun {{hostname}}", + welcome_body: "Tervetuloa palveluun {{hostname}}!\n\nKäyttäjänimesi on: @{{handle}}\n\nKiitos liittymisestä.", + password_reset_subject: "Salasanan palautus - {{hostname}}", + password_reset_body: "Hei @{{handle}},\n\nSalasanan palautuskoodisi on: {{code}}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", + email_update_subject: "Vahvista uusi sähköpostisi - {{hostname}}", + email_update_body: "Hei @{{handle}},\n\nVahvistuskoodisi on:\n{{code}}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{{verify_page}}\n\nTämä koodi vanhenee 10 minuutissa.\n\nTai jos pidät vaarallisesta elämästä:\n{{{verify_link}}}\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", + short_token_body: "Hei @{{handle}},\n\nVahvistuskoodisi on:\n{{code}}\n\nTämä koodi vanhenee 15 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", + account_deletion_subject: "Tilin poistopyyntö - {{hostname}}", + account_deletion_body: "Hei @{{handle}},\n\nTilin poiston vahvistuskoodisi on: {{code}}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.", + plc_operation_subject: "{{hostname}} - PLC-toimintotunniste", + plc_operation_body: "Hei @{{handle}},\n\nPyysit allekirjoittamaan PLC-toiminnon tilillesi.\n\nVahvistustunnisteesi on: {{token}}\n\nTämä tunniste vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit turvallisesti jättää tämän viestin huomiotta.", + two_factor_code_subject: "Kirjautumisen vahvistus - {{hostname}}", + two_factor_code_body: "Hei @{{handle}},\n\nKirjautumisen vahvistuskoodisi on: {{code}}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.", + passkey_recovery_subject: "Tilin palautus - {{hostname}}", + passkey_recovery_body: "Hei @{{handle}},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{{url}}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.", + signup_verification_subject: "Vahvista tilisi - {{hostname}}", + signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{{code}}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{{verify_page}}\n\nTämä koodi vanhenee 30 minuutissa.\n\nTai jos pidät vaarallisesta elämästä:\n{{{verify_link}}}\n\nJos et luonut tiliä palveluun {{hostname}}, jätä tämä viesti huomiotta.", + legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {{hostname}}", + legacy_login_body: "Hei @{{handle}},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {{timestamp}}\n- IP-osoite: {{ip}}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{{hostname}}", + migration_verification_subject: "Vahvista sähköpostisi - {{hostname}}", + migration_verification_body: "Tervetuloa palveluun {{hostname}}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{{code}}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{{verify_page}}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{{{verify_link}}}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.", + channel_verified_subject: "Ilmoituskanava vahvistettu - {{hostname}}", + channel_verified_body: "Hei {{handle}},\n\n{{channel}} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {{hostname}}.", + channel_verification_subject: "Vahvista kanavasi - {{hostname}}", + channel_verification_body: "Vahvistuskoodisi on:\n{{code}}\n\nTai vahvista suoraan:\n{{{verify_link}}}", }; static STRINGS_FR: NotificationStrings = NotificationStrings { - welcome_subject: "Bienvenue sur {hostname}", - welcome_body: "Bienvenue sur {hostname} !\n\nVotre identifiant est : @{handle}\n\nMerci de nous avoir rejoint.", - password_reset_subject: "Réinitialisation du mot de passe - {hostname}", - password_reset_body: "Bonjour @{handle},\n\nVotre code de réinitialisation du mot de passe est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message.", - email_update_subject: "Confirmer votre nouvelle adresse e-mail - {hostname}", - email_update_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 10 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.", - short_token_body: "Bonjour @{handle},\n\nVotre code de vérification est :\n{code}\n\nCe code expirera dans 15 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.", - account_deletion_subject: "Demande de suppression de compte - {hostname}", - account_deletion_body: "Bonjour @{handle},\n\nVotre code de confirmation de suppression de compte est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.", - plc_operation_subject: "{hostname} - Jeton d'opération PLC", - plc_operation_body: "Bonjour @{handle},\n\nVous avez demandé à signer une opération PLC pour votre compte.\n\nVotre jeton de vérification est : {token}\n\nCe jeton expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, vous pouvez ignorer ce message en toute sécurité.", - two_factor_code_subject: "Vérification de connexion - {hostname}", - two_factor_code_body: "Bonjour @{handle},\n\nVotre code de vérification de connexion est : {code}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.", - passkey_recovery_subject: "Récupération de compte - {hostname}", - passkey_recovery_body: "Bonjour @{handle},\n\nVous avez demandé la récupération de votre compte à clé d'accès uniquement.\n\nCliquez sur le lien ci-dessous pour définir un mot de passe temporaire et retrouver l'accès :\n{url}\n\nCe lien expirera dans 1 heure.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message. Votre compte reste sécurisé.", - signup_verification_subject: "Vérifier votre compte - {hostname}", - signup_verification_body: "Bienvenue ! Votre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 30 minutes.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas créé de compte sur {hostname}, veuillez ignorer ce message.", - legacy_login_subject: "Alerte de sécurité : Connexion classique détectée - {hostname}", - legacy_login_body: "Bonjour @{handle},\n\nUne connexion à votre compte a été détectée via une application classique (comme Bluesky) qui ne prend pas en charge la vérification TOTP.\n\nDétails :\n- Date : {timestamp}\n- Adresse IP : {ip}\n\nVotre protection TOTP a été contournée pour cette connexion. La session dispose de permissions limitées pour les opérations sensibles.\n\nSi ce n'était pas vous :\n1. Changez votre mot de passe immédiatement\n2. Vérifiez vos sessions actives\n3. Envisagez de désactiver les connexions d'applications classiques dans vos paramètres de sécurité\n\nRestez vigilant,\n{hostname}", - migration_verification_subject: "Vérifier votre adresse e-mail - {hostname}", - migration_verification_body: "Bienvenue sur {hostname} !\n\nVotre compte a été migré avec succès. Pour finaliser la configuration, veuillez vérifier votre adresse e-mail.\n\nVotre code de vérification est :\n{code}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{verify_page}\n\nCe code expirera dans 48 heures.\n\nOu si vous aimez vivre dangereusement :\n{verify_link}\n\nSi vous n'avez pas migré votre compte, veuillez ignorer cet e-mail.", - channel_verified_subject: "Canal de notification vérifié - {hostname}", - channel_verified_body: "Bonjour {handle},\n\n{channel} a été vérifié comme canal de notification pour votre compte sur {hostname}.", - channel_verification_subject: "Vérifier votre canal - {hostname}", - channel_verification_body: "Votre code de vérification est :\n{code}\n\nOu vérifiez directement :\n{verify_link}", + welcome_subject: "Bienvenue sur {{hostname}}", + welcome_body: "Bienvenue sur {{hostname}} !\n\nVotre identifiant est : @{{handle}}\n\nMerci de nous avoir rejoint.", + password_reset_subject: "Réinitialisation du mot de passe - {{hostname}}", + password_reset_body: "Bonjour @{{handle}},\n\nVotre code de réinitialisation du mot de passe est : {{code}}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message.", + email_update_subject: "Confirmer votre nouvelle adresse e-mail - {{hostname}}", + email_update_body: "Bonjour @{{handle}},\n\nVotre code de vérification est :\n{{code}}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{{verify_page}}\n\nCe code expirera dans 10 minutes.\n\nOu si vous aimez vivre dangereusement :\n{{{verify_link}}}\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.", + short_token_body: "Bonjour @{{handle}},\n\nVotre code de vérification est :\n{{code}}\n\nCe code expirera dans 15 minutes.\n\nSi vous n'avez pas demandé cela, veuillez ignorer cet e-mail.", + account_deletion_subject: "Demande de suppression de compte - {{hostname}}", + account_deletion_body: "Bonjour @{{handle}},\n\nVotre code de confirmation de suppression de compte est : {{code}}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.", + plc_operation_subject: "{{hostname}} - Jeton d'opération PLC", + plc_operation_body: "Bonjour @{{handle}},\n\nVous avez demandé à signer une opération PLC pour votre compte.\n\nVotre jeton de vérification est : {{token}}\n\nCe jeton expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, vous pouvez ignorer ce message en toute sécurité.", + two_factor_code_subject: "Vérification de connexion - {{hostname}}", + two_factor_code_body: "Bonjour @{{handle}},\n\nVotre code de vérification de connexion est : {{code}}\n\nCe code expirera dans 10 minutes.\n\nSi vous n'avez pas demandé cela, sécurisez votre compte immédiatement.", + passkey_recovery_subject: "Récupération de compte - {{hostname}}", + passkey_recovery_body: "Bonjour @{{handle}},\n\nVous avez demandé la récupération de votre compte à clé d'accès uniquement.\n\nCliquez sur le lien ci-dessous pour définir un mot de passe temporaire et retrouver l'accès :\n{{url}}\n\nCe lien expirera dans 1 heure.\n\nSi vous n'avez pas demandé cela, veuillez ignorer ce message. Votre compte reste sécurisé.", + signup_verification_subject: "Vérifier votre compte - {{hostname}}", + signup_verification_body: "Bienvenue ! Votre code de vérification est :\n{{code}}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{{verify_page}}\n\nCe code expirera dans 30 minutes.\n\nOu si vous aimez vivre dangereusement :\n{{{verify_link}}}\n\nSi vous n'avez pas créé de compte sur {{hostname}}, veuillez ignorer ce message.", + legacy_login_subject: "Alerte de sécurité : Connexion classique détectée - {{hostname}}", + legacy_login_body: "Bonjour @{{handle}},\n\nUne connexion à votre compte a été détectée via une application classique (comme Bluesky) qui ne prend pas en charge la vérification TOTP.\n\nDétails :\n- Date : {{timestamp}}\n- Adresse IP : {{ip}}\n\nVotre protection TOTP a été contournée pour cette connexion. La session dispose de permissions limitées pour les opérations sensibles.\n\nSi ce n'était pas vous :\n1. Changez votre mot de passe immédiatement\n2. Vérifiez vos sessions actives\n3. Envisagez de désactiver les connexions d'applications classiques dans vos paramètres de sécurité\n\nRestez vigilant,\n{{hostname}}", + migration_verification_subject: "Vérifier votre adresse e-mail - {{hostname}}", + migration_verification_body: "Bienvenue sur {{hostname}} !\n\nVotre compte a été migré avec succès. Pour finaliser la configuration, veuillez vérifier votre adresse e-mail.\n\nVotre code de vérification est :\n{{code}}\n\nCopiez le code ci-dessus et saisissez-le ici :\n{{verify_page}}\n\nCe code expirera dans 48 heures.\n\nOu si vous aimez vivre dangereusement :\n{{{verify_link}}}\n\nSi vous n'avez pas migré votre compte, veuillez ignorer cet e-mail.", + channel_verified_subject: "Canal de notification vérifié - {{hostname}}", + channel_verified_body: "Bonjour {{handle}},\n\n{{channel}} a été vérifié comme canal de notification pour votre compte sur {{hostname}}.", + channel_verification_subject: "Vérifier votre canal - {{hostname}}", + channel_verification_body: "Votre code de vérification est :\n{{code}}\n\nOu vérifiez directement :\n{{{verify_link}}}", }; -pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { - vars.iter() - .fold(template.to_string(), |result, (key, value)| { - result.replace(&format!("{{{}}}", key), value) - }) -} - #[cfg(test)] mod tests { use super::*; @@ -265,28 +254,21 @@ mod tests { assert_eq!(validate_locale("sv"), "sv"); assert_eq!(validate_locale("fi"), "fi"); assert_eq!(validate_locale("fr"), "fr"); - assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE); - assert_eq!(validate_locale(""), DEFAULT_LOCALE); - } - - #[test] - fn test_format_message() { - let template = "Hello {name}, your code is {code}"; - let result = format_message(template, &[("name", "Alice"), ("code", "123456")]); - assert_eq!(result, "Hello Alice, your code is 123456"); + assert_eq!(validate_locale("invalid"), "en"); + assert_eq!(validate_locale(""), "en"); } #[test] fn test_get_strings() { let en = get_strings("en"); - assert!(en.welcome_subject.contains("{hostname}")); + assert!(en.welcome_subject.contains("{{hostname}}")); let zh = get_strings("zh"); - assert!(zh.welcome_subject.contains("{hostname}")); + assert!(zh.welcome_subject.contains("{{hostname}}")); assert!(zh.welcome_body.contains("欢迎")); let fr = get_strings("fr"); - assert!(fr.welcome_subject.contains("{hostname}")); + assert!(fr.welcome_subject.contains("{{hostname}}")); assert!(fr.welcome_body.contains("Bienvenue")); } } diff --git a/crates/tranquil-comms/src/templates/error.rs b/crates/tranquil-comms/src/templates/error.rs new file mode 100644 index 00000000..54a59e3c --- /dev/null +++ b/crates/tranquil-comms/src/templates/error.rs @@ -0,0 +1,11 @@ +#[derive(Debug, thiserror::Error)] +pub enum TemplateError { + #[error("{0}")] + Message(String), +} + +impl TemplateError { + pub(super) fn msg(s: impl Into) -> Self { + Self::Message(s.into()) + } +} diff --git a/crates/tranquil-comms/src/templates/ids.rs b/crates/tranquil-comms/src/templates/ids.rs new file mode 100644 index 00000000..e7d0fe26 --- /dev/null +++ b/crates/tranquil-comms/src/templates/ids.rs @@ -0,0 +1,135 @@ +use crate::locale::get_strings; + +/// Required template ids when `comms.templates_dir` is configured. +pub const REQUIRED_TEMPLATE_IDS: &[&str] = &[ + "welcome", + "password_reset", + "email_update", + "short_token", + "account_deletion", + "plc_operation", + "two_factor_code", + "passkey_recovery", + "signup_verification", + "legacy_login", + "migration_verification", + "channel_verification", + "channel_verified", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TemplateId { + Welcome, + PasswordReset, + EmailUpdate, + ShortToken, + AccountDeletion, + PlcOperation, + TwoFactorCode, + PasskeyRecovery, + SignupVerification, + LegacyLogin, + MigrationVerification, + ChannelVerification, + ChannelVerified, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct BuiltinTemplateParts { + pub subject: &'static str, + pub body: &'static str, +} + +impl TemplateId { + pub(super) const ALL: &[TemplateId] = &[ + Self::Welcome, + Self::PasswordReset, + Self::EmailUpdate, + Self::ShortToken, + Self::AccountDeletion, + Self::PlcOperation, + Self::TwoFactorCode, + Self::PasskeyRecovery, + Self::SignupVerification, + Self::LegacyLogin, + Self::MigrationVerification, + Self::ChannelVerification, + Self::ChannelVerified, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Welcome => "welcome", + Self::PasswordReset => "password_reset", + Self::EmailUpdate => "email_update", + Self::ShortToken => "short_token", + Self::AccountDeletion => "account_deletion", + Self::PlcOperation => "plc_operation", + Self::TwoFactorCode => "two_factor_code", + Self::PasskeyRecovery => "passkey_recovery", + Self::SignupVerification => "signup_verification", + Self::LegacyLogin => "legacy_login", + Self::MigrationVerification => "migration_verification", + Self::ChannelVerification => "channel_verification", + Self::ChannelVerified => "channel_verified", + } + } + + pub(super) fn builtin_parts(self, locale: &str) -> BuiltinTemplateParts { + let s = get_strings(locale); + match self { + Self::Welcome => BuiltinTemplateParts { + subject: s.welcome_subject, + body: s.welcome_body, + }, + Self::PasswordReset => BuiltinTemplateParts { + subject: s.password_reset_subject, + body: s.password_reset_body, + }, + Self::EmailUpdate => BuiltinTemplateParts { + subject: s.email_update_subject, + body: s.email_update_body, + }, + Self::ShortToken => BuiltinTemplateParts { + subject: s.email_update_subject, + body: s.short_token_body, + }, + Self::AccountDeletion => BuiltinTemplateParts { + subject: s.account_deletion_subject, + body: s.account_deletion_body, + }, + Self::PlcOperation => BuiltinTemplateParts { + subject: s.plc_operation_subject, + body: s.plc_operation_body, + }, + Self::TwoFactorCode => BuiltinTemplateParts { + subject: s.two_factor_code_subject, + body: s.two_factor_code_body, + }, + Self::PasskeyRecovery => BuiltinTemplateParts { + subject: s.passkey_recovery_subject, + body: s.passkey_recovery_body, + }, + Self::SignupVerification => BuiltinTemplateParts { + subject: s.signup_verification_subject, + body: s.signup_verification_body, + }, + Self::LegacyLogin => BuiltinTemplateParts { + subject: s.legacy_login_subject, + body: s.legacy_login_body, + }, + Self::MigrationVerification => BuiltinTemplateParts { + subject: s.migration_verification_subject, + body: s.migration_verification_body, + }, + Self::ChannelVerification => BuiltinTemplateParts { + subject: s.channel_verification_subject, + body: s.channel_verification_body, + }, + Self::ChannelVerified => BuiltinTemplateParts { + subject: s.channel_verified_subject, + body: s.channel_verified_body, + }, + } + } +} diff --git a/crates/tranquil-comms/src/templates/mod.rs b/crates/tranquil-comms/src/templates/mod.rs new file mode 100644 index 00000000..2d48a3df --- /dev/null +++ b/crates/tranquil-comms/src/templates/mod.rs @@ -0,0 +1,77 @@ +mod error; +mod ids; +mod rendered; +mod store; + +#[cfg(test)] +mod tests; + +use std::path::Path; +use std::sync::OnceLock; + +pub use error::TemplateError; +pub use ids::{REQUIRED_TEMPLATE_IDS, TemplateId}; +pub use rendered::{RenderedTemplate, html_body_from_metadata}; +pub use store::TemplateStore; + +static RUNTIME_TEMPLATE_STORE: OnceLock = OnceLock::new(); + +/// Load templates from config without installing the global cache. +fn load_from_config(cfg: &tranquil_config::CommsConfig) -> Result { + let Some(dir) = cfg + .templates_dir + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return TemplateStore::builtins_only(); + }; + + if cfg.template_locales.is_empty() { + return Err(TemplateError::msg( + "comms.template_locales must be non-empty when comms.templates_dir is set", + )); + } + + TemplateStore::load( + Path::new(dir), + &cfg.template_locales, + cfg.fallback_to_builtin, + ) +} + +/// Check that configured notification templates are complete (does not install the global cache). +/// +/// Always compiles builtin Handlebars strings. When `templates_dir` is set, also +/// loads and compiles on-disk templates for `template_locales`. +pub fn validate(cfg: &tranquil_config::CommsConfig) -> Result<(), TemplateError> { + load_from_config(cfg).map(|_| ()) +} + +/// Load and cache notification templates from config. Call once at process startup. +/// +/// Always initializes with compiled builtins. When `templates_dir` is set, also +/// loads disk templates and fails if any required file is missing or has invalid +/// Handlebars syntax. +pub fn init(cfg: &tranquil_config::CommsConfig) -> Result<(), TemplateError> { + let store = load_from_config(cfg)?; + RUNTIME_TEMPLATE_STORE + .set(store) + .map_err(|_| TemplateError::msg("comms templates already initialized"))?; + Ok(()) +} + +fn runtime_template_store() -> Result<&'static TemplateStore, TemplateError> { + RUNTIME_TEMPLATE_STORE + .get() + .ok_or_else(|| TemplateError::msg("comms templates not initialized")) +} + +/// Resolve subject/body/(optional HTML) for a notification using the global template cache. +pub fn render( + id: TemplateId, + locale: &str, + vars: &[(&str, &str)], +) -> Result { + runtime_template_store()?.render(id, locale, vars) +} diff --git a/crates/tranquil-comms/src/templates/rendered.rs b/crates/tranquil-comms/src/templates/rendered.rs new file mode 100644 index 00000000..b0493001 --- /dev/null +++ b/crates/tranquil-comms/src/templates/rendered.rs @@ -0,0 +1,27 @@ +use serde_json::json; + +pub(super) const HTML_BODY_META_KEY: &str = "html_body"; + +#[derive(Debug, Clone)] +pub struct RenderedTemplate { + pub subject: String, + pub body: String, + pub html_body: Option, +} + +impl RenderedTemplate { + /// Metadata value for `enqueue_comms`, or `None` when there is no HTML part. + pub fn metadata(&self) -> Option { + self.html_body + .as_ref() + .map(|html| json!({ HTML_BODY_META_KEY: html })) + } +} + +/// Extract HTML body from queue metadata, if present. +pub fn html_body_from_metadata(metadata: &Option) -> Option<&str> { + metadata + .as_ref() + .and_then(|v| v.get(HTML_BODY_META_KEY)) + .and_then(|v| v.as_str()) +} diff --git a/crates/tranquil-comms/src/templates/store.rs b/crates/tranquil-comms/src/templates/store.rs new file mode 100644 index 00000000..a1d10199 --- /dev/null +++ b/crates/tranquil-comms/src/templates/store.rs @@ -0,0 +1,286 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use handlebars::Handlebars; +use serde_json::{Map, Value, json}; + +use super::error::TemplateError; +use super::ids::{REQUIRED_TEMPLATE_IDS, TemplateId}; +use super::rendered::RenderedTemplate; +use crate::locale::validate_locale; +use tranquil_types::Locale; + +fn template_name(prefix: &str, locale: &str, id: &str, part: &str) -> String { + format!("{prefix}/{locale}/{id}.{part}") +} + +fn vars_to_context(vars: &[(&str, &str)]) -> Value { + let mut map = Map::new(); + for (key, value) in vars { + map.insert((*key).to_string(), json!(value)); + } + Value::Object(map) +} + +/// Compiled notification templates (builtins always; disk overrides when configured). +pub struct TemplateStore { + plain: Handlebars<'static>, + html: Handlebars<'static>, + disk_locales: HashSet, + disk_enabled: bool, + fallback_to_builtin: bool, +} + +impl TemplateStore { + fn new_registries() -> (Handlebars<'static>, Handlebars<'static>) { + let mut plain = Handlebars::new(); + plain.register_escape_fn(handlebars::no_escape); + plain.set_strict_mode(false); + let mut html = Handlebars::new(); + html.set_strict_mode(false); + (plain, html) + } + + fn register_handlebars( + registry: &mut Handlebars<'static>, + name: &str, + source: &str, + ) -> Result<(), TemplateError> { + registry + .register_template_string(name, source) + .map_err(|e| TemplateError::msg(format!("invalid Handlebars template {name:?}: {e}"))) + } + + fn register_builtins(plain: &mut Handlebars<'static>) -> Result<(), TemplateError> { + for locale in Locale::ALL.iter().map(|l| l.as_str()) { + for id in TemplateId::ALL { + let parts = id.builtin_parts(locale); + let subject_name = template_name("builtin", locale, id.as_str(), "subject"); + let body_name = template_name("builtin", locale, id.as_str(), "body"); + Self::register_handlebars(plain, &subject_name, parts.subject)?; + Self::register_handlebars(plain, &body_name, parts.body)?; + } + } + Ok(()) + } + + /// Built-in Handlebars templates only (no `templates_dir`). + pub fn builtins_only() -> Result { + let (mut plain, html) = Self::new_registries(); + Self::register_builtins(&mut plain)?; + Ok(Self { + plain, + html, + disk_locales: HashSet::new(), + disk_enabled: false, + fallback_to_builtin: true, + }) + } + + pub fn load( + root: &Path, + locales: &[String], + fallback_to_builtin: bool, + ) -> Result { + if !root.is_dir() { + return Err(TemplateError::msg(format!( + "comms templates_dir {} is not a directory", + root.display() + ))); + } + + let (mut plain, mut html) = Self::new_registries(); + Self::register_builtins(&mut plain)?; + + let mut disk_locales = HashSet::new(); + + for locale_raw in locales { + let locale = locale_raw.trim(); + if locale.is_empty() { + return Err(TemplateError::msg( + "comms.template_locales entries must be non-empty", + )); + } + if locale.chars().any(|c| c == '/' || c == '\\' || c == '.') { + return Err(TemplateError::msg(format!( + "comms.template_locales entry {locale_raw:?} must be a plain locale code \ + (no path separators or dots)" + ))); + } + let locale_dir = root.join(locale); + if !locale_dir.is_dir() { + return Err(TemplateError::msg(format!( + "missing comms template locale directory: {}", + locale_dir.display() + ))); + } + + for id in REQUIRED_TEMPLATE_IDS { + let subject_path = locale_dir.join(format!("{id}.subject.txt")); + let body_txt_path = locale_dir.join(format!("{id}.body.txt")); + let body_html_path = locale_dir.join(format!("{id}.body.html")); + + let subject = read_required(&subject_path)?; + let body_txt = read_required(&body_txt_path)?; + let body_html = read_optional(&body_html_path)?; + + let subject_name = template_name("disk", locale, id, "subject"); + let body_name = template_name("disk", locale, id, "body"); + let html_name = template_name("disk", locale, id, "html"); + + Self::register_handlebars(&mut plain, &subject_name, &subject)?; + Self::register_handlebars(&mut plain, &body_name, &body_txt)?; + if let Some(body_html) = body_html { + Self::register_handlebars(&mut html, &html_name, &body_html)?; + } + } + disk_locales.insert(locale.to_string()); + } + + Ok(Self { + plain, + html, + disk_locales, + disk_enabled: true, + fallback_to_builtin, + }) + } + + pub fn has_locale(&self, locale: &str) -> bool { + self.disk_locales.contains(locale) + } + + pub fn fallback_to_builtin(&self) -> bool { + self.fallback_to_builtin + } + + fn render_named( + registry: &Handlebars<'static>, + name: &str, + data: &Value, + ) -> Result { + registry + .render(name, data) + .map_err(|e| TemplateError::msg(format!("failed to render template {name:?}: {e}"))) + } + + fn render_builtin( + &self, + id: TemplateId, + locale: &str, + data: &Value, + ) -> Result { + let subject_name: String = template_name("builtin", locale, id.as_str(), "subject"); + let body_name = template_name("builtin", locale, id.as_str(), "body"); + Ok(RenderedTemplate { + subject: Self::render_named(&self.plain, &subject_name, data)?, + body: Self::render_named(&self.plain, &body_name, data)?, + html_body: None, + }) + } + + fn render_disk( + &self, + id: TemplateId, + locale: &str, + data: &Value, + ) -> Result { + let subject_name = template_name("disk", locale, id.as_str(), "subject"); + let body_name = template_name("disk", locale, id.as_str(), "body"); + let html_name = template_name("disk", locale, id.as_str(), "html"); + let html_body = if self.html.has_template(&html_name) { + Some(Self::render_named(&self.html, &html_name, data)?) + } else { + None + }; + Ok(RenderedTemplate { + subject: Self::render_named(&self.plain, &subject_name, data)?, + body: Self::render_named(&self.plain, &body_name, data)?, + html_body, + }) + } + + pub fn render( + &self, + id: TemplateId, + locale: &str, + vars: &[(&str, &str)], + ) -> Result { + let locale = validate_locale(locale); + let data = vars_to_context(vars); + + if self.has_locale(locale) { + self.render_disk(id, locale, &data) + } else if !self.disk_enabled || self.fallback_to_builtin { + self.render_builtin(id, locale, &data) + } else { + Err(TemplateError::msg(format!( + "no comms templates for locale {locale:?} and fallback_to_builtin is false" + ))) + } + } +} + +fn read_required(path: &Path) -> Result { + fs::read_to_string(path) + .map(|s| s.trim_end_matches(['\r', '\n']).to_string()) + .map_err(|e| TemplateError::msg(format!("failed to read {}: {e}", path.display()))) +} + +fn read_optional(path: &Path) -> Result, TemplateError> { + match fs::read_to_string(path) { + Ok(s) => Ok(Some(s.trim_end_matches(['\r', '\n']).to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(TemplateError::msg(format!( + "failed to read {}: {e}", + path.display() + ))), + } +} + +// tests + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_optional() { + let path = Path::new("test/templates/builtin/en/welcome.body.html"); + let result = read_optional(path); + assert!(result.is_ok()); + } + + #[test] + fn test_html_body_is_none_when_no_html_exists() { + let store = TemplateStore::builtins_only().unwrap(); + let result = store.render(TemplateId::Welcome, "en", &[]); + assert!(result.is_ok()); + assert!(result.unwrap().html_body.is_none()); + } + + #[test] + fn test_html_body_is_some_when_html_exists() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/templates"); + let store = TemplateStore::load(&root, &["en".to_string()], false).unwrap(); + let rendered = store.render(TemplateId::Welcome, "en", &[]).unwrap(); + assert!(rendered.html_body.is_some()); + assert!( + rendered + .html_body + .unwrap() + .contains("

Hello html.

") + ); + } + + #[test] + fn test_html_body_is_none_when_html_does_not_exist() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/templates-txt"); + let store = TemplateStore::load(&root, &["en".to_string()], false).unwrap(); + let rendered = store.render(TemplateId::Welcome, "en", &[]).unwrap(); + assert!(rendered.html_body.is_none()); + assert_eq!(rendered.subject, "Hello World"); + assert_eq!(rendered.body, "Hello World"); + } +} diff --git a/crates/tranquil-comms/src/templates/tests.rs b/crates/tranquil-comms/src/templates/tests.rs new file mode 100644 index 00000000..387dc1ca --- /dev/null +++ b/crates/tranquil-comms/src/templates/tests.rs @@ -0,0 +1,373 @@ +use std::fs; +use std::io::Write; +use std::path::Path; + +use super::*; + +fn write_complete_locale(dir: &Path, locale: &str) { + let locale_dir = dir.join(locale); + fs::create_dir_all(&locale_dir).unwrap(); + for id in REQUIRED_TEMPLATE_IDS { + write_file( + &locale_dir.join(format!("{id}.subject.txt")), + &format!("{id} subject {{{{hostname}}}}"), + ); + write_file( + &locale_dir.join(format!("{id}.body.txt")), + &format!("{id} body handle={{{{handle}}}} code={{{{code}}}}"), + ); + write_file( + &locale_dir.join(format!("{id}.body.html")), + &format!("

{id} {{{{handle}}}} code={{{{code}}}}

"), + ); + } +} + +fn write_file(path: &Path, contents: &str) { + let mut f = fs::File::create(path).unwrap(); + f.write_all(contents.as_bytes()).unwrap(); +} + +fn tempfile_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "tranquil-comms-templates-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn example_templates_tree_loads() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/notification-templates"); + let store = TemplateStore::load(&root, &["en".into()], true).unwrap(); + let rendered = store + .render( + TemplateId::Welcome, + "en", + &[("hostname", "example.com"), ("handle", "alice")], + ) + .unwrap(); + assert!(rendered.subject.contains("example.com")); + assert!(rendered.body.contains("@alice")); + assert!(rendered.html_body.unwrap().contains("alice")); +} + +fn example_store() -> TemplateStore { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/notification-templates"); + TemplateStore::load(&root, &["en".into()], true).unwrap() +} + +#[test] +fn example_welcome_handle_conditional() { + let store = example_store(); + + let with_handle = store + .render( + TemplateId::Welcome, + "en", + &[("hostname", "example.com"), ("handle", "alice")], + ) + .unwrap(); + assert!(with_handle.body.contains("Welcome to example.com, @alice!")); + assert!(with_handle.body.contains("Your handle is @alice.")); + assert!(!with_handle.body.contains("Set your handle")); + let html = with_handle.html_body.unwrap(); + assert!(html.contains("@alice")); + assert!(!html.contains("Set your handle to personalize")); + + let no_handle = store + .render(TemplateId::Welcome, "en", &[("hostname", "example.com")]) + .unwrap(); + assert!(no_handle.body.contains("Welcome to example.com!")); + assert!(!no_handle.body.contains("Welcome to example.com, @")); + assert!( + no_handle + .body + .contains("Set your handle to personalize your account.") + ); + let html = no_handle.html_body.unwrap(); + assert!(html.contains("Set your handle to personalize your account.")); +} + +#[test] +fn example_email_update_verify_link_conditional() { + let store = example_store(); + let verify_link = "https://example.com/oneclick?token=abc123"; + + let with_link = store + .render( + TemplateId::EmailUpdate, + "en", + &[ + ("hostname", "example.com"), + ("handle", "alice"), + ("code", "123456"), + ("verify_page", "https://example.com/app/verify"), + ("verify_link", verify_link), + ], + ) + .unwrap(); + assert!(with_link.body.contains("verify in one click")); + assert!(with_link.body.contains(verify_link)); + let html = with_link.html_body.unwrap(); + assert!(html.contains("live dangerously")); + assert!(html.contains(&format!("href=\"{verify_link}\""))); + + let no_link = store + .render( + TemplateId::EmailUpdate, + "en", + &[ + ("hostname", "example.com"), + ("handle", "alice"), + ("code", "123456"), + ("verify_page", "https://example.com/app/verify"), + ], + ) + .unwrap(); + assert!(!no_link.body.contains("live dangerously")); + assert!(!no_link.body.contains(verify_link)); + let html = no_link.html_body.unwrap(); + assert!(!html.contains("live dangerously")); + assert!(!html.contains("href=")); +} + +#[test] +fn example_signup_verification_else_if_branches() { + let store = example_store(); + + let via_page = store + .render( + TemplateId::SignupVerification, + "en", + &[ + ("hostname", "example.com"), + ("code", "654321"), + ("verify_page", "https://example.com/app/verify"), + ], + ) + .unwrap(); + assert!( + via_page + .body + .contains("Copy the code above and enter it at:") + ); + assert!(via_page.body.contains("https://example.com/app/verify")); + assert!(!via_page.body.contains("Or verify directly:")); + + let via_link = store + .render( + TemplateId::SignupVerification, + "en", + &[ + ("hostname", "example.com"), + ("code", "654321"), + ("verify_link", "https://example.com/app/verify?token=abc"), + ], + ) + .unwrap(); + assert!(via_link.body.contains("Or verify directly:")); + assert!( + via_link + .body + .contains("https://example.com/app/verify?token=abc") + ); + assert!(!via_link.body.contains("Copy the code above")); +} + +#[test] +fn example_channel_verified_conditionals() { + let store = example_store(); + + let full = store + .render( + TemplateId::ChannelVerified, + "en", + &[ + ("hostname", "example.com"), + ("handle", "alice"), + ("channel", "Telegram"), + ], + ) + .unwrap(); + assert!(full.body.contains("Hello @alice,")); + assert!(full.body.contains("Telegram has been verified")); + assert!(!full.body.contains("Hello there,")); + let html = full.html_body.unwrap(); + assert!(html.contains("Hello @alice,")); + assert!(html.contains("Telegram")); + + let bare = store + .render( + TemplateId::ChannelVerified, + "en", + &[("hostname", "example.com")], + ) + .unwrap(); + assert!(bare.body.contains("Hello there,")); + assert!(!bare.body.contains("@")); + let html = bare.html_body.unwrap(); + assert!(html.contains("A channel has been verified")); +} + +#[test] +fn example_legacy_login_detail_conditionals() { + let store = example_store(); + + let detailed = store + .render( + TemplateId::LegacyLogin, + "en", + &[ + ("hostname", "example.com"), + ("handle", "alice"), + ("timestamp", "2026-08-10 12:00:00 UTC"), + ("ip", "203.0.113.7"), + ], + ) + .unwrap(); + assert!(detailed.body.contains("Time: 2026-08-10 12:00:00 UTC")); + assert!(detailed.body.contains("IP Address: 203.0.113.7")); + + let bare = store + .render( + TemplateId::LegacyLogin, + "en", + &[("hostname", "example.com"), ("handle", "alice")], + ) + .unwrap(); + assert!(!bare.body.contains("Time:")); + assert!(!bare.body.contains("IP Address:")); +} + +#[test] +fn load_fails_when_file_missing() { + let tmp = tempfile_dir(); + let locale_dir = tmp.join("en"); + fs::create_dir_all(&locale_dir).unwrap(); + write_file(&locale_dir.join("welcome.subject.txt"), "x"); + let Err(err) = TemplateStore::load(&tmp, &["en".into()], true) else { + panic!("expected load to fail when files are missing"); + }; + assert!(err.to_string().contains("failed to read"), "{err}"); +} + +#[test] +fn load_rejects_locale_path_traversal() { + let tmp = tempfile_dir(); + let Err(err) = TemplateStore::load(&tmp, &["../evil".into()], true) else { + panic!("expected load to reject path traversal locale"); + }; + assert!(err.to_string().contains("plain locale code"), "{err}"); +} + +#[test] +fn load_rejects_empty_locale() { + let tmp = tempfile_dir(); + let Err(err) = TemplateStore::load(&tmp, &[" ".into()], true) else { + panic!("expected load to reject empty locale"); + }; + assert!(err.to_string().contains("non-empty"), "{err}"); +} + +#[test] +fn load_fails_on_invalid_handlebars_syntax() { + let tmp = tempfile_dir(); + write_complete_locale(&tmp, "en"); + write_file( + &tmp.join("en").join("welcome.body.html"), + "

{{#if handle}}

", + ); + let Err(err) = TemplateStore::load(&tmp, &["en".into()], true) else { + panic!("expected load to fail on invalid Handlebars"); + }; + assert!(err.to_string().contains("invalid Handlebars"), "{err}"); +} + +#[test] +fn render_escapes_html_values() { + let tmp = tempfile_dir(); + write_complete_locale(&tmp, "en"); + let store = TemplateStore::load(&tmp, &["en".into()], true).unwrap(); + let rendered = store + .render( + TemplateId::Welcome, + "en", + &[("handle", "a&c"), ("hostname", "ex.com"), ("code", "1")], + ) + .unwrap(); + let html = rendered.html_body.unwrap(); + assert!(html.contains("a<b>&c"), "{html}"); + assert!(!html.contains("a&c"), "{html}"); + assert!(rendered.body.contains("a&c")); +} + +#[test] +fn fallback_locale_uses_builtin_when_enabled() { + let tmp = tempfile_dir(); + write_complete_locale(&tmp, "en"); + let store = TemplateStore::load(&tmp, &["en".into()], true).unwrap(); + let rendered = store + .render( + TemplateId::Welcome, + "fr", + &[("hostname", "ex.com"), ("handle", "bob")], + ) + .unwrap(); + assert!(rendered.html_body.is_none()); + assert!(rendered.body.contains("ex.com")); + assert!(rendered.subject.contains("ex.com")); +} + +#[test] +fn unsupported_locale_errors_when_fallback_disabled() { + let tmp = tempfile_dir(); + write_complete_locale(&tmp, "en"); + let store = TemplateStore::load(&tmp, &["en".into()], false).unwrap(); + let Err(err) = store.render( + TemplateId::Welcome, + "zh", + &[("hostname", "ex.com"), ("handle", "bob")], + ) else { + panic!("expected render to fail when fallback is disabled"); + }; + assert!(err.to_string().contains("fallback_to_builtin"), "{err}"); +} + +#[test] +fn builtins_only_renders_without_templates_dir() { + let store = TemplateStore::builtins_only().unwrap(); + let rendered = store + .render( + TemplateId::Welcome, + "en", + &[("hostname", "ex.com"), ("handle", "bob")], + ) + .unwrap(); + assert!(rendered.html_body.is_none()); + assert!(rendered.subject.contains("ex.com")); + assert!(rendered.body.contains("@bob")); + assert!(rendered.body.contains("ex.com")); +} + +#[test] +fn metadata_includes_html_body() { + let rendered = RenderedTemplate { + subject: "s".into(), + body: "b".into(), + html_body: Some("

hi

".into()), + }; + let meta = rendered.metadata().unwrap(); + assert_eq!(meta["html_body"], "

hi

"); + assert!( + RenderedTemplate { + subject: "s".into(), + body: "b".into(), + html_body: None, + } + .metadata() + .is_none() + ); +} diff --git a/crates/tranquil-comms/tests/templates-txt/en/account_deletion.body.txt b/crates/tranquil-comms/tests/templates-txt/en/account_deletion.body.txt new file mode 100644 index 00000000..022ad029 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/account_deletion.body.txt @@ -0,0 +1 @@ +account_deletion body diff --git a/crates/tranquil-comms/tests/templates-txt/en/account_deletion.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/account_deletion.subject.txt new file mode 100644 index 00000000..b39eda8b --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/account_deletion.subject.txt @@ -0,0 +1 @@ +account_deletion subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/channel_verification.body.txt b/crates/tranquil-comms/tests/templates-txt/en/channel_verification.body.txt new file mode 100644 index 00000000..4dec3dc2 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/channel_verification.body.txt @@ -0,0 +1 @@ +channel_verification body diff --git a/crates/tranquil-comms/tests/templates-txt/en/channel_verification.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/channel_verification.subject.txt new file mode 100644 index 00000000..407ca9be --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/channel_verification.subject.txt @@ -0,0 +1 @@ +channel_verification subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/channel_verified.body.txt b/crates/tranquil-comms/tests/templates-txt/en/channel_verified.body.txt new file mode 100644 index 00000000..a562686f --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/channel_verified.body.txt @@ -0,0 +1 @@ +channel_verified body diff --git a/crates/tranquil-comms/tests/templates-txt/en/channel_verified.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/channel_verified.subject.txt new file mode 100644 index 00000000..d3d12d63 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/channel_verified.subject.txt @@ -0,0 +1 @@ +channel_verified subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/email_update.body.txt b/crates/tranquil-comms/tests/templates-txt/en/email_update.body.txt new file mode 100644 index 00000000..23cd0ed6 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/email_update.body.txt @@ -0,0 +1 @@ +email_update body diff --git a/crates/tranquil-comms/tests/templates-txt/en/email_update.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/email_update.subject.txt new file mode 100644 index 00000000..9103f9ed --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/email_update.subject.txt @@ -0,0 +1 @@ +email_update subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/legacy_login.body.txt b/crates/tranquil-comms/tests/templates-txt/en/legacy_login.body.txt new file mode 100644 index 00000000..9f760f52 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/legacy_login.body.txt @@ -0,0 +1 @@ +legacy_login body diff --git a/crates/tranquil-comms/tests/templates-txt/en/legacy_login.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/legacy_login.subject.txt new file mode 100644 index 00000000..50353086 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/legacy_login.subject.txt @@ -0,0 +1 @@ +legacy_login subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/migration_verification.body.txt b/crates/tranquil-comms/tests/templates-txt/en/migration_verification.body.txt new file mode 100644 index 00000000..f58bcd4d --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/migration_verification.body.txt @@ -0,0 +1 @@ +migration_verification body diff --git a/crates/tranquil-comms/tests/templates-txt/en/migration_verification.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/migration_verification.subject.txt new file mode 100644 index 00000000..528711be --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/migration_verification.subject.txt @@ -0,0 +1 @@ +migration_verification subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.body.txt b/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.body.txt new file mode 100644 index 00000000..d66e54aa --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.body.txt @@ -0,0 +1 @@ +passkey_recovery body diff --git a/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.subject.txt new file mode 100644 index 00000000..3c721b19 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/passkey_recovery.subject.txt @@ -0,0 +1 @@ +passkey_recovery subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/password_reset.body.txt b/crates/tranquil-comms/tests/templates-txt/en/password_reset.body.txt new file mode 100644 index 00000000..f84b33ad --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/password_reset.body.txt @@ -0,0 +1 @@ +password_reset body diff --git a/crates/tranquil-comms/tests/templates-txt/en/password_reset.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/password_reset.subject.txt new file mode 100644 index 00000000..5b3b62f4 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/password_reset.subject.txt @@ -0,0 +1 @@ +password_reset subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/plc_operation.body.txt b/crates/tranquil-comms/tests/templates-txt/en/plc_operation.body.txt new file mode 100644 index 00000000..7fca8d76 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/plc_operation.body.txt @@ -0,0 +1 @@ +plc_operation body diff --git a/crates/tranquil-comms/tests/templates-txt/en/plc_operation.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/plc_operation.subject.txt new file mode 100644 index 00000000..f17fe741 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/plc_operation.subject.txt @@ -0,0 +1 @@ +plc_operation subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/short_token.body.txt b/crates/tranquil-comms/tests/templates-txt/en/short_token.body.txt new file mode 100644 index 00000000..cf7e5efe --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/short_token.body.txt @@ -0,0 +1 @@ +short_token body diff --git a/crates/tranquil-comms/tests/templates-txt/en/short_token.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/short_token.subject.txt new file mode 100644 index 00000000..386356b4 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/short_token.subject.txt @@ -0,0 +1 @@ +short_token subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/signup_verification.body.txt b/crates/tranquil-comms/tests/templates-txt/en/signup_verification.body.txt new file mode 100644 index 00000000..15d380a3 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/signup_verification.body.txt @@ -0,0 +1 @@ +signup_verification body diff --git a/crates/tranquil-comms/tests/templates-txt/en/signup_verification.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/signup_verification.subject.txt new file mode 100644 index 00000000..b31ccd3e --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/signup_verification.subject.txt @@ -0,0 +1 @@ +signup_verification subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.body.txt b/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.body.txt new file mode 100644 index 00000000..73c2c486 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.body.txt @@ -0,0 +1 @@ +two_factor_code body diff --git a/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.subject.txt new file mode 100644 index 00000000..29cff489 --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/two_factor_code.subject.txt @@ -0,0 +1 @@ +two_factor_code subject diff --git a/crates/tranquil-comms/tests/templates-txt/en/welcome.body.txt b/crates/tranquil-comms/tests/templates-txt/en/welcome.body.txt new file mode 100644 index 00000000..557db03d --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/welcome.body.txt @@ -0,0 +1 @@ +Hello World diff --git a/crates/tranquil-comms/tests/templates-txt/en/welcome.subject.txt b/crates/tranquil-comms/tests/templates-txt/en/welcome.subject.txt new file mode 100644 index 00000000..5e1c309d --- /dev/null +++ b/crates/tranquil-comms/tests/templates-txt/en/welcome.subject.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/crates/tranquil-comms/tests/templates/en/account_deletion.body.txt b/crates/tranquil-comms/tests/templates/en/account_deletion.body.txt new file mode 100644 index 00000000..022ad029 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/account_deletion.body.txt @@ -0,0 +1 @@ +account_deletion body diff --git a/crates/tranquil-comms/tests/templates/en/account_deletion.subject.txt b/crates/tranquil-comms/tests/templates/en/account_deletion.subject.txt new file mode 100644 index 00000000..b39eda8b --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/account_deletion.subject.txt @@ -0,0 +1 @@ +account_deletion subject diff --git a/crates/tranquil-comms/tests/templates/en/channel_verification.body.txt b/crates/tranquil-comms/tests/templates/en/channel_verification.body.txt new file mode 100644 index 00000000..4dec3dc2 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/channel_verification.body.txt @@ -0,0 +1 @@ +channel_verification body diff --git a/crates/tranquil-comms/tests/templates/en/channel_verification.subject.txt b/crates/tranquil-comms/tests/templates/en/channel_verification.subject.txt new file mode 100644 index 00000000..407ca9be --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/channel_verification.subject.txt @@ -0,0 +1 @@ +channel_verification subject diff --git a/crates/tranquil-comms/tests/templates/en/channel_verified.body.txt b/crates/tranquil-comms/tests/templates/en/channel_verified.body.txt new file mode 100644 index 00000000..a562686f --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/channel_verified.body.txt @@ -0,0 +1 @@ +channel_verified body diff --git a/crates/tranquil-comms/tests/templates/en/channel_verified.subject.txt b/crates/tranquil-comms/tests/templates/en/channel_verified.subject.txt new file mode 100644 index 00000000..d3d12d63 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/channel_verified.subject.txt @@ -0,0 +1 @@ +channel_verified subject diff --git a/crates/tranquil-comms/tests/templates/en/email_update.body.txt b/crates/tranquil-comms/tests/templates/en/email_update.body.txt new file mode 100644 index 00000000..23cd0ed6 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/email_update.body.txt @@ -0,0 +1 @@ +email_update body diff --git a/crates/tranquil-comms/tests/templates/en/email_update.subject.txt b/crates/tranquil-comms/tests/templates/en/email_update.subject.txt new file mode 100644 index 00000000..9103f9ed --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/email_update.subject.txt @@ -0,0 +1 @@ +email_update subject diff --git a/crates/tranquil-comms/tests/templates/en/legacy_login.body.txt b/crates/tranquil-comms/tests/templates/en/legacy_login.body.txt new file mode 100644 index 00000000..9f760f52 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/legacy_login.body.txt @@ -0,0 +1 @@ +legacy_login body diff --git a/crates/tranquil-comms/tests/templates/en/legacy_login.subject.txt b/crates/tranquil-comms/tests/templates/en/legacy_login.subject.txt new file mode 100644 index 00000000..50353086 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/legacy_login.subject.txt @@ -0,0 +1 @@ +legacy_login subject diff --git a/crates/tranquil-comms/tests/templates/en/migration_verification.body.txt b/crates/tranquil-comms/tests/templates/en/migration_verification.body.txt new file mode 100644 index 00000000..f58bcd4d --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/migration_verification.body.txt @@ -0,0 +1 @@ +migration_verification body diff --git a/crates/tranquil-comms/tests/templates/en/migration_verification.subject.txt b/crates/tranquil-comms/tests/templates/en/migration_verification.subject.txt new file mode 100644 index 00000000..528711be --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/migration_verification.subject.txt @@ -0,0 +1 @@ +migration_verification subject diff --git a/crates/tranquil-comms/tests/templates/en/passkey_recovery.body.txt b/crates/tranquil-comms/tests/templates/en/passkey_recovery.body.txt new file mode 100644 index 00000000..d66e54aa --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/passkey_recovery.body.txt @@ -0,0 +1 @@ +passkey_recovery body diff --git a/crates/tranquil-comms/tests/templates/en/passkey_recovery.subject.txt b/crates/tranquil-comms/tests/templates/en/passkey_recovery.subject.txt new file mode 100644 index 00000000..3c721b19 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/passkey_recovery.subject.txt @@ -0,0 +1 @@ +passkey_recovery subject diff --git a/crates/tranquil-comms/tests/templates/en/password_reset.body.txt b/crates/tranquil-comms/tests/templates/en/password_reset.body.txt new file mode 100644 index 00000000..f84b33ad --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/password_reset.body.txt @@ -0,0 +1 @@ +password_reset body diff --git a/crates/tranquil-comms/tests/templates/en/password_reset.subject.txt b/crates/tranquil-comms/tests/templates/en/password_reset.subject.txt new file mode 100644 index 00000000..5b3b62f4 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/password_reset.subject.txt @@ -0,0 +1 @@ +password_reset subject diff --git a/crates/tranquil-comms/tests/templates/en/plc_operation.body.txt b/crates/tranquil-comms/tests/templates/en/plc_operation.body.txt new file mode 100644 index 00000000..7fca8d76 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/plc_operation.body.txt @@ -0,0 +1 @@ +plc_operation body diff --git a/crates/tranquil-comms/tests/templates/en/plc_operation.subject.txt b/crates/tranquil-comms/tests/templates/en/plc_operation.subject.txt new file mode 100644 index 00000000..f17fe741 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/plc_operation.subject.txt @@ -0,0 +1 @@ +plc_operation subject diff --git a/crates/tranquil-comms/tests/templates/en/short_token.body.txt b/crates/tranquil-comms/tests/templates/en/short_token.body.txt new file mode 100644 index 00000000..cf7e5efe --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/short_token.body.txt @@ -0,0 +1 @@ +short_token body diff --git a/crates/tranquil-comms/tests/templates/en/short_token.subject.txt b/crates/tranquil-comms/tests/templates/en/short_token.subject.txt new file mode 100644 index 00000000..386356b4 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/short_token.subject.txt @@ -0,0 +1 @@ +short_token subject diff --git a/crates/tranquil-comms/tests/templates/en/signup_verification.body.txt b/crates/tranquil-comms/tests/templates/en/signup_verification.body.txt new file mode 100644 index 00000000..15d380a3 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/signup_verification.body.txt @@ -0,0 +1 @@ +signup_verification body diff --git a/crates/tranquil-comms/tests/templates/en/signup_verification.subject.txt b/crates/tranquil-comms/tests/templates/en/signup_verification.subject.txt new file mode 100644 index 00000000..b31ccd3e --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/signup_verification.subject.txt @@ -0,0 +1 @@ +signup_verification subject diff --git a/crates/tranquil-comms/tests/templates/en/two_factor_code.body.txt b/crates/tranquil-comms/tests/templates/en/two_factor_code.body.txt new file mode 100644 index 00000000..73c2c486 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/two_factor_code.body.txt @@ -0,0 +1 @@ +two_factor_code body diff --git a/crates/tranquil-comms/tests/templates/en/two_factor_code.subject.txt b/crates/tranquil-comms/tests/templates/en/two_factor_code.subject.txt new file mode 100644 index 00000000..29cff489 --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/two_factor_code.subject.txt @@ -0,0 +1 @@ +two_factor_code subject diff --git a/crates/tranquil-comms/tests/templates/en/welcome.body.html b/crates/tranquil-comms/tests/templates/en/welcome.body.html new file mode 100644 index 00000000..b9769b9c --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/welcome.body.html @@ -0,0 +1 @@ +

Hello html.

\ No newline at end of file diff --git a/crates/tranquil-comms/tests/templates/en/welcome.body.txt b/crates/tranquil-comms/tests/templates/en/welcome.body.txt new file mode 100644 index 00000000..557db03d --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/welcome.body.txt @@ -0,0 +1 @@ +Hello World diff --git a/crates/tranquil-comms/tests/templates/en/welcome.subject.txt b/crates/tranquil-comms/tests/templates/en/welcome.subject.txt new file mode 100644 index 00000000..5e1c309d --- /dev/null +++ b/crates/tranquil-comms/tests/templates/en/welcome.subject.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/crates/tranquil-config/Cargo.toml b/crates/tranquil-config/Cargo.toml index 8b4fa54c..9f22324a 100644 --- a/crates/tranquil-config/Cargo.toml +++ b/crates/tranquil-config/Cargo.toml @@ -8,3 +8,6 @@ license.workspace = true serde = { workspace = true } tranquil-types = { workspace = true } confique = { workspace = true } + +[dev-dependencies] +serial_test = "4.0.1" \ No newline at end of file diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index f0d30919..54d90865 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -1,8 +1,9 @@ use confique::Config; use std::fmt; use std::path::PathBuf; +use std::str::FromStr; use std::sync::OnceLock; -use tranquil_types::Domain; +use tranquil_types::{Domain, Locale}; static CONFIG: OnceLock = OnceLock::new(); @@ -136,6 +137,9 @@ pub struct TranquilConfig { #[config(nested)] pub email: EmailConfig, + #[config(nested)] + pub comms: CommsConfig, + #[config(nested)] pub discord: DiscordConfig, @@ -243,6 +247,9 @@ impl TranquilConfig { ); } + // -- comms ------------------------------------------------------------ + self.comms.validate(&mut errors); + // -- blob storage ----------------------------------------------------- match self.storage.backend.as_str() { "s3" => { @@ -1062,6 +1069,48 @@ impl EmailConfig { } } +/// Channel-neutral notification templates shared by every comms channel +/// (email, Telegram, Discord, Signal). +#[derive(Debug, Config)] +#[config(layer_attr(serde(deny_unknown_fields)))] +pub struct CommsConfig { + /// Directory of per-locale notification templates. When unset, built-in + /// plain-text strings are used. When set, every locale in `template_locales` + /// must have a complete template set (validated at startup). + /// Layout: `{templates_dir}/{locale}/{id}.{subject.txt,body.txt,body.html}`. + #[config(env = "COMMS_TEMPLATES_DIR")] + pub templates_dir: Option, + + /// Locales that must have a complete on-disk template set when + /// `templates_dir` is set. Comma-separated via `COMMS_TEMPLATE_LOCALES`. + #[config(env = "COMMS_TEMPLATE_LOCALES", parse_env = split_comma_list, default = [])] + pub template_locales: Vec, + + /// When `templates_dir` is set and the user's preferred locale is not in + /// `template_locales`, fall back to built-in `STRINGS_*` (plain text only). + /// When false, enqueue fails for unsupported locales. + #[config(env = "COMMS_TEMPLATE_FALLBACK_TO_BUILTIN", default = true)] + pub fallback_to_builtin: bool, +} + +impl CommsConfig { + pub fn validate(&self, errors: &mut Vec) { + if self.templates_dir.is_some() && self.template_locales.is_empty() { + errors.push( + "comms.template_locales must be set when comms.templates_dir is set".to_string(), + ); + } + + for locale in self.template_locales.iter() { + if Locale::from_str(locale).is_err() { + errors.push(format!( + "comms.template_locales entry {locale:?} is not a valid locale" + )); + } + } + } +} + fn looks_like_email_address(s: &str) -> bool { let trimmed = s.trim(); if trimmed.is_empty() || trimmed.chars().any(char::is_whitespace) { @@ -1756,7 +1805,10 @@ port = 587 result.expect("known keys must load successfully"); } + use serial_test::serial; + #[test] + #[serial] fn serial_validate_rejects_legacy_sendmail_path() { seed_required_env(); unsafe { std::env::set_var("SENDMAIL_PATH", "/usr/sbin/sendmail") }; @@ -1777,6 +1829,7 @@ port = 587 } #[test] + #[serial] fn serial_validate_passes_when_no_legacy_env_set() { seed_required_env(); unsafe { std::env::remove_var("SENDMAIL_PATH") }; @@ -2022,4 +2075,31 @@ port = 587 }, } } + + #[test] + fn comms_validate_accepts_valid_locales() { + let mut errors = Vec::new(); + CommsConfig { + template_locales: vec!["en".to_string(), "fr".to_string()], + templates_dir: None, + fallback_to_builtin: true, + } + .validate(&mut errors); + assert!(errors.is_empty(), "expected no errors, got {errors:?}"); + } + + #[test] + fn comms_validate_rejects_invalid_locales() { + let mut errors = Vec::new(); + CommsConfig { + template_locales: vec!["en/US".to_string(), "fr".to_string()], + templates_dir: None, + fallback_to_builtin: true, + } + .validate(&mut errors); + assert!( + errors.iter().any(|e| e.contains("is not a valid locale")), + "expected invalid locale error, got {errors:?}" + ); + } } diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs index 10bb9f10..a2836519 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/two_factor.rs @@ -1,4 +1,5 @@ use super::*; +use tracing::warn; use tranquil_pds::comms::Notice; use tranquil_pds::comms::comms_repo::enqueue_notice; use uuid::Uuid; @@ -369,12 +370,28 @@ impl CodeNotice { hostname: &str, did: &Did, ) -> Result<(), NoticeDeliveryError> { + let handle_option: Option = state + .repos + .user + .get_handle_by_did(did) + .await + .unwrap_or_else(|e| { + warn!(did = %did, error = %e, "Failed to get handle by did"); + None + }); + let handle: &str = handle_option + .as_ref() + .map(|h| -> &str { h.as_str() }) + .unwrap_or(did.as_str()); match enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), self.user_id, - Notice::TwoFactorCode { code: &self.code }, - hostname, + Notice::TwoFactorCode { + hostname, + handle, + code: &self.code, + }, ) .await { diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 3bef4ad2..afcdd3aa 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -1354,8 +1354,10 @@ pub async fn complete_registration( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id.unwrap_or(uuid::Uuid::nil()), - tranquil_pds::comms::Notice::Welcome, - hostname, + tranquil_pds::comms::Notice::Welcome { + hostname, + handle: handle.as_str(), + }, ) .await { diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 0f5d7e78..e36f5b1e 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -89,6 +89,7 @@ pub enum ApiError { MissingTelegramUsername, MissingSignalNumber, InvalidVerificationChannel, + VerificationChannelRequiresBot, SelfHostedDidWebDisabled, AccountAlreadyExists, HandleNotFound, @@ -232,6 +233,7 @@ impl ApiError { | Self::MissingTelegramUsername | Self::MissingSignalNumber | Self::InvalidVerificationChannel + | Self::VerificationChannelRequiresBot | Self::SelfHostedDidWebDisabled | Self::AccountAlreadyExists | Self::TokenRequired @@ -335,6 +337,7 @@ impl ApiError { Self::MissingTelegramUsername => Cow::Borrowed("MissingTelegramUsername"), Self::MissingSignalNumber => Cow::Borrowed("MissingSignalNumber"), Self::InvalidVerificationChannel => Cow::Borrowed("InvalidVerificationChannel"), + Self::VerificationChannelRequiresBot => Cow::Borrowed("VerificationChannelRequiresBot"), Self::SelfHostedDidWebDisabled => Cow::Borrowed("SelfHostedDidWebDisabled"), Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"), Self::HandleNotFound => Cow::Borrowed("HandleNotFound"), @@ -482,6 +485,7 @@ impl ApiError { "Signal username is required when using Signal verification".into() } Self::InvalidVerificationChannel => "Invalid verification channel".into(), + Self::VerificationChannelRequiresBot => "This verification channel requires a bot".into(), Self::SelfHostedDidWebDisabled => { "Self-hosted did:web accounts are disabled on this server".into() } diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index 2cf48d2f..9d71b03d 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -1,9 +1,12 @@ mod service; pub use service::repo::Notice; -pub use service::{CommsService, VerificationTarget, recipient_for, repo as comms_repo}; +pub use service::{ + CommsService, NotificationError, VerificationTarget, recipient_for, repo as comms_repo, +}; pub use tranquil_comms::{ - CommsChannel, CommsMessageType, CommsSender, CommsStatus, DEFAULT_LOCALE, DiscordSender, - EmailSender, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, - VALID_LOCALES, format_message, get_strings, is_valid_phone_number, validate_locale, + CommsChannel, CommsMessageType, CommsSender, CommsStatus, DiscordSender, EmailSender, + NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, TemplateId, + get_strings, init_templates, is_valid_phone_number, render_template, validate_locale, + validate_templates, }; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index 8d7cea1b..ad549bba 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -7,8 +8,8 @@ use chrono::Utc; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tranquil_comms::{ - CommsChannel, CommsMessageType, CommsSender, NotificationStrings, SendError, format_message, - get_strings, + CommsChannel, CommsMessageType, CommsSender, RenderedTemplate, SendError, TemplateId, + render_template, }; use tranquil_db_traits::{ DbError, InfraRepository, QueuedComms, Recipient, UserCommsPrefs, UserRepository, @@ -271,31 +272,125 @@ fn email_recipient(prefs: &UserCommsPrefs) -> Option { .map(Recipient::Email) } +#[derive(Debug)] +pub enum NotificationError { + InvalidRequest(String), + RequiresBot, + DbError(DbError), + PreferredLocaleNotFound(DbError), + TemplateRenderingError(String), +} + +impl fmt::Display for NotificationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRequest(e) => write!(f, "Invalid request: {}", e), + Self::RequiresBot => write!(f, "Requires bot"), + Self::DbError(e) => write!(f, "DB error: {}", e), + Self::PreferredLocaleNotFound(e) => write!(f, "Preferred locale not found: {}", e), + Self::TemplateRenderingError(e) => write!(f, "Template rendering error: {}", e), + } + } +} + +impl std::error::Error for NotificationError {} + pub mod repo { use super::*; pub enum Notice<'a> { - Welcome, - PasswordReset { code: &'a str }, - TwoFactorCode { code: &'a str }, - AccountDeletion { code: &'a str }, - PlcOperation { token: &'a str }, - PasskeyRecovery { url: &'a str }, - ShortTokenEmail { token: &'a str }, - LegacyLoginAlert { channel: CommsChannel, ip: &'a str }, + Welcome { + hostname: &'a str, + handle: &'a str, + }, + PasswordReset { + hostname: &'a str, + handle: &'a str, + code: &'a str, + }, + TwoFactorCode { + hostname: &'a str, + handle: &'a str, + code: &'a str, + }, + AccountDeletion { + hostname: &'a str, + handle: &'a str, + code: &'a str, + }, + PlcOperation { + hostname: &'a str, + handle: &'a str, + token: &'a str, + }, + PasskeyRecovery { + hostname: &'a str, + handle: &'a str, + url: &'a str, + }, + ShortTokenEmail { + hostname: &'a str, + handle: &'a str, + code: &'a str, + verify_page: &'a str, + verify_link: &'a str, + }, + LegacyLoginAlert { + hostname: &'a str, + handle: &'a str, + timestamp: &'a str, + ip: &'a str, + channel: CommsChannel, // Used for logging purposes + }, + EmailUpdate { + hostname: &'a str, + handle: &'a str, + code: &'a str, + verify_page: &'a str, + verify_link: &'a str, + }, + MigrationVerification { + hostname: &'a str, + handle: &'a str, + code: &'a str, + verify_page: &'a str, + verify_link: &'a str, + }, + SignupVerification { + hostname: &'a str, + handle: &'a str, + code: &'a str, + verify_page: &'a str, + verify_link: &'a str, + }, + ChannelVerification { + hostname: &'a str, + code: &'a str, + verify_link: &'a str, + }, + ChannelVerified { + hostname: &'a str, + handle: &'a str, + channel: CommsChannel, + }, } impl Notice<'_> { fn comms_type(&self) -> CommsMessageType { match self { - Self::Welcome => CommsMessageType::Welcome, + Self::Welcome { .. } => CommsMessageType::Welcome, Self::PasswordReset { .. } => CommsMessageType::PasswordReset, Self::TwoFactorCode { .. } => CommsMessageType::TwoFactorCode, Self::AccountDeletion { .. } => CommsMessageType::AccountDeletion, Self::PlcOperation { .. } => CommsMessageType::PlcOperation, Self::PasskeyRecovery { .. } => CommsMessageType::PasskeyRecovery, - Self::ShortTokenEmail { .. } => CommsMessageType::EmailUpdate, + Self::ShortTokenEmail { .. } => CommsMessageType::EmailUpdate, // Unfortunate naming inconsistency, EmailUpdate is the correct name for this. Self::LegacyLoginAlert { .. } => CommsMessageType::LegacyLoginAlert, + Self::EmailUpdate { .. } => CommsMessageType::EmailUpdate, + Self::MigrationVerification { .. } => CommsMessageType::MigrationVerification, + Self::SignupVerification { .. } => CommsMessageType::EmailVerification, + Self::ChannelVerification { .. } => CommsMessageType::ChannelVerification, + Self::ChannelVerified { .. } => CommsMessageType::ChannelVerified, } } @@ -308,68 +403,174 @@ pub mod repo { } } - fn subject(&self, strings: &NotificationStrings) -> &'static str { - match self { - Self::Welcome => strings.welcome_subject, - Self::PasswordReset { .. } => strings.password_reset_subject, - Self::TwoFactorCode { .. } => strings.two_factor_code_subject, - Self::AccountDeletion { .. } => strings.account_deletion_subject, - Self::PlcOperation { .. } => strings.plc_operation_subject, - Self::PasskeyRecovery { .. } => strings.passkey_recovery_subject, - Self::ShortTokenEmail { .. } => strings.email_update_subject, - Self::LegacyLoginAlert { .. } => strings.legacy_login_subject, - } - } - - fn body(&self, strings: &NotificationStrings, handle: &str, hostname: &str) -> String { - match self { - Self::Welcome => format_message( - strings.welcome_body, + fn rendered(&self, locale: &str) -> Result { + let result = match self { + Self::Welcome { hostname, handle } => render_template( + TemplateId::Welcome, + locale, &[("hostname", hostname), ("handle", handle)], ), - Self::PasswordReset { code } => format_message( - strings.password_reset_body, - &[("handle", handle), ("code", code)], + Self::PasswordReset { + hostname, + handle, + code, + } => render_template( + TemplateId::PasswordReset, + locale, + &[("hostname", hostname), ("handle", handle), ("code", code)], ), - Self::TwoFactorCode { code } => format_message( - strings.two_factor_code_body, - &[("handle", handle), ("code", code)], + Self::TwoFactorCode { + hostname, + handle, + code, + } => render_template( + TemplateId::TwoFactorCode, + locale, + &[("hostname", hostname), ("handle", handle), ("code", code)], ), - Self::AccountDeletion { code } => format_message( - strings.account_deletion_body, - &[("handle", handle), ("code", code)], + Self::AccountDeletion { + hostname, + handle, + code, + } => render_template( + TemplateId::AccountDeletion, + locale, + &[("hostname", hostname), ("handle", handle), ("code", code)], ), - Self::PlcOperation { token } => format_message( - strings.plc_operation_body, - &[("handle", handle), ("token", token)], + Self::PlcOperation { + hostname, + handle, + token, + } => render_template( + TemplateId::PlcOperation, + locale, + &[("hostname", hostname), ("handle", handle), ("token", token)], ), - Self::PasskeyRecovery { url } => format_message( - strings.passkey_recovery_body, - &[("handle", handle), ("url", url)], + Self::PasskeyRecovery { + hostname, + handle, + url, + } => render_template( + TemplateId::PasskeyRecovery, + locale, + &[("hostname", hostname), ("handle", handle), ("url", url)], ), - Self::ShortTokenEmail { token } => { - let verify_page = format!("https://{hostname}/app/settings"); - format_message( - strings.short_token_body, - &[ - ("handle", handle), - ("code", token), - ("verify_page", &verify_page), - ], - ) - } - Self::LegacyLoginAlert { ip, .. } => { - let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); - format_message( - strings.legacy_login_body, - &[ - ("handle", handle), - ("timestamp", ×tamp), - ("ip", ip), - ("hostname", hostname), - ], - ) - } + Self::ShortTokenEmail { + hostname, + handle, + code, + verify_page, + verify_link, + } => render_template( + TemplateId::EmailUpdate, + locale, + &[ + ("hostname", hostname), + ("handle", handle), + ("code", code), + ("verify_page", verify_page), + ("verify_link", verify_link), + ], + ), + Self::LegacyLoginAlert { + #[allow(unused)] + channel, + hostname, + handle, + timestamp, + ip, + } => render_template( + TemplateId::LegacyLogin, + locale, + &[ + ("handle", handle), + ("timestamp", timestamp), + ("ip", ip), + ("hostname", hostname), + ], + ), + Self::EmailUpdate { + hostname, + handle, + code, + verify_page, + verify_link, + } => render_template( + TemplateId::EmailUpdate, + locale, + &[ + ("hostname", hostname), + ("handle", handle), + ("code", code), + ("verify_page", verify_page), + ("verify_link", verify_link), + ], + ), + Self::MigrationVerification { + hostname, + handle, + code, + verify_page, + verify_link, + } => render_template( + TemplateId::MigrationVerification, + locale, + &[ + ("hostname", hostname), + ("handle", handle), + ("code", code), + ("verify_page", verify_page), + ("verify_link", verify_link), + ], + ), + Self::SignupVerification { + hostname, + handle, + code, + verify_page, + verify_link, + } => render_template( + TemplateId::SignupVerification, + locale, + &[ + ("hostname", hostname), + ("handle", handle), + ("code", code), + ("verify_page", verify_page), + ("verify_link", verify_link), + ], + ), + Self::ChannelVerification { + hostname, + code, + verify_link, + } => render_template( + TemplateId::ChannelVerification, + locale, + &[ + ("hostname", hostname), + ("code", code), + ("verify_link", verify_link), + ], + ), + Self::ChannelVerified { + hostname, + handle, + channel, + } => render_template( + TemplateId::ChannelVerified, + locale, + &[ + ("hostname", hostname), + ("handle", handle), + ("channel", channel.display_name()), + ], + ), + }; + + match result { + Ok(template) => Ok(template), + Err(e) => Err(NotificationError::TemplateRenderingError(e.to_string())), } } } @@ -379,12 +580,9 @@ pub mod repo { infra_repo: &dyn InfraRepository, user_id: Uuid, notice: Notice<'_>, - hostname: &str, - ) -> Result, DbError> { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; + ) -> Result, NotificationError> { + // TODO extract this to a function + let prefs = get_comms_prefs(user_repo, user_id).await?; let channel = notice.channel().unwrap_or(prefs.preferred_channel); let Some(recipient) = recipient_for(&prefs, channel) else { warn!( @@ -394,19 +592,19 @@ pub mod repo { ); return Ok(None); }; - let strings = get_strings(locale_of(&prefs)); - let subject = format_message(notice.subject(strings), &[("hostname", hostname)]); - let body = notice.body(strings, prefs.handle.as_str(), hostname); + let locale: &str = locale_of(&prefs); + let rendered = notice.rendered(locale)?; infra_repo .enqueue_comms( Some(user_id), &recipient, notice.comms_type(), - Some(&subject), - &body, - None, + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), ) .await + .map_err(NotificationError::DbError) .map(Some) } @@ -414,6 +612,20 @@ pub mod repo { prefs.preferred_locale.as_deref().unwrap_or("en") } + pub async fn get_comms_prefs( + user_repo: &dyn UserRepository, + user_id: Uuid, + ) -> Result { + let user = user_repo.get_comms_prefs(user_id).await; + match user { + Ok(Some(prefs)) => Ok(prefs), + Ok(None) => Err(NotificationError::PreferredLocaleNotFound( + DbError::NotFound, + )), + Err(e) => Err(NotificationError::PreferredLocaleNotFound(e)), + } + } + pub async fn enqueue_email_update( infra_repo: &dyn InfraRepository, user_id: Uuid, @@ -421,35 +633,34 @@ pub mod repo { handle: &crate::types::Handle, code: &str, hostname: &str, - ) -> Result { - let strings = get_strings("en"); - let encoded_email = urlencoding::encode(new_email.as_str()); - let encoded_token = urlencoding::encode(code); - let verify_page = format!("https://{}/app/verify", hostname); + ) -> Result { + // TODO support user locale for email update + let locale: &str = "en"; + let verify_page = format!("https://{hostname}/app/verify?type=email-update"); let verify_link = format!( - "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_email + "https://{}/app/verify?type=email-update&token={}", + hostname, + urlencoding::encode(code) ); - let body = format_message( - strings.email_update_body, - &[ - ("handle", handle.as_str()), - ("code", code), - ("verify_page", &verify_page), - ("verify_link", &verify_link), - ], - ); - let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); + let notice = Notice::EmailUpdate { + hostname, + handle: handle.as_str(), + code, + verify_page: &verify_page, + verify_link: &verify_link, + }; + let rendered = notice.rendered(locale)?; infra_repo .enqueue_comms( Some(user_id), &Recipient::Email(new_email.clone()), - CommsMessageType::EmailUpdate, - Some(&subject), - &body, - None, + notice.comms_type(), + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), ) .await + .map_err(NotificationError::DbError) } pub async fn enqueue_migration_verification( @@ -459,42 +670,35 @@ pub mod repo { target: &VerificationTarget, token: &str, hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(locale_of(&prefs)); - let encoded_id = urlencoding::encode(&target.id); - let encoded_token = urlencoding::encode(token); - let verify_page = format!("https://{}/app/verify", hostname); + ) -> Result { + let prefs = get_comms_prefs(user_repo, user_id).await?; + let locale: &str = locale_of(&prefs); + let verify_page = format!("https://{hostname}/app/verify"); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_id - ); - let body = format_message( - strings.migration_verification_body, - &[ - ("code", token), - ("hostname", hostname), - ("verify_page", &verify_page), - ("verify_link", &verify_link), - ], - ); - let subject = format_message( - strings.migration_verification_subject, - &[("hostname", hostname)], + hostname, + urlencoding::encode(token), + urlencoding::encode(&target.id) ); + let notice = Notice::MigrationVerification { + hostname, + handle: target.recipient.as_str(), + code: token, + verify_page: &verify_page, + verify_link: &verify_link, + }; + let rendered = notice.rendered(locale)?; infra_repo .enqueue_comms( Some(user_id), &target.recipient, - CommsMessageType::MigrationVerification, - Some(&subject), - &body, - None, + notice.comms_type(), + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), ) .await + .map_err(NotificationError::DbError) } pub async fn enqueue_signup_verification( @@ -504,46 +708,81 @@ pub mod repo { target: &VerificationTarget, code: &str, hostname: &str, - ) -> Result { - let prefs = match user_repo.get_comms_prefs(user_id).await { - Ok(p) => p, - Err(e) => { - tracing::warn!(user_id = %user_id, error = %e, "failed to fetch comms preferences, using defaults"); - None - } - }; - let locale = prefs.as_ref().map(locale_of).unwrap_or("en"); - let strings = get_strings(locale); - let encoded_token = urlencoding::encode(code); - let encoded_id = urlencoding::encode(&target.id); - let verify_page = format!("https://{}/app/verify", hostname); + ) -> Result { + let prefs = get_comms_prefs(user_repo, user_id).await?; + let locale = locale_of(&prefs); + let verify_page = format!("https://{hostname}/app/verify"); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_id - ); - let body = format_message( - strings.signup_verification_body, - &[ - ("code", code), - ("hostname", hostname), - ("verify_page", &verify_page), - ("verify_link", &verify_link), - ], - ); - let subject = format_message( - strings.signup_verification_subject, - &[("hostname", hostname)], + hostname, + urlencoding::encode(code), + urlencoding::encode(&target.id) ); + let notice = Notice::SignupVerification { + hostname, + handle: target.recipient.as_str(), + code, + verify_page: &verify_page, + verify_link: &verify_link, + }; + let rendered = notice.rendered(locale)?; infra_repo .enqueue_comms( Some(user_id), &target.recipient, - CommsMessageType::EmailVerification, - Some(&subject), - &body, - None, + notice.comms_type(), + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), ) .await + .map_err(NotificationError::DbError) + } + + pub async fn enqueue_channel_verification( + user_repo: &dyn UserRepository, + infra_repo: &dyn InfraRepository, + user_id: Uuid, + target: &VerificationTarget, + code: &str, + hostname: &str, + channel: CommsChannel, + ) -> Result { + if channel.verifies_via_bot() { + warn!( + user_id = %user_id, + channel = ?channel, + "This channel verifies through a bot. Please message the bot first so that it's able to reply with useful info for channel {}", + channel.display_name(), + ); + return Err(NotificationError::RequiresBot); + } + let prefs = get_comms_prefs(user_repo, user_id).await?; + let locale: &str = locale_of(&prefs); + // TODO clean up identifier encoding within the rendering + let encoded_token = urlencoding::encode(code); + let encoded_id = urlencoding::encode(&target.id); + let verify_link = format!( + "https://{}/app/verify?token={}&identifier={}", + hostname, encoded_token, encoded_id + ); + let notice = Notice::ChannelVerification { + hostname, + code, + verify_link: &verify_link, + }; + let rendered = notice.rendered(locale)?; + infra_repo + .enqueue_comms( + Some(user_id), + &target.recipient, + notice.comms_type(), + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), + ) + .await + .map_err(NotificationError::DbError) } pub async fn enqueue_channel_verified( @@ -552,31 +791,27 @@ pub mod repo { user_id: Uuid, recipient: &Recipient, hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(locale_of(&prefs)); - let body = format_message( - strings.channel_verified_body, - &[ - ("handle", &prefs.handle), - ("channel", recipient.channel().display_name()), - ("hostname", hostname), - ], - ); - let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]); + channel: CommsChannel, + ) -> Result { + let prefs = get_comms_prefs(user_repo, user_id).await?; + let locale: &str = locale_of(&prefs); + let notice = Notice::ChannelVerified { + hostname, + handle: prefs.handle.as_str(), + channel, + }; + let rendered = notice.rendered(locale)?; infra_repo .enqueue_comms( Some(user_id), recipient, - CommsMessageType::ChannelVerified, - Some(&subject), - &body, - None, + notice.comms_type(), + Some(&rendered.subject), + &rendered.body, + rendered.metadata(), ) .await + .map_err(NotificationError::DbError) } pub async fn try_channel_verified_notice( @@ -585,13 +820,129 @@ pub mod repo { user_id: Uuid, recipient: &Recipient, hostname: &str, + channel: CommsChannel, ) { if let Err(e) = - enqueue_channel_verified(user_repo, infra_repo, user_id, recipient, hostname).await + enqueue_channel_verified(user_repo, infra_repo, user_id, recipient, hostname, channel) + .await { warn!(error = %e, "Failed to enqueue channel verified notification"); } } + + #[cfg(test)] + mod notice_render_tests { + use super::Notice; + + fn builtins() { + let _ = tranquil_comms::init_templates(&tranquil_config::CommsConfig { + templates_dir: None, + template_locales: Vec::new(), + fallback_to_builtin: true, + }); + } + + fn assert_page_and_link(body: &str, page: &str, link: &str) { + assert!(body.contains(page), "{body}"); + assert!(body.contains(link), "{body}"); + assert!(!body.contains("{{"), "{body}"); + } + + #[test] + fn email_update_substitutes_verify_page_and_link() { + builtins(); + let page = "https://example.com/app/verify?type=email-update"; + let link = "https://example.com/app/verify?type=email-update&token=abc"; + let notice = Notice::EmailUpdate { + hostname: "example.com", + handle: "alice", + code: "abc", + verify_page: page, + verify_link: link, + }; + assert_page_and_link(¬ice.rendered("en").unwrap().body, page, link); + assert!( + notice + .rendered("en") + .unwrap() + .subject + .contains("example.com") + ); + } + + #[test] + fn short_token_email_substitutes_verify_page_and_link() { + builtins(); + let page = "https://example.com/app/verify?type=email-update"; + let link = "https://example.com/app/verify?type=email-update&token=abc"; + let notice = Notice::ShortTokenEmail { + hostname: "example.com", + handle: "alice", + code: "abc", + verify_page: page, + verify_link: link, + }; + assert_page_and_link(¬ice.rendered("en").unwrap().body, page, link); + assert!(notice.rendered("en").unwrap().body.contains("abc")); + } + + #[test] + fn migration_verification_substitutes_verify_page_and_link() { + builtins(); + let page = "https://example.com/app/verify"; + let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com"; + let notice = Notice::MigrationVerification { + hostname: "example.com", + handle: "alice", + code: "abc", + verify_page: page, + verify_link: link, + }; + let body = notice.rendered("en").unwrap().body; + assert_page_and_link(&body, page, link); + assert!(body.contains("example.com")); + assert!(body.contains("abc")); + } + + #[test] + fn signup_verification_substitutes_verify_page_and_link() { + builtins(); + let page = "https://example.com/app/verify"; + let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com"; + let notice = Notice::SignupVerification { + hostname: "example.com", + handle: "alice", + code: "abc", + verify_page: page, + verify_link: link, + }; + let body = notice.rendered("en").unwrap().body; + assert_page_and_link(&body, page, link); + assert!(body.contains("example.com")); + assert!( + notice + .rendered("en") + .unwrap() + .subject + .contains("example.com") + ); + } + + #[test] + fn channel_verification_substitutes_verify_link() { + builtins(); + let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com"; + let notice = Notice::ChannelVerification { + hostname: "example.com", + code: "abc", + verify_link: link, + }; + let body = notice.rendered("en").unwrap().body; + assert!(body.contains(link), "{body}"); + assert!(body.contains("abc"), "{body}"); + assert!(!body.contains("{{"), "{body}"); + } + } } #[cfg(test)] diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 204cf8bf..1a5b879b 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -593,6 +593,11 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance { std::env::set_var("PDS_HOSTNAME", format!("pds.test:{}", addr.port())); } tranquil_config::ensure_test_defaults(); + let _ = tranquil_pds::comms::init_templates(&tranquil_config::CommsConfig { + templates_dir: None, + template_locales: Vec::new(), + fallback_to_builtin: true, + }); let rate_limiters = RateLimiters::new() .with_login_limit(10000) .with_account_creation_limit(10000) diff --git a/crates/tranquil-pds/tests/it/account_notifications.rs b/crates/tranquil-pds/tests/it/account_notifications.rs index 6f80cb1a..981dfde6 100644 --- a/crates/tranquil-pds/tests/it/account_notifications.rs +++ b/crates/tranquil-pds/tests/it/account_notifications.rs @@ -260,7 +260,7 @@ async fn test_update_email_via_notification_prefs() { let comms = repos .infra - .get_latest_comms_for_user(user_id, CommsMessageType::EmailUpdate, 1) + .get_latest_comms_for_user(user_id, CommsMessageType::ChannelVerification, 1) .await .expect("DB error"); let body_text = comms diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index 12c028ba..da2eccd0 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -63,6 +63,10 @@ async fn main() -> ExitCode { eprint!("{e}"); return ExitCode::FAILURE; } + if let Err(e) = tranquil_pds::comms::validate_templates(&config.comms) { + eprintln!("Notification templates invalid: {e}"); + return ExitCode::FAILURE; + } if let Some(key) = config.secrets.plc_rotation_key.as_deref() && let Err(reason) = tranquil_pds::plc::validate_rotation_did_key(key) { @@ -219,6 +223,11 @@ async fn run() -> Result<(), Box> { let cfg = tranquil_config::get(); + if let Err(e) = tranquil_pds::comms::init_templates(&cfg.comms) { + error!(error = %e, "Notification template configuration invalid"); + return Err(e.into()); + } + match EmailSender::from_config(cfg) { Ok(Some(email_sender)) => { info!("Email comms enabled"); diff --git a/example.toml b/example.toml index 30f03c81..89452c35 100644 --- a/example.toml +++ b/example.toml @@ -585,6 +585,32 @@ # Can also be specified via environment variable `MAIL_DKIM_KEY_PATH`. #private_key_path = +[comms] +# Directory of per-locale notification templates. When unset, built-in +# plain-text strings are used. When set, every locale in `template_locales` +# must have a complete template set (validated at startup). +# Layout: `{templates_dir}/{locale}/{id}.{subject.txt,body.txt,body.html}`. +# +# Can also be specified via environment variable `COMMS_TEMPLATES_DIR`. +#templates_dir = + +# Locales that must have a complete on-disk template set when +# `templates_dir` is set. Comma-separated via `COMMS_TEMPLATE_LOCALES`. +# +# Can also be specified via environment variable `COMMS_TEMPLATE_LOCALES`. +# +# Default value: [] +#template_locales = [] + +# When `templates_dir` is set and the user's preferred locale is not in +# `template_locales`, fall back to built-in `STRINGS_*` (plain text only). +# When false, enqueue fails for unsupported locales. +# +# Can also be specified via environment variable `COMMS_TEMPLATE_FALLBACK_TO_BUILTIN`. +# +# Default value: true +#fallback_to_builtin = true + [discord] # Discord bot token. When unset, Discord integration is disabled. # diff --git a/examples/notification-templates/README.md b/examples/notification-templates/README.md new file mode 100644 index 00000000..0cd52772 --- /dev/null +++ b/examples/notification-templates/README.md @@ -0,0 +1,27 @@ +# Notification templates + +Channel-neutral templates used by every comms channel (email, Telegram, Discord, +Signal). The same `body.txt` renders the message content for each channel; the +optional `body.html` is used only for the email HTML part (via queue metadata). + +Layout expected by tranquil-pds when `comms.templates_dir` is set: + +``` +{templates_dir}/{locale}/{id}.subject.txt +{templates_dir}/{locale}/{id}.body.txt +{templates_dir}/{locale}/{id}.body.html +``` + +Templates use [Handlebars](https://handlebarsjs.com/) syntax (`{{hostname}}`, +`{{handle}}`, `{{code}}`, and so on). Built-in notification strings use the same +syntax. Plain-text templates support the full expression set (`{{#if}}`, +`{{else if}}`, `{{#unless}}`, `{{#with}}`); HTML templates HTML-escape +interpolated values by default, while subjects and plain-text bodies do not. + +Required ids: welcome, password_reset, email_update, short_token, account_deletion, +plc_operation, two_factor_code, passkey_recovery, signup_verification, legacy_login, +migration_verification, channel_verification, channel_verified. + +The `en/` directory is a complete example set you can copy and brand. It shows +conditionals in both plain text and HTML, e.g. `email_update` only renders the +one-click verify link when `verify_link` is provided. diff --git a/examples/notification-templates/en/account_deletion.body.html b/examples/notification-templates/en/account_deletion.body.html new file mode 100644 index 00000000..a2c15599 --- /dev/null +++ b/examples/notification-templates/en/account_deletion.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


Your account deletion confirmation code is: {{code}}


This code will expire in 10 minutes.


If you did not request this, please secure your account immediately.

+
+
+ + + diff --git a/examples/notification-templates/en/account_deletion.body.txt b/examples/notification-templates/en/account_deletion.body.txt new file mode 100644 index 00000000..c52ad307 --- /dev/null +++ b/examples/notification-templates/en/account_deletion.body.txt @@ -0,0 +1,7 @@ +Hello @{{handle}}, + +Your account deletion confirmation code is: {{code}} + +This code will expire in 10 minutes. + +If you did not request this, please secure your account immediately. diff --git a/examples/notification-templates/en/account_deletion.subject.txt b/examples/notification-templates/en/account_deletion.subject.txt new file mode 100644 index 00000000..21ba2b81 --- /dev/null +++ b/examples/notification-templates/en/account_deletion.subject.txt @@ -0,0 +1 @@ +Account Deletion Request - {{hostname}} diff --git a/examples/notification-templates/en/channel_verification.body.html b/examples/notification-templates/en/channel_verification.body.html new file mode 100644 index 00000000..a7afb049 --- /dev/null +++ b/examples/notification-templates/en/channel_verification.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Your verification code is:

{{code}}


Or verify directly:

{{{verify_link}}}

+
+
+ + + diff --git a/examples/notification-templates/en/channel_verification.body.txt b/examples/notification-templates/en/channel_verification.body.txt new file mode 100644 index 00000000..ba564326 --- /dev/null +++ b/examples/notification-templates/en/channel_verification.body.txt @@ -0,0 +1,5 @@ +Your verification code is: +{{code}} + +Or verify directly: +{{{verify_link}}} diff --git a/examples/notification-templates/en/channel_verification.subject.txt b/examples/notification-templates/en/channel_verification.subject.txt new file mode 100644 index 00000000..e392162d --- /dev/null +++ b/examples/notification-templates/en/channel_verification.subject.txt @@ -0,0 +1 @@ +Verify your channel - {{hostname}} diff --git a/examples/notification-templates/en/channel_verified.body.html b/examples/notification-templates/en/channel_verified.body.html new file mode 100644 index 00000000..bf7b3117 --- /dev/null +++ b/examples/notification-templates/en/channel_verified.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

{{#if handle}}Hello @{{handle}},{{else}}Hello,{{/if}}

+

{{#if channel}}{{channel}}{{else}}A channel{{/if}} has been verified as a notification channel for your account on {{hostname}}.

+
+
+ + diff --git a/examples/notification-templates/en/channel_verified.body.txt b/examples/notification-templates/en/channel_verified.body.txt new file mode 100644 index 00000000..c920be87 --- /dev/null +++ b/examples/notification-templates/en/channel_verified.body.txt @@ -0,0 +1,3 @@ +Hello {{#if handle}}@{{handle}}{{else}}there{{/if}}, + +{{channel}} has been verified as a notification channel for your account on {{hostname}}. diff --git a/examples/notification-templates/en/channel_verified.subject.txt b/examples/notification-templates/en/channel_verified.subject.txt new file mode 100644 index 00000000..d0d0dfdf --- /dev/null +++ b/examples/notification-templates/en/channel_verified.subject.txt @@ -0,0 +1 @@ +Channel verified - {{hostname}} diff --git a/examples/notification-templates/en/email_update.body.html b/examples/notification-templates/en/email_update.body.html new file mode 100644 index 00000000..2c1cc320 --- /dev/null +++ b/examples/notification-templates/en/email_update.body.html @@ -0,0 +1,38 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},

+

Your verification code is:

+

{{code}}

+

Copy the code above and enter it at:

+

{{verify_page}}

+

This code will expire in 10 minutes.

+ {{#if verify_link}} +
+

Or, if you like to live dangerously, verify in one click:

+

{{{verify_link}}}

+ {{/if}} +
+

If you did not request this, please ignore this email.

+
+
+ + diff --git a/examples/notification-templates/en/email_update.body.txt b/examples/notification-templates/en/email_update.body.txt new file mode 100644 index 00000000..86ff31a7 --- /dev/null +++ b/examples/notification-templates/en/email_update.body.txt @@ -0,0 +1,16 @@ +Hello @{{handle}}, + +Your verification code is: +{{code}} + +Copy the code above and enter it at: +{{verify_page}} + +This code will expire in 10 minutes. + +{{#if verify_link}} +Or, if you like to live dangerously, verify in one click: +{{{verify_link}}} +{{/if}} + +If you did not request this, please ignore this email. diff --git a/examples/notification-templates/en/email_update.subject.txt b/examples/notification-templates/en/email_update.subject.txt new file mode 100644 index 00000000..0850caa4 --- /dev/null +++ b/examples/notification-templates/en/email_update.subject.txt @@ -0,0 +1 @@ +Confirm your new email - {{hostname}} diff --git a/examples/notification-templates/en/legacy_login.body.html b/examples/notification-templates/en/legacy_login.body.html new file mode 100644 index 00000000..9e7f28e6 --- /dev/null +++ b/examples/notification-templates/en/legacy_login.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.


Details:

- Time: {{timestamp}}

- IP Address: {{ip}}


Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.


If this wasn't you, please:

1. Change your password immediately

2. Review your active sessions

3. Consider disabling legacy app logins in your security settings


Stay safe,

{{hostname}}

+
+
+ + + diff --git a/examples/notification-templates/en/legacy_login.body.txt b/examples/notification-templates/en/legacy_login.body.txt new file mode 100644 index 00000000..20c33a3d --- /dev/null +++ b/examples/notification-templates/en/legacy_login.body.txt @@ -0,0 +1,20 @@ +Hello @{{handle}}, + +A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification. + +{{#if timestamp}} +Time: {{timestamp}} +{{/if}} +{{#if ip}} +IP Address: {{ip}} +{{/if}} + +Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations. + +If this wasn't you, please: +1. Change your password immediately +2. Review your active sessions +3. Consider disabling legacy app logins in your security settings + +Stay safe, +{{hostname}} diff --git a/examples/notification-templates/en/legacy_login.subject.txt b/examples/notification-templates/en/legacy_login.subject.txt new file mode 100644 index 00000000..db64e67e --- /dev/null +++ b/examples/notification-templates/en/legacy_login.subject.txt @@ -0,0 +1 @@ +Security Alert: Legacy Login Detected - {{hostname}} diff --git a/examples/notification-templates/en/migration_verification.body.html b/examples/notification-templates/en/migration_verification.body.html new file mode 100644 index 00000000..e01d5fa6 --- /dev/null +++ b/examples/notification-templates/en/migration_verification.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Welcome to {{hostname}}!


Your account has been migrated successfully. To complete the setup, please verify your email address.


Your verification code is:

{{code}}


Copy the code above and enter it at:

{{verify_page}}


This code will expire in 48 hours.


Or if you like to live dangerously:

{{{verify_link}}}


If you did not migrate your account, please ignore this email.

+
+
+ + + diff --git a/examples/notification-templates/en/migration_verification.body.txt b/examples/notification-templates/en/migration_verification.body.txt new file mode 100644 index 00000000..c9873876 --- /dev/null +++ b/examples/notification-templates/en/migration_verification.body.txt @@ -0,0 +1,16 @@ +Welcome to {{hostname}}! + +Your account has been migrated successfully. To complete the setup, please verify your email address. + +Your verification code is: +{{code}} + +Copy the code above and enter it at: +{{verify_page}} + +This code will expire in 48 hours. + +Or if you like to live dangerously: +{{{verify_link}}} + +If you did not migrate your account, please ignore this email. diff --git a/examples/notification-templates/en/migration_verification.subject.txt b/examples/notification-templates/en/migration_verification.subject.txt new file mode 100644 index 00000000..c4a802c0 --- /dev/null +++ b/examples/notification-templates/en/migration_verification.subject.txt @@ -0,0 +1 @@ +Verify your email - {{hostname}} diff --git a/examples/notification-templates/en/passkey_recovery.body.html b/examples/notification-templates/en/passkey_recovery.body.html new file mode 100644 index 00000000..24012552 --- /dev/null +++ b/examples/notification-templates/en/passkey_recovery.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


You requested to recover your passkey-only account.


Click the link below to set a temporary password and regain access:

{{url}}


This link will expire in 1 hour.


If you did not request this, please ignore this message. Your account remains secure.

+
+
+ + + diff --git a/examples/notification-templates/en/passkey_recovery.body.txt b/examples/notification-templates/en/passkey_recovery.body.txt new file mode 100644 index 00000000..762a6a95 --- /dev/null +++ b/examples/notification-templates/en/passkey_recovery.body.txt @@ -0,0 +1,10 @@ +Hello @{{handle}}, + +You requested to recover your passkey-only account. + +Click the link below to set a temporary password and regain access: +{{url}} + +This link will expire in 1 hour. + +If you did not request this, please ignore this message. Your account remains secure. diff --git a/examples/notification-templates/en/passkey_recovery.subject.txt b/examples/notification-templates/en/passkey_recovery.subject.txt new file mode 100644 index 00000000..b75987a3 --- /dev/null +++ b/examples/notification-templates/en/passkey_recovery.subject.txt @@ -0,0 +1 @@ +Account Recovery - {{hostname}} diff --git a/examples/notification-templates/en/password_reset.body.html b/examples/notification-templates/en/password_reset.body.html new file mode 100644 index 00000000..df878b10 --- /dev/null +++ b/examples/notification-templates/en/password_reset.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


Your password reset code is: {{code}}


This code will expire in 10 minutes.


If you did not request this, please ignore this message.

+
+
+ + + diff --git a/examples/notification-templates/en/password_reset.body.txt b/examples/notification-templates/en/password_reset.body.txt new file mode 100644 index 00000000..ee6ad53a --- /dev/null +++ b/examples/notification-templates/en/password_reset.body.txt @@ -0,0 +1,7 @@ +Hello @{{handle}}, + +Your password reset code is: {{code}} + +This code will expire in 10 minutes. + +If you did not request this, please ignore this message. diff --git a/examples/notification-templates/en/password_reset.subject.txt b/examples/notification-templates/en/password_reset.subject.txt new file mode 100644 index 00000000..8fe70fc0 --- /dev/null +++ b/examples/notification-templates/en/password_reset.subject.txt @@ -0,0 +1 @@ +Password Reset - {{hostname}} diff --git a/examples/notification-templates/en/plc_operation.body.html b/examples/notification-templates/en/plc_operation.body.html new file mode 100644 index 00000000..457997d5 --- /dev/null +++ b/examples/notification-templates/en/plc_operation.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


You requested to sign a PLC operation for your account.


Your verification token is: {{token}}


This token will expire in 10 minutes.


If you did not request this, you can safely ignore this message.

+
+
+ + + diff --git a/examples/notification-templates/en/plc_operation.body.txt b/examples/notification-templates/en/plc_operation.body.txt new file mode 100644 index 00000000..fa0521cb --- /dev/null +++ b/examples/notification-templates/en/plc_operation.body.txt @@ -0,0 +1,9 @@ +Hello @{{handle}}, + +You requested to sign a PLC operation for your account. + +Your verification token is: {{token}} + +This token will expire in 10 minutes. + +If you did not request this, you can safely ignore this message. diff --git a/examples/notification-templates/en/plc_operation.subject.txt b/examples/notification-templates/en/plc_operation.subject.txt new file mode 100644 index 00000000..450fb573 --- /dev/null +++ b/examples/notification-templates/en/plc_operation.subject.txt @@ -0,0 +1 @@ +{{hostname}} - PLC Operation Token diff --git a/examples/notification-templates/en/short_token.body.html b/examples/notification-templates/en/short_token.body.html new file mode 100644 index 00000000..a6efef40 --- /dev/null +++ b/examples/notification-templates/en/short_token.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


Your verification code is:

{{code}}


This code will expire in 15 minutes.


If you did not request this, please ignore this email.

+
+
+ + + diff --git a/examples/notification-templates/en/short_token.body.txt b/examples/notification-templates/en/short_token.body.txt new file mode 100644 index 00000000..e532374d --- /dev/null +++ b/examples/notification-templates/en/short_token.body.txt @@ -0,0 +1,8 @@ +Hello @{{handle}}, + +Your verification code is: +{{code}} + +This code will expire in 15 minutes. + +If you did not request this, please ignore this email. diff --git a/examples/notification-templates/en/short_token.subject.txt b/examples/notification-templates/en/short_token.subject.txt new file mode 100644 index 00000000..0850caa4 --- /dev/null +++ b/examples/notification-templates/en/short_token.subject.txt @@ -0,0 +1 @@ +Confirm your new email - {{hostname}} diff --git a/examples/notification-templates/en/signup_verification.body.html b/examples/notification-templates/en/signup_verification.body.html new file mode 100644 index 00000000..6dbc497d --- /dev/null +++ b/examples/notification-templates/en/signup_verification.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Welcome! Your verification code is:

{{code}}


Copy the code above and enter it at:

{{verify_page}}


This code will expire in 30 minutes.


Or if you like to live dangerously:

{{{verify_link}}}


If you did not create an account on {{hostname}}, please ignore this message.

+
+
+ + + diff --git a/examples/notification-templates/en/signup_verification.body.txt b/examples/notification-templates/en/signup_verification.body.txt new file mode 100644 index 00000000..70af93f4 --- /dev/null +++ b/examples/notification-templates/en/signup_verification.body.txt @@ -0,0 +1,14 @@ +Welcome! Your verification code is: +{{code}} + +{{#if verify_page}} +Copy the code above and enter it at: +{{verify_page}} +{{else if verify_link}} +Or verify directly: +{{{verify_link}}} +{{/if}} + +This code will expire in 30 minutes. + +If you did not create an account on {{hostname}}, please ignore this message. diff --git a/examples/notification-templates/en/signup_verification.subject.txt b/examples/notification-templates/en/signup_verification.subject.txt new file mode 100644 index 00000000..f8f45ce8 --- /dev/null +++ b/examples/notification-templates/en/signup_verification.subject.txt @@ -0,0 +1 @@ +Verify your account - {{hostname}} diff --git a/examples/notification-templates/en/two_factor_code.body.html b/examples/notification-templates/en/two_factor_code.body.html new file mode 100644 index 00000000..b27ca1f0 --- /dev/null +++ b/examples/notification-templates/en/two_factor_code.body.html @@ -0,0 +1,27 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

Hello @{{handle}},


Your sign-in verification code is: {{code}}


This code will expire in 10 minutes.


If you did not request this, please secure your account immediately.

+
+
+ + + diff --git a/examples/notification-templates/en/two_factor_code.body.txt b/examples/notification-templates/en/two_factor_code.body.txt new file mode 100644 index 00000000..ad55c7e4 --- /dev/null +++ b/examples/notification-templates/en/two_factor_code.body.txt @@ -0,0 +1,7 @@ +Hello @{{handle}}, + +Your sign-in verification code is: {{code}} + +This code will expire in 10 minutes. + +If you did not request this, please secure your account immediately. diff --git a/examples/notification-templates/en/two_factor_code.subject.txt b/examples/notification-templates/en/two_factor_code.subject.txt new file mode 100644 index 00000000..4a277094 --- /dev/null +++ b/examples/notification-templates/en/two_factor_code.subject.txt @@ -0,0 +1 @@ +Sign-in Verification - {{hostname}} diff --git a/examples/notification-templates/en/welcome.body.html b/examples/notification-templates/en/welcome.body.html new file mode 100644 index 00000000..2f7292a2 --- /dev/null +++ b/examples/notification-templates/en/welcome.body.html @@ -0,0 +1,34 @@ + + + + + + {{hostname}} + + + + + + +
+ + + + + + + +
{{hostname}}
+

{{#if handle}}Welcome to {{hostname}}, @{{handle}}!{{else}}Welcome to {{hostname}}!{{/if}}

+
+ {{#if handle}} +

Your handle is: @{{handle}}

+ {{else}} +

Set your handle to personalize your account.

+ {{/if}} +
+

Thank you for joining us.

+
+
+ + diff --git a/examples/notification-templates/en/welcome.body.txt b/examples/notification-templates/en/welcome.body.txt new file mode 100644 index 00000000..8da29b3a --- /dev/null +++ b/examples/notification-templates/en/welcome.body.txt @@ -0,0 +1,12 @@ +Welcome to {{hostname}}{{#if handle}}, @{{handle}}{{/if}}! + +Thanks for joining us. + +{{#if handle}} +Your handle is @{{handle}}. +{{else}} +Set your handle to personalize your account. +{{/if}} + +Stay in touch, +{{hostname}} diff --git a/examples/notification-templates/en/welcome.subject.txt b/examples/notification-templates/en/welcome.subject.txt new file mode 100644 index 00000000..ba2003ce --- /dev/null +++ b/examples/notification-templates/en/welcome.subject.txt @@ -0,0 +1 @@ +Welcome to {{hostname}}