Merge feature/inbox-0: Gmail request inbox backend (MailKit reader, Request entity, MailSettings, sync/triage endpoints) [god-QA: 75 + snapshot clean]

This commit is contained in:
2026-06-06 09:44:42 +02:00
16 changed files with 2153 additions and 0 deletions

3
.gitignore vendored
View File

@@ -131,3 +131,6 @@ $RECYCLE.BIN/
# MemPalace per-project files (issue #185) # MemPalace per-project files (issue #185)
mempalace.yaml mempalace.yaml
entities.json entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/

View File

@@ -0,0 +1,82 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Inbox;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace GerbilManager.Tests;
/// <summary>INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage,
/// and the Gmail App Password is never returned by GET /api/mail-settings.</summary>
public class RequestEndpointsTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
/// <summary>Test double for the IMAP reader: returns whatever the test stages, filtered by UID.</summary>
private sealed class CannedReader : IGmailMailReader
{
public static List<MailSummary> Messages { get; set; } = new();
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
=> Task.FromResult((IReadOnlyList<MailSummary>)Messages.Where(m => m.Uid > sinceUid).ToList());
}
private HttpClient ClientWithCannedReader() =>
_factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
{
s.RemoveAll<IGmailMailReader>();
s.AddScoped<IGmailMailReader, CannedReader>();
})).CreateClient();
[Fact]
public async Task Sync_imports_then_triage_assigns_contact()
{
var client = ClientWithCannedReader();
CannedReader.Messages = new()
{
new("<triage@1>", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!",
DateTimeOffset.UtcNow, Uid: 1001),
};
// configure mail (otherwise sync reports MailNotConfigured)
Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings",
new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode);
var sync = await client.PostAsync("/api/requests/sync", null);
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1);
// find the imported request via the status filter
var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100"));
var item = listed.RootElement.GetProperty("items").EnumerateArray()
.Single(r => r.GetProperty("gmailMessageId").GetString() == "<triage@1>");
var reqId = item.GetProperty("id").GetString();
// a contact + triage
var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" }))
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement;
Assert.Equal("Assigned", got.GetProperty("status").GetString());
Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString());
}
[Fact]
public async Task MailSettings_stores_password_but_never_returns_it()
{
var client = _factory.CreateClient();
var put = await client.PutAsJsonAsync("/api/mail-settings",
new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" });
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
var json = await client.GetStringAsync("/api/mail-settings");
Assert.Contains("sekret@gmail.com", json);
Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", ""));
Assert.DoesNotContain("abcd", json); // the app password must never leave the server
Assert.DoesNotContain("appPassword", json);
}
}

View File

@@ -0,0 +1,86 @@
using GerbilManagerWebAPI.Inbox;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests;
/// <summary>INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a
/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password).</summary>
public class RequestSyncServiceTests
{
private static ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // seeds the MailSettings singleton
return db;
}
private sealed class FakeReader : IGmailMailReader
{
public List<MailSummary> Canned { get; set; } = new();
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
=> Task.FromResult((IReadOnlyList<MailSummary>)Canned.Where(m => m.Uid > sinceUid).ToList());
}
private static MailSummary M(uint uid, string msgId, string from) =>
new(msgId, ThreadId: null, InReplyTo: null, References: null,
FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo",
ReceivedAt: DateTimeOffset.UtcNow, Uid: uid);
private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true)
{
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-1234");
await db.SaveChangesAsync();
}
var reader = new FakeReader();
return (db, new RequestSyncService(db, reader, settings), reader);
}
[Fact]
public async Task Sync_imports_new_and_dedups_within_batch()
{
var (db, sync, reader) = await SetupAsync();
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de"), M(3, "<b@y>", "b@y.de") };
var res = await sync.SyncAsync();
Assert.Null(res.Error);
Assert.Equal(2, res.Imported); // <b@y> deduped
Assert.Equal(2, await db.Requests.CountAsync());
Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status));
}
[Fact]
public async Task Resync_imports_nothing_new()
{
var (db, sync, reader) = await SetupAsync();
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de") };
await sync.SyncAsync();
var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer
Assert.Equal(0, second.Imported);
Assert.Equal(2, await db.Requests.CountAsync());
}
[Fact]
public async Task Sync_without_config_reports_not_configured()
{
var (_, sync, reader) = await SetupAsync(configured: false);
reader.Canned = new() { M(1, "<a@x>", "a@x.de") };
var res = await sync.SyncAsync();
Assert.Equal("MailNotConfigured", res.Error);
Assert.Equal(0, res.Imported);
}
}

View File

@@ -21,6 +21,8 @@ public class ApplicationContext : DbContext
public DbSet<Page> Pages => Set<Page>(); public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>(); public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>(); 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), // 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. // 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>()); modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder); 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); 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> <ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" /> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" /> <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.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" 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

@@ -927,6 +927,46 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Litters"); b.ToTable("Litters");
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AppPasswordProtected")
.HasColumnType("text");
b.Property<bool>("BackgroundPollEnabled")
.HasColumnType("boolean");
b.Property<string>("Folder")
.IsRequired()
.HasColumnType("text");
b.Property<string>("GmailAddress")
.HasColumnType("text");
b.Property<long>("LastUid")
.HasColumnType("bigint");
b.Property<int>("PollIntervalMinutes")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("MailSettings");
b.HasData(
new
{
Id = new Guid("ab0c0000-0000-0000-0000-000000000001"),
BackgroundPollEnabled = false,
Folder = "INBOX",
LastUid = 0L,
PollIntervalMinutes = 15
});
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1028,6 +1068,64 @@ namespace GerbilManagerWebAPI.Migrations
}); });
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("AssignedContactId")
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<string>("DraftReply")
.HasColumnType("text");
b.Property<string>("FromAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FromName")
.HasColumnType("text");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("InReplyToMessageId")
.HasColumnType("text");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferencesHeader")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subject")
.HasColumnType("text");
b.Property<string>("ThreadId")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssignedContactId");
b.HasIndex("GmailMessageId")
.IsUnique();
b.ToTable("Requests");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1210,6 +1308,16 @@ namespace GerbilManagerWebAPI.Migrations
b.Navigation("Mother"); b.Navigation("Mother");
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b =>
{
b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact")
.WithMany()
.HasForeignKey("AssignedContactId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("AssignedContact");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
{ {
b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact")

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>( builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); 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(); var app = builder.Build();
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
@@ -77,6 +83,7 @@ app.MapContractEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapExportEndpoints(); app.MapExportEndpoints();
app.MapCmsEndpoints(); app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.Run(); app.Run();