From ace65db980cfb9c4beb4a9560d160ca157eff712 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 5 Feb 2026 00:34:11 +0800 Subject: [PATCH] :recycle: Refactored email localization & template engine --- DysonNetwork.Pass/DysonNetwork.Pass.csproj | 1 + DysonNetwork.Pass/Mailer/EmailService.cs | 28 +++++ .../Templates/en/AccountDeletion.cshtml | 73 ++++++++++++ .../Templates/en/ContactVerification.cshtml | 73 ++++++++++++ .../Resources/Templates/en/FactorCode.cshtml | 110 ++++++++++++++++++ .../Templates/en/PasswordReset.cshtml | 73 ++++++++++++ .../Resources/Templates/en/Welcome.cshtml | 74 ++++++++++++ .../Resources/Templates/en/_Layout.cshtml | 56 +++++++++ .../Templates/zh-hans/AccountDeletion.cshtml | 73 ++++++++++++ .../zh-hans/ContactVerification.cshtml | 73 ++++++++++++ .../Templates/zh-hans/FactorCode.cshtml | 110 ++++++++++++++++++ .../Templates/zh-hans/PasswordReset.cshtml | 73 ++++++++++++ .../Templates/zh-hans/Welcome.cshtml | 74 ++++++++++++ .../Templates/zh-hans/_Layout.cshtml | 56 +++++++++ .../Startup/ServiceCollectionExtensions.cs | 6 + .../DysonNetwork.Shared.csproj | 3 + .../Templating/ITemplateService.cs | 7 ++ .../Templating/RazorLightTemplateService.cs | 108 +++++++++++++++++ .../Templating/TemplateServiceLocator.cs | 6 + 19 files changed, 1077 insertions(+) create mode 100644 DysonNetwork.Pass/Resources/Templates/en/AccountDeletion.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/en/ContactVerification.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/en/FactorCode.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/en/PasswordReset.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/en/Welcome.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/en/_Layout.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/AccountDeletion.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/ContactVerification.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/FactorCode.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/PasswordReset.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/Welcome.cshtml create mode 100644 DysonNetwork.Pass/Resources/Templates/zh-hans/_Layout.cshtml create mode 100644 DysonNetwork.Shared/Templating/ITemplateService.cs create mode 100644 DysonNetwork.Shared/Templating/RazorLightTemplateService.cs create mode 100644 DysonNetwork.Shared/Templating/TemplateServiceLocator.cs diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 24643860..46086b05 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -137,5 +137,6 @@ + diff --git a/DysonNetwork.Pass/Mailer/EmailService.cs b/DysonNetwork.Pass/Mailer/EmailService.cs index 7bb2ecc0..3d459af2 100644 --- a/DysonNetwork.Pass/Mailer/EmailService.cs +++ b/DysonNetwork.Pass/Mailer/EmailService.cs @@ -1,4 +1,5 @@ using DysonNetwork.Shared.Proto; +using DysonNetwork.Shared.Templating; using Microsoft.AspNetCore.Components; namespace DysonNetwork.Pass.Mailer; @@ -6,6 +7,7 @@ namespace DysonNetwork.Pass.Mailer; public class EmailService( RingService.RingServiceClient pusher, RazorViewRenderer viewRenderer, + ITemplateService templateService, ILogger logger ) { @@ -45,4 +47,30 @@ public class EmailService( throw; } } + + /// + /// Sends an email using a RazorLight template with locale support. + /// + /// The model type for the template. + /// The recipient's display name. + /// The recipient's email address. + /// The email subject. + /// The template name (e.g., "welcome", "factor-code"). + /// The model data for the template. + /// Optional locale override (defaults to CurrentUICulture). + public async Task SendRazorTemplateEmailAsync(string? recipientName, string recipientEmail, + string subject, string templateName, TModel model, string? locale = null) + { + try + { + var htmlBody = await templateService.RenderAsync(templateName, model, locale); + await SendEmailAsync(recipientName, recipientEmail, subject, htmlBody); + } + catch (Exception err) + { + logger.LogError(err, "Failed to render RazorLight email template {TemplateName} for locale {Locale}", + templateName, locale ?? "default"); + throw; + } + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Templates/en/AccountDeletion.cshtml b/DysonNetwork.Pass/Resources/Templates/en/AccountDeletion.cshtml new file mode 100644 index 00000000..e5e1d593 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/AccountDeletion.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var accountDeletionBody = localizer?.Get("accountDeletionBody") ?? "We've received a request to delete your Solar Network account. We're sorry to see you go. To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone."; + var accountDeletionButton = localizer?.Get("accountDeletionButton") ?? "Confirm Account Deletion"; + var accountDeletionHint = localizer?.Get("accountDeletionHint") ?? "If you did not request to delete your account, please ignore this email or contact our support team immediately."; +} + +
+ Account Deletion Confirmation +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @accountDeletionBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @accountDeletionHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/en/ContactVerification.cshtml b/DysonNetwork.Pass/Resources/Templates/en/ContactVerification.cshtml new file mode 100644 index 00000000..3bd7e311 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/ContactVerification.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var contactVerificationBody = localizer?.Get("contactVerificationBody") ?? "Thank you for updating your contact method on the Solar Network. To ensure your account security, we need to verify this change. Please click the button below to verify your contact method:"; + var contactVerificationButton = localizer?.Get("contactVerificationButton") ?? "Verify"; + var contactVerificationHint = localizer?.Get("contactVerificationHint") ?? "If you didn't request this change, please contact our support team immediately."; +} + +
+ Verify Contact Method +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @contactVerificationBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @contactVerificationHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/en/FactorCode.cshtml b/DysonNetwork.Pass/Resources/Templates/en/FactorCode.cshtml new file mode 100644 index 00000000..b28ad505 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/FactorCode.cshtml @@ -0,0 +1,110 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var codeEmailBody = localizer?.Get("codeEmailBody") ?? "Someone trying to use email auth factor to authorize an access request. If that is you, enter the code below to continue."; + var codeEmailHint = localizer?.Get("codeEmailHint") ?? "This code will expire in 30 minutes."; + var codeEmailHintSecondary = localizer?.Get("codeEmailHintSecondary") ?? "If you didn't request this, you can ignore this email safely."; +} + +
+ Email One-time-password +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @codeEmailBody +

