🧱 Render email based on razor components

This commit is contained in:
LittleSheep 2025-05-17 17:00:46 +08:00
parent cbef69ba5e
commit d3b56b741e
11 changed files with 323 additions and 64 deletions

View File

@ -0,0 +1,80 @@
using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Components;
using MimeKit;
namespace DysonNetwork.Sphere.Account.Email;
public class EmailServiceConfiguration
{
public string Server { get; set; } = null!;
public int Port { get; set; }
public bool UseSsl { get; set; }
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public string FromAddress { get; set; } = null!;
public string FromName { get; set; } = null!;
public string SubjectPrefix { get; set; } = null!;
}
public class EmailService
{
private readonly EmailServiceConfiguration _configuration;
private readonly RazorViewRenderer _viewRenderer;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
{
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
_viewRenderer = viewRenderer;
_logger = logger;
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
string? htmlBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
var emailMessage = new MimeMessage();
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
if (!string.IsNullOrEmpty(htmlBody))
bodyBuilder.HtmlBody = htmlBody;
emailMessage.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl);
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
await client.SendAsync(emailMessage);
await client.DisconnectAsync(true);
}
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
string subject, TModel model, string fallbackTextBody)
where TComponent : IComponent
{
try
{
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
}
catch (Exception err)
{
_logger.LogError(err, "Failed to render email template...");
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody);
}
}
}

View File

@ -0,0 +1,7 @@
namespace DysonNetwork.Sphere.Account.Email;
public class LandingEmailModel
{
public required string Name { get; set; }
public required string VerificationLink { get; set; }
}

View File

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
namespace DysonNetwork.Sphere.Account.Email;
public class RazorViewRenderer(
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory,
ILogger<RazorViewRenderer> logger
)
{
public async Task<string> RenderComponentToStringAsync<TComponent, TModel>(TModel model)
where TComponent : IComponent
{
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
var viewDictionary = new ViewDataDictionary<TModel>(
new EmptyModelMetadataProvider(),
new ModelStateDictionary())
{
Model = model
};
return await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
try
{
var parameterView = ParameterView.FromDictionary(viewDictionary);
var output = await htmlRenderer.RenderComponentAsync<TComponent>(parameterView);
return output.ToHtmlString();
}
catch (Exception ex)
{
logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name);
throw;
}
});
}
}

View File

@ -1,49 +0,0 @@
using MailKit.Net.Smtp;
using MimeKit;
namespace DysonNetwork.Sphere.Account;
public class EmailServiceConfiguration
{
public string Server { get; set; }
public int Port { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string FromAddress { get; set; }
public string FromName { get; set; }
public string SubjectPrefix { get; set; }
}
public class EmailService
{
private readonly EmailServiceConfiguration _configuration;
public EmailService(IConfiguration configuration)
{
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
var emailMessage = new MimeMessage();
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
emailMessage.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Server, _configuration.Port, true);
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
await client.SendAsync(emailMessage);
await client.DisconnectAsync(true);
}
}

View File

