INBOX-0: Gmail request inbox backend (MailKit) + Request entity + sync/triage endpoints

- 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).
This commit is contained in:
2026-06-06 09:38:02 +02:00
parent 84181ed74d
commit 5d7cbc66bf
15 changed files with 1818 additions and 0 deletions

View File

@@ -21,6 +21,8 @@ public class ApplicationContext : DbContext
public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
@@ -152,6 +154,19 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder);
// INBOX epic: Gmail request inbox.
modelBuilder.Entity<Request>(e =>
{
e.Property(r => r.Status).HasConversion<string>();
e.HasIndex(r => r.GmailMessageId).IsUnique();
e.HasOne(r => r.AssignedContact).WithMany()
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
});
// exactly one MailSettings row, fixed id.
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
SeedColorVarieties(modelBuilder);
}

View File

@@ -0,0 +1,38 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
public record RequestDto(
Guid Id,
string GmailMessageId,
string? ThreadId,
string FromAddress,
string? FromName,
string? Subject,
string? BodyText,
DateTimeOffset ReceivedAt,
RequestStatus Status,
Guid? AssignedContactId,
string? DraftReply,
DateTimeOffset? AnsweredAt);
/// <summary>Triage update: change status and/or assign a contact.</summary>
public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
/// <summary>Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).</summary>
public record MailSettingsDto(
string? GmailAddress,
int PollIntervalMinutes,
string Folder,
bool BackgroundPollEnabled,
bool HasAppPassword);
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
/// empty string clears it.</summary>
public record MailSettingsInput(
string? GmailAddress,
string? AppPassword,
int? PollIntervalMinutes,
string? Folder,
bool? BackgroundPollEnabled);
}

View File

@@ -0,0 +1,82 @@
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);
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">

View File

@@ -0,0 +1,58 @@
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MailKit.Security;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Live Gmail reader over IMAP (App Password auth). Reads UIDs newer than the last seen,
/// fetches envelope + Gmail thread id + the plain-text body. Gated on Julian's App Password
/// (tests use a fake IGmailMailReader).
/// </summary>
public sealed class GmailMailReader : IGmailMailReader
{
public async Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default)
{
using var client = new ImapClient();
await client.ConnectAsync("imap.gmail.com", 993, SecureSocketOptions.SslOnConnect, ct);
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
var folder = string.Equals(connection.Folder, "INBOX", StringComparison.OrdinalIgnoreCase)
? client.Inbox
: await client.GetFolderAsync(connection.Folder, ct);
await folder.OpenAsync(FolderAccess.ReadOnly, ct);
var range = new UniqueIdRange(new UniqueId(sinceUid + 1), UniqueId.MaxValue);
var uids = await folder.SearchAsync(SearchQuery.Uids(range), ct);
const MessageSummaryItems items = MessageSummaryItems.Envelope
| MessageSummaryItems.UniqueId
| MessageSummaryItems.GMailThreadId
| MessageSummaryItems.InternalDate;
var summaries = await folder.FetchAsync(uids, items, ct);
var result = new List<MailSummary>();
foreach (var s in summaries)
{
var env = s.Envelope;
var msg = await folder.GetMessageAsync(s.UniqueId, ct); // body + threading headers
var from = env?.From?.Mailboxes?.FirstOrDefault();
result.Add(new MailSummary(
MessageId: env?.MessageId ?? msg.MessageId ?? "",
ThreadId: s.GMailThreadId?.ToString(),
InReplyTo: msg.InReplyTo,
References: msg.References is { Count: > 0 } ? string.Join(' ', msg.References) : null,
FromAddress: from?.Address ?? "",
FromName: string.IsNullOrWhiteSpace(from?.Name) ? null : from!.Name,
Subject: env?.Subject ?? msg.Subject,
BodyText: msg.TextBody,
ReceivedAt: s.InternalDate ?? env?.Date ?? DateTimeOffset.UtcNow,
Uid: s.UniqueId.Id));
}
await client.DisconnectAsync(true, ct);
return result;
}
}
}

View File

