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.
This commit is contained in:
2026-06-06 09:50:24 +02:00
parent dcf849cfa7
commit 68366ad8c9
7 changed files with 287 additions and 0 deletions

View File

@@ -27,6 +27,9 @@ namespace GerbilManagerWebAPI.Dtos
bool BackgroundPollEnabled,
bool HasAppPassword);
/// <summary>Body of the (edited) reply to send for POST /api/requests/{id}/send.</summary>
public record SendReplyInput(string Body);
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
/// empty string clears it.</summary>
public record MailSettingsInput(

View File

@@ -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<Results<Ok<RequestDto>, 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) =>
{

View File

@@ -0,0 +1,43 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>Builds the reply MimeMessage with In-Reply-To / References / Subject set for
/// Gmail threading. Public so the threading headers are unit-testable without SMTP.</summary>
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;
}
}
}

View File

@@ -0,0 +1,27 @@
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>A reply to send, with RFC threading headers already computed.</summary>
public sealed record OutgoingMail(
string To,
string Subject,
string Body,
string? InReplyTo,
string? References);
/// <summary>Outcome of a send attempt. Error is null on success; otherwise a stable code
/// ("MailNotConfigured", "MailAuthFailed") the UI maps to a German hint.</summary>
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);
}
/// <summary>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).</summary>
public interface IGmailMailSender
{
/// <summary>Sends the reply. Throws <see cref="MailKit.Security.AuthenticationException"/>
/// on bad/revoked credentials (surfaced as "MailAuthFailed").</summary>
Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,73 @@
using GerbilManagerWebAPI.Models;
using MailKit.Security;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// 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.
/// </summary>
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<SendOutcome> 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;
}
/// <summary>"Re: " prefix without double-prefixing an existing reply subject.</summary>
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}";
}
/// <summary>References = existing chain + the original Message-Id (deduped, in order).</summary>
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);
}
}
}

View File

@@ -47,6 +47,8 @@ builder.Services.AddDataProtection();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
var app = builder.Build();