+

+ @Model.Code +

+
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @codeEmailHint +

+

+ @codeEmailHintSecondary +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/en/PasswordReset.cshtml b/DysonNetwork.Pass/Resources/Templates/en/PasswordReset.cshtml new file mode 100644 index 00000000..d9694e49 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/PasswordReset.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var passwordResetBody = localizer?.Get("passwordResetBody") ?? "We received a request to reset your Solar Network account password. Click the button below to continue and reset it."; + var passwordResetButton = localizer?.Get("passwordResetButton") ?? "Reset Password"; + var passwordResetHint = localizer?.Get("passwordResetHint") ?? "If you didn't request this, you can ignore this email safely."; +} + +
+ Password Reset Request +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @passwordResetBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @passwordResetHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/en/Welcome.cshtml b/DysonNetwork.Pass/Resources/Templates/en/Welcome.cshtml new file mode 100644 index 00000000..f4ec12e1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/Welcome.cshtml @@ -0,0 +1,74 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var regConfirmBody = localizer?.Get("regConfirmBody") ?? "We're happy to have you joining our community! Please confirm your registration in order to activate your account to unlock all the features available."; + var regConfirmButton = localizer?.Get("regConfirmButton") ?? "Confirm Registration"; + var alternativeLinkHint = localizer?.Get("alternativeLinkHint") ?? "If you're having trouble clicking the button, copy and paste the following URL into your web browser:"; +} + +
+ Welcome to the Solar Network! +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @regConfirmBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @alternativeLinkHint + @Model.Link +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/en/_Layout.cshtml b/DysonNetwork.Pass/Resources/Templates/en/_Layout.cshtml new file mode 100644 index 00000000..860747a6 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/en/_Layout.cshtml @@ -0,0 +1,56 @@ +@using RazorLight +@inherits TemplatePage + + + + + + + + + + + + + + + + + +@RenderBody() + + diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/AccountDeletion.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/AccountDeletion.cshtml new file mode 100644 index 00000000..e5e1d593 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/AccountDeletion.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var accountDeletionBody = localizer?.Get("accountDeletionBody") ?? "We've received a request to delete your Solar Network account. We're sorry to see you go. To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone."; + var accountDeletionButton = localizer?.Get("accountDeletionButton") ?? "Confirm Account Deletion"; + var accountDeletionHint = localizer?.Get("accountDeletionHint") ?? "If you did not request to delete your account, please ignore this email or contact our support team immediately."; +} + +
+ Account Deletion Confirmation +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @accountDeletionBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @accountDeletionHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/ContactVerification.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/ContactVerification.cshtml new file mode 100644 index 00000000..3bd7e311 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/ContactVerification.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var contactVerificationBody = localizer?.Get("contactVerificationBody") ?? "Thank you for updating your contact method on the Solar Network. To ensure your account security, we need to verify this change. Please click the button below to verify your contact method:"; + var contactVerificationButton = localizer?.Get("contactVerificationButton") ?? "Verify"; + var contactVerificationHint = localizer?.Get("contactVerificationHint") ?? "If you didn't request this change, please contact our support team immediately."; +} + +
+ Verify Contact Method +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @contactVerificationBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @contactVerificationHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/FactorCode.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/FactorCode.cshtml new file mode 100644 index 00000000..b28ad505 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/FactorCode.cshtml @@ -0,0 +1,110 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var codeEmailBody = localizer?.Get("codeEmailBody") ?? "Someone trying to use email auth factor to authorize an access request. If that is you, enter the code below to continue."; + var codeEmailHint = localizer?.Get("codeEmailHint") ?? "This code will expire in 30 minutes."; + var codeEmailHintSecondary = localizer?.Get("codeEmailHintSecondary") ?? "If you didn't request this, you can ignore this email safely."; +} + +
+ Email One-time-password +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +  ͏  ͏  ͏  ͏  ͏ +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @codeEmailBody +

