From 68366ad8c9e6fd3dc42f9e291ec56825a1bbe42b Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 09:50:24 +0200 Subject: [PATCH] INBOX-3: SMTP send + Gmail threading (POST /api/requests/{id}/send) - IGmailMailSender (mockable) + MailKit GmailMailSender (smtp.gmail.com:587 STARTTLS, App Password). SendReplyService builds a threaded reply: In-Reply-To = original Message-Id, References = stored chain + original (deduped), Subject = 'Re: ' (no double-prefix); sends, then sets Status=Answered + AnsweredAt + stores sent body in DraftReply. Never auto-sends. - POST /api/requests/{id}/send {body}: 200 updated RequestDto; 404 unknown; 503 MailNotConfigured; 502 MailAuthFailed (UI hint: re-enter App Password). No entity change (no migration). - Tests (fake SMTP): threading headers, Answered transition, no-double-Re, not-configured + auth-failure keep status; BuildMimeMessage header mapping. Live send gated on App Password. --- GerbilManager.Tests/SendReplyServiceTests.cs | 120 ++++++++++++++++++ GerbilManagerWebAPI/Dtos/InboxDtos.cs | 3 + .../Endpoints/RequestEndpoints.cs | 19 +++ GerbilManagerWebAPI/Inbox/GmailMailSender.cs | 43 +++++++ .../Inbox/MailSendContracts.cs | 27 ++++ GerbilManagerWebAPI/Inbox/SendReplyService.cs | 73 +++++++++++ GerbilManagerWebAPI/Program.cs | 2 + 7 files changed, 287 insertions(+) create mode 100644 GerbilManager.Tests/SendReplyServiceTests.cs create mode 100644 GerbilManagerWebAPI/Inbox/GmailMailSender.cs create mode 100644 GerbilManagerWebAPI/Inbox/MailSendContracts.cs create mode 100644 GerbilManagerWebAPI/Inbox/SendReplyService.cs diff --git a/GerbilManager.Tests/SendReplyServiceTests.cs b/GerbilManager.Tests/SendReplyServiceTests.cs new file mode 100644 index 0000000..63b162f --- /dev/null +++ b/GerbilManager.Tests/SendReplyServiceTests.cs @@ -0,0 +1,120 @@ +using GerbilManagerWebAPI.Inbox; +using GerbilManagerWebAPI.Models; +using MailKit.Security; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManager.Tests; + +/// INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status +/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP +/// sender (live Gmail gated on Julian's App Password). +public class SendReplyServiceTests +{ + private sealed class CapturingSender : IGmailMailSender + { + public OutgoingMail? Last; + public bool ThrowAuth; + public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default) + { + if (ThrowAuth) throw new AuthenticationException("bad app password"); + Last = m; + return Task.CompletedTask; + } + } + + private static ApplicationContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options; + var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + return db; + } + + private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)> + SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = " ") + { + var db = NewDb(); + var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider()); + var s = await settings.GetAsync(); + if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); } + var req = new Request + { + Id = Guid.NewGuid(), + GmailMessageId = "", + ReferencesHeader = refs, + FromAddress = "kunde@web.de", + Subject = subject, + Status = RequestStatus.New, + ReceivedAt = DateTimeOffset.UtcNow, + }; + db.Requests.Add(req); + await db.SaveChangesAsync(); + var sender = new CapturingSender(); + return (db, new SendReplyService(db, sender, settings), sender, req); + } + + [Fact] + public async Task Send_threads_and_marks_answered() + { + var (db, svc, sender, req) = await SetupAsync(); + + var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!"); + + Assert.True(outcome.Sent); + Assert.Null(outcome.Error); + Assert.NotNull(sender.Last); + Assert.Equal("kunde@web.de", sender.Last!.To); + Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject); + Assert.Equal("", sender.Last.InReplyTo); + Assert.Equal(" ", sender.Last.References); // chain + original + + var saved = await db.Requests.SingleAsync(); + Assert.Equal(RequestStatus.Answered, saved.Status); + Assert.NotNull(saved.AnsweredAt); + Assert.Equal("Hallo, sehr gerne!", saved.DraftReply); + } + + [Fact] + public async Task Send_does_not_double_prefix_re_subject() + { + var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null); + await svc.SendAsync(req, "ok"); + Assert.Equal("Re: läuft schon", sender.Last!.Subject); + Assert.Equal("", sender.Last.References); // no prior chain -> just the original + } + + [Fact] + public async Task Send_without_config_fails_and_keeps_status() + { + var (db, svc, sender, req) = await SetupAsync(configured: false); + var outcome = await svc.SendAsync(req, "ok"); + Assert.Equal("MailNotConfigured", outcome.Error); + Assert.Null(sender.Last); + Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status); + } + + [Fact] + public async Task Send_auth_failure_does_not_mark_answered() + { + var (db, svc, sender, req) = await SetupAsync(); + sender.ThrowAuth = true; + var outcome = await svc.SendAsync(req, "ok"); + Assert.Equal("MailAuthFailed", outcome.Error); + Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status); + } + + [Fact] + public void BuildMimeMessage_sets_threading_headers() + { + var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com", + new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "", " ")); + + Assert.Equal("Re: Test", msg.Subject); + Assert.Contains("kunde@web.de", msg.To.ToString()); + Assert.Contains("orig@gmail", msg.InReplyTo); + Assert.Contains("orig@gmail", string.Join(' ', msg.References)); + Assert.Contains("a@x", string.Join(' ', msg.References)); + } +} diff --git a/GerbilManagerWebAPI/Dtos/InboxDtos.cs b/GerbilManagerWebAPI/Dtos/InboxDtos.cs index 557b6f1..e81010a 100644 --- a/GerbilManagerWebAPI/Dtos/InboxDtos.cs +++ b/GerbilManagerWebAPI/Dtos/InboxDtos.cs @@ -27,6 +27,9 @@ namespace GerbilManagerWebAPI.Dtos bool BackgroundPollEnabled, bool HasAppPassword); + /// Body of the (edited) reply to send for POST /api/requests/{id}/send. + public record SendReplyInput(string Body); + /// Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged, /// empty string clears it. public record MailSettingsInput( diff --git a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs index 2125a7d..2166b6f 100644 --- a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs @@ -51,6 +51,25 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.NoContent(); }); + // POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered + api.MapPost("/requests/{id:guid}/send", async Task, NotFound, ProblemHttpResult>> ( + Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) => + { + var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id); + if (r is null) return TypedResults.NotFound(); + + var outcome = await sender.SendAsync(r, input.Body ?? ""); + return outcome.Error switch + { + null => TypedResults.Ok(ToDto(r)), + "MailNotConfigured" => TypedResults.Problem( + "Gmail ist noch nicht konfiguriert.", statusCode: 503, title: "MailNotConfigured"), + "MailAuthFailed" => TypedResults.Problem( + "Gmail-Anmeldung fehlgeschlagen — App-Passwort erneut eingeben.", statusCode: 502, title: "MailAuthFailed"), + var code => TypedResults.Problem(code, statusCode: 500, title: code), + }; + }); + // GET/PUT /api/mail-settings — App Password never leaves the server api.MapGet("/mail-settings", async (MailSettingsService svc) => { diff --git a/GerbilManagerWebAPI/Inbox/GmailMailSender.cs b/GerbilManagerWebAPI/Inbox/GmailMailSender.cs new file mode 100644 index 0000000..724fea1 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/GmailMailSender.cs @@ -0,0 +1,43 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// Live Gmail sender over SMTP (App Password auth, STARTTLS). Builds a correctly-threaded + /// reply so it lands in the same Gmail conversation. Gated on Julian's App Password + /// (tests use a fake IGmailMailSender + assert BuildMimeMessage). + /// + public sealed class GmailMailSender : IGmailMailSender + { + public async Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default) + { + var message = BuildMimeMessage(connection.GmailAddress, mail); + using var client = new SmtpClient(); + await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls, ct); + await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct); + await client.SendAsync(message, ct); + await client.DisconnectAsync(true, ct); + } + + /// Builds the reply MimeMessage with In-Reply-To / References / Subject set for + /// Gmail threading. Public so the threading headers are unit-testable without SMTP. + public static MimeMessage BuildMimeMessage(string fromAddress, OutgoingMail mail) + { + var message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse(fromAddress)); + message.To.Add(MailboxAddress.Parse(mail.To)); + message.Subject = mail.Subject; + message.Body = new TextPart("plain") { Text = mail.Body }; + + if (!string.IsNullOrWhiteSpace(mail.InReplyTo)) + message.InReplyTo = mail.InReplyTo; + if (!string.IsNullOrWhiteSpace(mail.References)) + foreach (var id in mail.References.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + message.References.Add(id); + + return message; + } + } +} diff --git a/GerbilManagerWebAPI/Inbox/MailSendContracts.cs b/GerbilManagerWebAPI/Inbox/MailSendContracts.cs new file mode 100644 index 0000000..0bd4f74 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/MailSendContracts.cs @@ -0,0 +1,27 @@ +namespace GerbilManagerWebAPI.Inbox +{ + /// A reply to send, with RFC threading headers already computed. + public sealed record OutgoingMail( + string To, + string Subject, + string Body, + string? InReplyTo, + string? References); + + /// Outcome of a send attempt. Error is null on success; otherwise a stable code + /// ("MailNotConfigured", "MailAuthFailed") the UI maps to a German hint. + public sealed record SendOutcome(bool Sent, string? Error) + { + public static readonly SendOutcome Ok = new(true, null); + public static SendOutcome Fail(string code) => new(false, code); + } + + /// Sends mail. Abstracted so tests can assert the message/capture it without a live + /// SMTP server (the live MailKit/Gmail path is gated on Julian's App Password). + public interface IGmailMailSender + { + /// Sends the reply. Throws + /// on bad/revoked credentials (surfaced as "MailAuthFailed"). + Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default); + } +} diff --git a/GerbilManagerWebAPI/Inbox/SendReplyService.cs b/GerbilManagerWebAPI/Inbox/SendReplyService.cs new file mode 100644 index 0000000..309c914 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/SendReplyService.cs @@ -0,0 +1,73 @@ +using GerbilManagerWebAPI.Models; +using MailKit.Security; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// Sends a (human-edited) reply to a Request via SMTP, threaded into the original Gmail + /// conversation, and marks the request Answered. Only ever called on the explicit + /// POST /api/requests/{id}/send — never auto-sends. + /// + public sealed class SendReplyService + { + private readonly ApplicationContext _db; + private readonly IGmailMailSender _sender; + private readonly MailSettingsService _settings; + + public SendReplyService(ApplicationContext db, IGmailMailSender sender, MailSettingsService settings) + { + _db = db; + _sender = sender; + _settings = settings; + } + + public async Task SendAsync(Request request, string body, CancellationToken ct = default) + { + var settings = await _settings.GetAsync(ct); + var password = _settings.DecryptPassword(settings); + if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password)) + return SendOutcome.Fail("MailNotConfigured"); + + var mail = new OutgoingMail( + To: request.FromAddress, + Subject: ReplySubject(request.Subject), + Body: body, + InReplyTo: request.GmailMessageId, + References: BuildReferences(request.ReferencesHeader, request.GmailMessageId)); + + try + { + await _sender.SendAsync(new MailConnection(settings.GmailAddress!, password!, settings.Folder), mail, ct); + } + catch (AuthenticationException) + { + return SendOutcome.Fail("MailAuthFailed"); + } + + request.Status = RequestStatus.Answered; + request.AnsweredAt = DateTimeOffset.UtcNow; + request.DraftReply = body; // record what was actually sent + await _db.SaveChangesAsync(ct); + return SendOutcome.Ok; + } + + /// "Re: " prefix without double-prefixing an existing reply subject. + internal static string ReplySubject(string? original) + { + var s = (original ?? "").Trim(); + if (s.Length == 0) return "Re:"; + return s.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? s : $"Re: {s}"; + } + + /// References = existing chain + the original Message-Id (deduped, in order). + internal static string BuildReferences(string? existingChain, string originalMessageId) + { + var ids = (existingChain ?? "") + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + if (!string.IsNullOrWhiteSpace(originalMessageId) && !ids.Contains(originalMessageId)) + ids.Add(originalMessageId); + return string.Join(' ', ids); + } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 4cc0402..5f4d470 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -47,6 +47,8 @@ builder.Services.AddDataProtection(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build();