using MailSharp.MailClient.Models; using MailSharp.MailClient.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; namespace MailSharp.MailClient.Controllers.Api; [ApiController] [Route("api/settings")] public class SettingsApiController( IAccountStore accountStore, SessionAccountManager sessionManager, IImapService imapService, IFolderSettingsStore folderSettingsStore, IOptions mailSettings, LocalizationService localizer, ILogger logger) : ControllerBase { private readonly MailSettings _settings = mailSettings.Value; private (Account account, string password)? GetActive() { var id = sessionManager.GetActiveAccountId(); if (id == null) return null; var account = accountStore.Get(id.Value); var password = sessionManager.GetPassword(id.Value); if (account == null || password == null) return null; return (account, password); } [HttpGet] public IActionResult Get() { var active = GetActive(); if (active == null) return Unauthorized(); var settings = AccountSettingsDto.From(active.Value.account); // Accounts created before these fields existed (or otherwise still at their zero default) // show the app-wide default instead of a blank input - see also AuthApiController.Login, // which sets these explicitly on brand-new accounts so this fallback rarely kicks in there. if (settings.MessagesPerPage <= 0) settings.MessagesPerPage = _settings.MessageListPageSize; if (settings.ContactsPerPage <= 0) settings.ContactsPerPage = _settings.ContactMailsPageSize; return Ok(new SettingsStateDto { Settings = settings, TimeZones = [.. TimeZoneInfo.GetSystemTimeZones().Select(ToIanaId).Distinct().OrderBy(x => x, StringComparer.OrdinalIgnoreCase)], Languages = [.. localizer.AvailableLanguages().Select(l => new LanguageOptionDto { Code = l.Code, Name = l.Name })] }); } [HttpPost] public IActionResult Save(AccountSettingsDto request) { var active = GetActive(); if (active == null) return Unauthorized(); var account = active.Value.account; account.DisplayName = string.IsNullOrWhiteSpace(request.FriendlyName) ? account.EmailAddress : request.FriendlyName; account.Signature = request.Signature; account.MessagesPerPage = Math.Max(0, request.MessagesPerPage); account.ContactsPerPage = Math.Max(0, request.ContactsPerPage); account.AutoCheckIntervalMinutes = Math.Max(0, request.AutoCheckIntervalMinutes); account.TimeZoneId = request.TimeZoneId; account.Language = request.Language; accountStore.Update(account); if (localizer.LanguageExists(request.Language)) { Response.Cookies.Append("lang", request.Language, new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1), IsEssential = true, SameSite = SameSiteMode.Lax, HttpOnly = true }); } return Ok(AccountSettingsDto.From(account)); } // The client (browser Intl API) only understands IANA ids; TimeZoneInfo.GetSystemTimeZones() // returns Windows ids on Windows, so this is the single conversion point for the list exposed // to the UI (see also Account.ResolveDefaultTimeZoneId, which does the same for the default). private static string ToIanaId(TimeZoneInfo z) => OperatingSystem.IsWindows() && TimeZoneInfo.TryConvertWindowsIdToIanaId(z.Id, out var iana) ? iana : z.Id; [HttpGet("folders")] public async Task Folders(bool refresh = false, CancellationToken ct = default) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; // Unlike the Mail app's folder sidebar, Mappen beheren's whole point is to let the user see // and toggle folders they haven't subscribed to yet (e.g. one just created - see // CreateFolderAsync) - so this is the one caller that needs every folder, not just LSUB's // subscribed-only view. return Ok(await imapService.GetFoldersAsync(account, password, includeSizes: true, forceRefreshSizes: refresh, includeUnsubscribed: true, ct: ct)); } // The array's own sequence doubles as the new display order (see IFolderSettingsStore. // SaveOrder) - the client always submits every row it currently shows, in its current // (possibly drag-reordered) on-screen order, so there's no separate "save order" step. [HttpPost("folders")] public async Task SaveFolders(SaveFolderSettingsRequest request, CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; var accountId = account.Id; foreach (var f in request.Folders) { if (string.IsNullOrWhiteSpace(f.FullName)) continue; folderSettingsStore.Save(accountId, f.FullName, f.SyncMode, f.Subscribed); } // The checkbox drives real IMAP subscription state (see GetFoldersAsync's includeUnsubscribed/ // SetFolderSubscriptionsAsync) - folderSettingsStore.Save above only records the app's own // per-folder sync-mode preference now, Subscribed there is vestigial but left alone rather // than migrating/removing it for a handful of leftover reads elsewhere. var subscriptions = request.Folders .Where(f => !string.IsNullOrWhiteSpace(f.FullName)) .ToDictionary(f => f.FullName, f => f.Subscribed); if (subscriptions.Count > 0) await imapService.SetFolderSubscriptionsAsync(account, password, subscriptions, ct); folderSettingsStore.SaveOrder(accountId, [.. request.Folders.Select(f => f.FullName).Where(n => !string.IsNullOrWhiteSpace(n))]); return Ok(); } [HttpPost("folders/add")] public async Task AddFolder(AddFolderRequest request, CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(); var (account, password) = active.Value; await imapService.CreateFolderAsync(account, password, request.Name, ct); return Ok(); } [HttpPost("folders/rename")] public async Task RenameFolder(RenameFolderRequest request, CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Folder) || string.IsNullOrWhiteSpace(request.NewName)) return BadRequest(); var (account, password) = active.Value; try { await imapService.RenameFolderAsync(account, password, request.Folder, request.NewName, ct); return Ok(); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } [HttpPost("folders/move")] public async Task MoveFolder(MoveFolderRequest request, CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Folder)) return BadRequest(); var (account, password) = active.Value; try { await imapService.MoveFolderAsync(account, password, request.Folder, request.NewParent, ct); return Ok(); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } [HttpPost("folders/delete")] public async Task DeleteFolders(DeleteFoldersRequest request, CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; var failed = await imapService.DeleteFoldersAsync(account, password, request.FolderNames, ct); foreach (var name in request.FolderNames.Except(failed)) folderSettingsStore.Remove(account.Id, name); return Ok(new { failed }); } // One-time bootstrap escape hatch for installs that already have accounts but no admin yet // (e.g. upgraded from before IsAdmin existed). Only works while zero admins exist system-wide - // once any account is an admin, this always 403s and further promotion is an admin-only action. [HttpPost("claim-admin")] public async Task ClaimAdmin() { var active = GetActive(); if (active == null) return Unauthorized(); var account = active.Value.account; if (accountStore.GetAll().Any(a => a.IsAdmin)) return Forbid(); account.IsAdmin = true; accountStore.Update(account); logger.LogInformation("Account {AccountId} ({EmailAddress}) claimed admin via bootstrap (no prior admin existed)", account.Id, account.EmailAddress); // Re-issue the auth cookie so the admin claim takes effect immediately, without a full logout. await sessionManager.SetActiveAccountIdAsync(account.Id); return Ok(AccountDto.From(account)); } }