@ -1,11 +1,13 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DysonNetwork.Sphere.Account.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Account; namespace DysonNetwork.Sphere.Account;
public class MagicSpellService(AppDatabase db, EmailService email, ILogger<MagicSpellService> logger) public class MagicSpellService(AppDatabase db, EmailService email, IConfiguration configuration, ILogger<MagicSpellService> logger)
{ {
public async Task<MagicSpell> CreateMagicSpell( public async Task<MagicSpell> CreateMagicSpell(
Account account, Account account,
@ -42,23 +44,25 @@ public class MagicSpellService(AppDatabase db, EmailService email, ILogger<Magic
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (contact is null) throw new ArgumentException("Account has no contact method that can use"); if (contact is null) throw new ArgumentException("Account has no contact method that can use");
// TODO replace the baseurl var link = $"${configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
var link = $"https://api.sn.solsynth.dev/spells/{Uri.EscapeDataString(spell.Spell)}";
logger.LogError($"Sending magic spell... {link}"); logger.LogInformation($"Sending magic spell... {link}");
try try
{ {
switch (spell.Type) switch (spell.Type)
{ {
case MagicSpellType.AccountActivation: case MagicSpellType.AccountActivation:
await email.SendEmailAsync( await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Name, contact.Account.Name,
contact.Content, contact.Content,
"Confirm your registration", "Confirm your registration",
"Thank you for creating an account.\n" + new LandingEmailModel
"For accessing all the features, confirm your registration with the link below:\n\n" + {
$"{link}" Name = contact.Account.Name,
VerificationLink = link
},
$"Thank you for creating an account.\nFor accessing all the features, confirm your registration with the link below:\n\n{link}"
); );
break; break;
default: default:

View File

@ -0,0 +1,18 @@
@inherits LayoutComponentBase
<div class="min-h-screen bg-gray-100 py-8 font-sans">
<div class="max-w-2xl mx-auto px-8">
<div class="bg-white rounded-lg shadow-md p-6">
@ChildContent
</div>
<div class="text-center mt-6 text-sm text-gray-500">
<p class="m-0">&copy; @DateTime.Now.Year DysonNetwork. All rights reserved.</p>
</div>
</div>
</div>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}

View File

@ -0,0 +1,47 @@
<EmailLayout>
<div style="text-align: center; margin-bottom: 2rem;">
<h1 style="font-size: 1.875rem; font-weight: 700; color: #111827; margin: 0;">Welcome to DysonNetwork!</h1>
</div>
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<p style="color: #374151; margin: 0;">
Dear @Name,
</p>
<p style="color: #374151; margin: 0;">
Thank you for creating an account with DysonNetwork. We're excited to have you join our community!
</p>
<p style="color: #374151; margin: 0;">
To access all features and ensure the security of your account, please confirm your registration by clicking
the button below:
</p>
<div style="text-align: center; margin: 2rem 0;">
<a href="@VerificationLink"
style="display: inline-block; padding: 0.75rem 1.5rem; background-color: #2563eb; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 0.5rem;">
Confirm Registration
</a>
</div>
<p style="color: #374151; margin: 0;">
If the button doesn't work, you can also copy and paste this link into your browser:
<br>
<span style="color: #2563eb; word-break: break-all;">@VerificationLink</span>
</p>
<p style="color: #374151; margin: 0;">
If you didn't create this account, please ignore this email.
</p>
<p style="color: #374151; margin: 2rem 0 0 0;">
Best regards,<br>
The DysonNetwork Team
</p>
</div>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string VerificationLink { get; set; }
}

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewData["Title"]</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 42rem; margin: 0 auto; padding: 2rem;">
<div style="background-color: #ffffff; border-radius: 0.5rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); padding: 1.5rem;">
@RenderBody()
</div>
<div style="text-align: center; margin-top: 1.5rem; font-size: 0.875rem; color: #6b7280;">
<p style="margin: 0;">&copy; @DateTime.Now.Year DysonNetwork. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@ -1,11 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Sphere; using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Account.Email;
using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
@ -20,12 +20,11 @@ using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet; using DysonNetwork.Sphere.Wallet;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
@ -34,7 +33,6 @@ using tusdotnet;
using tusdotnet.Models; using tusdotnet.Models;
using tusdotnet.Models.Configuration; using tusdotnet.Models.Configuration;
using tusdotnet.Stores; using tusdotnet.Stores;
using File = System.IO.File;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -141,6 +139,7 @@ builder.Services.AddSingleton(tusDiskStore);
builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>(); builder.Services.AddScoped<IWebSocketPacketHandler, MessageReadHandler>();
// Services // Services
builder.Services.AddScoped<RazorViewRenderer>();
builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP")); builder.Services.Configure<GeoIpOptions>(builder.Configuration.GetSection("GeoIP"));
builder.Services.AddScoped<GeoIpService>(); builder.Services.AddScoped<GeoIpService>();
builder.Services.AddScoped<WebSocketService>(); builder.Services.AddScoped<WebSocketService>();

View File

@ -1,5 +1,6 @@
{ {
"Debug": true, "Debug": true,
"BaseUrl": "http://localhost:5017",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
@ -63,10 +64,11 @@
} }
}, },
"Email": { "Email": {
"Server": "smtpdm.aliyun.com", "Server": "smtp4dev.orb.local",
"Port": 465, "Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev", "Username": "no-reply@mail.solsynth.dev",
"Password": "Pe1UeV405PMcQZgv", "Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev", "FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot", "FromName": "Alphabot",
"SubjectPrefix": "Solar Network" "SubjectPrefix": "Solar Network"

View File

@ -227,9 +227,15 @@
.z-50 { .z-50 {
z-index: 50; z-index: 50;
} }
.m-0 {
margin: calc(var(--spacing) * 0);
}
.mx-auto { .mx-auto {
margin-inline: auto; margin-inline: auto;
} }
.my-8 {
margin-block: calc(var(--spacing) * 8);
}
.mt-4 { .mt-4 {
margin-top: calc(var(--spacing) * 4); margin-top: calc(var(--spacing) * 4);
} }
@ -269,6 +275,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline-block {
display: inline-block;
}
.table { .table {
display: table; display: table;
} }
@ -281,6 +290,9 @@
.h-full { .h-full {
height: 100%; height: 100%;
} }
.min-h-screen {
min-height: 100vh;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -305,6 +317,13 @@
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
}
}
.gap-x-6 { .gap-x-6 {
column-gap: calc(var(--spacing) * 6); column-gap: calc(var(--spacing) * 6);
} }
@ -314,6 +333,12 @@
.bg-blue-500 { .bg-blue-500 {
background-color: var(--color-blue-500); background-color: var(--color-blue-500);
} }
.bg-blue-600 {
background-color: var(--color-blue-600);
}
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-green-100 { .bg-green-100 {
background-color: var(--color-green-100); background-color: var(--color-green-100);
} }
@ -329,21 +354,33 @@
.p-6 { .p-6 {
padding: calc(var(--spacing) * 6); padding: calc(var(--spacing) * 6);
} }
.p-8 {
padding: calc(var(--spacing) * 8);
}
.px-4 { .px-4 {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
.px-8 {
padding-inline: calc(var(--spacing) * 8);
}
.py-3 { .py-3 {
padding-block: calc(var(--spacing) * 3); padding-block: calc(var(--spacing) * 3);
} }
.py-8 {
padding-block: calc(var(--spacing) * 8);
}
.py-12 { .py-12 {
padding-block: calc(var(--spacing) * 12); padding-block: calc(var(--spacing) * 12);
} }
.text-center { .text-center {
text-align: center; text-align: center;
} }
.font-sans {
font-family: var(--font-sans);
}
.text-2xl { .text-2xl {
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
@ -368,6 +405,10 @@
font-size: var(--text-xl); font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height)); line-height: var(--tw-leading, var(--text-xl--line-height));
} }
.leading-6 {
--tw-leading: calc(var(--spacing) * 6);
line-height: calc(var(--spacing) * 6);
}
.leading-8 { .leading-8 {
--tw-leading: calc(var(--spacing) * 8); --tw-leading: calc(var(--spacing) * 8);
line-height: calc(var(--spacing) * 8); line-height: calc(var(--spacing) * 8);
@ -388,6 +429,12 @@
--tw-tracking: var(--tracking-tight); --tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.break-all {
word-break: break-all;
}
.text-blue-600 {
color: var(--color-blue-600);
}
.text-gray-500 { .text-gray-500 {
color: var(--color-gray-500); color: var(--color-gray-500);
} }
@ -420,6 +467,10 @@
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
} }
.shadow-md {
--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm { .shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@ -453,6 +504,13 @@
} }
} }
} }
.hover\:bg-blue-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-700);
}
}
}
.hover\:text-blue-600 { .hover\:text-blue-600 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -460,6 +518,13 @@
} }
} }
} }
.hover\:text-gray-700 {
&:hover {
@media (hover: hover) {
color: var(--color-gray-700);
}
}
}
.focus\:ring-2 { .focus\:ring-2 {
&:focus { &:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@ -503,6 +568,11 @@
background-color: var(--color-yellow-900); background-color: var(--color-yellow-900);
} }
} }
.dark\:text-gray-100 {
@media (prefers-color-scheme: dark) {
color: var(--color-gray-100);
}
}
.dark\:text-gray-300 { .dark\:text-gray-300 {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
color: var(--color-gray-300); color: var(--color-gray-300);
@ -537,6 +607,15 @@
} }
} }
} }
.dark\:hover\:text-gray-300 {
@media (prefers-color-scheme: dark) {
&:hover {
@media (hover: hover) {
color: var(--color-gray-300);
}
}
}
}
} }
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@layer theme; @layer theme;
@ -779,6 +858,11 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-space-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-leading { @property --tw-leading {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@ -932,6 +1016,7 @@
--tw-rotate-z: initial; --tw-rotate-z: initial;
--tw-skew-x: initial; --tw-skew-x: initial;
--tw-skew-y: initial; --tw-skew-y: initial;
--tw-space-y-reverse: 0;
--tw-leading: initial; --tw-leading: initial;
--tw-font-weight: initial; --tw-font-weight: initial;
--tw-tracking: initial; --tw-tracking: initial;