feat(custom-templates): Add notification templates capability for customizes notifications with examples

- use tranquil_pds not comms since the validate email and templates are reexported from pds package
- implement handlebars syntax
- update templates to be transport agnostic, email, discord, telegram with handlebar examples with conditions
- refactor template and notice logic from PR #269, and #299 to be self contained enforcing all substitutions to be defined within the enum properties
- fix open TODOs, store.rs uses get_string within the ids.rs get_built_ins method
- UTC logging for User facing notices restored
- new api error for verification channel requires bot
- fix tests to use metadata for html body...
- remove unwrap and handle template rendering error explicitly
- update verify_link to use handlebars trusted url syntax {{{verify_link}}}
- fix verification enqueueing to include channel in template rendering
- use locale enum for validation and display instead of string array separately maintained
- support text only templates for signal and discord
- fix missing verify_link and verify_page in migration and signup verification templates with unit tests
- consolidated body and subject notice methods into a single rendered function
This commit is contained in:
straiforos
2026-10-14 10:24:25 +00:00
committed by Tangled
parent c0a74b276e
commit fcd2030275
126 changed files with 2706 additions and 503 deletions
Generated
+178 -5
View File
@@ -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"
+1
View File
@@ -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"] }
+2 -1
View File
@@ -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!({
@@ -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
{
+25 -68
View File
@@ -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<String, ApiError> {
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);
@@ -569,14 +569,16 @@ pub async fn request_account_delete(
) -> Result<Json<EmptyResponse>, 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
{
+13 -2
View File
@@ -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
{
@@ -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;
+2 -1
View File
@@ -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
{
+19 -9
View File
@@ -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<Active>,
Json(input): Json<UpdateLocaleInput>,
) -> Result<Json<PreferredLocaleOutput>, 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::<Vec<&str>>()
.join(", ")
)));
}
@@ -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;
}
@@ -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;
}
+1
View File
@@ -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 }
+59 -14
View File
@@ -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<EmailDomain, SendError> {
@@ -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<serde_json::Value>,
) -> 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": "<p>Hello <b>html</b>.</p>"})),
),
&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("<p>Hello <b>html</b>.</p>"));
}
#[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();
+10 -5
View File
@@ -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};
+183 -201
View File
@@ -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"));
}
}
@@ -0,0 +1,11 @@
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
#[error("{0}")]
Message(String),
}
impl TemplateError {
pub(super) fn msg(s: impl Into<String>) -> Self {
Self::Message(s.into())
}
}
+135
View File
@@ -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,
},
}
}
}
@@ -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<TemplateStore> = OnceLock::new();
/// Load templates from config without installing the global cache.
fn load_from_config(cfg: &tranquil_config::CommsConfig) -> Result<TemplateStore, TemplateError> {
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<RenderedTemplate, TemplateError> {
runtime_template_store()?.render(id, locale, vars)
}
@@ -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<String>,
}
impl RenderedTemplate {
/// Metadata value for `enqueue_comms`, or `None` when there is no HTML part.
pub fn metadata(&self) -> Option<serde_json::Value> {
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<serde_json::Value>) -> Option<&str> {
metadata
.as_ref()
.and_then(|v| v.get(HTML_BODY_META_KEY))
.and_then(|v| v.as_str())
}
@@ -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<String>,
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<Self, TemplateError> {
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<Self, TemplateError> {
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<String, TemplateError> {
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<RenderedTemplate, TemplateError> {
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<RenderedTemplate, TemplateError> {
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<RenderedTemplate, TemplateError> {
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<String, TemplateError> {
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<Option<String>, 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("<p>Hello <b>html</b>.</p>")
);
}
#[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");
}
}
@@ -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!("<p>{id} <b>{{{{handle}}}}</b> code={{{{code}}}}</p>"),
);
}
}
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("<strong>Telegram</strong>"));
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"),
"<p>{{#if handle}}</p>",
);
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<b>&c"), ("hostname", "ex.com"), ("code", "1")],
)
.unwrap();
let html = rendered.html_body.unwrap();
assert!(html.contains("a&lt;b&gt;&amp;c"), "{html}");
assert!(!html.contains("a<b>&c"), "{html}");
assert!(rendered.body.contains("a<b>&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("<p>hi</p>".into()),
};
let meta = rendered.metadata().unwrap();
assert_eq!(meta["html_body"], "<p>hi</p>");
assert!(
RenderedTemplate {
subject: "s".into(),
body: "b".into(),
html_body: None,
}
.metadata()
.is_none()
);
}
@@ -0,0 +1 @@
account_deletion body
@@ -0,0 +1 @@
account_deletion subject
@@ -0,0 +1 @@
channel_verification body
@@ -0,0 +1 @@
channel_verification subject
@@ -0,0 +1 @@
channel_verified body
@@ -0,0 +1 @@
channel_verified subject
@@ -0,0 +1 @@
email_update body
@@ -0,0 +1 @@
email_update subject
@@ -0,0 +1 @@
legacy_login body
@@ -0,0 +1 @@
legacy_login subject
@@ -0,0 +1 @@
migration_verification body
@@ -0,0 +1 @@
migration_verification subject
@@ -0,0 +1 @@
passkey_recovery body
@@ -0,0 +1 @@
passkey_recovery subject
@@ -0,0 +1 @@
password_reset body
@@ -0,0 +1 @@
password_reset subject
@@ -0,0 +1 @@
plc_operation body
@@ -0,0 +1 @@
plc_operation subject
@@ -0,0 +1 @@
short_token body
@@ -0,0 +1 @@
short_token subject
@@ -0,0 +1 @@
signup_verification body
@@ -0,0 +1 @@
signup_verification subject
@@ -0,0 +1 @@
two_factor_code body
@@ -0,0 +1 @@
two_factor_code subject
@@ -0,0 +1 @@
Hello World
@@ -0,0 +1 @@
Hello World
@@ -0,0 +1 @@
account_deletion body
@@ -0,0 +1 @@
account_deletion subject
@@ -0,0 +1 @@
channel_verification body
@@ -0,0 +1 @@
channel_verification subject
@@ -0,0 +1 @@
channel_verified body
@@ -0,0 +1 @@
channel_verified subject
@@ -0,0 +1 @@
email_update body
@@ -0,0 +1 @@
email_update subject
@@ -0,0 +1 @@
legacy_login body
@@ -0,0 +1 @@
legacy_login subject
@@ -0,0 +1 @@
migration_verification body
@@ -0,0 +1 @@
migration_verification subject
@@ -0,0 +1 @@
passkey_recovery body
@@ -0,0 +1 @@
passkey_recovery subject
@@ -0,0 +1 @@
password_reset body
@@ -0,0 +1 @@
password_reset subject
@@ -0,0 +1 @@
plc_operation body
@@ -0,0 +1 @@
plc_operation subject
@@ -0,0 +1 @@
short_token body
@@ -0,0 +1 @@
short_token subject
@@ -0,0 +1 @@
signup_verification body
@@ -0,0 +1 @@
signup_verification subject
@@ -0,0 +1 @@
two_factor_code body
@@ -0,0 +1 @@
two_factor_code subject
@@ -0,0 +1 @@
<p>Hello <b>html</b>.</p>
@@ -0,0 +1 @@
Hello World
@@ -0,0 +1 @@
Hello World
+3
View File
@@ -8,3 +8,6 @@ license.workspace = true
serde = { workspace = true }
tranquil-types = { workspace = true }
confique = { workspace = true }
[dev-dependencies]
serial_test = "4.0.1"
+81 -1
View File
@@ -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<TranquilConfig> = 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<String>,
/// 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<String>,
/// 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<String>) {
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:?}"
);
}
}
@@ -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<Handle> = 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
{
@@ -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
{
+4
View File
@@ -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()
}
+7 -4
View File
@@ -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,
};
+529 -178
View File
@@ -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<Recipient> {
.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<RenderedTemplate, NotificationError> {
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", &timestamp),
("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<Option<Uuid>, DbError> {
let prefs = user_repo
.get_comms_prefs(user_id)
.await?
.ok_or(DbError::NotFound)?;
) -> Result<Option<Uuid>, 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<UserCommsPrefs, NotificationError> {
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<Uuid, DbError> {
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<Uuid, NotificationError> {
// 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<Uuid, DbError> {
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<Uuid, NotificationError> {
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<Uuid, DbError> {
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<Uuid, NotificationError> {
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<Uuid, NotificationError> {
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<Uuid, DbError> {
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<Uuid, NotificationError> {
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(&notice.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(&notice.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)]
+5
View File
@@ -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)
@@ -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
+9
View File
@@ -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<dyn std::error::Error>> {
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");
+26
View File
@@ -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.
#
+27
View File
@@ -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.
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{hostname}}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafa;font-family:Georgia,'Times New Roman',serif;color:#1a1d1d;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f9fafa;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" style="background:#ffffff;border:1px solid #dce0df;padding:32px;">
<tr>
<td style="font-size:20px;font-weight:700;padding-bottom:16px;">{{hostname}}</td>
</tr>
<tr>
<td style="font-size:16px;line-height:1.5;">
<p>Hello @{{handle}},</p><br/><p>Your account deletion confirmation code is: {{code}}</p><br/><p>This code will expire in 10 minutes.</p><br/><p>If you did not request this, please secure your account immediately.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -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.
@@ -0,0 +1 @@
Account Deletion Request - {{hostname}}
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{hostname}}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafa;font-family:Georgia,'Times New Roman',serif;color:#1a1d1d;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f9fafa;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" style="background:#ffffff;border:1px solid #dce0df;padding:32px;">
<tr>
<td style="font-size:20px;font-weight:700;padding-bottom:16px;">{{hostname}}</td>
</tr>
<tr>
<td style="font-size:16px;line-height:1.5;">
<p>Your verification code is:</p><p>{{code}}</p><br/><p>Or verify directly:</p><p>{{{verify_link}}}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,5 @@
Your verification code is:
{{code}}
Or verify directly:
{{{verify_link}}}
@@ -0,0 +1 @@
Verify your channel - {{hostname}}
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{hostname}}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafa;font-family:Georgia,'Times New Roman',serif;color:#1a1d1d;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f9fafa;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" style="background:#ffffff;border:1px solid #dce0df;padding:32px;">
<tr>
<td style="font-size:20px;font-weight:700;padding-bottom:16px;">{{hostname}}</td>
</tr>
<tr>
<td style="font-size:16px;line-height:1.5;">
<p>{{#if handle}}Hello @{{handle}},{{else}}Hello,{{/if}}</p>
<p>{{#if channel}}<strong>{{channel}}</strong>{{else}}A channel{{/if}} has been verified as a notification channel for your account on {{hostname}}.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,3 @@
Hello {{#if handle}}@{{handle}}{{else}}there{{/if}},
{{channel}} has been verified as a notification channel for your account on {{hostname}}.
@@ -0,0 +1 @@
Channel verified - {{hostname}}
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{hostname}}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafa;font-family:Georgia,'Times New Roman',serif;color:#1a1d1d;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f9fafa;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" style="background:#ffffff;border:1px solid #dce0df;padding:32px;">
<tr>
<td style="font-size:20px;font-weight:700;padding-bottom:16px;">{{hostname}}</td>
</tr>
<tr>
<td style="font-size:16px;line-height:1.5;">
<p>Hello @{{handle}},</p>
<p>Your verification code is:</p>
<p style="font-size:22px;font-weight:700;letter-spacing:2px;">{{code}}</p>
<p>Copy the code above and enter it at:</p>
<p>{{verify_page}}</p>
<p>This code will expire in 10 minutes.</p>
{{#if verify_link}}
<br/>
<p>Or, if you like to live dangerously, verify in one click:</p>
<p><a href="{{{verify_link}}}" style="color:#1a1d1d;">{{{verify_link}}}</a></p>
{{/if}}
<br/>
<p>If you did not request this, please ignore this email.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -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.
@@ -0,0 +1 @@
Confirm your new email - {{hostname}}
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{hostname}}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafa;font-family:Georgia,'Times New Roman',serif;color:#1a1d1d;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f9fafa;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" style="background:#ffffff;border:1px solid #dce0df;padding:32px;">
<tr>
<td style="font-size:20px;font-weight:700;padding-bottom:16px;">{{hostname}}</td>
</tr>
<tr>
<td style="font-size:16px;line-height:1.5;">
<p>Hello @{{handle}},</p><br/><p>A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.</p><br/><p>Details:</p><p>- Time: {{timestamp}}</p><p>- IP Address: {{ip}}</p><br/><p>Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.</p><br/><p>If this wasn't you, please:</p><p>1. Change your password immediately</p><p>2. Review your active sessions</p><p>3. Consider disabling legacy app logins in your security settings</p><br/><p>Stay safe,</p><p>{{hostname}}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More