diff --git a/Rnwood.Smtp4dev/ApiModel/GitHubRelease.cs b/Rnwood.Smtp4dev/ApiModel/GitHubRelease.cs new file mode 100644 index 000000000..ac30a1024 --- /dev/null +++ b/Rnwood.Smtp4dev/ApiModel/GitHubRelease.cs @@ -0,0 +1,12 @@ +namespace Rnwood.Smtp4dev.ApiModel +{ + public class GitHubRelease + { + public string TagName { get; set; } + public string Name { get; set; } + public string Body { get; set; } + public bool Prerelease { get; set; } + public string PublishedAt { get; set; } + public string HtmlUrl { get; set; } + } +} diff --git a/Rnwood.Smtp4dev/ApiModel/Server.cs b/Rnwood.Smtp4dev/ApiModel/Server.cs index cbd8179e8..0fd1551e0 100644 --- a/Rnwood.Smtp4dev/ApiModel/Server.cs +++ b/Rnwood.Smtp4dev/ApiModel/Server.cs @@ -76,6 +76,8 @@ public class Server public bool DisableHtmlValidation { get; set; } public bool DisableHtmlCompatibilityCheck { get; set; } public string CommandValidationExpression { get; set; } + public bool DisableWhatsNewNotifications { get; set; } + public bool DisableUpdateNotifications { get; set; } } } diff --git a/Rnwood.Smtp4dev/ApiModel/UpdateCheckResult.cs b/Rnwood.Smtp4dev/ApiModel/UpdateCheckResult.cs new file mode 100644 index 000000000..2d78a9ee8 --- /dev/null +++ b/Rnwood.Smtp4dev/ApiModel/UpdateCheckResult.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Rnwood.Smtp4dev.ApiModel +{ + public class UpdateCheckResult + { + public bool UpdateAvailable { get; set; } + public List NewReleases { get; set; } = new List(); + public string CurrentVersion { get; set; } + public bool ShowWhatsNew { get; set; } + public string LastSeenVersion { get; set; } + } +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/GitHubRelease.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/GitHubRelease.ts new file mode 100644 index 000000000..39927ae04 --- /dev/null +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/GitHubRelease.ts @@ -0,0 +1,8 @@ +export default class GitHubRelease { + tagName: string; + name: string; + body: string; + prerelease: boolean; + publishedAt: string; + htmlUrl: string; +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdateCheckResult.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdateCheckResult.ts new file mode 100644 index 000000000..dd6cd1e0d --- /dev/null +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdateCheckResult.ts @@ -0,0 +1,9 @@ +import GitHubRelease from "./GitHubRelease"; + +export default class UpdateCheckResult { + updateAvailable: boolean; + newReleases: GitHubRelease[]; + currentVersion: string; + showWhatsNew: boolean; + lastSeenVersion: string; +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdatesController.ts b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdatesController.ts new file mode 100644 index 000000000..51aa45266 --- /dev/null +++ b/Rnwood.Smtp4dev/ClientApp/src/ApiClient/UpdatesController.ts @@ -0,0 +1,26 @@ +import axios from "axios"; +import UpdateCheckResult from "./UpdateCheckResult"; + +export default class UpdatesController { + constructor() {} + + public checkForUpdates_url(username: string | null = null): string { + let url = "api/Updates/check"; + if (username) { + url += `?username=${encodeURIComponent(username)}`; + } + return url; + } + + public async checkForUpdates(username: string | null = null): Promise { + return (await axios.get(this.checkForUpdates_url(username))).data as UpdateCheckResult; + } + + public markVersionAsSeen_url(username: string, version: string): string { + return `api/Updates/mark-seen?username=${encodeURIComponent(username)}&version=${encodeURIComponent(version)}`; + } + + public async markVersionAsSeen(username: string, version: string): Promise { + await axios.post(this.markVersionAsSeen_url(username, version)); + } +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/UpdateNotificationManager.ts b/Rnwood.Smtp4dev/ClientApp/src/UpdateNotificationManager.ts new file mode 100644 index 000000000..951d65a1c --- /dev/null +++ b/Rnwood.Smtp4dev/ClientApp/src/UpdateNotificationManager.ts @@ -0,0 +1,101 @@ +import UpdatesController from "./ApiClient/UpdatesController"; +import UpdateCheckResult from "./ApiClient/UpdateCheckResult"; + +export default class UpdateNotificationManager { + private updatesController: UpdatesController; + private lastCheckDate: Date | null = null; + private checkIntervalMs = 24 * 60 * 60 * 1000; // 24 hours + private intervalId: any = null; + private onUpdateAvailableCallback: ((result: UpdateCheckResult) => void) | null = null; + private onWhatsNewCallback: ((result: UpdateCheckResult) => void) | null = null; + + constructor() { + this.updatesController = new UpdatesController(); + } + + public async checkForUpdates(): Promise { + const username = this.getUsername(); + const result = await this.updatesController.checkForUpdates(username); + this.lastCheckDate = new Date(); + this.saveLastCheckDate(); + + if (result.updateAvailable && this.onUpdateAvailableCallback) { + this.onUpdateAvailableCallback(result); + } + + if (result.showWhatsNew && this.onWhatsNewCallback) { + this.onWhatsNewCallback(result); + } + + return result; + } + + public async markVersionAsSeen(version: string): Promise { + const username = this.getUsername(); + await this.updatesController.markVersionAsSeen(username, version); + this.saveSeenVersion(version); + } + + public onUpdateAvailable(callback: (result: UpdateCheckResult) => void): void { + this.onUpdateAvailableCallback = callback; + } + + public onWhatsNew(callback: (result: UpdateCheckResult) => void): void { + this.onWhatsNewCallback = callback; + } + + public startPeriodicCheck(): void { + // Check immediately if we haven't checked today + const lastCheck = this.getLastCheckDate(); + if (!lastCheck || this.isDayOld(lastCheck)) { + this.checkForUpdates(); + } + + // Set up periodic checks + if (this.intervalId) { + clearInterval(this.intervalId); + } + + this.intervalId = setInterval(() => { + this.checkForUpdates(); + }, this.checkIntervalMs); + } + + public stopPeriodicCheck(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + private getUsername(): string { + // Try to get username from storage or use anonymous + return localStorage.getItem('smtp4dev_username') || 'anonymous'; + } + + private getLastCheckDate(): Date | null { + const stored = localStorage.getItem('smtp4dev_last_update_check'); + if (stored) { + return new Date(stored); + } + return null; + } + + private saveLastCheckDate(): void { + localStorage.setItem('smtp4dev_last_update_check', new Date().toISOString()); + } + + private isDayOld(date: Date): boolean { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + return diffMs >= this.checkIntervalMs; + } + + private saveSeenVersion(version: string): void { + localStorage.setItem('smtp4dev_last_seen_version', version); + } + + public getSeenVersion(): string | null { + return localStorage.getItem('smtp4dev_last_seen_version'); + } +} diff --git a/Rnwood.Smtp4dev/ClientApp/src/components/home/home.vue b/Rnwood.Smtp4dev/ClientApp/src/components/home/home.vue index cf9ea171f..b8c2a66e5 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/components/home/home.vue +++ b/Rnwood.Smtp4dev/ClientApp/src/components/home/home.vue @@ -32,6 +32,12 @@ + @@ -95,10 +101,13 @@ import HubConnectionManager from "@/HubConnectionManager"; import ServerStatus from "@/components/serverstatus.vue"; import SettingsDialog from "@/components/settingsdialog.vue"; + import WhatsNewDialog from "@/components/whatsnewdialog.vue"; import HubConnectionStatus from "@/components/hubconnectionstatus.vue"; import { Splitpanes, Pane } from 'splitpanes'; import 'splitpanes/dist/splitpanes.css'; - import { ElIcon } from "element-plus"; + import { ElIcon, ElNotification } from "element-plus"; + import UpdateNotificationManager from "@/UpdateNotificationManager"; + import UpdateCheckResult from "@/ApiClient/UpdateCheckResult"; @Component({ components: { @@ -109,6 +118,7 @@ hubconnstatus: HubConnectionStatus, serverstatus: ServerStatus, settingsdialog: SettingsDialog, + whatsnewdialog: WhatsNewDialog, splitpanes: Splitpanes, pane: Pane, VersionInfo, @@ -123,6 +133,10 @@ connection: HubConnectionManager | null = null; settingsVisible: boolean = false; + whatsNewVisible: boolean = false; + updateCheckResult: UpdateCheckResult | null = null; + updateManager = new UpdateNotificationManager(); + updateNotification: any = null; showSettings(visible: boolean) { this.settingsVisible = visible; @@ -185,12 +199,58 @@ async mounted() { this.connection = new HubConnectionManager("hubs/notifications") this.connection.start(); + + // Setup update notification callbacks + this.updateManager.onWhatsNew((result) => { + this.updateCheckResult = result; + this.whatsNewVisible = true; + }); + + this.updateManager.onUpdateAvailable((result) => { + this.showUpdateNotification(result); + }); + + // Start periodic update checks + this.updateManager.startPeriodicCheck(); } destroyed() { if (this.connection) { this.connection.stop(); } + this.updateManager.stopPeriodicCheck(); + if (this.updateNotification) { + this.updateNotification.close(); + } + } + + showUpdateNotification(result: UpdateCheckResult) { + if (this.updateNotification) { + this.updateNotification.close(); + } + + this.updateNotification = ElNotification({ + title: 'Update Available', + message: `A new version (${result.newReleases[0]?.tagName}) is available. Click to view release notes.`, + type: 'warning', + duration: 0, // Don't auto-dismiss + onClick: () => { + this.updateCheckResult = result; + this.whatsNewVisible = true; + } + }); + } + + dismissWhatsNew() { + this.whatsNewVisible = false; + } + + async onMarkedRead() { + this.whatsNewVisible = false; + if (this.updateNotification) { + this.updateNotification.close(); + this.updateNotification = null; + } } } diff --git a/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue b/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue index 703d65b5f..7cc26a41d 100644 --- a/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue +++ b/Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue @@ -82,6 +82,18 @@ + + + + + + + + + + + + diff --git a/Rnwood.Smtp4dev/ClientApp/src/components/whatsnewdialog.vue b/Rnwood.Smtp4dev/ClientApp/src/components/whatsnewdialog.vue new file mode 100644 index 000000000..9a11fda2b --- /dev/null +++ b/Rnwood.Smtp4dev/ClientApp/src/components/whatsnewdialog.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/Rnwood.Smtp4dev/Controllers/ServerController.cs b/Rnwood.Smtp4dev/Controllers/ServerController.cs index dae1a1fe9..ff0e21409 100644 --- a/Rnwood.Smtp4dev/Controllers/ServerController.cs +++ b/Rnwood.Smtp4dev/Controllers/ServerController.cs @@ -122,7 +122,9 @@ public ApiModel.Server GetServer() CurrentUserDefaultMailboxName = currentUserDefaultMailbox, HtmlValidateConfig = serverOptionsCurrentValue.HtmlValidateConfig != null ? serverOptionsCurrentValue.HtmlValidateConfig : null, DisableHtmlValidation = serverOptionsCurrentValue.DisableHtmlValidation, - DisableHtmlCompatibilityCheck = serverOptionsCurrentValue.DisableHtmlCompatibilityCheck + DisableHtmlCompatibilityCheck = serverOptionsCurrentValue.DisableHtmlCompatibilityCheck, + DisableWhatsNewNotifications = serverOptionsCurrentValue.DisableWhatsNewNotifications, + DisableUpdateNotifications = serverOptionsCurrentValue.DisableUpdateNotifications }; } @@ -289,6 +291,8 @@ public ActionResult UpdateServer(ApiModel.Server serverUpdate) newSettings.HtmlValidateConfig = serverUpdate.HtmlValidateConfig != defaultSettingsFile.ServerOptions.HtmlValidateConfig ? serverUpdate.HtmlValidateConfig : null; newSettings.DisableHtmlValidation = serverUpdate.DisableHtmlValidation != defaultSettingsFile.ServerOptions.DisableHtmlValidation ? serverUpdate.DisableHtmlValidation : null; newSettings.DisableHtmlCompatibilityCheck = serverUpdate.DisableHtmlCompatibilityCheck != defaultSettingsFile.ServerOptions.DisableHtmlCompatibilityCheck ? serverUpdate.DisableHtmlCompatibilityCheck : null; + newSettings.DisableWhatsNewNotifications = serverUpdate.DisableWhatsNewNotifications != defaultSettingsFile.ServerOptions.DisableWhatsNewNotifications ? serverUpdate.DisableWhatsNewNotifications : null; + newSettings.DisableUpdateNotifications = serverUpdate.DisableUpdateNotifications != defaultSettingsFile.ServerOptions.DisableUpdateNotifications ? serverUpdate.DisableUpdateNotifications : null; newRelaySettings.SmtpServer = serverUpdate.RelaySmtpServer != defaultSettingsFile.RelayOptions.SmtpServer ? serverUpdate.RelaySmtpServer : null; newRelaySettings.SmtpPort = serverUpdate.RelaySmtpPort != defaultSettingsFile.RelayOptions.SmtpPort ? serverUpdate.RelaySmtpPort : null; diff --git a/Rnwood.Smtp4dev/Controllers/UpdatesController.cs b/Rnwood.Smtp4dev/Controllers/UpdatesController.cs new file mode 100644 index 000000000..e57875268 --- /dev/null +++ b/Rnwood.Smtp4dev/Controllers/UpdatesController.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Rnwood.Smtp4dev.ApiModel; +using Rnwood.Smtp4dev.Service; + +namespace Rnwood.Smtp4dev.Controllers +{ + /// + /// Handles update notifications and what's new functionality + /// + [Route("api/[controller]")] + [ApiController] + public class UpdatesController : Controller + { + private readonly UpdateNotificationService _updateService; + + public UpdatesController(UpdateNotificationService updateService) + { + _updateService = updateService; + } + + /// + /// Checks for updates and new releases + /// + /// Optional username to track version per user + /// Update check result with available updates and what's new information + [HttpGet("check")] + public async Task> CheckForUpdates([FromQuery] string username = null) + { + var result = await _updateService.CheckForUpdatesAsync(username); + return Ok(result); + } + + /// + /// Marks a version as seen by the user + /// + /// Username to track version for + /// Version that was seen + [HttpPost("mark-seen")] + public async Task MarkVersionAsSeen([FromQuery] string username, [FromQuery] string version) + { + await _updateService.MarkVersionAsSeenAsync(username ?? "anonymous", version); + return Ok(); + } + } +} diff --git a/Rnwood.Smtp4dev/Data/Smtp4devDbContext.cs b/Rnwood.Smtp4dev/Data/Smtp4devDbContext.cs index 9b5a36385..c6488f6cd 100644 --- a/Rnwood.Smtp4dev/Data/Smtp4devDbContext.cs +++ b/Rnwood.Smtp4dev/Data/Smtp4devDbContext.cs @@ -61,5 +61,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet Mailboxes { get; set; } public DbSet MailboxFolders { get; set; } + + public DbSet UserVersionInfos { get; set; } } } \ No newline at end of file diff --git a/Rnwood.Smtp4dev/DbModel/UserVersionInfo.cs b/Rnwood.Smtp4dev/DbModel/UserVersionInfo.cs new file mode 100644 index 000000000..2abaad008 --- /dev/null +++ b/Rnwood.Smtp4dev/DbModel/UserVersionInfo.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Rnwood.Smtp4dev.DbModel +{ + public class UserVersionInfo + { + [Key] + public Guid Id { get; set; } + + public string Username { get; set; } + + public string LastSeenVersion { get; set; } + + public DateTime LastCheckedDate { get; set; } + + public bool WhatsNewDismissed { get; set; } + + public bool UpdateNotificationDismissed { get; set; } + } +} diff --git a/Rnwood.Smtp4dev/Migrations/20250915000000_AddUserVersionInfo.cs b/Rnwood.Smtp4dev/Migrations/20250915000000_AddUserVersionInfo.cs new file mode 100644 index 000000000..6465f468a --- /dev/null +++ b/Rnwood.Smtp4dev/Migrations/20250915000000_AddUserVersionInfo.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +#nullable disable + +namespace Rnwood.Smtp4dev.Migrations +{ + /// + public partial class AddUserVersionInfo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserVersionInfos", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", nullable: true), + LastSeenVersion = table.Column(type: "TEXT", nullable: true), + LastCheckedDate = table.Column(type: "TEXT", nullable: false), + WhatsNewDismissed = table.Column(type: "INTEGER", nullable: false), + UpdateNotificationDismissed = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserVersionInfos", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserVersionInfos"); + } + } +} diff --git a/Rnwood.Smtp4dev/Program.cs b/Rnwood.Smtp4dev/Program.cs index 5332d3b14..8e07125ca 100644 --- a/Rnwood.Smtp4dev/Program.cs +++ b/Rnwood.Smtp4dev/Program.cs @@ -118,6 +118,26 @@ public static async Task StartApp(IEnumerable args, bool isDeskto _log.Information("Now listening on: {url}", url); } + // Check for updates and what's new on startup + _ = Task.Run(async () => + { + try + { + using var scope = host.Services.CreateScope(); + var updateService = scope.ServiceProvider.GetService(); + if (updateService != null) + { + var result = await updateService.CheckForUpdatesAsync(); + updateService.LogWhatsNewNotification(result); + updateService.LogUpdateNotification(result); + } + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to check for updates on startup"); + } + }); + return host; diff --git a/Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs b/Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs index 558965006..7f5447045 100644 --- a/Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs +++ b/Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs @@ -87,6 +87,10 @@ public record ServerOptions public bool ValidateBareLineFeed { get; set; } = false; public bool Pop3SecureConnectionRequired { get; set; } = false; + + public bool DisableWhatsNewNotifications { get; set; } = false; + + public bool DisableUpdateNotifications { get; set; } = false; } } diff --git a/Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs b/Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs index 3667b6479..55b866bb3 100644 --- a/Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs +++ b/Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs @@ -77,5 +77,9 @@ public record ServerOptionsSource public bool? DisableHtmlCompatibilityCheck { get; set; } public long? MaxMessageSize { get; set; } + + public bool? DisableWhatsNewNotifications { get; set; } + + public bool? DisableUpdateNotifications { get; set; } } } diff --git a/Rnwood.Smtp4dev/Service/UpdateNotificationService.cs b/Rnwood.Smtp4dev/Service/UpdateNotificationService.cs new file mode 100644 index 000000000..13db81754 --- /dev/null +++ b/Rnwood.Smtp4dev/Service/UpdateNotificationService.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Rnwood.Smtp4dev.ApiModel; +using Rnwood.Smtp4dev.Data; +using Rnwood.Smtp4dev.DbModel; +using Rnwood.Smtp4dev.Server.Settings; + +namespace Rnwood.Smtp4dev.Service +{ + public class UpdateNotificationService + { + private readonly ILogger _logger; + private readonly ServerOptions _serverOptions; + private readonly Smtp4devDbContext _dbContext; + private readonly IHttpClientFactory _httpClientFactory; + private static readonly string _currentVersion; + private static readonly bool _isPrerelease; + private static readonly string _prereleasePrefix; + + static UpdateNotificationService() + { + var infoVersion = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion; + _currentVersion = infoVersion ?? "0.0.0"; + + // Check if current version is a prerelease (contains - after version number) + var match = Regex.Match(_currentVersion, @"^(\d+\.\d+\.\d+)(?:-([^+]+))?"); + _isPrerelease = match.Success && !string.IsNullOrEmpty(match.Groups[2].Value); + _prereleasePrefix = match.Success && _isPrerelease ? match.Groups[2].Value.Split('.')[0] : ""; + } + + public UpdateNotificationService( + ILogger logger, + ServerOptions serverOptions, + Smtp4devDbContext dbContext, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _serverOptions = serverOptions; + _dbContext = dbContext; + _httpClientFactory = httpClientFactory; + } + + public async Task CheckForUpdatesAsync(string username = null) + { + var result = new UpdateCheckResult + { + CurrentVersion = _currentVersion + }; + + try + { + // Get last seen version from database or default + UserVersionInfo userVersionInfo = null; + if (!string.IsNullOrEmpty(username)) + { + userVersionInfo = _dbContext.UserVersionInfos + .FirstOrDefault(u => u.Username == username); + } + + var lastSeenVersion = userVersionInfo?.LastSeenVersion; + result.LastSeenVersion = lastSeenVersion; + + // Fetch releases from GitHub + var releases = await FetchGitHubReleasesAsync(); + + if (releases == null || releases.Count == 0) + { + return result; + } + + // Filter releases based on prerelease rules + var relevantReleases = FilterRelevantReleases(releases); + + // Check for what's new + if (!_serverOptions.DisableWhatsNewNotifications) + { + result.ShowWhatsNew = ShouldShowWhatsNew(userVersionInfo, relevantReleases); + if (result.ShowWhatsNew) + { + result.NewReleases = GetReleasesSince(relevantReleases, lastSeenVersion, 10); + } + } + + // Check for updates + if (!_serverOptions.DisableUpdateNotifications) + { + var newerReleases = GetNewerReleases(relevantReleases, _currentVersion); + if (newerReleases.Any()) + { + result.UpdateAvailable = true; + result.NewReleases = newerReleases; + } + } + + // Update last checked date + if (userVersionInfo != null) + { + userVersionInfo.LastCheckedDate = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check for updates"); + } + + return result; + } + + public async Task MarkVersionAsSeenAsync(string username, string version) + { + var userVersionInfo = _dbContext.UserVersionInfos + .FirstOrDefault(u => u.Username == username); + + if (userVersionInfo == null) + { + userVersionInfo = new UserVersionInfo + { + Id = Guid.NewGuid(), + Username = username ?? "anonymous", + LastSeenVersion = version, + LastCheckedDate = DateTime.UtcNow, + WhatsNewDismissed = false, + UpdateNotificationDismissed = false + }; + _dbContext.UserVersionInfos.Add(userVersionInfo); + } + else + { + userVersionInfo.LastSeenVersion = version; + userVersionInfo.WhatsNewDismissed = true; + userVersionInfo.UpdateNotificationDismissed = true; + } + + await _dbContext.SaveChangesAsync(); + } + + private async Task> FetchGitHubReleasesAsync() + { + try + { + var client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", $"smtp4dev/{_currentVersion}"); + + var response = await client.GetAsync("https://api.github.com/repos/rnwood/smtp4dev/releases"); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to fetch GitHub releases: {StatusCode}", response.StatusCode); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(); + var releases = JsonSerializer.Deserialize>(json); + + return releases?.Select(r => new GitHubRelease + { + TagName = r.GetProperty("tag_name").GetString(), + Name = r.GetProperty("name").GetString(), + Body = r.TryGetProperty("body", out var body) ? body.GetString() : "", + Prerelease = r.GetProperty("prerelease").GetBoolean(), + PublishedAt = r.GetProperty("published_at").GetString(), + HtmlUrl = r.GetProperty("html_url").GetString() + }).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching GitHub releases"); + return new List(); + } + } + + private List FilterRelevantReleases(List releases) + { + if (!_isPrerelease) + { + // For stable versions, only include non-prerelease versions + return releases.Where(r => !r.Prerelease).ToList(); + } + else + { + // For prerelease versions, include same prerelease type + return releases.Where(r => + { + if (!r.Prerelease) return false; + + var match = Regex.Match(r.TagName, @"^v?(\d+\.\d+\.\d+)-([^+]+)"); + if (!match.Success) return false; + + var releasePrefix = match.Groups[2].Value.Split('.')[0]; + return releasePrefix == _prereleasePrefix; + }).ToList(); + } + } + + private bool ShouldShowWhatsNew(UserVersionInfo userVersionInfo, List releases) + { + if (userVersionInfo == null || string.IsNullOrEmpty(userVersionInfo.LastSeenVersion)) + { + // First time user + return true; + } + + if (userVersionInfo.WhatsNewDismissed && + userVersionInfo.LastSeenVersion == _currentVersion) + { + // User has already dismissed for current version + return false; + } + + // Check if there are releases since last seen version + return GetReleasesSince(releases, userVersionInfo.LastSeenVersion, 1).Any(); + } + + private List GetReleasesSince(List releases, string sinceVersion, int maxCount) + { + if (string.IsNullOrEmpty(sinceVersion)) + { + // Return last N releases + return releases.Take(maxCount).ToList(); + } + + var result = new List(); + foreach (var release in releases) + { + if (CompareVersions(release.TagName, sinceVersion) > 0) + { + result.Add(release); + if (result.Count >= maxCount) break; + } + } + + return result; + } + + private List GetNewerReleases(List releases, string currentVersion) + { + // For update notifications, also include stable releases if we're on prerelease + var relevantReleases = _isPrerelease + ? releases.Where(r => !r.Prerelease || FilterRelevantReleases(new List { r }).Any()).ToList() + : releases; + + return relevantReleases + .Where(r => CompareVersions(r.TagName, currentVersion) > 0) + .ToList(); + } + + private int CompareVersions(string version1, string version2) + { + // Remove 'v' prefix if present + version1 = version1.TrimStart('v'); + version2 = version2.TrimStart('v'); + + // Try to parse as Version objects + if (Version.TryParse(version1.Split('-')[0], out var v1) && + Version.TryParse(version2.Split('-')[0], out var v2)) + { + var result = v1.CompareTo(v2); + if (result != 0) return result; + + // If base versions are equal, compare prerelease suffixes + var pre1 = version1.Contains('-') ? version1.Substring(version1.IndexOf('-')) : ""; + var pre2 = version2.Contains('-') ? version2.Substring(version2.IndexOf('-')) : ""; + + // No prerelease is greater than prerelease + if (string.IsNullOrEmpty(pre1) && !string.IsNullOrEmpty(pre2)) return 1; + if (!string.IsNullOrEmpty(pre1) && string.IsNullOrEmpty(pre2)) return -1; + + return string.Compare(pre1, pre2, StringComparison.Ordinal); + } + + return string.Compare(version1, version2, StringComparison.Ordinal); + } + + public void LogUpdateNotification(UpdateCheckResult result) + { + if (result.UpdateAvailable && result.NewReleases.Any()) + { + _logger.LogInformation("\u001b[1;33m╔════════════════════════════════════════════════════════════════╗\u001b[0m"); + _logger.LogInformation("\u001b[1;33m║ UPDATE AVAILABLE: {Version,-45}║\u001b[0m", result.NewReleases.First().TagName); + _logger.LogInformation("\u001b[1;33m║ View release notes: https://github.com/rnwood/smtp4dev/releases ║\u001b[0m"); + _logger.LogInformation("\u001b[1;33m╚════════════════════════════════════════════════════════════════╝\u001b[0m"); + } + } + + public void LogWhatsNewNotification(UpdateCheckResult result) + { + if (result.ShowWhatsNew) + { + var releaseCount = result.NewReleases.Count; + _logger.LogInformation("\u001b[1;36m╔════════════════════════════════════════════════════════════════╗\u001b[0m"); + _logger.LogInformation("\u001b[1;36m║ WHAT'S NEW: {Count} new release(s) since last use ║\u001b[0m", releaseCount); + _logger.LogInformation("\u001b[1;36m║ View release notes in the web UI or at: ║\u001b[0m"); + _logger.LogInformation("\u001b[1;36m║ https://github.com/rnwood/smtp4dev/releases ║\u001b[0m"); + _logger.LogInformation("\u001b[1;36m╚════════════════════════════════════════════════════════════════╝\u001b[0m"); + } + } + } +} diff --git a/Rnwood.Smtp4dev/Startup.cs b/Rnwood.Smtp4dev/Startup.cs index da3760d28..310e7587c 100644 --- a/Rnwood.Smtp4dev/Startup.cs +++ b/Rnwood.Smtp4dev/Startup.cs @@ -102,6 +102,7 @@ public void ConfigureServices(IServiceCollection services) services.Configure(Configuration.GetSection("DesktopOptions")); ServerOptions serverOptions = Configuration.GetSection("ServerOptions").Get(); + services.AddSingleton(serverOptions); services.AddDbContext(opt => { @@ -221,6 +222,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); + services.AddHttpClient(); services.AddSingleton>(relayOptions => {