mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-10-22 08:32:38 +00:00
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:
Generated
+178
-5
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<b>&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
|
||||
@@ -8,3 +8,6 @@ license.workspace = true
|
||||
serde = { workspace = true }
|
||||
tranquil-types = { workspace = true }
|
||||
confique = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "4.0.1"
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", ×tamp),
|
||||
("ip", ip),
|
||||
("hostname", hostname),
|
||||
],
|
||||
)
|
||||
}
|
||||
Self::ShortTokenEmail {
|
||||
hostname,
|
||||
handle,
|
||||
code,
|
||||
verify_page,
|
||||
verify_link,
|
||||
} => render_template(
|
||||
TemplateId::EmailUpdate,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("handle", handle),
|
||||
("code", code),
|
||||
("verify_page", verify_page),
|
||||
("verify_link", verify_link),
|
||||
],
|
||||
),
|
||||
Self::LegacyLoginAlert {
|
||||
#[allow(unused)]
|
||||
channel,
|
||||
hostname,
|
||||
handle,
|
||||
timestamp,
|
||||
ip,
|
||||
} => render_template(
|
||||
TemplateId::LegacyLogin,
|
||||
locale,
|
||||
&[
|
||||
("handle", handle),
|
||||
("timestamp", timestamp),
|
||||
("ip", ip),
|
||||
("hostname", hostname),
|
||||
],
|
||||
),
|
||||
Self::EmailUpdate {
|
||||
hostname,
|
||||
handle,
|
||||
code,
|
||||
verify_page,
|
||||
verify_link,
|
||||
} => render_template(
|
||||
TemplateId::EmailUpdate,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("handle", handle),
|
||||
("code", code),
|
||||
("verify_page", verify_page),
|
||||
("verify_link", verify_link),
|
||||
],
|
||||
),
|
||||
Self::MigrationVerification {
|
||||
hostname,
|
||||
handle,
|
||||
code,
|
||||
verify_page,
|
||||
verify_link,
|
||||
} => render_template(
|
||||
TemplateId::MigrationVerification,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("handle", handle),
|
||||
("code", code),
|
||||
("verify_page", verify_page),
|
||||
("verify_link", verify_link),
|
||||
],
|
||||
),
|
||||
Self::SignupVerification {
|
||||
hostname,
|
||||
handle,
|
||||
code,
|
||||
verify_page,
|
||||
verify_link,
|
||||
} => render_template(
|
||||
TemplateId::SignupVerification,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("handle", handle),
|
||||
("code", code),
|
||||
("verify_page", verify_page),
|
||||
("verify_link", verify_link),
|
||||
],
|
||||
),
|
||||
Self::ChannelVerification {
|
||||
hostname,
|
||||
code,
|
||||
verify_link,
|
||||
} => render_template(
|
||||
TemplateId::ChannelVerification,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("code", code),
|
||||
("verify_link", verify_link),
|
||||
],
|
||||
),
|
||||
Self::ChannelVerified {
|
||||
hostname,
|
||||
handle,
|
||||
channel,
|
||||
} => render_template(
|
||||
TemplateId::ChannelVerified,
|
||||
locale,
|
||||
&[
|
||||
("hostname", hostname),
|
||||
("handle", handle),
|
||||
("channel", channel.display_name()),
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(template) => Ok(template),
|
||||
Err(e) => Err(NotificationError::TemplateRenderingError(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,12 +580,9 @@ pub mod repo {
|
||||
infra_repo: &dyn InfraRepository,
|
||||
user_id: Uuid,
|
||||
notice: Notice<'_>,
|
||||
hostname: &str,
|
||||
) -> Result<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(¬ice.rendered("en").unwrap().body, page, link);
|
||||
assert!(
|
||||
notice
|
||||
.rendered("en")
|
||||
.unwrap()
|
||||
.subject
|
||||
.contains("example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_token_email_substitutes_verify_page_and_link() {
|
||||
builtins();
|
||||
let page = "https://example.com/app/verify?type=email-update";
|
||||
let link = "https://example.com/app/verify?type=email-update&token=abc";
|
||||
let notice = Notice::ShortTokenEmail {
|
||||
hostname: "example.com",
|
||||
handle: "alice",
|
||||
code: "abc",
|
||||
verify_page: page,
|
||||
verify_link: link,
|
||||
};
|
||||
assert_page_and_link(¬ice.rendered("en").unwrap().body, page, link);
|
||||
assert!(notice.rendered("en").unwrap().body.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_verification_substitutes_verify_page_and_link() {
|
||||
builtins();
|
||||
let page = "https://example.com/app/verify";
|
||||
let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com";
|
||||
let notice = Notice::MigrationVerification {
|
||||
hostname: "example.com",
|
||||
handle: "alice",
|
||||
code: "abc",
|
||||
verify_page: page,
|
||||
verify_link: link,
|
||||
};
|
||||
let body = notice.rendered("en").unwrap().body;
|
||||
assert_page_and_link(&body, page, link);
|
||||
assert!(body.contains("example.com"));
|
||||
assert!(body.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signup_verification_substitutes_verify_page_and_link() {
|
||||
builtins();
|
||||
let page = "https://example.com/app/verify";
|
||||
let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com";
|
||||
let notice = Notice::SignupVerification {
|
||||
hostname: "example.com",
|
||||
handle: "alice",
|
||||
code: "abc",
|
||||
verify_page: page,
|
||||
verify_link: link,
|
||||
};
|
||||
let body = notice.rendered("en").unwrap().body;
|
||||
assert_page_and_link(&body, page, link);
|
||||
assert!(body.contains("example.com"));
|
||||
assert!(
|
||||
notice
|
||||
.rendered("en")
|
||||
.unwrap()
|
||||
.subject
|
||||
.contains("example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_verification_substitutes_verify_link() {
|
||||
builtins();
|
||||
let link = "https://example.com/app/verify?token=abc&identifier=user%40example.com";
|
||||
let notice = Notice::ChannelVerification {
|
||||
hostname: "example.com",
|
||||
code: "abc",
|
||||
verify_link: link,
|
||||
};
|
||||
let body = notice.rendered("en").unwrap().body;
|
||||
assert!(body.contains(link), "{body}");
|
||||
assert!(body.contains("abc"), "{body}");
|
||||
assert!(!body.contains("{{"), "{body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user