diff --git a/DysonNetwork.Sphere/Account/Email/EmailService.cs b/DysonNetwork.Sphere/Account/Email/EmailService.cs new file mode 100644 index 0000000..24284c1 --- /dev/null +++ b/DysonNetwork.Sphere/Account/Email/EmailService.cs @@ -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 _logger; + + public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger logger) + { + var cfg = configuration.GetSection("Email").Get(); + _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(string? recipientName, string recipientEmail, + string subject, TModel model, string fallbackTextBody) + where TComponent : IComponent + { + try + { + var htmlBody = await _viewRenderer.RenderComponentToStringAsync(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); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/Email/LandingEmail.cs b/DysonNetwork.Sphere/Account/Email/LandingEmail.cs new file mode 100644 index 0000000..c8b1596 --- /dev/null +++ b/DysonNetwork.Sphere/Account/Email/LandingEmail.cs @@ -0,0 +1,7 @@ +namespace DysonNetwork.Sphere.Account.Email; + +public class LandingEmailModel +{ + public required string Name { get; set; } + public required string VerificationLink { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/Email/RazorViewRenderer.cs b/DysonNetwork.Sphere/Account/Email/RazorViewRenderer.cs new file mode 100644 index 0000000..0e89e86 --- /dev/null +++ b/DysonNetwork.Sphere/Account/Email/RazorViewRenderer.cs @@ -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 logger +) +{ + public async Task RenderComponentToStringAsync(TModel model) + where TComponent : IComponent + { + await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory); + + var viewDictionary = new ViewDataDictionary( + new EmptyModelMetadataProvider(), + new ModelStateDictionary()) + { + Model = model + }; + + return await htmlRenderer.Dispatcher.InvokeAsync(async () => + { + try + { + var parameterView = ParameterView.FromDictionary(viewDictionary); + var output = await htmlRenderer.RenderComponentAsync(parameterView); + return output.ToHtmlString(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name); + throw; + } + }); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/EmailService.cs b/DysonNetwork.Sphere/Account/EmailService.cs deleted file mode 100644 index 159c40a..0000000 --- a/DysonNetwork.Sphere/Account/EmailService.cs +++ /dev/null @@ -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(); - _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); - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/MagicSpellService.cs b/DysonNetwork.Sphere/Account/MagicSpellService.cs index 8c0536a..c772502 100644 --- a/DysonNetwork.Sphere/Account/MagicSpellService.cs +++ b/DysonNetwork.Sphere/Account/MagicSpellService.cs @@ -1,11 +1,13 @@ using System.Security.Cryptography; +using DysonNetwork.Sphere.Account.Email; +using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Sphere.Permission; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Account; -public class MagicSpellService(AppDatabase db, EmailService email, ILogger logger) +public class MagicSpellService(AppDatabase db, EmailService email, IConfiguration configuration, ILogger logger) { public async Task CreateMagicSpell( Account account, @@ -42,23 +44,25 @@ public class MagicSpellService(AppDatabase db, EmailService email, ILogger("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}"; - logger.LogError($"Sending magic spell... {link}"); + logger.LogInformation($"Sending magic spell... {link}"); try { switch (spell.Type) { case MagicSpellType.AccountActivation: - await email.SendEmailAsync( + await email.SendTemplatedEmailAsync( contact.Account.Name, contact.Content, "Confirm your registration", - "Thank you for creating an account.\n" + - "For accessing all the features, confirm your registration with the link below:\n\n" + - $"{link}" + new LandingEmailModel + { + 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; default: diff --git a/DysonNetwork.Sphere/Pages/Emails/EmailLayout.razor b/DysonNetwork.Sphere/Pages/Emails/EmailLayout.razor new file mode 100644 index 0000000..23046ec --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Emails/EmailLayout.razor @@ -0,0 +1,18 @@ +@inherits LayoutComponentBase + +
+
+
+ @ChildContent +
+ +
+

© @DateTime.Now.Year DysonNetwork. All rights reserved.

+
+
+
+ +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Emails/LandingEmail.razor b/DysonNetwork.Sphere/Pages/Emails/LandingEmail.razor new file mode 100644 index 0000000..5259a74 --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Emails/LandingEmail.razor @@ -0,0 +1,47 @@ + +
+

Welcome to DysonNetwork!

+
+ +
+

+ Dear @Name, +

+ +

+ Thank you for creating an account with DysonNetwork. We're excited to have you join our community! +

+ +

+ To access all features and ensure the security of your account, please confirm your registration by clicking + the button below: +

+ + + +

+ If the button doesn't work, you can also copy and paste this link into your browser: +
+ @VerificationLink +

+ +

+ If you didn't create this account, please ignore this email. +

+ +

+ Best regards,
+ The DysonNetwork Team +

+
+
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string VerificationLink { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Emails/_EmailLayout.cshtml b/DysonNetwork.Sphere/Pages/Emails/_EmailLayout.cshtml new file mode 100644 index 0000000..1ca2dc7 --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Emails/_EmailLayout.cshtml @@ -0,0 +1,19 @@ + + + + + + @ViewData["Title"] + + +
+
+ @RenderBody() +
+ +
+

© @DateTime.Now.Year DysonNetwork. All rights reserved.

+
+
+ + \ No newline at end of file diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 86520f9..f437643 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -1,11 +1,11 @@ using System.Globalization; using System.Net; -using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.RateLimiting; using DysonNetwork.Sphere; using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Account.Email; using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Chat; @@ -20,12 +20,11 @@ using DysonNetwork.Sphere.Sticker; using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Wallet; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using NodaTime; using NodaTime.Serialization.SystemTextJson; @@ -34,7 +33,6 @@ using tusdotnet; using tusdotnet.Models; using tusdotnet.Models.Configuration; using tusdotnet.Stores; -using File = System.IO.File; var builder = WebApplication.CreateBuilder(args); @@ -141,6 +139,7 @@ builder.Services.AddSingleton(tusDiskStore); builder.Services.AddScoped(); // Services +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("GeoIP")); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 2e2fe4d..a592d9f 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -1,5 +1,6 @@ { "Debug": true, + "BaseUrl": "http://localhost:5017", "Logging": { "LogLevel": { "Default": "Information", @@ -63,10 +64,11 @@ } }, "Email": { - "Server": "smtpdm.aliyun.com", - "Port": 465, + "Server": "smtp4dev.orb.local", + "Port": 25, + "UseSsl": false, "Username": "no-reply@mail.solsynth.dev", - "Password": "Pe1UeV405PMcQZgv", + "Password": "password", "FromAddress": "no-reply@mail.solsynth.dev", "FromName": "Alphabot", "SubjectPrefix": "Solar Network" diff --git a/DysonNetwork.Sphere/wwwroot/css/styles.css b/DysonNetwork.Sphere/wwwroot/css/styles.css index e011b4d..0c9b08e 100644 --- a/DysonNetwork.Sphere/wwwroot/css/styles.css +++ b/DysonNetwork.Sphere/wwwroot/css/styles.css @@ -227,9 +227,15 @@ .z-50 { z-index: 50; } + .m-0 { + margin: calc(var(--spacing) * 0); + } .mx-auto { margin-inline: auto; } + .my-8 { + margin-block: calc(var(--spacing) * 8); + } .mt-4 { margin-top: calc(var(--spacing) * 4); } @@ -269,6 +275,9 @@ .hidden { display: none; } + .inline-block { + display: inline-block; + } .table { display: table; } @@ -281,6 +290,9 @@ .h-full { height: 100%; } + .min-h-screen { + min-height: 100vh; + } .w-full { width: 100%; } @@ -305,6 +317,13 @@ .justify-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 { column-gap: calc(var(--spacing) * 6); } @@ -314,6 +333,12 @@ .bg-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 { background-color: var(--color-green-100); } @@ -329,21 +354,33 @@ .p-6 { padding: calc(var(--spacing) * 6); } + .p-8 { + padding: calc(var(--spacing) * 8); + } .px-4 { padding-inline: calc(var(--spacing) * 4); } .px-6 { padding-inline: calc(var(--spacing) * 6); } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } .py-3 { padding-block: calc(var(--spacing) * 3); } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } .py-12 { padding-block: calc(var(--spacing) * 12); } .text-center { text-align: center; } + .font-sans { + font-family: var(--font-sans); + } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); @@ -368,6 +405,10 @@ font-size: var(--text-xl); 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 { --tw-leading: calc(var(--spacing) * 8); line-height: calc(var(--spacing) * 8); @@ -388,6 +429,12 @@ --tw-tracking: 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 { 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)); 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 { --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); @@ -453,6 +504,13 @@ } } } + .hover\:bg-blue-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-700); + } + } + } .hover\:text-blue-600 { &: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 { --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); } } + .dark\:text-gray-100 { + @media (prefers-color-scheme: dark) { + color: var(--color-gray-100); + } + } .dark\:text-gray-300 { @media (prefers-color-scheme: dark) { 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; @@ -779,6 +858,11 @@ syntax: "*"; inherits: false; } +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-leading { syntax: "*"; inherits: false; @@ -932,6 +1016,7 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-space-y-reverse: 0; --tw-leading: initial; --tw-font-weight: initial; --tw-tracking: initial;