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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
38
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal 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);
|
||||||
|
}
|
||||||
82
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
82
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal file
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal file
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal 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 > <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);
|
||||||
|
}
|
||||||
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal file
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal file
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1151
GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
1151
GerbilManagerWebAPI/Migrations/20260606073142_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal file
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user