Files
GerbilManager/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Gulum 68366ad8c9 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.
2026-06-06 09:50:24 +02:00

102 lines
5.3 KiB
C#

using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage
/// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API.
/// </summary>
public static class RequestEndpoints
{
public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api").WithTags("Inbox");
// POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id)
api.MapPost("/requests/sync", async (RequestSyncService sync) =>
TypedResults.Ok(await sync.SyncAsync()));
// GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged)
api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.Requests.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
api.MapGet("/requests/{id:guid}", async Task<Results<Ok<RequestDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
});
// PUT /api/requests/{id} — triage: status and/or assigned contact
api.MapPut("/requests/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
Guid id, RequestTriageInput input, ApplicationContext db) =>
{
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
if (r is null) return TypedResults.NotFound();
if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid))
return TypedResults.BadRequest("Assigned contact does not exist.");
r.AssignedContactId = input.AssignedContactId;
if (input.Status is RequestStatus s)
{
r.Status = s;
if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
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) =>
{
var s = await svc.GetAsync();
return TypedResults.Ok(new MailSettingsDto(
s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s)));
});
api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) =>
{
var s = await svc.GetAsync();
if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim();
if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m;
if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim();
if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b;
// AppPassword: null => leave unchanged; "" => clear; value => set (encrypted)
if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword);
await db.SaveChangesAsync();
return TypedResults.NoContent();
});
return app;
}
private static RequestDto ToDto(Request r) => new(
r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText,
r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt);
}
}