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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -131,3 +131,6 @@ $RECYCLE.BIN/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
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<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);
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
<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">
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1378
GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
1378
GerbilManagerWebAPI/Migrations/20260606074229_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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -927,6 +927,46 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
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 =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1210,6 +1308,16 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact")
|
||||
|
||||
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>(
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user