fix(scale): page mail and batch roles
CI and Deploy / test (pull_request) Failing after 2m46s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 21:02:50 +02:00
parent 8ef8b098c8
commit e7cacad7d6
11 changed files with 284 additions and 20 deletions
@@ -85,4 +85,45 @@ public sealed class CorrespondenceControllerTests
var otherResult = await new CorrespondenceController(otherDb).GetMessage(messageId, CancellationToken.None);
Assert.IsType<NotFoundResult>(otherResult.Result);
}
[Fact]
public async Task Paged_inbox_returns_every_owned_message_without_the_legacy_cap()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { Name = "Scale fixture", OwnerUserId = "user-1" };
var otherCompany = new Company { Name = "Other tenant", OwnerUserId = "user-2" };
db.Companies.AddRange(company, otherCompany);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Paged role", CompanyId = company.Id, OwnerUserId = "user-1" };
var otherJob = new JobApplication { JobTitle = "Private role", CompanyId = otherCompany.Id, OwnerUserId = "user-2" };
db.JobApplications.AddRange(job, otherJob);
await db.SaveChangesAsync();
db.Correspondences.AddRange(Enumerable.Range(1, 205).Select(index => new Correspondence
{
JobApplicationId = job.Id,
From = "Recruiter",
Subject = $"Message {index}",
Content = $"Content {index}",
Date = new DateTime(2026, 1, 1).AddMinutes(index)
}));
db.Correspondences.Add(new Correspondence
{
JobApplicationId = otherJob.Id,
From = "Other recruiter",
Subject = "Private message",
Content = "Must stay tenant isolated",
Date = new DateTime(2026, 2, 1)
});
await db.SaveChangesAsync();
var result = await new CorrespondenceController(db).GetInboxPage(null, null, null, 3, 100, CancellationToken.None);
var page = Assert.IsType<CorrespondenceController.CorrespondenceInboxPageDto>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal(205, page.Total);
Assert.Equal(3, page.TotalPages);
Assert.Equal(3, page.Page);
Assert.Equal(5, page.Items.Count);
Assert.Equal("Message 5", page.Items[0].Subject);
Assert.Equal("Message 1", page.Items[^1].Subject);
}
}
+61 -2
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Linq.Expressions;
using System.Data.Common;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -11,6 +12,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Data.Sqlite;
using JobTrackerApi.Data;
using Moq;
using Xunit;
@@ -102,6 +107,42 @@ public sealed class UsersControllerTests
Assert.True(Assert.Single(rows, row => row.Id == member.Id).CanRemoveAdmin);
}
[Fact]
public async Task List_uses_a_fixed_number_of_role_queries_for_many_users()
{
var counter = new CommandCounter();
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("admin-1");
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseSqlite(connection)
.AddInterceptors(counter)
.Options;
await using var db = new JobTrackerContext(options, currentUser.Object);
await db.Database.EnsureCreatedAsync();
var adminRole = new IdentityRole("Admin") { Id = "role-admin", NormalizedName = "ADMIN" };
var memberRole = new IdentityRole("Member") { Id = "role-member", NormalizedName = "MEMBER" };
db.Roles.AddRange(adminRole, memberRole);
var usersToAdd = Enumerable.Range(1, 75).Select(index => User(index == 1 ? "admin-1" : $"user-{index}")).ToList();
db.Users.AddRange(usersToAdd);
db.UserRoles.Add(new IdentityUserRole<string> { UserId = "admin-1", RoleId = adminRole.Id });
foreach (var user in usersToAdd.Skip(1))
db.UserRoles.Add(new IdentityUserRole<string> { UserId = user.Id, RoleId = memberRole.Id });
await db.SaveChangesAsync();
counter.Reset();
var manager = TestHostFactory.CreateUserManager();
var controller = CreateController(manager, "admin-1", db);
var action = await controller.List(CancellationToken.None);
var rows = Assert.IsType<List<UsersController.UserDto>>(Assert.IsType<OkObjectResult>(action.Result).Value);
Assert.Equal(75, rows.Count);
Assert.Equal(2, counter.ReaderCount);
manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny<ApplicationUser>()), Times.Never);
manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny<string>()), Times.Never);
}
private static ApplicationUser User(string id) => new()
{
Id = id,
@@ -109,7 +150,7 @@ public sealed class UsersControllerTests
UserName = $"{id}@example.com"
};
private static UsersController CreateController(Mock<UserManager<ApplicationUser>> users, string currentUserId)
private static UsersController CreateController(Mock<UserManager<ApplicationUser>> users, string currentUserId, JobTrackerContext? db = null)
{
var roleStore = new Mock<IRoleStore<IdentityRole>>();
var roles = new Mock<RoleManager<IdentityRole>>(
@@ -125,7 +166,8 @@ public sealed class UsersControllerTests
Mock.Of<IAppEmailSender>(),
new ConfigurationBuilder().Build(),
new NullLogger<UsersController>(),
ExternalOrigin.Parse("http://localhost:3000", production: false));
ExternalOrigin.Parse("http://localhost:3000", production: false),
db: db);
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, currentUserId)], "test");
controller.ControllerContext = new ControllerContext
@@ -135,6 +177,23 @@ public sealed class UsersControllerTests
return controller;
}
private sealed class CommandCounter : DbCommandInterceptor
{
public int ReaderCount { get; private set; }
public void Reset() => ReaderCount = 0;
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ReaderCount++;
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
private sealed class TestAsyncQueryProvider<TEntity>(IQueryProvider inner) : IAsyncQueryProvider
{
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
@@ -51,14 +51,48 @@ namespace JobTrackerApi.Controllers
int LabelCount,
int AttachmentCount);
public sealed record CorrespondenceInboxPageDto(
List<CorrespondenceInboxItemDto> Items,
int Page,
int PageSize,
int Total,
int TotalPages);
[HttpGet]
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
[FromQuery] string? q,
[FromQuery] string? direction,
[FromQuery] string? linkState,
CancellationToken cancellationToken)
{
var query = BuildInboxQuery(q, direction, linkState);
var items = await LoadInboxItemsAsync(query, 0, 200, cancellationToken);
return Ok(items);
}
[HttpGet("page")]
public async Task<ActionResult<CorrespondenceInboxPageDto>> GetInboxPage(
[FromQuery] string? q,
[FromQuery] string? direction,
[FromQuery] string? linkState,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = BuildInboxQuery(q, direction, linkState);
var total = await query.CountAsync(cancellationToken);
var totalPages = total == 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
if (totalPages > 0) page = Math.Min(page, totalPages);
var items = await LoadInboxItemsAsync(query, (page - 1) * pageSize, pageSize, cancellationToken);
return Ok(new CorrespondenceInboxPageDto(items, page, pageSize, total, totalPages));
}
private IQueryable<Correspondence> BuildInboxQuery(string? q, string? direction, string? linkState)
{
var query = _db.Correspondences
.AsNoTracking()
.Include(c => c.JobApplication)
.ThenInclude(j => j.Company)
.AsQueryable();
@@ -88,9 +122,20 @@ namespace JobTrackerApi.Controllers
query = query.Where(c => c.ExternalThreadId == null);
}
return query;
}
private static async Task<List<CorrespondenceInboxItemDto>> LoadInboxItemsAsync(
IQueryable<Correspondence> query,
int skip,
int take,
CancellationToken cancellationToken)
{
var rows = await query
.OrderByDescending(c => c.Date)
.Take(200)
.ThenByDescending(c => c.Id)
.Skip(skip)
.Take(take)
.Select(c => new
{
c.Id,
@@ -132,7 +177,7 @@ namespace JobTrackerApi.Controllers
DeserializeLabels(c.ExternalLabelsJson).Count,
DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList();
return Ok(items);
return items;
}
// GET all messages for a job
+34 -1
View File
@@ -1,5 +1,6 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -20,7 +21,8 @@ public sealed class UsersController : ControllerBase
private readonly ExternalOrigin _externalOrigin;
private readonly ILogger<UsersController> _logger;
private readonly AccountDeletionService? _deletions;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null)
private readonly JobTrackerContext? _db;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null, JobTrackerContext? db = null)
{
_users = users;
_roles = roles;
@@ -28,6 +30,7 @@ public sealed class UsersController : ControllerBase
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
_logger = logger;
_deletions = deletions;
_db = db;
}
public sealed record UserDto(
@@ -47,6 +50,36 @@ public sealed class UsersController : ControllerBase
[HttpGet]
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
{
if (_db is not null)
{
var users = await _db.Users.AsNoTracking()
.OrderBy(user => user.Email)
.ToListAsync(cancellationToken);
var roleRows = await (
from userRole in _db.UserRoles.AsNoTracking()
join role in _db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
select new { userRole.UserId, role.Name })
.ToListAsync(cancellationToken);
var rolesByUser = roleRows
.Where(row => !string.IsNullOrWhiteSpace(row.Name))
.GroupBy(row => row.UserId)
.ToDictionary(group => group.Key, group => group.Select(row => row.Name!).ToList());
var relationalAdminCount = roleRows
.Where(row => string.Equals(row.Name, "Admin", StringComparison.OrdinalIgnoreCase))
.Select(row => row.UserId)
.Distinct(StringComparer.Ordinal)
.Count();
var relationalCurrentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
return Ok(users.Select(user =>
{
var roles = rolesByUser.GetValueOrDefault(user.Id) ?? [];
return ToDto(user, roles, relationalCurrentUserId, !roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) || relationalAdminCount > 1);
}).ToList());
}
// Retained only for isolated controller tests/manual construction. The application DI path
// always supplies JobTrackerContext and uses the fixed two-query projection above.
var items = await _users.Users
.OrderBy(u => u.Email)
.ToListAsync(cancellationToken);
+2
View File
@@ -35,6 +35,8 @@ operator/external dependencies belong in `BLOCKERS.md`.
public artifact is a real PDF in the browser smoke suite.
- Sandboxed authenticated CV preview iframes without enabling scripts, isolated public-PDF request
budgets by client and slug, and removed/ignored the tracked expired acceptance JWT artifact.
- Replaced the admin user list's per-user role lookup with a fixed two-query relational projection,
and moved the Job email UI to an additive paged inbox endpoint while preserving the legacy route.
- Removed unfinished Portfolio/Notes workspace navigation promises; existing project, attachment,
and application-note surfaces remain authoritative, and stale section links fall back to Overview.
+1
View File
@@ -219,3 +219,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-185 | Stripe gateway seam, mocked checkout/webhook lifecycle, entitlement tests and full backend | Repository root | Prove checkout identity and downgrade safety without using external Stripe | PASS — entitlement/billing 33/33 and backend 677/677; configured `price_` and stable user metadata reach Checkout, active grants Pro, `past_due` revokes it, canceled replay remains revoked without duplicate role mutation, non-AI profile data survives, and `prod_` in the price setting fails closed | In-process fake only; no Stripe network, customer, secret mutation, MariaDB or production call | Local POL-001 Stripe lifecycle gap closed; configured Stripe account journey remains blocked |
| V-186 | Migration-chain regression tests, EF model parity/scripts, direct EF SQLite, real application startup and disposable MariaDB 11.8 fresh/restart | Repository root / disposable local databases | Close the historical blank-chain defect without changing applied production state or losing populated rows | PASS — migration tests 3/3 and backend 680/680; blank SQLite reaches all 29 migrations twice, an older populated checkpoint preserves job title/date/owner/summary, EF-only SQLite subsequently serves `/health`, and fresh/restarted MariaDB serves `/health` with 29 migrations, 49 tables and provider-correct sampled ID/owner/decimal/timestamp types. MariaDB script constrains identifiers to 64 characters | Synthetic disposable databases only; no production migration, downgrade, backup restore or private row. Migration/reconciler dual ownership remains JT-019 architectural debt | Blank-chain blocker closed; production restore/rollout remains gated |
| V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree |
| V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence |
@@ -191,6 +191,18 @@ Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads, explicit-c
- Preserve minimal audit metadata without sensitive body logging. Free non-AI access is verified; future AI assistance remains a Pro/privacy-gated addition, not a prerequisite for basic email.
- Complete remaining link/unlink/dismiss/draft/send/failure/two-user browser/production provider gates. Shared application-context behavior is now covered locally. No real email may be sent during repository verification.
## Implemented complete inbox pagination
- The current Job email UI uses an additive `/api/correspondence/page` contract with bounded page
sizes, total/page metadata and deterministic date/ID ordering. The original endpoint remains for
compatibility rather than changing an existing client contract in place.
- A 205-message tenant fixture proves the third page returns the final five owned messages and
excludes another tenant. The UI exposes accessible page navigation, resets to page one when a
filter changes and reports the complete filtered total rather than the current page count.
- Focused backend/admin tests pass 9/9, correspondence UI 15/15, full backend 683/683, full frontend
58 suites/239 tests and the optimized production build passes. Provider and production mailbox
performance remain unmeasured; no provider or email was contacted.
## Validation limitation
The first focused Jest invocation exhibited the repository's open-handle delay. The passing focused and full runs used `--forceExit`; the full run took 228.709 seconds. A Next build process also failed to exit after compilation; only the exact PIDs started by those build attempts were stopped, then a clean build completed. This is recorded as tooling/runtime behavior, not hidden.
+2 -2
View File
@@ -10,9 +10,9 @@ Updated: 2026-08-15
- **Production-verified work:** None.
- **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification.
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Immediate order:** the eight-item immediate queue is complete locally: admin version (`a6cffe0`), Career persistence (`f0b9b22`), CV contrast (`3b86ea2`), JOBS-002 (`deed948`), accessibility (`a7c2549`), PRODUCT-001 (`a25c31b`), VER-001 and tracking reconciliation. SEC-009 is complete at `842e793`; PROD-001 read-only evidence and the PROD-003 plan-only harness are complete pending this documentation commit. No further independent implementation remains.
- **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain.
- **Status counts:** 8 `VERIFIED LOCALLY`; 25 `IMPLEMENTED — NOT VERIFIED`; 0 `IN PROGRESS`; 1 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 680/680; frontend 58/58 suites and 237/237 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest force-exit/open-handle behavior remains recorded.
- **Test status:** backend 683/683; frontend 58/58 suites and 239/239 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest's slow/open-handle behavior remains recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt.
+2 -1
View File
@@ -69,6 +69,7 @@ This queue records the highest-value work that can proceed without production cr
| 13 | Remove the Job email 100-application selector ceiling | MAIL-001 | Locally complete through bounded owner-filtered server search and tenant/UI regressions. |
| 14 | Repair the full historical migration chain | CORE-001, JT-019 | Locally complete for blank/idempotent/populated SQLite, EF-only-to-application startup, provider scripts and disposable MariaDB 11.8 fresh/restart. Production restore/rollout remains blocked. |
| 15 | Close low-risk CV/public-edge hardening | JT-020, JT-023, JT-025 | Locally complete. Authenticated previews are sandboxed without scripts, public PDF budgets are client-and-slug scoped, and the expired tracked JWT fixture is removed and ignored. Git history remediation remains a separate coordinated decision. |
| 16 | Remove measured admin/mail scaling defects | JT-021, MAIL-001 | Locally complete. Admin roles use two reads independent of user count; Job email has bounded accessible pagination with totals and deterministic ordering while the old endpoint remains compatible. Provider/production capacity measurement remains external. |
## Requirement coverage index
@@ -673,7 +674,7 @@ This queue records the highest-value work that can proceed without production cr
- **Required production verification:** provider read/draft/send requires explicit authorized synthetic account; never real unsolicited email.
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** real provider/re-consent, full SEC-009 deletion, MariaDB, production and required 375/768/1440/theme/keyboard browser gates are unavailable or require new authority.
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126V-153. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; prior send export/cascade focused 16/16; recovery/send focused 10/10; legacy follow-up/worker 10/10; delivery/capability 18/18; provider/correspondence 5/5; hub detail 5/5; backend 630/630; frontend 50/50 suites and 198/198 tests plus build/audit; local empty/disconnected and compatibility-route browser smoke at 1280×720.
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126V-153/V-183/V-188. Draft/new-message UI 13/13, API/idempotency/rotation 10/10, persistence 1/1 with dual-provider reversible SQL and readable export 4/4; Free send policy 7/7; provider states 9/9; hub unlink 8/8 UI and 2/2 API; shared application context focused 10/10; paged inbox/admin focused 9/9 and UI 15/15; backend 683/683; frontend 58 suites/239 tests plus build; local empty/disconnected and compatibility-route browser smoke at 1280×720.
- **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent), `123fc55` (explicit-confirmed send API), `449faeb` (confirmed reply composer), `ee5ef7e` (interrupted-send recovery), `8fe3903` (legacy SMTP retirement), `aff34cc` (content-free export and cascade evidence), `ff547df` (shared application context), `1dabbeb` (confirmed hub unlink), `f9e641c` (honest provider states), `7f41cb2` (Free email policy regression), `14b396a` (inert tenant draft persistence), `2fa4e38` (owner-isolated readable draft export), `a9bb22e` (tenant-safe revisioned draft API), `80b5532` (persisted draft send identity), `d3d2b67` (saved reply recovery/conflicts), `29de263` (definitive-failure identity rotation), `b735963` (new-message job/provider drafting).
- **Remaining work:** SEC-009 repository deletion coverage is complete but production activation remains gated; provider mailbox category capabilities require separately authorized scopes/re-consent and remain absent; browser/production verification remains. Server-side owner-filtered job choice search now reaches applications beyond the old first-page ceiling (V-183). Existing connections need explicit re-consent; IMAP remains read-only. No real email; uncertain sends need manual reconciliation. The clean full-chain SQLite rehearsal now passes (V-186).
@@ -44,7 +44,7 @@ describe('CorrespondenceInboxPage', () => {
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
{ provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false },
] } as any);
if (url === '/correspondence') return Promise.resolve({ data: [
if (url === '/correspondence/page') return Promise.resolve({ data: { items: [
{
id: 1,
jobApplicationId: 42,
@@ -64,7 +64,7 @@ describe('CorrespondenceInboxPage', () => {
labelCount: 2,
attachmentCount: 1,
},
] } as any);
], page: 1, pageSize: 50, total: 1, totalPages: 1 } } as any);
if (url === '/email/drafts') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
if (url === '/email/message') return Promise.resolve({ data: {
@@ -101,10 +101,12 @@ describe('CorrespondenceInboxPage', () => {
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
await waitFor(() => {
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({
params: expect.objectContaining({
q: 'Maria',
direction: 'inbound',
page: 1,
pageSize: 50,
}),
}));
});
@@ -137,6 +139,44 @@ describe('CorrespondenceInboxPage', () => {
});
});
test('navigates beyond the first correspondence page', async () => {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/correspondence/page') {
const page = config?.params?.page ?? 1;
return Promise.resolve({ data: {
items: [{
id: page,
jobApplicationId: 42,
companyName: page === 2 ? 'Second page company' : 'First page company',
jobTitle: 'Engineer',
from: 'Recruiter',
direction: 'inbound',
subject: `Page ${page}`,
channel: 'Email',
date: new Date().toISOString(),
contentPreview: `Page ${page} message`,
labelCount: 0,
attachmentCount: 0,
}],
page,
pageSize: 50,
total: 51,
totalPages: 2,
} } as any);
}
return original!(url, config);
});
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /go to page 2/i }));
expect(await screen.findByText(/second page company/i)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.objectContaining({
params: expect.objectContaining({ page: 2, pageSize: 50 }),
}));
});
test('unlinks a Gmail thread only after confirmation and returns it to review', async () => {
mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any);
renderPage();
@@ -154,7 +194,7 @@ describe('CorrespondenceInboxPage', () => {
note: 'Unlinked from Job email hub',
nextDecision: 'review',
}));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence', expect.anything()));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.anything()));
expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument();
});
@@ -452,6 +492,6 @@ describe('CorrespondenceInboxPage', () => {
);
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence', expect.anything());
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything());
});
});
@@ -9,6 +9,7 @@ import {
FormControl,
InputLabel,
MenuItem,
Pagination,
Paper,
Select,
Stack,
@@ -42,6 +43,14 @@ export type CorrespondenceInboxItem = {
attachmentCount: number;
};
type CorrespondenceInboxPage = {
items: CorrespondenceInboxItem[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
type EmailProviderStatus = {
provider: string;
displayName: string;
@@ -116,6 +125,9 @@ export default function CorrespondenceInboxPage() {
const { toast } = useToast();
const { confirm } = useConfirm();
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
const [inboxPage, setInboxPage] = useState(1);
const [inboxTotal, setInboxTotal] = useState(0);
const [inboxTotalPages, setInboxTotalPages] = useState(0);
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
const [jobs, setJobs] = useState<JobChoice[]>([]);
@@ -148,20 +160,27 @@ export default function CorrespondenceInboxPage() {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
params: {
q: query.trim() || undefined,
direction: direction === "all" ? undefined : direction,
linkState: linkState === "all" ? undefined : linkState,
page: inboxPage,
pageSize: 50,
},
});
setItems(res.data ?? []);
setItems(res.data?.items ?? []);
setInboxTotal(res.data?.total ?? 0);
setInboxTotalPages(res.data?.totalPages ?? 0);
if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page);
setSelectedMessageId(null);
setMessageDetail(null);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
} finally {
setLoading(false);
}
}, [direction, linkState, query, toast]);
}, [direction, inboxPage, linkState, query, toast]);
useEffect(() => {
if (view === "inbox") void load();
@@ -524,7 +543,7 @@ export default function CorrespondenceInboxPage() {
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> : null}
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${inboxTotal} items`} variant="outlined" /> : null}
{view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
@@ -596,10 +615,10 @@ export default function CorrespondenceInboxPage() {
{view === "review" ? <GmailReviewPage embedded /> : <>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
<TextField label="Search" value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" />
<FormControl fullWidth>
<InputLabel>Direction</InputLabel>
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
<Select value={direction} label="Direction" onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="inbound">Inbound</MenuItem>
<MenuItem value="outbound">Outbound</MenuItem>
@@ -608,7 +627,7 @@ export default function CorrespondenceInboxPage() {
</FormControl>
<FormControl fullWidth>
<InputLabel>Link state</InputLabel>
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
<Select value={linkState} label="Link state" onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="linked">Linked threads</MenuItem>
<MenuItem value="manual">Manual/internal only</MenuItem>
@@ -711,6 +730,17 @@ export default function CorrespondenceInboxPage() {
</Paper>
))}
</Stack>
{inboxTotalPages > 1 ? (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<Pagination
page={inboxPage}
count={inboxTotalPages}
onChange={(_, value) => setInboxPage(value)}
color="primary"
aria-label="Correspondence pages"
/>
</Box>
) : null}
</>}
</Paper>
);