- Request entity (GmailMessageId unique, ThreadId, threading headers, From*, Subject, BodyText,
ReceivedAt, Status enum-as-string New|InProgress|Assigned|Answered|Abandoned, AssignedContactId? FK,
DraftReply?, AnsweredAt?). MailSettings singleton (GmailAddress, AppPassword ENCRYPTED via Data
Protection, PollInterval, Folder, LastUid). Migration AddRequestsAndMailSettings.
- IGmailMailReader (mockable) + MailKit GmailMailReader (imap.gmail.com:993 SSL, envelope+X-GM-THRID
+body). RequestSyncService: dedup on Message-Id, track LastUid. MailKit 4.17.0.
- Endpoints: POST /api/requests/sync, GET /api/requests (Gridify, filter status), GET /api/requests/{id},
PUT triage (status+assignedContactId), GET/PUT /api/mail-settings (App Password never returned).
- Tests (in-memory DB + canned reader): sync dedup/idempotency/config-guard, HTTP sync->triage->assign,
mail-settings secrecy. + gitignore the runtime photo-storage/ dir (god housekeeping).
83 lines
4.2 KiB
C#
83 lines
4.2 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();
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
}
|