@@ -0,0 +1,29 @@
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>Connection params for an IMAP fetch (decrypted password — never persisted/logged).</summary>
public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder);
/// <summary>A fetched email reduced to what the Request entity needs.</summary>
public sealed record MailSummary(
string MessageId,
string? ThreadId,
string? InReplyTo,
string? References,
string FromAddress,
string? FromName,
string? Subject,
string? BodyText,
DateTimeOffset ReceivedAt,
uint Uid);
/// <summary>Reads mail from a folder. Abstracted so tests feed canned summaries
/// (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
public interface IGmailMailReader
{
/// <summary>Fetch messages with UID &gt; <paramref name="sinceUid"/> from the configured folder.</summary>
Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default);
}
/// <summary>Outcome of a sync run.</summary>
public sealed record SyncResult(int Imported, string? Error);
}

View File

@@ -0,0 +1,43 @@
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Reads/updates the singleton MailSettings. The Gmail App Password is encrypted at rest
/// with ASP.NET Data Protection and is NEVER returned over the wire (only used server-side
/// to open the IMAP/SMTP connection).
/// </summary>
public sealed class MailSettingsService
{
private readonly ApplicationContext _db;
private readonly IDataProtector _protector;
public MailSettingsService(ApplicationContext db, IDataProtectionProvider dp)
{
_db = db;
_protector = dp.CreateProtector("GerbilManager.MailSettings.AppPassword.v1");
}
public async Task<MailSettings> GetAsync(CancellationToken ct = default)
{
var s = await _db.MailSettings.FirstOrDefaultAsync(ct);
if (s is null)
{
s = new MailSettings { Id = MailSettings.SingletonId };
_db.MailSettings.Add(s);
await _db.SaveChangesAsync(ct);
}
return s;
}
public bool HasAppPassword(MailSettings s) => !string.IsNullOrEmpty(s.AppPasswordProtected);
public string? DecryptPassword(MailSettings s) =>
string.IsNullOrEmpty(s.AppPasswordProtected) ? null : _protector.Unprotect(s.AppPasswordProtected);
public void SetPassword(MailSettings s, string? plain) =>
s.AppPasswordProtected = string.IsNullOrEmpty(plain) ? null : _protector.Protect(plain);
}
}

View File