+

+ @Model.Code +

+
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @codeEmailHint +

+

+ @codeEmailHintSecondary +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/PasswordReset.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/PasswordReset.cshtml new file mode 100644 index 00000000..d9694e49 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/PasswordReset.cshtml @@ -0,0 +1,73 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var passwordResetBody = localizer?.Get("passwordResetBody") ?? "We received a request to reset your Solar Network account password. Click the button below to continue and reset it."; + var passwordResetButton = localizer?.Get("passwordResetButton") ?? "Reset Password"; + var passwordResetHint = localizer?.Get("passwordResetHint") ?? "If you didn't request this, you can ignore this email safely."; +} + +
+ Password Reset Request +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @passwordResetBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @passwordResetHint +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/Welcome.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/Welcome.cshtml new file mode 100644 index 00000000..f4ec12e1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/Welcome.cshtml @@ -0,0 +1,74 @@ +@using DysonNetwork.Pass.Mailer +@using DysonNetwork.Shared.Localization +@using RazorLight +@inherits TemplatePage + +@{ + Layout = "_Layout"; + + var localizer = LocalizationServiceLocator.Service; + var name = localizer?.Get("usernameFormat", args: new { name = Model.Name }) ?? $"Dear {Model.Name}"; + var regConfirmBody = localizer?.Get("regConfirmBody") ?? "We're happy to have you joining our community! Please confirm your registration in order to activate your account to unlock all the features available."; + var regConfirmButton = localizer?.Get("regConfirmButton") ?? "Confirm Registration"; + var alternativeLinkHint = localizer?.Get("alternativeLinkHint") ?? "If you're having trouble clicking the button, copy and paste the following URL into your web browser:"; +} + +
+ Welcome to the Solar Network! +
+
+
+ + + + +
+
+ + + + +
+ + Solar Network Logo + +
+

+ @name +

+

+ @regConfirmBody +

+ +
+

+ Thanks, +
+ Solar Network Team +

+
+ ‍ +
+

+ @alternativeLinkHint + @Model.Link +

+
+ + + + +
+

+ © 2025 Solsynth LLC. All rights reserved. +

