Merge feature/inbox-3: Gmail SMTP send + reply threading (In-Reply-To/References/Re:), MailNotConfigured/MailAuthFailed handling [god-QA: 80 + snapshot clean]
This commit is contained in:
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using GerbilManagerWebAPI.Inbox;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using MailKit.Security;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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<ApplicationContext>()
|
||||||
|
.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 = "<a@x> <b@y>")
|
||||||
|
{
|
||||||
|
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 = "<orig@gmail>",
|
||||||
|
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("<orig@gmail>", sender.Last.InReplyTo);
|
||||||
|
Assert.Equal("<a@x> <b@y> <orig@gmail>", 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("<orig@gmail>", 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", "<orig@gmail>", "<a@x> <orig@gmail>"));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
bool BackgroundPollEnabled,
|
bool BackgroundPollEnabled,
|
||||||
bool HasAppPassword);
|
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,
|
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
|
||||||
/// empty string clears it.</summary>
|
/// empty string clears it.</summary>
|
||||||
public record MailSettingsInput(
|
public record MailSettingsInput(
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.NoContent();
|
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
|
// GET/PUT /api/mail-settings — App Password never leaves the server
|
||||||
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
|
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
|
||||||
{
|
{
|
||||||
|
|||||||
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal file
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal file
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal file
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ builder.Services.AddDataProtection();
|
|||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user