@@ -0,0 +1,69 @@
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Inbox
{
/// <summary>
/// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP
/// UID to avoid rescans. The actual fetch is behind <see cref="IGmailMailReader"/> (mockable).
/// </summary>
public sealed class RequestSyncService
{
private readonly ApplicationContext _db;
private readonly IGmailMailReader _reader;
private readonly MailSettingsService _settings;
public RequestSyncService(ApplicationContext db, IGmailMailReader reader, MailSettingsService settings)
{
_db = db;
_reader = reader;
_settings = settings;
}
public async Task<SyncResult> SyncAsync(CancellationToken ct = default)
{
var settings = await _settings.GetAsync(ct);
var password = _settings.DecryptPassword(settings);
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
return new SyncResult(0, "MailNotConfigured");
var conn = new MailConnection(settings.GmailAddress!, password!, settings.Folder);
var summaries = await _reader.FetchAsync(conn, settings.LastUid, ct);
uint maxUid = settings.LastUid;
int imported = 0;
if (summaries.Count > 0)
{
var fetchedIds = summaries.Select(s => s.MessageId).Where(id => !string.IsNullOrEmpty(id)).ToList();
var existing = (await _db.Requests
.Where(r => fetchedIds.Contains(r.GmailMessageId))
.Select(r => r.GmailMessageId).ToListAsync(ct)).ToHashSet();
foreach (var m in summaries)
{
if (m.Uid > maxUid) maxUid = m.Uid;
if (string.IsNullOrEmpty(m.MessageId) || !existing.Add(m.MessageId)) continue;
_db.Requests.Add(new Request
{
Id = Guid.NewGuid(),
GmailMessageId = m.MessageId,
ThreadId = m.ThreadId,
InReplyToMessageId = m.InReplyTo,
ReferencesHeader = m.References,
FromAddress = m.FromAddress,
FromName = m.FromName,
Subject = m.Subject,
BodyText = m.BodyText,
ReceivedAt = m.ReceivedAt,
Status = RequestStatus.New,
});
imported++;
}
}
settings.LastUid = maxUid;
await _db.SaveChangesAsync(ct);
return new SyncResult(imported, null);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddRequestsAndMailSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MailSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GmailAddress = table.Column<string>(type: "text", nullable: true),
AppPasswordProtected = table.Column<string>(type: "text", nullable: true),
PollIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
Folder = table.Column<string>(type: "text", nullable: false),
BackgroundPollEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastUid = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MailSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Requests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GmailMessageId = table.Column<string>(type: "text", nullable: false),
ThreadId = table.Column<string>(type: "text", nullable: true),
InReplyToMessageId = table.Column<string>(type: "text", nullable: true),
ReferencesHeader = table.Column<string>(type: "text", nullable: true),
FromAddress = table.Column<string>(type: "text", nullable: false),
FromName = table.Column<string>(type: "text", nullable: true),
Subject = table.Column<string>(type: "text", nullable: true),
BodyText = table.Column<string>(type: "text", nullable: true),
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
AssignedContactId = table.Column<Guid>(type: "uuid", nullable: true),
DraftReply = table.Column<string>(type: "text", nullable: true),
AnsweredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Requests", x => x.Id);
table.ForeignKey(
name: "FK_Requests_Contacts_AssignedContactId",
column: x => x.AssignedContactId,
principalTable: "Contacts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
table: "MailSettings",
columns: new[] { "Id", "AppPasswordProtected", "BackgroundPollEnabled", "Folder", "GmailAddress", "LastUid", "PollIntervalMinutes" },
values: new object[] { new Guid("ab0c0000-0000-0000-0000-000000000001"), null, false, "INBOX", null, 0L, 15 });
migrationBuilder.CreateIndex(
name: "IX_Requests_AssignedContactId",
table: "Requests",
column: "AssignedContactId");
migrationBuilder.CreateIndex(
name: "IX_Requests_GmailMessageId",
table: "Requests",
column: "GmailMessageId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MailSettings");
migrationBuilder.DropTable(
name: "Requests");
}
}
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>Triage state of an incoming Gmail request (serialised as string name).</summary>
public enum RequestStatus
{
New = 0,
InProgress = 1,
Assigned = 2,
Answered = 3,
Abandoned = 4,
}
/// <summary>
/// An incoming inquiry imported from Gmail (INBOX epic). One row per email,
/// deduped on the RFC Message-Id.
/// </summary>
public class Request
{
[Key]
public Guid Id { get; set; }
/// <summary>RFC 5322 Message-Id — unique; the dedup key.</summary>
public required string GmailMessageId { get; set; }
/// <summary>Gmail conversation id (X-GM-THRID).</summary>
public string? ThreadId { get; set; }
public string? InReplyToMessageId { get; set; }
public string? ReferencesHeader { get; set; }
public required string FromAddress { get; set; }
public string? FromName { get; set; }
public string? Subject { get; set; }
public string? BodyText { get; set; }
public DateTimeOffset ReceivedAt { get; set; }
public RequestStatus Status { get; set; } = RequestStatus.New;
public Guid? AssignedContactId { get; set; }
public Contact? AssignedContact { get; set; }
/// <summary>AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted.</summary>
public string? DraftReply { get; set; }
public DateTimeOffset? AnsweredAt { get; set; }
}
/// <summary>
/// Singleton mail configuration (INBOX epic). The Gmail App Password is stored
/// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire.
/// </summary>
public class MailSettings
{
public static readonly Guid SingletonId = new("ab0c0000-0000-0000-0000-000000000001");
[Key]
public Guid Id { get; set; }
public string? GmailAddress { get; set; }
/// <summary>Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire).</summary>
public string? AppPasswordProtected { get; set; }
public int PollIntervalMinutes { get; set; } = 15;
public string Folder { get; set; } = "INBOX";
public bool BackgroundPollEnabled { get; set; }
/// <summary>Highest IMAP UID seen, to avoid rescanning the whole folder.</summary>
public uint LastUid { get; set; }
}
}

View File

@@ -42,6 +42,12 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
var app = builder.Build();
app.MapDefaultEndpoints();
@@ -77,6 +83,7 @@ app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.Run();