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();