using MailSharp.MailClient.Models; using MailSharp.MailClient.Services; using MailSharp.MailClient.Services.Logging; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using MimeKit; using System.Text.Json; namespace MailSharp.MailClient.Controllers.Api; [ApiController] [Route("api/mail")] public class MailApiController( IAccountStore accountStore, SessionAccountManager sessionManager, IImapService imapService, ISmtpService smtpService, IMailCacheStore mailCacheStore, IContactStore contactStore, IOptions mailSettings, LocalizationService localizer, IBackgroundIndexingStatus indexingStatus, ILogger logger) : ControllerBase { private readonly MailSettings _settings = mailSettings.Value; private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); 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); } // Lightweight, non-admin view of the same background-indexing state the Maintenance page shows // (see BackgroundIndexingStatus) - polled by the topbar toast on every page so users notice // their inbox is still being indexed without needing admin rights to open Maintenance. [HttpGet("indexing-status")] public IActionResult IndexingStatus() { return Ok(new { maxConcurrentIndexing = indexingStatus.MaxConcurrentIndexing, currentlyAvailableSlots = indexingStatus.CurrentlyAvailableSlots, inProgress = indexingStatus.InProgressKeys.Select(key => { indexingStatus.TryGetProgress(key, out var processed, out var total); return new { key, processed, total }; }) }); } private object BuildIndexingStatusPayload() => new { maxConcurrentIndexing = indexingStatus.MaxConcurrentIndexing, currentlyAvailableSlots = indexingStatus.CurrentlyAvailableSlots, inProgress = indexingStatus.InProgressKeys.Select(key => { indexingStatus.TryGetProgress(key, out var processed, out var total); return new { key, processed, total }; }) }; private static object BuildFlagsRefreshPayload(RecentFlagsRefreshResult r) => new { folder = r.Folder, checkedCount = r.CheckedCount, changedCount = r.ChangedCount, elapsedMs = r.ElapsedMs, timestamp = r.TimestampUtc }; private async Task WriteSseEventAsync(string eventName, string data, CancellationToken ct) { await Response.WriteAsync($"event: {eventName}\ndata: {data}\n\n", ct); await Response.Body.FlushAsync(ct); } // Server-Sent Events push channel for the topbar - started once per page load and kept open, // replacing what used to be a client-side setInterval poll (see common.js). An EventSource just // sits and receives pushes, so it no longer shares apiFetch's activeControllers/loading-overlay // plumbing - a background status refresh could previously call hideLoading() and dismiss the // Cancel button/overlay for a real, unrelated, still-in-flight request. // // Multiplexes more than one kind of push over this one connection rather than opening a stream // per feature: "indexing" (background-indexing toast, global - not account-scoped, matching the // old polled endpoint's behaviour) and "flags" (ImapService.RecentFlagsRefreshResults - results // of the lightweight recent-messages flag recheck, scoped to the caller's own account so one // user's folder names/activity never reach another). The push side is still a poll internally // (in-memory static state has no change notification), but it only writes to the client when a // given snapshot actually changes, and the client opens exactly one long-lived connection instead // of a new HTTP request every few seconds. [HttpGet("indexing-status/stream")] public async Task IndexingStatusStream(CancellationToken ct) { var active = GetActive(); if (active == null) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; } var accountId = active.Value.account.Id; Response.Headers.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-cache"; Response.Headers["X-Accel-Buffering"] = "no"; string? lastIndexingPayload = null; var lastFlagsPayloads = new Dictionary(); try { while (!ct.IsCancellationRequested) { var indexingPayload = JsonSerializer.Serialize(BuildIndexingStatusPayload()); if (indexingPayload != lastIndexingPayload) { await WriteSseEventAsync("indexing", indexingPayload, ct); lastIndexingPayload = indexingPayload; } foreach (var kvp in ImapService.RecentFlagsRefreshResults) { if (kvp.Value.AccountId != accountId) continue; var flagsPayload = JsonSerializer.Serialize(BuildFlagsRefreshPayload(kvp.Value)); if (!lastFlagsPayloads.TryGetValue(kvp.Key, out var prev) || prev != flagsPayload) { await WriteSseEventAsync("flags", flagsPayload, ct); lastFlagsPayloads[kvp.Key] = flagsPayload; } } await Task.Delay(1000, ct); } } catch (OperationCanceledException) { // Client navigated away or closed the EventSource - not an error. } } [HttpGet("folders")] public async Task Folders(CancellationToken ct) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; return Ok(await imapService.GetFoldersAsync(account, password, ct: ct)); } [HttpGet("messages")] public async Task Messages(string folder = "INBOX", string sort = "date", bool desc = true, int page = 1, bool refresh = false, bool unreadOnly = false, CancellationToken ct = default) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; var pageSize = account.MessagesPerPage > 0 ? account.MessagesPerPage : _settings.MessageListPageSize; var messages = await imapService.GetMessagesAsync(account, password, folder, refresh, ct); if (unreadOnly) messages = [.. messages.Where(m => !m.IsRead)]; messages = sort switch { "subject" => desc ? [.. messages.OrderByDescending(m => m.Subject)] : [.. messages.OrderBy(m => m.Subject)], "from" => desc ? [.. messages.OrderByDescending(m => m.From)] : [.. messages.OrderBy(m => m.From)], "size" => desc ? [.. messages.OrderByDescending(m => m.SizeBytes)] : [.. messages.OrderBy(m => m.SizeBytes)], _ => desc ? [.. messages.OrderByDescending(m => m.Date)] : [.. messages.OrderBy(m => m.Date)], }; var totalCount = messages.Count; var totalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize)); page = Math.Min(Math.Max(1, page), totalPages); return Ok(new MessagesResponse { Messages = [.. messages.Skip((page - 1) * pageSize).Take(pageSize)], Page = page, PageSize = pageSize, TotalCount = totalCount, TotalPages = totalPages }); } [HttpGet("messages/{uid}")] public async Task Message(uint uid, [FromQuery] string folder = "INBOX", CancellationToken ct = default) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; return Ok(await imapService.GetMessageAsync(account, password, folder, uid, ct)); } [HttpPost("allow-images")] public IActionResult AllowImages(AllowImagesRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); // Defense in depth: the client already hides this action inside Spam (see ImapService. // GetMessageAsync/IsSpamFolder), but a direct API call should be refused too, not just // silently accepted and then ignored on the next render. if (ImapService.IsSpamFolder(request.Folder)) return BadRequest(new { error = "Externe afbeeldingen kunnen niet worden toegestaan voor berichten in de Spam-map." }); if (!string.IsNullOrWhiteSpace(request.Sender)) mailCacheStore.SetAllowExternalImages(active.Value.account.Id, request.Sender, true); return Ok(); } // Fetched via a plain authenticated fetch() call, never navigated to directly - the client // turns the response into a blob and opens/downloads it via a local blob: URL, so this // address is never shown in an address bar, new tab, or browser history. [HttpGet("attachments/{uid}/{part}")] public async Task Attachment(uint uid, int part, [FromQuery] string folder = "INBOX", CancellationToken ct = default) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; var detail = await imapService.GetMessageAsync(account, password, folder, uid, ct); var info = detail.Attachments.FirstOrDefault(a => a.PartIndex == part); var fileName = info?.FileName ?? "attachment"; var contentType = ResolveContentType(info?.ContentType, fileName); var stream = await imapService.GetAttachmentAsync(account, password, folder, uid, part, ct); return File(stream, contentType); } // Prefers the type implied by the file's own extension over the Content-Type the sender's // mail client declared for the MIME part: senders frequently stamp every attachment with a // generic/wrong type (e.g. application/octet-stream, or a stale type left over from a // forward), while the extension in the actual filename is what the user - and the browser's // viewer - can see and trust. private static string ResolveContentType(string? storedContentType, string fileName) { if (ContentTypeProvider.TryGetContentType(fileName, out var guessedFromExtension)) { return guessedFromExtension; } return !string.IsNullOrWhiteSpace(storedContentType) ? storedContentType : "application/octet-stream"; } [HttpPost("mark-read")] public async Task MarkRead(UidsRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.SetSeenAsync(account, password, request.Folder, request.Uids, true); return Ok(); } [HttpPost("mark-unread")] public async Task MarkUnread(UidsRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.SetSeenAsync(account, password, request.Folder, request.Uids, false); return Ok(); } [HttpPost("flag")] public async Task Flag(UidsRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.SetFlaggedAsync(account, password, request.Folder, request.Uids, true); return Ok(); } [HttpPost("unflag")] public async Task Unflag(UidsRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.SetFlaggedAsync(account, password, request.Folder, request.Uids, false); return Ok(); } [HttpPost("mark-all-read")] public async Task MarkAllRead(FolderRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.MarkAllAsync(account, password, request.Folder, true); return Ok(); } [HttpPost("mark-all-unread")] public async Task MarkAllUnread(FolderRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.MarkAllAsync(account, password, request.Folder, false); return Ok(); } [HttpPost("move")] public async Task Move(MoveRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.MoveAsync(account, password, request.Folder, request.Uids, request.TargetFolder); return Ok(); } [HttpPost("delete")] public async Task Delete(UidsRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.DeleteAsync(account, password, request.Folder, request.Uids); return Ok(); } [HttpPost("empty-folder")] public async Task EmptyFolder(FolderRequest request) { var active = GetActive(); if (active == null) return Unauthorized(); var (account, password) = active.Value; await imapService.EmptyFolderAsync(account, password, request.Folder); return Ok(); } [HttpPost("send")] public async Task Send([FromForm] ComposeModel model, [FromForm] List? files) { var account = accountStore.Get(model.AccountId); var password = sessionManager.GetPassword(model.AccountId); if (account == null || password == null) return Unauthorized(); var attachments = await ReadAttachments(files); var inlineImages = await ReadInlineImages(); MimeMessage message; try { message = MimeMessageFactory.Build(account, model, attachments, inlineImages); await smtpService.SendAsync(account, password, message, HttpContext.RequestAborted); } finally { foreach (var (_, content) in attachments) content.Dispose(); foreach (var (_, _, content) in inlineImages) content.Dispose(); } // Best-effort: the message has already been sent, so a failure to save it to Sent (e.g. no // such folder, transient IMAP error) shouldn't fail the request - but it's surfaced in the // response so the UI can warn instead of silently losing the copy. string? sentError = null; try { await imapService.AppendSentAsync(account, password, message, HttpContext.RequestAborted); } catch (Exception ex) { sentError = ex.Message; logger.LogWarning(ex, "Message sent for account {AccountId} but saving a copy to Sent failed", account.Id); } var recipients = message.To.Concat(message.Cc).OfType() .Select(mb => new ContactAddress { Email = mb.Address, Name = mb.Name ?? "" }); contactStore.AddContacts(account.Id, recipients); return Ok(new { message = localizer["compose_sent"], sentError }); } [HttpPost("save-draft")] public async Task SaveDraft([FromForm] ComposeModel model, [FromForm] List? files) { var account = accountStore.Get(model.AccountId); var password = sessionManager.GetPassword(model.AccountId); if (account == null || password == null) return Unauthorized(); var attachments = await ReadAttachments(files); var inlineImages = await ReadInlineImages(); try { await imapService.SaveDraftAsync(account, password, model, attachments, inlineImages); } finally { foreach (var (_, content) in attachments) content.Dispose(); foreach (var (_, _, content) in inlineImages) content.Dispose(); } return Ok(new { message = localizer["compose_draft_saved"] }); } private static async Task> ReadAttachments(List? files) { var result = new List<(string, Stream)>(); if (files == null) return result; foreach (var file in files) { if (file.Length == 0) continue; var ms = new MemoryStream(); await file.CopyToAsync(ms); ms.Position = 0; result.Add((file.FileName, ms)); } return result; } // Inline images (pictures inserted into the compose editor via file upload, see compose.js) // arrive as regular form files, but under a "inline:" field name rather than "files" - the // [FromForm] List binder above only picks up fields literally named "files", so these // are read directly off Request.Form.Files instead to recover the cid <-> content mapping. private async Task> ReadInlineImages() { var result = new List<(string, string, Stream)>(); foreach (var file in Request.Form.Files) { if (!file.Name.StartsWith("inline:", StringComparison.Ordinal) || file.Length == 0) continue; var cid = file.Name["inline:".Length..]; var ms = new MemoryStream(); await file.CopyToAsync(ms); ms.Position = 0; result.Add((cid, file.FileName, ms)); } return result; } }