+
+
+
+
diff --git a/DysonNetwork.Pass/Resources/Templates/zh-hans/_Layout.cshtml b/DysonNetwork.Pass/Resources/Templates/zh-hans/_Layout.cshtml new file mode 100644 index 00000000..49c9725e --- /dev/null +++ b/DysonNetwork.Pass/Resources/Templates/zh-hans/_Layout.cshtml @@ -0,0 +1,56 @@ +@using RazorLight +@inherits TemplatePage + + + + + + + + + + + + + + + + + +@RenderBody() + + diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index ecbe2a84..998dbb3e 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -134,6 +134,12 @@ public static class ServiceCollectionExtensions IConfiguration configuration) { services.AddScoped(); + services.AddScoped(sp => + { + var assembly = System.Reflection.Assembly.GetExecutingAssembly(); + var resourceNamespace = "DysonNetwork.Pass.Resources.Templates"; + return new DysonNetwork.Shared.Templating.RazorLightTemplateService(assembly, resourceNamespace); + }); services.Configure(configuration.GetSection("GeoIP")); services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index f1f1ebf7..8f38351a 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -9,6 +9,8 @@ net10.0 enable enable + true + true @@ -61,6 +63,7 @@ + diff --git a/DysonNetwork.Shared/Templating/ITemplateService.cs b/DysonNetwork.Shared/Templating/ITemplateService.cs new file mode 100644 index 00000000..e55cf490 --- /dev/null +++ b/DysonNetwork.Shared/Templating/ITemplateService.cs @@ -0,0 +1,7 @@ +namespace DysonNetwork.Shared.Templating; + +public interface ITemplateService +{ + Task RenderAsync(string templateName, object model, string? locale = null); + Task RenderWithLayoutAsync(string templateName, string layoutName, object model, string? locale = null); +} diff --git a/DysonNetwork.Shared/Templating/RazorLightTemplateService.cs b/DysonNetwork.Shared/Templating/RazorLightTemplateService.cs new file mode 100644 index 00000000..aa279acb --- /dev/null +++ b/DysonNetwork.Shared/Templating/RazorLightTemplateService.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Reflection; +using RazorLight; + +namespace DysonNetwork.Shared.Templating; + +public class RazorLightTemplateService : ITemplateService +{ + private readonly IRazorLightEngine _engine; + private readonly Assembly _assembly; + private readonly string _resourceNamespace; + private readonly List _availableLocales = new(); + private readonly object _lock = new(); + + public RazorLightTemplateService(Assembly? assembly = null, string? resourceNamespace = null) + { + _assembly = assembly ?? Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly(); + _resourceNamespace = resourceNamespace ?? "DysonNetwork.Pass.Resources.Templates"; + + DiscoverAvailableLocales(); + + _engine = new RazorLightEngineBuilder() + .UseEmbeddedResourcesProject(_assembly, _resourceNamespace) + .UseMemoryCachingProvider() + .Build(); + } + + private void DiscoverAvailableLocales() + { + var resourceNames = _assembly.GetManifestResourceNames(); + var prefix = $"{_resourceNamespace}."; + var suffix = ".cshtml"; + + foreach (var resourceName in resourceNames) + { + if (resourceName.StartsWith(prefix) && resourceName.EndsWith(suffix)) + { + var locale = ExtractLocaleFromResourceName(resourceName, prefix, suffix); + if (!string.IsNullOrEmpty(locale) && !_availableLocales.Contains(locale)) + { + _availableLocales.Add(locale); + } + } + } + } + + private string? ExtractLocaleFromResourceName(string resourceName, string prefix, string suffix) + { + // Resource name format: Namespace.Locale.TemplateName.cshtml + // We need to extract the locale part + var contentPart = resourceName.Substring(prefix.Length, resourceName.Length - prefix.Length - suffix.Length); + + // Split by dot to get locale (first part before template name) + var parts = contentPart.Split('.'); + if (parts.Length >= 2) + { + // First part is locale (e.g., "en", "zh-hans") + return parts[0]; + } + + return null; + } + + public async Task RenderAsync(string templateName, object model, string? locale = null) + { + locale ??= CultureInfo.CurrentUICulture.Name; + + var templateKey = $"{locale}.{templateName}"; + + try + { + return await _engine.CompileRenderAsync(templateKey, model); + } + catch (TemplateNotFoundException) + { + // Fallback: try all available locales + foreach (var availableLocale in _availableLocales) + { + if (availableLocale.Equals(locale, StringComparison.OrdinalIgnoreCase)) + continue; + + var fallbackKey = $"{availableLocale}.{templateName}"; + try + { + return await _engine.CompileRenderAsync(fallbackKey, model); + } + catch (TemplateNotFoundException) + { + // Continue to next locale + } + } + + return $"[Template not found: {templateName}]"; + } + } + + public async Task RenderWithLayoutAsync(string templateName, string layoutName, object model, string? locale = null) + { + // RazorLight handles layouts via @Layout directive in the template + // This method is provided for API consistency + return await RenderAsync(templateName, model, locale); + } + + public IReadOnlyList GetAvailableLocales() + { + return _availableLocales.AsReadOnly(); + } +} diff --git a/DysonNetwork.Shared/Templating/TemplateServiceLocator.cs b/DysonNetwork.Shared/Templating/TemplateServiceLocator.cs new file mode 100644 index 00000000..8d5ab9f6 --- /dev/null +++ b/DysonNetwork.Shared/Templating/TemplateServiceLocator.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Shared.Templating; + +public static class TemplateServiceLocator +{ + public static ITemplateService? Service { get; set; } +}