From 4103f84f859cc3f167a9f784f175f4901ec2f333 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 28 Mar 2026 15:30:07 +0100 Subject: [PATCH] Fix account and backup admin settings flows --- Data/JobTrackerContext.cs | 1 + .../AuthAndSystemControllerTests.cs | 8 +- JobTrackerApi.Tests/BackupControllerTests.cs | 44 ++++++ .../GoogleTokenValidatorTests.cs | 58 +++++++ .../JobApplicationsApplicationPackageTests.cs | 90 +++++++++++ .../Controllers/AdminSystemController.cs | 31 +++- JobTrackerApi/Controllers/BackupController.cs | 5 - JobTrackerApi/Program.cs | 32 +++- .../Services/EmailSettingsResolver.cs | 148 ++++++++++++++++++ .../Services/GoogleTokenValidator.cs | 19 ++- JobTrackerApi/Services/SmtpEmailSender.cs | 32 ++-- Models/SystemEmailSettings.cs | 15 ++ 12 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 JobTrackerApi.Tests/BackupControllerTests.cs create mode 100644 JobTrackerApi.Tests/GoogleTokenValidatorTests.cs create mode 100644 JobTrackerApi/Services/EmailSettingsResolver.cs create mode 100644 Models/SystemEmailSettings.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 69d18e3..bfe90aa 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -20,6 +20,7 @@ namespace JobTrackerApi.Data public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); + public DbSet SystemEmailSettings => Set(); public DbSet JobEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index b2e3e61..08667e2 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -139,7 +139,13 @@ public sealed class AuthAndSystemControllerTests var summarizer = new Mock(); summarizer.Setup(x => x.RunProbeAsync(It.IsAny())).Returns(Task.CompletedTask); - var controller = new AdminSystemController(BuildConfig(), new AppPaths(BuildConfig(), new FakeHostEnv()), null!, summarizer.Object, new FakeEnv()); + var controller = new AdminSystemController( + BuildConfig(), + new AppPaths(BuildConfig(), new FakeHostEnv()), + null!, + summarizer.Object, + new FakeEnv(), + Mock.Of()); var result = await controller.RunSummarizerProbe(CancellationToken.None); diff --git a/JobTrackerApi.Tests/BackupControllerTests.cs b/JobTrackerApi.Tests/BackupControllerTests.cs new file mode 100644 index 0000000..0a2f3e8 --- /dev/null +++ b/JobTrackerApi.Tests/BackupControllerTests.cs @@ -0,0 +1,44 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class BackupControllerTests +{ + [Fact] + public async Task Encrypted_returns_file_payload_on_non_windows_platforms_too() + { + await using var db = CreateDb(); + db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" }); + db.JobApplications.Add(new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" }); + await db.SaveChangesAsync(); + + var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}"))); + var controller = new BackupController(db, new NullLogger(), provider); + + var result = await controller.Encrypted(CancellationToken.None); + + var file = Assert.IsType(result); + Assert.Equal("application/octet-stream", file.ContentType); + Assert.EndsWith(".jtbackup", file.FileDownloadName); + Assert.NotEmpty(file.FileContents); + } + + private static JobTrackerContext CreateDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(x => x.UserId).Returns("user-1"); + return new JobTrackerContext(options, currentUser.Object); + } +} diff --git a/JobTrackerApi.Tests/GoogleTokenValidatorTests.cs b/JobTrackerApi.Tests/GoogleTokenValidatorTests.cs new file mode 100644 index 0000000..750e5dd --- /dev/null +++ b/JobTrackerApi.Tests/GoogleTokenValidatorTests.cs @@ -0,0 +1,58 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using JobTrackerApi.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class GoogleTokenValidatorTests +{ + [Fact] + public async Task ValidateAsync_accepts_subject_mapped_to_nameidentifier_claim() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Auth:GoogleClientId"] = "client-123", + }) + .Build(); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret")); + var oidc = new OpenIdConnectConfiguration(); + oidc.SigningKeys.Add(signingKey); + + var configManager = new Mock>(); + configManager.Setup(x => x.GetConfigurationAsync(It.IsAny())).ReturnsAsync(oidc); + + var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: "https://accounts.google.com", + audience: "client-123", + claims: new[] + { + new Claim(JwtRegisteredClaimNames.Sub, "google-subject-1"), + new Claim("email", "demo@example.com"), + new Claim("email_verified", "true"), + new Claim("given_name", "Demo"), + new Claim("family_name", "User"), + new Claim("name", "Demo User"), + }, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256))); + + var validator = new GoogleTokenValidator(config, configManager.Object); + var result = await validator.ValidateAsync(token); + + Assert.Equal("google-subject-1", result.Subject); + Assert.Equal("demo@example.com", result.Email); + Assert.True(result.EmailVerified); + Assert.Equal("Demo", result.GivenName); + Assert.Equal("User", result.FamilyName); + Assert.Equal("Demo User", result.Name); + } +} diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index 1f31b2d..8662c89 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -144,6 +144,96 @@ public sealed class JobApplicationsApplicationPackageTests Assert.Contains(payload.KeyPoints, item => item.Contains("recruiter language", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task Generate_application_package_passes_typed_structured_cv_context_to_summarizer() + { + await using var db = CreateDb(); + var company = new Company + { + Name = "Acme", + OwnerUserId = "user-1" + }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser + { + Id = "user-1", + UserName = "user@example.test", + Email = "user@example.test", + ProfileCvText = "Built APIs and shipped backend work.", + ProfileCvStructureJson = """ + { + "version": "1", + "contact": { + "fullName": "Demo User", + "headline": "Backend Developer", + "email": "user@example.test", + "location": "Oslo" + }, + "summary": ["Backend-focused developer with strong API delivery experience."], + "jobs": [ + { + "title": "System Developer", + "company": "Acme Consulting", + "location": "Oslo", + "start": "2020", + "end": "2024", + "isCurrent": false, + "bullets": ["Owned .NET API delivery across multiple services."], + "skills": [".NET", "SQL", "APIs"] + } + ], + "education": [], + "skills": [".NET", "SQL", "APIs"], + "languages": [{ "name": "English", "level": "Native" }], + "interests": [], + "otherSections": [] + } + """ + }); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1", + Description = "Need .NET API ownership and strong SQL skills." + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + string? capturedContext = null; + var summarizer = new Mock(); + summarizer + .Setup(service => service.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string instruction, string context, int _, int __) => + { + if (instruction.Contains("Rewrite the candidate CV", StringComparison.OrdinalIgnoreCase)) + { + capturedContext = context; + return "Tailored CV"; + } + + if (instruction.Contains("List up to 4 concrete application-package signals", StringComparison.OrdinalIgnoreCase)) + { + return "Lead with .NET API ownership."; + } + + return "Draft"; + }); + + var controller = CreateController(db, summarizer.Object, "user-1"); + var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None); + + Assert.IsType(result.Result); + Assert.NotNull(capturedContext); + Assert.Contains("Structured CV:", capturedContext); + Assert.Contains("Name: Demo User", capturedContext); + Assert.Contains("Skills:\n.NET, SQL, APIs", capturedContext); + Assert.Contains("Work Experience:", capturedContext); + Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext); + } + private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) { var controller = new JobApplicationsController(db, summarizer, Mock.Of(), CreateUserManager().Object, NullLogger.Instance); diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index 093822a..4c81362 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -18,14 +18,16 @@ public sealed class AdminSystemController : ControllerBase private readonly JobTrackerContext _db; private readonly ISummarizerService _summarizer; private readonly IWebHostEnvironment _env; + private readonly IEmailSettingsResolver _emailSettings; - public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env) + public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings) { _cfg = cfg; _paths = paths; _db = db; _summarizer = summarizer; _env = env; + _emailSettings = emailSettings; } public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount); @@ -70,6 +72,20 @@ public sealed class AdminSystemController : ControllerBase return NoContent(); } + [HttpGet("email-settings")] + public async Task> GetEmailSettings(CancellationToken cancellationToken) + { + return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken)); + } + + [HttpPut("email-settings")] + public async Task> UpdateEmailSettings([FromBody] UpdateEmailSettingsRequest request, CancellationToken cancellationToken) + { + if (request.Port <= 0) return BadRequest("SMTP port must be greater than 0."); + if (request.TimeoutMs <= 0) return BadRequest("SMTP timeout must be greater than 0."); + return Ok(await _emailSettings.UpdateAsync(request, cancellationToken)); + } + [HttpGet] public async Task> Get(CancellationToken cancellationToken) { @@ -191,6 +207,7 @@ public sealed class AdminSystemController : ControllerBase var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim()) && !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim()); + var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken); return Ok(new SystemStatusDto( Environment: _env.EnvironmentName, @@ -208,12 +225,12 @@ public sealed class AdminSystemController : ControllerBase DeletedCount: deletedCount ), Email: new EmailStatusDto( - Enabled: _cfg.GetValue("Email:Enabled", false), - Host: (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(), - Port: _cfg.GetValue("Email:SmtpPort", 587), - EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true), - From: (_cfg["Email:From"] ?? string.Empty).Trim(), - FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim() + Enabled: emailSettings.Enabled, + Host: emailSettings.Host, + Port: emailSettings.Port, + EnableSsl: emailSettings.EnableSsl, + From: emailSettings.From, + FromName: emailSettings.FromName ), Database: new DatabaseStatusDto( Provider: provider, diff --git a/JobTrackerApi/Controllers/BackupController.cs b/JobTrackerApi/Controllers/BackupController.cs index 3481dc4..1d19379 100644 --- a/JobTrackerApi/Controllers/BackupController.cs +++ b/JobTrackerApi/Controllers/BackupController.cs @@ -31,11 +31,6 @@ namespace JobTrackerApi.Controllers [HttpPost("encrypted")] public async Task Encrypted(CancellationToken cancellationToken) { - if (!OperatingSystem.IsWindows()) - { - return StatusCode(501, "Encrypted backups are only implemented for Windows (DPAPI) in this build."); - } - var data = await BuildExport(cancellationToken); var envelope = new BackupEnvelope("jtbackup.v1", DateTime.Now, data); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 931a90b..72e45c6 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -28,7 +28,8 @@ builder.Logging.AddDebug(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -722,6 +723,15 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( return cmd.ExecuteScalar() is not null; } + static bool HasMySqlTable(MySqlConnection c, string table) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; + cmd.Parameters.AddWithValue("@schema", c.Database); + cmd.Parameters.AddWithValue("@table", table); + return cmd.ExecuteScalar() is not null; + } + static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl) { using var existsCmd = c.CreateCommand(); @@ -782,6 +792,25 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;"); + if (!HasMySqlTable(conn, "SystemEmailSettings")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` ( +`Id` int NOT NULL, +`Enabled` tinyint(1) NULL, +`SmtpHost` longtext NULL, +`SmtpPort` int NULL, +`SmtpUser` longtext NULL, +`SmtpPassword` longtext NULL, +`From` longtext NULL, +`FromName` longtext NULL, +`SmtpEnableSsl` tinyint(1) NULL, +`SmtpTimeoutMs` int NULL, +PRIMARY KEY (`Id`) +);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); @@ -905,3 +934,4 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); +app.Run(); diff --git a/JobTrackerApi/Services/EmailSettingsResolver.cs b/JobTrackerApi/Services/EmailSettingsResolver.cs new file mode 100644 index 0000000..c46ab86 --- /dev/null +++ b/JobTrackerApi/Services/EmailSettingsResolver.cs @@ -0,0 +1,148 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed record EmailSettingsSnapshot( + bool Enabled, + string Host, + int Port, + string User, + string Password, + string From, + string FromName, + bool EnableSsl, + int TimeoutMs, + bool UsesOverrides, + bool HasPassword); + +public sealed record EmailSettingsAdminDto( + bool Enabled, + string Host, + int Port, + string User, + string From, + string FromName, + bool EnableSsl, + int TimeoutMs, + bool UsesOverrides, + bool HasPassword); + +public sealed record UpdateEmailSettingsRequest( + bool Enabled, + string? Host, + int Port, + string? User, + string? Password, + bool ClearPassword, + string? From, + string? FromName, + bool EnableSsl, + int TimeoutMs); + +public interface IEmailSettingsResolver +{ + Task GetSnapshotAsync(CancellationToken cancellationToken = default); + Task GetAdminDtoAsync(CancellationToken cancellationToken = default); + Task UpdateAsync(UpdateEmailSettingsRequest request, CancellationToken cancellationToken = default); +} + +public sealed class EmailSettingsResolver : IEmailSettingsResolver +{ + private readonly IConfiguration _cfg; + private readonly JobTrackerContext _db; + + public EmailSettingsResolver(IConfiguration cfg, JobTrackerContext db) + { + _cfg = cfg; + _db = db; + } + + public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) + { + var overrides = await _db.SystemEmailSettings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); + return Merge(overrides); + } + + public async Task GetAdminDtoAsync(CancellationToken cancellationToken = default) + { + var snapshot = await GetSnapshotAsync(cancellationToken); + return new EmailSettingsAdminDto( + Enabled: snapshot.Enabled, + Host: snapshot.Host, + Port: snapshot.Port, + User: snapshot.User, + From: snapshot.From, + FromName: snapshot.FromName, + EnableSsl: snapshot.EnableSsl, + TimeoutMs: snapshot.TimeoutMs, + UsesOverrides: snapshot.UsesOverrides, + HasPassword: snapshot.HasPassword); + } + + public async Task UpdateAsync(UpdateEmailSettingsRequest request, CancellationToken cancellationToken = default) + { + var settings = await _db.SystemEmailSettings.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken); + if (settings is null) + { + settings = new SystemEmailSettings { Id = 1 }; + _db.SystemEmailSettings.Add(settings); + } + + settings.Enabled = request.Enabled; + settings.SmtpHost = TrimOrNull(request.Host); + settings.SmtpPort = request.Port <= 0 ? 587 : request.Port; + settings.SmtpUser = TrimOrNull(request.User); + settings.From = TrimOrNull(request.From); + settings.FromName = TrimOrNull(request.FromName); + settings.SmtpEnableSsl = request.EnableSsl; + settings.SmtpTimeoutMs = request.TimeoutMs <= 0 ? 15000 : request.TimeoutMs; + + if (request.ClearPassword) + { + settings.SmtpPassword = null; + } + else if (!string.IsNullOrWhiteSpace(request.Password)) + { + settings.SmtpPassword = request.Password.Trim(); + } + + await _db.SaveChangesAsync(cancellationToken); + return await GetAdminDtoAsync(cancellationToken); + } + + private EmailSettingsSnapshot Merge(SystemEmailSettings? overrides) + { + var host = overrides?.SmtpHost ?? (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(); + var user = overrides?.SmtpUser ?? (_cfg["Email:SmtpUser"] ?? string.Empty).Trim(); + var password = overrides?.SmtpPassword ?? (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim(); + var from = overrides?.From ?? (_cfg["Email:From"] ?? user).Trim(); + var fromName = overrides?.FromName ?? (_cfg["Email:FromName"] ?? "Jobbjakt").Trim(); + var port = overrides?.SmtpPort ?? _cfg.GetValue("Email:SmtpPort", 587); + if (port <= 0) port = 587; + var enableSsl = overrides?.SmtpEnableSsl ?? _cfg.GetValue("Email:SmtpEnableSsl", true); + var timeoutMs = overrides?.SmtpTimeoutMs ?? _cfg.GetValue("Email:SmtpTimeoutMs", 15000); + if (timeoutMs <= 0) timeoutMs = 15000; + var enabled = overrides?.Enabled ?? _cfg.GetValue("Email:Enabled", false); + var usesOverrides = overrides is not null; + + return new EmailSettingsSnapshot( + Enabled: enabled, + Host: host, + Port: port, + User: user, + Password: password, + From: from, + FromName: fromName, + EnableSsl: enableSsl, + TimeoutMs: timeoutMs, + UsesOverrides: usesOverrides, + HasPassword: !string.IsNullOrWhiteSpace(password)); + } + + private static string? TrimOrNull(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/JobTrackerApi/Services/GoogleTokenValidator.cs b/JobTrackerApi/Services/GoogleTokenValidator.cs index 538f64f..ac05b6f 100644 --- a/JobTrackerApi/Services/GoogleTokenValidator.cs +++ b/JobTrackerApi/Services/GoogleTokenValidator.cs @@ -1,4 +1,5 @@ using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -25,6 +26,12 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator new OpenIdConnectConfigurationRetriever()); } + public GoogleTokenValidator(IConfiguration cfg, IConfigurationManager configManager) + { + _cfg = cfg; + _configManager = configManager; + } + public async Task ValidateAsync(string idToken, CancellationToken cancellationToken = default) { var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim(); @@ -47,7 +54,9 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator ClockSkew = TimeSpan.FromMinutes(2), }, out _); - var subject = principal.FindFirst("sub")?.Value?.Trim(); + var subject = principal.FindFirst("sub")?.Value?.Trim() + ?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim() + ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim(); if (string.IsNullOrWhiteSpace(subject)) { throw new InvalidOperationException("Google token is missing a subject."); @@ -55,11 +64,11 @@ public sealed class GoogleTokenValidator : IGoogleTokenValidator return new GoogleTokenPrincipal( Subject: subject, - Email: principal.FindFirst("email")?.Value?.Trim(), + Email: principal.FindFirst("email")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim(), EmailVerified: IsEmailVerified(principal), - GivenName: principal.FindFirst("given_name")?.Value?.Trim(), - FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), - Name: principal.FindFirst("name")?.Value?.Trim() + GivenName: principal.FindFirst("given_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.GivenName)?.Value?.Trim(), + FamilyName: principal.FindFirst("family_name")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Surname)?.Value?.Trim(), + Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim() ); } diff --git a/JobTrackerApi/Services/SmtpEmailSender.cs b/JobTrackerApi/Services/SmtpEmailSender.cs index 18e5b82..a308489 100644 --- a/JobTrackerApi/Services/SmtpEmailSender.cs +++ b/JobTrackerApi/Services/SmtpEmailSender.cs @@ -10,34 +10,31 @@ public interface IAppEmailSender public sealed class SmtpEmailSender : IAppEmailSender { - private readonly IConfiguration _cfg; + private readonly IEmailSettingsResolver _settings; private readonly ILogger _logger; - public SmtpEmailSender(IConfiguration cfg, ILogger logger) + public SmtpEmailSender(IEmailSettingsResolver settings, ILogger logger) { - _cfg = cfg; + _settings = settings; _logger = logger; } public async Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default) { - var host = (_cfg["Email:SmtpHost"] ?? "").Trim(); - var user = (_cfg["Email:SmtpUser"] ?? "").Trim(); - var pass = (_cfg["Email:SmtpPassword"] ?? "").Trim(); - var from = (_cfg["Email:From"] ?? user).Trim(); - var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim(); + var snapshot = await _settings.GetSnapshotAsync(cancellationToken); + var host = snapshot.Host; + var user = snapshot.User; + var pass = snapshot.Password; + var from = snapshot.From; + var fromName = snapshot.FromName; + var port = snapshot.Port; + var enableSsl = snapshot.EnableSsl; + var timeoutMs = snapshot.TimeoutMs; - var port = _cfg.GetValue("Email:SmtpPort", 587); - if (port <= 0) port = 587; - - var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true); - var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000); - if (timeoutMs <= 0) timeoutMs = 15000; - - var enabled = _cfg.GetValue("Email:Enabled", false); + var enabled = snapshot.Enabled; if (!enabled) { - _logger.LogWarning("Email sending is disabled (Email:Enabled=false). Suppressed email to {To} subject={Subject}", toEmail, subject); + _logger.LogWarning("Email sending is disabled. Suppressed email to {To} subject={Subject}", toEmail, subject); return; } @@ -63,7 +60,6 @@ public sealed class SmtpEmailSender : IAppEmailSender smtp.Credentials = new NetworkCredential(user, pass); } - // SmtpClient has no CancellationToken support; run on thread pool. await Task.Run(() => smtp.Send(msg), cancellationToken); } } diff --git a/Models/SystemEmailSettings.cs b/Models/SystemEmailSettings.cs new file mode 100644 index 0000000..8188730 --- /dev/null +++ b/Models/SystemEmailSettings.cs @@ -0,0 +1,15 @@ +namespace JobTrackerApi.Models; + +public sealed class SystemEmailSettings +{ + public int Id { get; set; } = 1; + public bool? Enabled { get; set; } + public string? SmtpHost { get; set; } + public int? SmtpPort { get; set; } + public string? SmtpUser { get; set; } + public string? SmtpPassword { get; set; } + public string? From { get; set; } + public string? FromName { get; set; } + public bool? SmtpEnableSsl { get; set; } + public int? SmtpTimeoutMs { get; set; } +}