diff --git a/.gitignore b/.gitignore
index db7439a..f1653ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/GerbilManager.Tests/RequestEndpointsTests.cs b/GerbilManager.Tests/RequestEndpointsTests.cs
new file mode 100644
index 0000000..596fb68
--- /dev/null
+++ b/GerbilManager.Tests/RequestEndpointsTests.cs
@@ -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;
+
+/// 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.
+public class RequestEndpointsTests : IClassFixture
+{
+ private readonly ApiFactory _factory;
+ public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
+
+ /// Test double for the IMAP reader: returns whatever the test stages, filtered by UID.
+ private sealed class CannedReader : IGmailMailReader
+ {
+ public static List Messages { get; set; } = new();
+ public Task> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
+ => Task.FromResult((IReadOnlyList)Messages.Where(m => m.Uid > sinceUid).ToList());
+ }
+
+ private HttpClient ClientWithCannedReader() =>
+ _factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
+ {
+ s.RemoveAll();
+ s.AddScoped();
+ })).CreateClient();
+
+ [Fact]
+ public async Task Sync_imports_then_triage_assigns_contact()
+ {
+ var client = ClientWithCannedReader();
+ CannedReader.Messages = new()
+ {
+ new("", 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() == "");
+ 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);
+ }
+}
diff --git a/GerbilManager.Tests/RequestSyncServiceTests.cs b/GerbilManager.Tests/RequestSyncServiceTests.cs
new file mode 100644
index 0000000..2a7a6cc
--- /dev/null
+++ b/GerbilManager.Tests/RequestSyncServiceTests.cs
@@ -0,0 +1,86 @@
+using GerbilManagerWebAPI.Inbox;
+using GerbilManagerWebAPI.Models;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManager.Tests;
+
+/// 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).
+public class RequestSyncServiceTests
+{
+ private static ApplicationContext NewDb()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .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 Canned { get; set; } = new();
+ public Task> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
+ => Task.FromResult((IReadOnlyList)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.de"), M(2, "", "b@y.de"), M(3, "", "b@y.de") };
+
+ var res = await sync.SyncAsync();
+
+ Assert.Null(res.Error);
+ Assert.Equal(2, res.Imported); // 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.de"), M(2, "", "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.de") };
+
+ var res = await sync.SyncAsync();
+
+ Assert.Equal("MailNotConfigured", res.Error);
+ Assert.Equal(0, res.Imported);
+ }
+}
diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs
index a18ca88..fa1dce7 100644
--- a/GerbilManagerWebAPI/ApplicationContext.cs
+++ b/GerbilManagerWebAPI/ApplicationContext.cs
@@ -21,6 +21,8 @@ public class ApplicationContext : DbContext
public DbSet Pages => Set();
public DbSet Blocks => Set();
public DbSet Media => Set();
+ public DbSet Requests => Set();
+ public DbSet MailSettings => Set();
// 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(e => e.Property(b => b.Type).HasConversion());
SeedCms(modelBuilder);
+
+ // INBOX epic: Gmail request inbox.
+ modelBuilder.Entity(e =>
+ {
+ e.Property(r => r.Status).HasConversion();
+ 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()
+ .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
+
SeedColorVarieties(modelBuilder);
}
diff --git a/GerbilManagerWebAPI/Dtos/InboxDtos.cs b/GerbilManagerWebAPI/Dtos/InboxDtos.cs
new file mode 100644
index 0000000..557b6f1
--- /dev/null
+++ b/GerbilManagerWebAPI/Dtos/InboxDtos.cs
@@ -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);
+
+ /// Triage update: change status and/or assign a contact.
+ public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
+
+ /// Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).
+ public record MailSettingsDto(
+ string? GmailAddress,
+ int PollIntervalMinutes,
+ string Folder,
+ bool BackgroundPollEnabled,
+ bool HasAppPassword);
+
+ /// Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
+ /// empty string clears it.
+ public record MailSettingsInput(
+ string? GmailAddress,
+ string? AppPassword,
+ int? PollIntervalMinutes,
+ string? Folder,
+ bool? BackgroundPollEnabled);
+}
diff --git a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
new file mode 100644
index 0000000..2125a7d
--- /dev/null
+++ b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ 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, 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>> (
+ 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);
+ }
+}
diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
index cd572d9..b64ea9e 100644
--- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
+++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
@@ -9,6 +9,7 @@
+
diff --git a/GerbilManagerWebAPI/Inbox/GmailMailReader.cs b/GerbilManagerWebAPI/Inbox/GmailMailReader.cs
new file mode 100644
index 0000000..4664b4c
--- /dev/null
+++ b/GerbilManagerWebAPI/Inbox/GmailMailReader.cs
@@ -0,0 +1,58 @@
+using MailKit;
+using MailKit.Net.Imap;
+using MailKit.Search;
+using MailKit.Security;
+
+namespace GerbilManagerWebAPI.Inbox
+{
+ ///
+ /// 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).
+ ///
+ public sealed class GmailMailReader : IGmailMailReader
+ {
+ public async Task> 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();
+ 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;
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Inbox/MailContracts.cs b/GerbilManagerWebAPI/Inbox/MailContracts.cs
new file mode 100644
index 0000000..725f97d
--- /dev/null
+++ b/GerbilManagerWebAPI/Inbox/MailContracts.cs
@@ -0,0 +1,29 @@
+namespace GerbilManagerWebAPI.Inbox
+{
+ /// Connection params for an IMAP fetch (decrypted password — never persisted/logged).
+ public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder);
+
+ /// A fetched email reduced to what the Request entity needs.
+ public sealed record MailSummary(
+ string MessageId,
+ string? ThreadId,
+ string? InReplyTo,
+ string? References,
+ string FromAddress,
+ string? FromName,
+ string? Subject,
+ string? BodyText,
+ DateTimeOffset ReceivedAt,
+ uint Uid);
+
+ /// Reads mail from a folder. Abstracted so tests feed canned summaries
+ /// (the live MailKit/Gmail path is gated on Julian's App Password).
+ public interface IGmailMailReader
+ {
+ /// Fetch messages with UID > from the configured folder.
+ Task> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default);
+ }
+
+ /// Outcome of a sync run.
+ public sealed record SyncResult(int Imported, string? Error);
+}
diff --git a/GerbilManagerWebAPI/Inbox/MailSettingsService.cs b/GerbilManagerWebAPI/Inbox/MailSettingsService.cs
new file mode 100644
index 0000000..c97ee24
--- /dev/null
+++ b/GerbilManagerWebAPI/Inbox/MailSettingsService.cs
@@ -0,0 +1,43 @@
+using GerbilManagerWebAPI.Models;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManagerWebAPI.Inbox
+{
+ ///
+ /// 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).
+ ///
+ 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 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);
+ }
+}
diff --git a/GerbilManagerWebAPI/Inbox/RequestSyncService.cs b/GerbilManagerWebAPI/Inbox/RequestSyncService.cs
new file mode 100644
index 0000000..2aa723c
--- /dev/null
+++ b/GerbilManagerWebAPI/Inbox/RequestSyncService.cs
@@ -0,0 +1,69 @@
+using GerbilManagerWebAPI.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManagerWebAPI.Inbox
+{
+ ///
+ /// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP
+ /// UID to avoid rescans. The actual fetch is behind (mockable).
+ ///
+ 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 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);
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
new file mode 100644
index 0000000..9acf48a
--- /dev/null
+++ b/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
@@ -0,0 +1,1378 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace GerbilManagerWebAPI.Migrations
+{
+ [DbContext(typeof(ApplicationContext))]
+ [Migration("20260606074229_AddRequestsAndMailSettings")]
+ partial class AddRequestsAndMailSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Order")
+ .HasColumnType("integer");
+
+ b.Property("PageId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PageId");
+
+ b.ToTable("Blocks");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000001"),
+ Data = "{\"text\":\"Startseite\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000001"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000002"),
+ Data = "{\"text\":\"Über die Zucht\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000002"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000003"),
+ Data = "{\"text\":\"Abgabetiere\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000003"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000004"),
+ Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000004"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000005"),
+ Data = "{\"text\":\"Farben & Genetik\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000005"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000006"),
+ Data = "{\"text\":\"Kontakt\",\"level\":1}",
+ Order = 0,
+ PageId = new Guid("51720001-0000-0000-0000-000000000006"),
+ Type = "Heading"
+ },
+ new
+ {
+ Id = new Guid("51720002-0000-0000-0000-000000000010"),
+ Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
+ Order = 1,
+ PageId = new Guid("51720001-0000-0000-0000-000000000003"),
+ Type = "AbgabetiereList"
+ });
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("City")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Homepage")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ZuchtName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("BreederSettings");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("11111111-1111-1111-1111-000000000001"),
+ Address = "",
+ City = "",
+ Email = "",
+ Homepage = "",
+ Name = "",
+ Phone = "",
+ ZuchtName = ""
+ });
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CanonicalGenotype")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ColorVarieties");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
+ Name = "Pink Eyed White (PEW)",
+ SortOrder = 0
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000002"),
+ CanonicalGenotype = "aa chch DD EE GG PP spsp rere",
+ Name = "Hermelin",
+ SortOrder = 1
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000003"),
+ CanonicalGenotype = "AA chch DD EE GG PP spsp rere",
+ Name = "Himalaya",
+ SortOrder = 2
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000004"),
+ CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
+ Name = "Zobel",
+ SortOrder = 3
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000005"),
+ CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
+ Name = "Schwarzschimmel",
+ SortOrder = 4
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000006"),
+ CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
+ Name = "Rotaugenschimmel",
+ SortOrder = 5
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000007"),
+ CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
+ Name = "Agouti",
+ SortOrder = 6
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000008"),
+ CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
+ Name = "Schwarz",
+ SortOrder = 7
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000009"),
+ CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
+ Name = "Silberagouti",
+ SortOrder = 8
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000010"),
+ CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
+ Name = "Anthrazit",
+ SortOrder = 9
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000011"),
+ CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
+ Name = "Algierfuchs",
+ SortOrder = 10
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000012"),
+ CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
+ Name = "Blau",
+ SortOrder = 11
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000013"),
+ CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
+ Name = "Gold",
+ SortOrder = 12
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000014"),
+ CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
+ Name = "Platin",
+ SortOrder = 13
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000015"),
+ CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
+ Name = "Goldfuchs",
+ SortOrder = 14
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000016"),
+ CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
+ Name = "Rotfuchs",
+ SortOrder = 15
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000017"),
+ CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
+ Name = "dd Gold",
+ SortOrder = 16
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000018"),
+ CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
+ Name = "dd Platin",
+ SortOrder = 17
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000019"),
+ CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
+ Name = "Altweiss (REW)",
+ SortOrder = 18
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000020"),
+ CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
+ Name = "Apricot (Blassfuchs)",
+ SortOrder = 19
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000021"),
+ CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
+ Name = "Blaufuchs",
+ SortOrder = 20
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000022"),
+ CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
+ Name = "C-Separator",
+ SortOrder = 21
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000023"),
+ CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
+ Name = "Elfenbein",
+ SortOrder = 22
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000024"),
+ CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
+ Name = "Kohlfuchs",
+ SortOrder = 23
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000025"),
+ CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
+ Name = "Marder",
+ SortOrder = 24
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000026"),
+ CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
+ Name = "Siam (Marder-Hell)",
+ SortOrder = 25
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000027"),
+ CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
+ Name = "Polarfuchs",
+ SortOrder = 26
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000028"),
+ CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
+ Name = "Saphir",
+ SortOrder = 27
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000029"),
+ CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
+ Name = "Schimmel (Orangeschimmel)",
+ SortOrder = 28
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000030"),
+ CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
+ Name = "Topas",
+ SortOrder = 29
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000031"),
+ CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
+ Name = "Platin-Hell",
+ SortOrder = 30
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000032"),
+ CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
+ Name = "Agouti dd",
+ SortOrder = 31
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000033"),
+ CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
+ Name = "Silberagouti dd",
+ SortOrder = 32
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000034"),
+ CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
+ Name = "Kohlfuchs dd",
+ SortOrder = 33
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000035"),
+ CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
+ Name = "Anthrazit dd",
+ SortOrder = 34
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000036"),
+ CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
+ Name = "Agouti CP-Hell",
+ SortOrder = 35
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000037"),
+ CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
+ Name = "Blaufuchs CP",
+ SortOrder = 36
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000038"),
+ CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
+ Name = "Polarfuchsschimmel",
+ SortOrder = 37
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000039"),
+ CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
+ Name = "Silberschimmel",
+ SortOrder = 38
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000040"),
+ CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
+ Name = "Algierfuchsschimmel",
+ SortOrder = 39
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000041"),
+ CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
+ Name = "Polarfuchs-Hell CP",
+ SortOrder = 40
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000042"),
+ CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
+ Name = "Kohlfuchsschimmel",
+ SortOrder = 41
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000043"),
+ CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
+ Name = "Blaufuchsschimmel",
+ SortOrder = 42
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000044"),
+ CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
+ Name = "Kohlfuchs, hell",
+ SortOrder = 43
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000045"),
+ CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
+ Name = "Goldfuchs, hell",
+ SortOrder = 44
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000046"),
+ CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
+ Name = "Goldfuchsschimmel",
+ SortOrder = 45
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000047"),
+ CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
+ Name = "Gold-Hell",
+ SortOrder = 46
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000048"),
+ CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
+ Name = "Siam (Marder-Hell) dd",
+ SortOrder = 47
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000049"),
+ CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
+ Name = "Marder dd",
+ SortOrder = 48
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000050"),
+ CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
+ Name = "Zobel-Hell",
+ SortOrder = 49
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000051"),
+ CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
+ Name = "Silberagouti dd CP",
+ SortOrder = 50
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000052"),
+ CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
+ Name = "Silberagouti-Hell dd CP",
+ SortOrder = 51
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000053"),
+ CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
+ Name = "Agouti dd CP",
+ SortOrder = 52
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000054"),
+ CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
+ Name = "Agouti-Hell dd CP",
+ SortOrder = 53
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000055"),
+ CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
+ Name = "Blaufuchs, hell",
+ SortOrder = 54
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000056"),
+ CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
+ Name = "Rotfuchsschimmel",
+ SortOrder = 55
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000057"),
+ CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
+ Name = "Polarfuchs, hell",
+ SortOrder = 56
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000058"),
+ CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
+ Name = "Kohlfuchsschimmel, hell",
+ SortOrder = 57
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000059"),
+ CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
+ Name = "Rotfuchs, hell",
+ SortOrder = 58
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000060"),
+ CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
+ Name = "Zobel dd",
+ SortOrder = 59
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000061"),
+ CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
+ Name = "Kohlfuchs-Hell",
+ SortOrder = 60
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000062"),
+ CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
+ Name = "Kohlfuchs CP",
+ SortOrder = 61
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000063"),
+ CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
+ Name = "Algierfuchs CP",
+ SortOrder = 62
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000064"),
+ CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
+ Name = "Silberagouti CP",
+ SortOrder = 63
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000065"),
+ CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
+ Name = "Agouti CP",
+ SortOrder = 64
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000066"),
+ CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
+ Name = "Algierfuchs-Hell CP",
+ SortOrder = 65
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000067"),
+ CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
+ Name = "Kohlfuchs,hell CP",
+ SortOrder = 66
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000068"),
+ CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
+ Name = "Polarfuchs CP",
+ SortOrder = 67
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000069"),
+ CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
+ Name = "Algierfuchs, hell",
+ SortOrder = 68
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000070"),
+ CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
+ Name = "Topas dd",
+ SortOrder = 69
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000071"),
+ CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
+ Name = "Zobel-Hell dd",
+ SortOrder = 70
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000072"),
+ CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
+ Name = "Kohlfuchsschimmel CP",
+ SortOrder = 71
+ },
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000073"),
+ CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
+ Name = "Blaufuchs dd",
+ SortOrder = 72
+ });
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Contacts");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Enclosures");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CauseOfDeath")
+ .HasColumnType("text");
+
+ b.Property("CharacterNote")
+ .HasColumnType("text");
+
+ b.Property("CharacterTraits")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ColorVarietyId")
+ .HasColumnType("uuid");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date");
+
+ b.Property("DateOfDeath")
+ .HasColumnType("date");
+
+ b.Property("EnclosureId")
+ .HasColumnType("uuid");
+
+ b.Property("ExternalRef")
+ .HasColumnType("text");
+
+ b.Property("Gender")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Genotype")
+ .HasColumnType("text");
+
+ b.Property("GoHomeDate")
+ .HasColumnType("date");
+
+ b.Property("ImportSource")
+ .HasColumnType("text");
+
+ b.Property("LitterId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NameSearch")
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("OriginBreeder")
+ .HasColumnType("text");
+
+ b.Property("OriginContactId")
+ .HasColumnType("uuid");
+
+ b.Property("RawImportData")
+ .HasColumnType("text");
+
+ b.Property("ReceiverContactId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ColorVarietyId");
+
+ b.HasIndex("EnclosureId");
+
+ b.HasIndex("LitterId");
+
+ b.HasIndex("OriginContactId");
+
+ b.HasIndex("ReceiverContactId");
+
+ b.ToTable("Gerbils");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Caption")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GerbilId")
+ .HasColumnType("uuid");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GerbilId");
+
+ b.ToTable("GerbilPhotos");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GerbilId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Veterinarian")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GerbilId");
+
+ b.ToTable("HealthRecords");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("ExpectedGoHomeDate")
+ .HasColumnType("date");
+
+ b.Property("FatherId")
+ .HasColumnType("uuid");
+
+ b.Property("MotherId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("PairingCode")
+ .HasColumnType("text");
+
+ b.Property("TotalBorn")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FatherId");
+
+ b.HasIndex("MotherId");
+
+ b.ToTable("Litters");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppPasswordProtected")
+ .HasColumnType("text");
+
+ b.Property("BackgroundPollEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Folder")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GmailAddress")
+ .HasColumnType("text");
+
+ b.Property("LastUid")
+ .HasColumnType("bigint");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Alt")
+ .HasColumnType("text");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Height")
+ .HasColumnType("integer");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Width")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Media");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("SeoDescription")
+ .HasColumnType("text");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Pages");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000001"),
+ Slug = "start",
+ Status = "Published",
+ Title = "Startseite"
+ },
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000002"),
+ Slug = "ueber-die-zucht",
+ Status = "Published",
+ Title = "Über die Zucht"
+ },
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000003"),
+ Slug = "abgabetiere",
+ Status = "Published",
+ Title = "Abgabetiere"
+ },
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000004"),
+ Slug = "abgabebedingungen",
+ Status = "Published",
+ Title = "Abgabebedingungen"
+ },
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000005"),
+ Slug = "farben-genetik",
+ Status = "Published",
+ Title = "Farben & Genetik"
+ },
+ new
+ {
+ Id = new Guid("51720001-0000-0000-0000-000000000006"),
+ Slug = "kontakt",
+ Status = "Published",
+ Title = "Kontakt"
+ });
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AnsweredAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AssignedContactId")
+ .HasColumnType("uuid");
+
+ b.Property("BodyText")
+ .HasColumnType("text");
+
+ b.Property("DraftReply")
+ .HasColumnType("text");
+
+ b.Property("FromAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FromName")
+ .HasColumnType("text");
+
+ b.Property("GmailMessageId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InReplyToMessageId")
+ .HasColumnType("text");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReferencesHeader")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Subject")
+ .HasColumnType("text");
+
+ b.Property("ThreadId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AssignedContactId");
+
+ b.HasIndex("GmailMessageId")
+ .IsUnique();
+
+ b.ToTable("Requests");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContactId")
+ .HasColumnType("uuid");
+
+ b.Property("ContractDate")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("HandoverDate")
+ .HasColumnType("date");
+
+ b.Property("Price")
+ .HasPrecision(10, 2)
+ .HasColumnType("numeric(10,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContactId");
+
+ b.ToTable("SaleContracts");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b =>
+ {
+ b.Property("SaleContractId")
+ .HasColumnType("uuid");
+
+ b.Property("GerbilId")
+ .HasColumnType("uuid");
+
+ b.HasKey("SaleContractId", "GerbilId");
+
+ b.HasIndex("GerbilId");
+
+ b.ToTable("SaleContractAnimal");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DefaultLocale")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NavOrder")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Sites");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("5172e000-0000-0000-0000-000000000001"),
+ DefaultLocale = "de",
+ NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]"
+ });
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("GerbilId")
+ .HasColumnType("uuid");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("WeightGrams")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GerbilId");
+
+ b.ToTable("WeightRecords");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Page", null)
+ .WithMany("Blocks")
+ .HasForeignKey("PageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
+ .WithMany()
+ .HasForeignKey("ColorVarietyId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure")
+ .WithMany("Gerbils")
+ .HasForeignKey("EnclosureId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter")
+ .WithMany()
+ .HasForeignKey("LitterId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact")
+ .WithMany()
+ .HasForeignKey("OriginContactId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact")
+ .WithMany()
+ .HasForeignKey("ReceiverContactId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("ColorVariety");
+
+ b.Navigation("Enclosure");
+
+ b.Navigation("Litter");
+
+ b.Navigation("OriginContact");
+
+ b.Navigation("ReceiverContact");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
+ .WithMany()
+ .HasForeignKey("GerbilId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
+ .WithMany()
+ .HasForeignKey("GerbilId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father")
+ .WithMany()
+ .HasForeignKey("FatherId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother")
+ .WithMany()
+ .HasForeignKey("MotherId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Father");
+
+ 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")
+ .WithMany()
+ .HasForeignKey("ContactId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Contact");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil")
+ .WithMany()
+ .HasForeignKey("GerbilId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null)
+ .WithMany("Animals")
+ .HasForeignKey("SaleContractId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Gerbil");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
+ {
+ b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null)
+ .WithMany()
+ .HasForeignKey("GerbilId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
+ {
+ b.Navigation("Gerbils");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b =>
+ {
+ b.Navigation("Blocks");
+ });
+
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
+ {
+ b.Navigation("Animals");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.cs b/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.cs
new file mode 100644
index 0000000..dc300e6
--- /dev/null
+++ b/GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.cs
@@ -0,0 +1,88 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GerbilManagerWebAPI.Migrations
+{
+ ///
+ public partial class AddRequestsAndMailSettings : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "MailSettings",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ GmailAddress = table.Column(type: "text", nullable: true),
+ AppPasswordProtected = table.Column(type: "text", nullable: true),
+ PollIntervalMinutes = table.Column(type: "integer", nullable: false),
+ Folder = table.Column(type: "text", nullable: false),
+ BackgroundPollEnabled = table.Column(type: "boolean", nullable: false),
+ LastUid = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_MailSettings", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Requests",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ GmailMessageId = table.Column(type: "text", nullable: false),
+ ThreadId = table.Column(type: "text", nullable: true),
+ InReplyToMessageId = table.Column(type: "text", nullable: true),
+ ReferencesHeader = table.Column(type: "text", nullable: true),
+ FromAddress = table.Column(type: "text", nullable: false),
+ FromName = table.Column(type: "text", nullable: true),
+ Subject = table.Column(type: "text", nullable: true),
+ BodyText = table.Column(type: "text", nullable: true),
+ ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ Status = table.Column(type: "text", nullable: false),
+ AssignedContactId = table.Column(type: "uuid", nullable: true),
+ DraftReply = table.Column(type: "text", nullable: true),
+ AnsweredAt = table.Column(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);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "MailSettings");
+
+ migrationBuilder.DropTable(
+ name: "Requests");
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs
index cebc813..177faf8 100644
--- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs
+++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs
@@ -927,6 +927,46 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Litters");
});
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppPasswordProtected")
+ .HasColumnType("text");
+
+ b.Property("BackgroundPollEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Folder")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GmailAddress")
+ .HasColumnType("text");
+
+ b.Property("LastUid")
+ .HasColumnType("bigint");
+
+ b.Property("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("Id")
@@ -1028,6 +1068,64 @@ namespace GerbilManagerWebAPI.Migrations
});
});
+ modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AnsweredAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AssignedContactId")
+ .HasColumnType("uuid");
+
+ b.Property("BodyText")
+ .HasColumnType("text");
+
+ b.Property("DraftReply")
+ .HasColumnType("text");
+
+ b.Property("FromAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FromName")
+ .HasColumnType("text");
+
+ b.Property("GmailMessageId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InReplyToMessageId")
+ .HasColumnType("text");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReferencesHeader")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Subject")
+ .HasColumnType("text");
+
+ b.Property("ThreadId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AssignedContactId");
+
+ b.HasIndex("GmailMessageId")
+ .IsUnique();
+
+ b.ToTable("Requests");
+ });
+
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
{
b.Property("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")
diff --git a/GerbilManagerWebAPI/Models/InboxModels.cs b/GerbilManagerWebAPI/Models/InboxModels.cs
new file mode 100644
index 0000000..34ba21a
--- /dev/null
+++ b/GerbilManagerWebAPI/Models/InboxModels.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace GerbilManagerWebAPI.Models
+{
+ /// Triage state of an incoming Gmail request (serialised as string name).
+ public enum RequestStatus
+ {
+ New = 0,
+ InProgress = 1,
+ Assigned = 2,
+ Answered = 3,
+ Abandoned = 4,
+ }
+
+ ///
+ /// An incoming inquiry imported from Gmail (INBOX epic). One row per email,
+ /// deduped on the RFC Message-Id.
+ ///
+ public class Request
+ {
+ [Key]
+ public Guid Id { get; set; }
+
+ /// RFC 5322 Message-Id — unique; the dedup key.
+ public required string GmailMessageId { get; set; }
+ /// Gmail conversation id (X-GM-THRID).
+ 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; }
+
+ /// AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted.
+ public string? DraftReply { get; set; }
+ public DateTimeOffset? AnsweredAt { get; set; }
+ }
+
+ ///
+ /// Singleton mail configuration (INBOX epic). The Gmail App Password is stored
+ /// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire.
+ ///
+ 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; }
+ /// Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire).
+ public string? AppPasswordProtected { get; set; }
+ public int PollIntervalMinutes { get; set; } = 15;
+ public string Folder { get; set; } = "INBOX";
+ public bool BackgroundPollEnabled { get; set; }
+ /// Highest IMAP UID seen, to avoid rescanning the whole folder.
+ public uint LastUid { get; set; }
+ }
+}
diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs
index 5891c35..4cc0402 100644
--- a/GerbilManagerWebAPI/Program.cs
+++ b/GerbilManagerWebAPI/Program.cs
@@ -42,6 +42,12 @@ builder.Services.AddOptions()
builder.Services.AddHttpClient(
http => http.Timeout = TimeSpan.FromSeconds(60));
+// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
+builder.Services.AddDataProtection();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
var app = builder.Build();
app.MapDefaultEndpoints();
@@ -77,6 +83,7 @@ app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
+app.MapRequestEndpoints();
app.Run();