fix(ui): preserve compose draft identity and delivery safeguards
This commit is contained in:
@@ -115,7 +115,7 @@ public sealed class EmailDraftsControllerTests
|
||||
|
||||
var refused = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default);
|
||||
Assert.IsType<ConflictObjectResult>(refused.Result);
|
||||
db.EmailSendAttempts.Add(new EmailSendAttempt
|
||||
var failedAttempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = "user-1",
|
||||
@@ -127,7 +127,8 @@ public sealed class EmailDraftsControllerTests
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
CompletedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
};
|
||||
db.EmailSendAttempts.Add(failedAttempt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var rotatedResult = await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default);
|
||||
@@ -139,6 +140,218 @@ public sealed class EmailDraftsControllerTests
|
||||
Assert.IsType<ConflictObjectResult>((await controller.NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(1), default)).Result);
|
||||
await using var otherDb = fixture.Context("user-2");
|
||||
Assert.IsType<NotFoundResult>((await Controller(otherDb, "user-2").NewAttempt(original.Id, new EmailDraftsController.NewAttemptRequest(2), default)).Result);
|
||||
|
||||
await using var verify = fixture.Context("user-1");
|
||||
var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(rotated.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(2, persisted.Revision);
|
||||
var readback = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await Controller(verify, "user-1").Get(original.Id, default)).Result).Value);
|
||||
Assert.Equal(rotated, readback);
|
||||
AssertAttemptUnchanged(failedAttempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EmailSendStatuses.Pending)]
|
||||
[InlineData(EmailSendStatuses.Sending)]
|
||||
[InlineData(EmailSendStatuses.Uncertain)]
|
||||
[InlineData(EmailSendStatuses.Sent)]
|
||||
public async Task Nonfailed_attempts_cannot_rotate_identity_or_change_draft_and_history(string status)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using var db = fixture.Context("user-1");
|
||||
var original = await db.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
var attempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = original.OwnerUserId,
|
||||
JobApplicationId = original.JobApplicationId,
|
||||
Provider = original.Provider,
|
||||
ClientRequestId = original.ClientRequestId,
|
||||
PayloadHash = new string('a', 64),
|
||||
Status = status,
|
||||
ProviderMessageId = status == EmailSendStatuses.Sent ? "sent-message" : null,
|
||||
FailureCategory = status == EmailSendStatuses.Uncertain ? "delivery_unknown" : null,
|
||||
CreatedAtUtc = original.CreatedAtUtc,
|
||||
StartedAtUtc = status == EmailSendStatuses.Pending ? null : original.UpdatedAtUtc,
|
||||
CompletedAtUtc = status is EmailSendStatuses.Uncertain or EmailSendStatuses.Sent ? original.UpdatedAtUtc : null,
|
||||
};
|
||||
db.EmailSendAttempts.Add(attempt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await Controller(db, "user-1").NewAttempt(original.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(original.Revision), default);
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
|
||||
await using var verify = fixture.Context("user-1");
|
||||
var persisted = await verify.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(original.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(original.Revision, persisted.Revision);
|
||||
Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc);
|
||||
AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Failures_with_another_owner_or_request_key_cannot_rotate_or_change_either_tenant(bool wrongOwner)
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(seedDrafts: true);
|
||||
await using var seed = fixture.Context(null);
|
||||
var originals = await seed.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync();
|
||||
var ownDraft = originals.Single(draft => draft.OwnerUserId == "user-1");
|
||||
var foreignDraft = originals.Single(draft => draft.OwnerUserId == "user-2");
|
||||
var attempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = wrongOwner ? foreignDraft.OwnerUserId : ownDraft.OwnerUserId,
|
||||
JobApplicationId = wrongOwner ? foreignDraft.JobApplicationId : ownDraft.JobApplicationId,
|
||||
Provider = ownDraft.Provider,
|
||||
ClientRequestId = wrongOwner ? ownDraft.ClientRequestId : foreignDraft.ClientRequestId,
|
||||
PayloadHash = new string('b', 64),
|
||||
Status = EmailSendStatuses.Failed,
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = ownDraft.CreatedAtUtc,
|
||||
StartedAtUtc = ownDraft.UpdatedAtUtc,
|
||||
CompletedAtUtc = ownDraft.UpdatedAtUtc,
|
||||
};
|
||||
seed.EmailSendAttempts.Add(attempt);
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
await using var db = fixture.Context("user-1");
|
||||
var result = await Controller(db, "user-1").NewAttempt(ownDraft.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(ownDraft.Revision), default);
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
|
||||
await using var verify = fixture.Context(null);
|
||||
var persistedDrafts = await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().OrderBy(draft => draft.OwnerUserId).ToListAsync();
|
||||
Assert.Equal(originals.Count, persistedDrafts.Count);
|
||||
foreach (var original in originals)
|
||||
{
|
||||
var persisted = persistedDrafts.Single(draft => draft.Id == original.Id);
|
||||
AssertDraftContentUnchanged(original, persisted);
|
||||
Assert.Equal(original.ClientRequestId, persisted.ClientRequestId);
|
||||
Assert.Equal(original.Revision, persisted.Revision);
|
||||
Assert.Equal(original.UpdatedAtUtc, persisted.UpdatedAtUtc);
|
||||
}
|
||||
AssertAttemptUnchanged(attempt, Assert.Single(await verify.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_DTO_timestamps_keep_UTC_shape_and_exact_ticks_after_SQLite_roundtrips()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
await using var createDb = fixture.Context("user-1");
|
||||
var created = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<CreatedAtActionResult>((await Controller(createDb, "user-1").Create(
|
||||
new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "one@example.test", "UTC subject", "UTC body", "utc-thread"), default)).Result).Value);
|
||||
AssertUtcTimestamps(created);
|
||||
|
||||
await using var readDb = fixture.Context("user-1");
|
||||
var beforeReads = await readDb.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
var reader = Controller(readDb, "user-1");
|
||||
var retrieved = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.Get(created.Id, default)).Result).Value);
|
||||
var listed = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await reader.List(fixture.UserOneJobId, default)).Result).Value));
|
||||
|
||||
await using var verifyReads = fixture.Context("user-1");
|
||||
var afterReads = await verifyReads.EmailDrafts.AsNoTracking().SingleAsync();
|
||||
AssertDraftContentUnchanged(beforeReads, afterReads);
|
||||
Assert.Equal(beforeReads.ClientRequestId, afterReads.ClientRequestId);
|
||||
Assert.Equal(beforeReads.Revision, afterReads.Revision);
|
||||
Assert.Equal(beforeReads.UpdatedAtUtc, afterReads.UpdatedAtUtc);
|
||||
Assert.Equal(beforeReads.CreatedAtUtc.Kind, afterReads.CreatedAtUtc.Kind);
|
||||
Assert.Equal(beforeReads.UpdatedAtUtc.Kind, afterReads.UpdatedAtUtc.Kind);
|
||||
Assert.Empty(await verifyReads.EmailSendAttempts.AsNoTracking().ToListAsync());
|
||||
Assert.Equal(created, retrieved);
|
||||
Assert.Equal(created, listed);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, retrieved.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.UpdatedAtUtc.Ticks, retrieved.UpdatedAtUtc.Ticks);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, listed.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.UpdatedAtUtc.Ticks, listed.UpdatedAtUtc.Ticks);
|
||||
AssertUtcTimestamps(retrieved);
|
||||
AssertUtcTimestamps(listed);
|
||||
|
||||
var updated = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.Update(created.Id,
|
||||
new EmailDraftsController.UpdateDraftRequest(created.Revision, "one@example.test", "Updated UTC subject", "Updated UTC body"), default)).Result).Value);
|
||||
AssertUtcTimestamps(updated);
|
||||
Assert.Equal(created.CreatedAtUtc.Ticks, updated.CreatedAtUtc.Ticks);
|
||||
Assert.Equal(created.ClientRequestId, updated.ClientRequestId);
|
||||
Assert.Equal(2, updated.Revision);
|
||||
|
||||
var failedAttempt = new EmailSendAttempt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = "user-1",
|
||||
JobApplicationId = updated.JobApplicationId,
|
||||
Provider = updated.Provider,
|
||||
ClientRequestId = updated.ClientRequestId,
|
||||
PayloadHash = new string('c', 64),
|
||||
Status = EmailSendStatuses.Failed,
|
||||
FailureCategory = "provider_rejected",
|
||||
CreatedAtUtc = updated.CreatedAtUtc,
|
||||
CompletedAtUtc = updated.UpdatedAtUtc,
|
||||
};
|
||||
readDb.EmailSendAttempts.Add(failedAttempt);
|
||||
await readDb.SaveChangesAsync();
|
||||
var rotated = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await reader.NewAttempt(updated.Id,
|
||||
new EmailDraftsController.NewAttemptRequest(updated.Revision), default)).Result).Value);
|
||||
AssertUtcTimestamps(rotated);
|
||||
Assert.Equal(updated.CreatedAtUtc.Ticks, rotated.CreatedAtUtc.Ticks);
|
||||
Assert.NotEqual(updated.ClientRequestId, rotated.ClientRequestId);
|
||||
Assert.Equal(3, rotated.Revision);
|
||||
|
||||
await using var verifyRotation = fixture.Context("user-1");
|
||||
var finalReader = Controller(verifyRotation, "user-1");
|
||||
var finalRetrieved = Assert.IsType<EmailDraftsController.DraftDto>(
|
||||
Assert.IsType<OkObjectResult>((await finalReader.Get(rotated.Id, default)).Result).Value);
|
||||
var finalListed = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<EmailDraftsController.DraftDto>>(
|
||||
Assert.IsType<OkObjectResult>((await finalReader.List(null, default)).Result).Value));
|
||||
Assert.Equal(rotated, finalRetrieved);
|
||||
Assert.Equal(rotated, finalListed);
|
||||
AssertUtcTimestamps(finalRetrieved);
|
||||
AssertUtcTimestamps(finalListed);
|
||||
AssertAttemptUnchanged(failedAttempt, Assert.Single(await verifyRotation.EmailSendAttempts.AsNoTracking().ToListAsync()));
|
||||
}
|
||||
|
||||
private static void AssertUtcTimestamps(EmailDraftsController.DraftDto draft)
|
||||
{
|
||||
Assert.Equal(DateTimeKind.Utc, draft.CreatedAtUtc.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, draft.UpdatedAtUtc.Kind);
|
||||
}
|
||||
|
||||
private static void AssertDraftContentUnchanged(EmailDraft expected, EmailDraft actual)
|
||||
{
|
||||
Assert.Equal(expected.Id, actual.Id);
|
||||
Assert.Equal(expected.OwnerUserId, actual.OwnerUserId);
|
||||
Assert.Equal(expected.JobApplicationId, actual.JobApplicationId);
|
||||
Assert.Equal(expected.Provider, actual.Provider);
|
||||
Assert.Equal(expected.To, actual.To);
|
||||
Assert.Equal(expected.Subject, actual.Subject);
|
||||
Assert.Equal(expected.BodyText, actual.BodyText);
|
||||
Assert.Equal(expected.ThreadId, actual.ThreadId);
|
||||
Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc);
|
||||
}
|
||||
|
||||
private static void AssertAttemptUnchanged(EmailSendAttempt expected, EmailSendAttempt actual)
|
||||
{
|
||||
Assert.Equal(expected.Id, actual.Id);
|
||||
Assert.Equal(expected.OwnerUserId, actual.OwnerUserId);
|
||||
Assert.Equal(expected.JobApplicationId, actual.JobApplicationId);
|
||||
Assert.Equal(expected.Provider, actual.Provider);
|
||||
Assert.Equal(expected.ClientRequestId, actual.ClientRequestId);
|
||||
Assert.Equal(expected.PayloadHash, actual.PayloadHash);
|
||||
Assert.Equal(expected.Status, actual.Status);
|
||||
Assert.Equal(expected.ProviderMessageId, actual.ProviderMessageId);
|
||||
Assert.Equal(expected.FailureCategory, actual.FailureCategory);
|
||||
Assert.Equal(expected.CreatedAtUtc, actual.CreatedAtUtc);
|
||||
Assert.Equal(expected.StartedAtUtc, actual.StartedAtUtc);
|
||||
Assert.Equal(expected.CompletedAtUtc, actual.CompletedAtUtc);
|
||||
}
|
||||
|
||||
private static EmailDraftsController Controller(JobTrackerContext db, string userId)
|
||||
@@ -232,6 +445,7 @@ public sealed class EmailDraftsControllerTests
|
||||
To = recipient,
|
||||
Subject = "Synthetic",
|
||||
BodyText = "Synthetic private draft.",
|
||||
ThreadId = "thread-1",
|
||||
ClientRequestId = Guid.NewGuid().ToString("D"),
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
|
||||
@@ -65,8 +65,8 @@ public sealed class EmailDraftsController(
|
||||
draft.ThreadId,
|
||||
draft.ClientRequestId,
|
||||
draft.Revision,
|
||||
draft.CreatedAtUtc,
|
||||
draft.UpdatedAtUtc))
|
||||
DateTime.SpecifyKind(draft.CreatedAtUtc, DateTimeKind.Utc),
|
||||
DateTime.SpecifyKind(draft.UpdatedAtUtc, DateTimeKind.Utc)))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -235,6 +235,6 @@ public sealed class EmailDraftsController(
|
||||
draft.ThreadId,
|
||||
draft.ClientRequestId,
|
||||
draft.Revision,
|
||||
draft.CreatedAtUtc,
|
||||
draft.UpdatedAtUtc);
|
||||
DateTime.SpecifyKind(draft.CreatedAtUtc, DateTimeKind.Utc),
|
||||
DateTime.SpecifyKind(draft.UpdatedAtUtc, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
@@ -76,3 +76,11 @@ Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be4
|
||||
[Inbox report](p7-06/INBOX.md) and [portable results](p7-06/evidence/inbox-results.json) retain actual42/5 frontend,25 unchanged-DLL/no-build backend and fresh8/8 native on build uhKavi8_u6PrcEm2rZ1On. App/browser types, lint and build pass; all20 latest PNGs actually displayed/reviewed, all-enabled20 axe scans0 violations/84 incomplete contrast contexts retained. Meaningful I7 and I8 RED→GREEN prove literal all/hash/current-query recovery and one-step Back after typing. The incorrect55 native assumption was corrected to positively preserve the existing owned trashed-job56/page50+6/detail200 contract; authorization stays strict. Twelve added cases/zero suites derive729/94 frontend and eight added native derive291 browser only. Implemented-scope CONDITIONAL GO, final hygiene/normal push pending; commit hash follows direct receipt in the successor compose boundary. Main unchanged, production NO-GO. Compose/send remains next.
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
Inbox/read committed and normal push verified **a791e353b062cc91e10429cd767350ec9f0aaef2** at2026-10-14T05:14:53.828571UTC: cleanHEAD=origin=live development; local/origin/live main27657f3 unchanged. Second slice implemented CONDITIONAL GO; overallP7-06 ongoing. Mandatory compose/send follows; no production or Phase8 action.
|
||||
|
||||
### P7-06 compose/send final bounded gate
|
||||
|
||||
[Compose report](p7-06/COMPOSE.md) and [portable results](p7-06/evidence/compose-results.json): Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Captured operation/UUID and all field dirty protections, definitive-failure preparation retaining unsaved edits, GET-only recovery and current locale/focus/contrast are verified within recorded bounds. Sole API change marks existing UTC DTO values without tick/data/schema changes. All failed attempts and true source/timing limits remain. Real disconnected server failure precedes provider delivery; simulated delivery states are labelled, actual grants/delivery/manual/production remain unverified. All57groups56PARTIAL/C11DEFERRED and32routes;RD11/RD21/06F/P513 retained. All three mandatoryP7-06 boundaries implemented CONDITIONAL GO; final hygiene/normal push pending. P7-07 contract then UI follows, no Phase 8 ormain push.
|
||||
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
|
||||
@@ -176,3 +176,11 @@ Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pendin
|
||||
[Inbox/read evidence](p7-06/evidence/inbox-results.json) confirms repairs for initial failure versus empty, exact GET-only list/detail/provider recovery, late-query/fallback exclusion, locale-only no-GET, review-note retention, synchronous explicit decisions and created-resource recovery. Literal all, URL hash/state/source and one-step Back after text-search edits have ordinary failing then passing regressions. The owned trashed-mail56/page50+6 contract is preserved; failed55 was an unsupported test premise, not a backend policy defect. Final42/5 units and8/8 native pass; all20 PNGs reviewed,20axe0/84 incomplete contrast contexts remain. Strict real language204 passed in this final run; this does not diagnose or close earlier RD11 HTTP500. Actual provider grants/sync/create atomicity, human reader/device/zoom and release evidence remain open. The confirmed compose UUID/save/send/complete-dirty-fields safety dependency remains mandatory at the next approved third boundary; no delivery or new attempt is enabled by this read slice.
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
### P7-06 compose/send final bounded gate
|
||||
|
||||
[Compose report](p7-06/COMPOSE.md) and [portable results](p7-06/evidence/compose-results.json): Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Captured operation/UUID and all field dirty protections, definitive-failure preparation retaining unsaved edits, GET-only recovery and current locale/focus/contrast are verified within recorded bounds. Sole API change marks existing UTC DTO values without tick/data/schema changes. All failed attempts and true source/timing limits remain. Real disconnected server failure precedes provider delivery; simulated delivery states are labelled, actual grants/delivery/manual/production remain unverified. All57groups56PARTIAL/C11DEFERRED and32routes;RD11/RD21/06F/P513 retained. All three mandatoryP7-06 boundaries implemented CONDITIONAL GO; final hygiene/normal push pending. P7-07 contract then UI follows, no Phase 8 ormain push.
|
||||
|
||||
P7-11 residual localisation evidence from current compose/inbox images: after a saved draft opens in EN, a dirty switch to NB leaves the generic saved-draft context in its opening locale, while controls update. English saved-mail counts use singular `1 attachments` / `1 items`. Actual user text and resource names stay verbatim. These limited copy findings remain open; no blanket localisation acceptance is claimed.
|
||||
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
|
||||
@@ -186,3 +186,11 @@ Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pendin
|
||||
[Inbox/read report](p7-06/INBOX.md) and [exact evidence](p7-06/evidence/inbox-results.json) add M03 owner-page/filter/direction/link/detail/Back/hash/literal-all/GET-retry and late-query evidence, preserving owned trashed-parent correspondence56/page50+6/detail200 and foreign/anonymous denial. M02/M05 notes, synchronous explicit decisions, hidden pending decisions, created-ID recovery and saved-decision GET-only Retry have focused unit/controller proof; native queue/provider projections remain explicitly qualified. M06 provider-status GET recovery does not establish real grants/reconnect/sync.42/5 units,25 unchanged-DLL backend,8/8 native,20 reviewed PNGs/20axe0/84 incomplete; strict final language204 passes but RD11 remains undiagnosed. M04 compose/send is not accepted by this second slice. All57 groups remain56 PARTIAL/C11 DEFERRED,32routes unchanged; M01–M12/provider/output/release conditions persist. Hygiene/normal push pending.
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
### P7-06 compose/send final bounded gate
|
||||
|
||||
[Compose report](p7-06/COMPOSE.md) and [portable results](p7-06/evidence/compose-results.json): Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Captured operation/UUID and all field dirty protections, definitive-failure preparation retaining unsaved edits, GET-only recovery and current locale/focus/contrast are verified within recorded bounds. Sole API change marks existing UTC DTO values without tick/data/schema changes. All failed attempts and true source/timing limits remain. Real disconnected server failure precedes provider delivery; simulated delivery states are labelled, actual grants/delivery/manual/production remain unverified. All57groups56PARTIAL/C11DEFERRED and32routes;RD11/RD21/06F/P513 retained. All three mandatoryP7-06 boundaries implemented CONDITIONAL GO; final hygiene/normal push pending. P7-07 contract then UI follows, no Phase 8 ormain push.
|
||||
|
||||
P7-11 residual localisation evidence from current compose/inbox images: after a saved draft opens in EN, a dirty switch to NB leaves the generic saved-draft context in its opening locale, while controls update. English saved-mail counts use singular `1 attachments` / `1 items`. Actual user text and resource names stay verbatim. These limited copy findings remain open; no blanket localisation acceptance is claimed.
|
||||
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
|
||||
@@ -14,7 +14,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | Source-ID and structured-editor commits pushed; final import/history runtime/design CONDITIONAL GO: affected76/6, native35, backend98/seeder24, types/lint/build PASS;33/54 final images inspected. Import17 scans0/302, editor13 scans0/377, Career22 scans0/99 retained. Final staged hygiene PASS; normal push5d6e3235 directly verified17:28:54UTC with clean worktree/main unchanged. RD21/human/provider/output gates remain;57/32 unchanged |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene PASS/normal push bfa0ab6 directly verified22:24:44UTC, clean main unchanged |
|
||||
| [P7-05](p7-05/README.md) | Discovery, Companies, Offers and Reminders | Entry bfa0ab6 verified; Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual PNG reviews/qualified81+52incompletes. Discovery implemented CONDITIONAL GO, hygiene PASS/normal push34a4d6d verified; Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews/qualified91+163incompletes. Companies implemented CONDITIONAL GO, hygiene PASS/normal pusha8aba85 verified; Offers9/1,new4native/backend6/types/lint/build pass;14actual PNG reviews/15incompletes qualified. Offers implemented CONDITIONAL GO, hygiene PASS/normal pushc723a4e verified; Reminders17/3,new6native/backend6/types/lint/build pass;17actual PNG reviews/114incompletes qualified. P7-05 implemented CONDITIONAL GO, hygiene PASS/normal push627955a directly verified11:49:32UTC, clean localHEAD=origin=live/main unchanged; P7-06 next |
|
||||
| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | Overall ongoing. Forms4c325 pushed/55/4 units/30 backend; current15PASS/1FAIL relevant16 retains strict RD11, qualified prior consumers30/1 relevant31. [Inbox/read](p7-06/INBOX.md) implemented CONDITIONAL GO, final hygiene/push pending:42/5 units,25 sameDLL backend,8/8 native, types/lint/build pass;20 reviewed PNGs/20axe0/84 incomplete. +12front derives729/94; +8native derives291, no full-suite claim. Compose/send is the mandatory third boundary;06F deferred;57/32/main unchanged |
|
||||
| [P7-06](p7-06/README.md) | Remaining Application forms and global correspondence | All three boundaries implemented CONDITIONAL GO: forms4c325/inboxa791 normally pushed; [compose](p7-06/COMPOSE.md) Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Final hygiene/normal push pending;06Fdeferred/57groups32routes/main unchanged; P7-07 contract first next |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
| P7-07 | Documents owner-scoped index | Authenticated metadata-only paginated backend projection first; source destinations from P7-04/06; planned |
|
||||
| P7-08 | Existing notifications and operations | Existing store and real destinations; new enabled producers require F20 recovery evidence; planned |
|
||||
@@ -64,3 +64,11 @@ Final embedded-forms implemented scope is **CONDITIONAL GO**, normal push pendin
|
||||
Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be435b** at2026-10-13T21:27:19.353249UTC: cleanHEAD=origin=live development, main27657f3 unchanged. First slice implemented CONDITIONAL GO; overallP7-06 ongoing. Inbox/read entered next; compose/send follows. No production or Phase8 action.
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
Inbox/read committed and normal push verified **a791e353b062cc91e10429cd767350ec9f0aaef2** at2026-10-14T05:14:53.828571UTC: cleanHEAD=origin=live development; local/origin/live main27657f3 unchanged. Second slice implemented CONDITIONAL GO; overallP7-06 ongoing. Mandatory compose/send follows; no production or Phase8 action.
|
||||
|
||||
### P7-06 compose/send final bounded gate
|
||||
|
||||
[Compose report](p7-06/COMPOSE.md) and [portable results](p7-06/evidence/compose-results.json): Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Captured operation/UUID and all field dirty protections, definitive-failure preparation retaining unsaved edits, GET-only recovery and current locale/focus/contrast are verified within recorded bounds. Sole API change marks existing UTC DTO values without tick/data/schema changes. All failed attempts and true source/timing limits remain. Real disconnected server failure precedes provider delivery; simulated delivery states are labelled, actual grants/delivery/manual/production remain unverified. All57groups56PARTIAL/C11DEFERRED and32routes;RD11/RD21/06F/P513 retained. All three mandatoryP7-06 boundaries implemented CONDITIONAL GO; final hygiene/normal push pending. P7-07 contract then UI follows, no Phase 8 ormain push.
|
||||
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# P7-06 — Compose and send
|
||||
|
||||
**Implemented scope: CONDITIONAL GO. Affected regression, final native consumers, actual47-image review and finite hygiene pass; normal development push pending.** This is the third mandatory P7-06 boundary after [embedded forms](FORMS.md) and [inbox/read review](INBOX.md). Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries); optional P7-06F remains DEFERRED pending its mode→draft/review action and fallback decision.
|
||||
|
||||
[Compose entry](evidence/compose-entry.json) records the preceding inbox commit `a791e353b062cc91e10429cd767350ec9f0aaef2`. Its normal push was observed on **2026-10-14 at 05:14:53.828571 UTC**: clean local HEAD = origin = live `codex/ui-redesign-phase4`; local/origin/live `main` remained `27657f31131cd95d42f0e1290853ec9c9b3959a4`. That receipt establishes synchronization of the predecessor, not acceptance or push of this slice.
|
||||
|
||||
## Existing contract and scope
|
||||
|
||||
The existing global correspondence composer remains the owner of new messages, replies, saved drafts, review confirmation and send feedback. It keeps the existing provider registry, owned-job choices, draft routes, explicit send confirmation, shared confirmation/toast infrastructure and English/Bokmål catalogue. The sole API presentation correction marks existing draft DTO timestamps as UTC consistently after SQLite reload. No new document persistence, mailbox grant, provider integration, renderer, route or schema is introduced.
|
||||
|
||||
The server assigns a saved draft's request UUID and revision. PUT and DELETE require the captured revision; new-attempt preparation changes identity only for that owner's matching definitive failed attempt. Confirmed send uses the owned job, provider, request UUID and payload hash. An uncertain or terminal attempt cannot restart. The stored-draft DTO has no delivery-status field; resuming a draft does not prove provider delivery status or authorize a new identity.
|
||||
|
||||
The entry inspection identified concrete safety defects: a held Save could merge into later work, Save and Send could race while persistence changed the UUID, edits could clear recorded delivery protection, and recipient-only or subject-only changes could be discarded without confirmation. This slice repairs those paths before accepting the migrated presentation.
|
||||
|
||||
## Bounded repairs
|
||||
|
||||
A synchronous operation guard reserves Save, Send, Discard, failed-attempt preparation and draft replacement before any confirmation or asynchronous work. Each completion belongs to its captured draft/job/provider/request identity and mounted owner. Conflicting controls lock during writes; confirmation triggers remain available for Cancel focus restoration. An unmounted completion does not refresh drafts or attach its result to another screen.
|
||||
|
||||
Recipient, subject and body form the complete dirty check. Saved content has a separate persisted baseline, so an unchanged saved draft can be resumed later while unsaved edits require deliberate replacement or discard confirmation. Cancel preserves all fields and makes no write. Explicit Send captures the reviewed payload; repeated activation cannot create another confirmation or another concurrent POST.
|
||||
|
||||
Recorded failed, uncertain, pending, sending and sent results lock field editing and ordinary Save/Send. Edits cannot implicitly rotate the request UUID or erase the result. Only an authoritative failed result offers explicit new-attempt preparation. Managed drafts use the server's new UUID/revision, retaining all local unsaved fields and comparing them with the returned persisted baseline. A generic HTTP409 is treated as uncertain; plain preflight 4xx errors keep the same request key without inventing a failed attempt. Preparation remains a separate action from reviewing and sending again.
|
||||
|
||||
Owned job choices and saved drafts have independent loading/error states and GET-only Retry. A successful write updates the known draft DTO locally before a following list read. If that read fails, recovery repeats the GET without replaying the write. A post-write read starts a fresh generation even when an earlier list GET is still pending; a late old snapshot cannot replace the committed state. HTTP401/403 recovery withholds unauthorized saved data. Revision errors keep the current fields and captured revision with localized feedback on the original action.
|
||||
|
||||
The composer uses existing density tokens, H3 hierarchy, named focus groups, shared feedback and wrapping content. Three new catalogue entries describe owned-job read failure, no matching jobs and discard failure; existing saved-draft feedback is reused. The unsaved-change wording now covers all three fields. Both English and Bokmål remain catalogue-backed. The disabled editor's informational character counter uses the existing secondary-text token after a measured contrast failure. No second toast/dialog framework or dependency is introduced.
|
||||
|
||||
## Affected regression evidence
|
||||
|
||||
All times below are UTC on **2026-10-14**. Exact commands, reports, failed attempts and frozen source captures remain under `.artifacts/phase7/p7-06/compose/`.
|
||||
|
||||
| Observation | Actual result | Evidence boundary |
|
||||
|---|---|---|
|
||||
| Published entry frontend | **42 cases/5 suites PASS** | Earlier inbox GREEN, 05:08:44.577597–05:09:06.324638. All 14 frozen as-run sources equal published `a791e353` Git blobs under UTF-8/LF normalization. This is source equivalence to the earlier execution, not a new run at the clean commit |
|
||||
| Existing backend baseline | **18 PASS** | 05:17:30.164673–05:17:33.709705, `--no-build` against the existing Release DLL. Entry source and binary equality are recorded separately |
|
||||
| Compose meaningful RED | **43 pass/7 fail of 50** | `unit-red`, 05:21:42.841428–05:22:08.085477. All original 42 cases pass; the new repeated-confirmation case already passes. Seven failures expose write ownership, lost protection, dirty confirmation, GET recovery, unmount and revision feedback defects |
|
||||
| First candidate | **35 pass/15 fail of 50** | `unit-candidate-1`, 05:29:14.641920–05:29:40.212958. An unconditional asynchronous approval wait delayed the no-confirmation fast path; this implementation regression was repaired rather than waived |
|
||||
| Second candidate | **47 pass/3 fail of 50** | `unit-candidate-2`, 05:31:13.767478–05:31:53.017579. Canonical localized HTTP409 feedback and the new uncertain-state observation were corrected before the next run |
|
||||
| Initial compose GREEN | **50 cases/5 suites PASS** | `unit-candidate-3`, 05:34:01.640374–05:34:28.524596; zero failed/pending/todo cases |
|
||||
| Review-added RED | **50 pass/2 fail of 52** | `unit-review-red`, 05:37:16.529544–05:37:49.453325. Two ordinary additions reproduce unsaved-field loss during server identity rotation and a post-write list read suppressed by an older pending GET |
|
||||
| Review GREEN | **52 cases/5 suites PASS** | `unit-review-green`, 05:40:17.199755–05:40:46.294482; all 14 pre/post source captures match, zero failed/pending/todo cases |
|
||||
| Fresh backend guard regression | **24 PASS** | 05:29:23.773379–05:29:43.744878, fresh Release compilation and test execution with `--no-restore`; zero failed/not-executed cases |
|
||||
| UTC contract RED | **24 pass/1 fail of 25** | `backend-utc-red`, 05:51:46.857292–05:51:56.549754. The ordinary new Fact observes `Unspecified` after SQLite reload where the DTO contract requires UTC; all ten sources have true pre/post captures |
|
||||
| UTC contract GREEN | **25 PASS** | `backend-utc-green`, 05:53:02.535746–05:53:11.849241, fresh Release compilation and execution; all ten pre/post sources match, zero failed/not-executed cases |
|
||||
| Final affected GREEN | **52 cases/5 suites PASS** | `unit-final`, 06:00:56.823199–06:01:25.904825, after the counter-color correction. All 14 pre/post sources match; zero failed/pending/todo cases. The read-only 10:36:21.072658 resumption check confirms the same 14 working sources |
|
||||
|
||||
The final affected frontend source includes `CorrespondenceInboxPage.tsx` SHA256 `38ed35060aab502bf64c947dfad6d6e0cc9e4970d7ee16dd3518a3da1d3543f4`. The latest product export is `LFCrTZzDtX5hNBSRBj5Xx`; its 10:36 gates and final native pre/post receipts retain source/export correspondence. Later C8/C5 fixture corrections change only the browser specification; browser TypeScript and native checks rerun against the same product export, with no product rebuild inferred.
|
||||
|
||||
Six new backend cases cover pending/sending/uncertain/sent rejection and mismatched owner/request-key failures. They assert unchanged draft content, revision, request identity and attempt history across fresh contexts. The existing successful rotation case now also proves persisted identity/content/history readback. A seventh case proves UTC kinds, exact ticks and complete DTO fields through Create/Get/List/Put/NewAttempt, with stored values and failed history unchanged by reads.
|
||||
|
||||
The original native whole-DTO comparison found Create timestamps ending in `Z` while reloaded Get/List timestamps omitted it. Four `DateTime.SpecifyKind(value, DateTimeKind.Utc)` DTO mappings restore the existing UTC transport contract without converting ticks or mutating stored data. Routes, ownership, request identity, revision checks and schema remain unchanged. Assertions retain exact DTO equality; no timestamp normalization hides the failure.
|
||||
|
||||
The fresh 24-case API DLL was SHA256 `5e32c5b5b60fa0fda7bed6f392b75c3087a321476244f5aa9ff229d6a1372fa8`; compilation reflects current assembly revision metadata. Its ten-source receipt is timestamped **05:29:52.777062**, after the command ended at **05:29:43.744878**, with matching later captures at 05:31:55.584513. `backend-final-source/capture-qualification.json` corrects the original receipt and post-receipt's inaccurate “during compilation” wording while retaining their raw bytes. That evidence is not a pre-build capture. The later UTC RED/GREEN runs have genuine ten-source pre/post captures; current GREEN API DLL SHA256 is `951ca8d7b46345c4aab976215d50d42b769bad1aa4a1e1b97fd3cfaaf5615527`.
|
||||
|
||||
## Browser, responsive and accessibility evidence
|
||||
|
||||
Ten new native cases cover actual owned draft create/list/detail/update/delete/reload and stale revisions; anonymous/foreign-owner denial; job-choice and draft-list GET recovery; held Save/unmount; dirty Cancel and Send Cancel; real failed-only identity preparation; explicitly labelled delivery-state fixtures; strict actual language HTTP204; and finite bilingual/theme/width reflow. Every failed attempt below remains immutable, with complete reports, source qualifications, axe vectors and PNGs.
|
||||
|
||||
| Native observation | Actual result | Qualification and repair |
|
||||
|---|---|---|
|
||||
| Previous committed export `uhKavi8_u6PrcEm2rZ1On` | **0 pass/10 fail**, 05:42:41.382361–05:48:48.604257 | `native-red-old-uhK` is an old-export RED comparison, not current UI acceptance. Four cases fail whole-DTO UTC serialization before later assertions; remaining failures expose missing recovery/dirty feedback and write or delivery locks. Case 7 stops at the missing dirty indicator and cannot prove its later preparation assertions. The separate unit RED proves unsaved-field loss |
|
||||
| First repaired export `sk-T5pqHLrLW1H-3l7x6i` | **8 pass/2 fail**, 05:56:16.089420–05:57:12.095693 | `native-candidate-1-skT`; 19 source pre/post captures match. Both failures are the 12px informational character counter at **4.48:1**, `#777680` on white, in held-Save and uncertain states. The existing secondary-text token repairs it; the 4.5:1 assertion and all axe rules remain unchanged. Later assertions in those failed cases were not reached |
|
||||
| Counter-corrected export `LFCrTZzDtX5hNBSRBj5Xx` | **9 pass/1 fail**, 10:36:54.419717–10:37:55.849468 | `native-candidate-2-counter`; 19 pre/post captures match. Held Save/unmount and counter checks pass. C8 passes its first uncertain fixture but cannot find the independently seeded second pending draft: navigation to the identical URL/fragment retains the mounted list. C8 now reloads between independent fixtures and proves a new `performance.timeOrigin`; no product auto-refetch, lost-state reset, skip or relaxed assertion is added |
|
||||
| C8 fixture-corrected run, same `LFCrTZzDtX5hNBSRBj5Xx` export | **9 pass/1 fail**, 10:46:44.269454–10:47:53.113854 | `native-candidate-3-c8`, result SHA256 `142c0bba53863e1c518386746a8a2e6946d448f13011b242044d4743aebad4c0`; 19 pre/post sources match. All five C8 status fixtures pass. C5 waits for the destination URL before proving the lazy workspace rendered; its trace has no destination-workspace GET before Back. Releasing Save and going Back encounters a replacement dialog for saved A before B's field assertion. The fixture now requires B's actual heading/tablist and zero old draft regions before release. Product code and strict persistence/identity assertions remain unchanged |
|
||||
| Final compose, same product export | **10 PASS**, 10:51:11.697665–10:52:14.846477 | `native-final-verified`; all five C8 status branches and C5's actual-unmount/late-completion assertions execute and pass; zero skipped, unexpected or flaky cases, retries 0 |
|
||||
| Final inbox/read consumer | **8 PASS**, 10:52:14.851996–10:53:01.436862 | Same final capsule/export; prior inbox query, decision and recovery assertions remain ordinary passing cases, retries 0 |
|
||||
| Final ordinary P2-D01 | **1 PASS**, 10:53:01.442415–10:53:13.190106 | Existing `legacy workspace preserves frozen documents` case, retries 0; no skip or expected-failure marker |
|
||||
|
||||
The earlier C8-corrected specification SHA256 is `1cb1d3f458bdfc8ce856b9e1f54a1d59ffa25e535ff4835b8bc99375a130902c`; `native-final-c8-pre.json` captures that attempt. The final C5/C8 specification SHA256 is `5cb7a8f67926eb8abaf87e740eef5192921a52d39d14cbdad556476d4e818f0d`. `native-final-unmount-pre.json` and `native-final-unmount-post.json` prove all 19 inputs unchanged across final compose/inbox/P2-D01 execution. Final capsule `native-final-verified/results.json` SHA256 is `21a02c6d67dc2732e4a230787261b406be8db3100d5df3a10065f5669f482a11`.
|
||||
|
||||
The four retained attempts contain, respectively, **13/22/23/23 axe scans**, **0/2/0/0 violation nodes**, **143/228/239/235 incomplete nodes**, and **25/26/26/26 PNGs**. Incomplete categories are short badge content, overlapped contrast backgrounds and `aria-hidden-focus` modal-open checks; retain their complete contexts for the final contextual review. Zero automated violations in a failed run do not establish workflow or human accessibility acceptance.
|
||||
|
||||
Finite responsive samples cover 320, 360, 390, 768, 1024, 1280, 1440 and 1920px, including 1280×320 landscape, both languages and themes. Long drafts, keyboard setup, review, Cancel and viewport-focused captures use zero app-root overflow assertions. This is a finite sample, not every width/theme/language/state combination.
|
||||
|
||||
Final compose records **24 all-enabled axe scans, 0 violation nodes and 246 incomplete nodes**: 24 short notification badges, 219 overlapped contrast backgrounds and 3 modal hidden-root focus checks. Final inbox adds **20 scans, 0 violations and 84 incomplete nodes**: 20 badges and 64 overlap backgrounds. Ordinary P2-D01 performs no axe scan. The combined final evidence is **44 scans / 0 violations / 330 incompletes**, with every vector retained. `axe-triage-final.json` records category dispositions; badge/background contrast and full modal Tab/Shift-Tab containment and reader behavior remain conformance obligations. Incompletes are not counted as passing.
|
||||
|
||||
The triage receipt measures **23 informational character helpers**, including locked states, from recorded foreground and nearest Paper background; minimum calculated ratio is **9.3359:1**. This resolves the specific counter-color failure and does not settle blending, every other contrast target, fonts or contextual manual contrast acceptance.
|
||||
|
||||
The final capsule retains **47 PNGs**. Root has inspected 16; the remaining 31 are under independent visual review. Image existence does not establish review completion. Root's actual bilingual review identifies a residual localisation issue for **P7-11**: changing an open saved draft from English to Bokmål preserves the generic context label **“Saved email draft”** in its opening locale while surrounding controls translate. Stored source/job/subject names remain verbatim. Strict language HTTP204 and unsaved-field/identity preservation pass, but this residual prevents a blanket localisation PASS.
|
||||
|
||||
The browser boundary distinguishes actual isolated HTTP/SQLite draft persistence from capability/delivery presentation fixtures. One case permits real `/email/send` only after independently proving the fresh owner has no Gmail connection: the server must record `failed/reauthorization_required` before delivery. Its failed-only new-attempt response is real. Other send requests are blocked or fulfilled by labelled browser fixtures. Uncertain/pending/sending/sent/generic409 UI states do not prove provider persistence or successful delivery. No external recipient is sent mail, and no provider grant, Google/Outlook/IMAP execution, Gmail sync/import/create/unlink or model call is authorized by this evidence.
|
||||
|
||||
Current product app TypeScript, lint, browser TypeScript and frontend build all exit **0** between **10:36:14.414955 and 10:36:54.413694**, producing `LFCrTZzDtX5hNBSRBj5Xx`. Browser TypeScript reruns after final test-only C8/C5 corrections at **10:51:06.686512–10:51:11.691657**, exit **0**. Automated names, keyboard and axe evidence cannot close human screen-reader, contextual contrast, physical touch/software-keyboard, real zoom, fonts/photo or print qualification.
|
||||
|
||||
### Final acceptance checks pending
|
||||
|
||||
Final browser TypeScript and **19/19 native cases** pass on the unchanged export, with matching pre/post sources. Complete the remaining visual review and record its actual scope; retain the triaged manual accessibility and localisation qualifications. Documentation, secret, whitespace, route/preservation checks, commit and normal development push remain **PENDING**. This draft does not claim final slice acceptance or push.
|
||||
|
||||
## Count reconciliation and preservation
|
||||
|
||||
| Inventory | Inbox exit | Compose additions | Derived inventory |
|
||||
|---|---:|---:|---:|
|
||||
| Frontend cases/suites | 729/94 | +10 cases/+0 suites | **739/94** |
|
||||
| Browser cases | 291 | +10 | **301** |
|
||||
| Backend cases | 1,034 | +7 | **1,041** |
|
||||
|
||||
These are inventory calculations, not fresh whole-suite pass counts. Latest actual whole frontend **573/89**, backend **1,033** and sidecar **49** remain inherited until P7-12's integrated run. New cases remain ordinary assertions; no skipped or expected-failure case conceals a regression.
|
||||
|
||||
All **57 preservation groups remain 56 PARTIAL / PRES-C11 DEFERRED**, with **32 routes**. Evidence narrows W05–W10 and M01–M06 workflow risks without declaring any complete group accepted. M04 remains qualified by the actual draft/send boundary and pending provider/manual evidence. Original owner authorization, explicit confirmation, payload identity, revision conflicts, uncertain delivery, frozen package bytes, CV associations and canonical Career Profile authority remain binding.
|
||||
|
||||
M01–M12 remain NOT VERIFIED. Keep RD11, RD21, actual provider delivery, screen-reader, physical touch/software keyboard, real zoom, production fonts/photo/print, Board/Dashboard reliability and other recorded debt. P5-13 remains withheld; P7-06F remains deferred. F04 ingress, backup/restore, schema/configuration verification and tested rollback still prevent production acceptance.
|
||||
|
||||
## Remaining acceptance work
|
||||
|
||||
Complete visual review, run finite documentation/secret/whitespace/route/preservation checks, then commit this bounded repair and verify the normal development push against HEAD. Record the commit and actual receipt after the push. Carry the generic saved-draft locale residual into P7-11 and preserve all manual qualifications. Proceed to the approved P7-07 metadata contract before its Documents UI; do not restructure correspondence persistence there.
|
||||
|
||||
**Compose/send acceptance: PENDING. Overall P7-06: ongoing. Production: NO-GO; main unchanged. Phase 8 implementation: none.**
|
||||
|
||||
### Final visual observation
|
||||
|
||||
All **47 final PNGs** were actually displayed and reviewed: root16 and independent reviewer31, with exact per-image references in the portable result. No blocking visual defect was found. Full-page/sticky/dialog/transition captures and resized long images retain their stated limits. In addition to the opening-locale draft-context label, English singular count copy (`1 attachments`, `1 items`) remains a P7-11 residual. No human reader, physical-device, zoom, provider or production acceptance follows. Final runtime/source/visual gates establish bounded **CONDITIONAL GO**; finite hygiene and normal development push follow below.
|
||||
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
@@ -61,3 +61,5 @@ All 57 preservation groups remain **56 PARTIAL / PRES-C11 DEFERRED** and 32 rout
|
||||
Complete review and push this second boundary before implementing the separately scoped compose/send repair. Preserve exact draft identity/revision and uncertain-delivery safeguards there. **P5-13 remains withheld; P7-06F remains deferred; production remains NO-GO; main is unchanged; Phase 8 has not started.**
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
Inbox/read committed and normal push verified **a791e353b062cc91e10429cd767350ec9f0aaef2** at2026-10-14T05:14:53.828571UTC: cleanHEAD=origin=live development; local/origin/live main27657f3 unchanged. Second slice implemented CONDITIONAL GO; overallP7-06 ongoing. Mandatory compose/send follows; no production or Phase8 action.
|
||||
|
||||
@@ -1,53 +1,35 @@
|
||||
# P7-06 — Remaining Application forms and global correspondence
|
||||
|
||||
**Overall P7-06: ongoing. Embedded forms: implemented-scope CONDITIONAL GO, committed and pushed. Inbox/read review: bounded CONDITIONAL GO; final static checks and normal push pending. Compose/send: next mandatory boundary.**
|
||||
**Implemented scope: CONDITIONAL GO.** All three approved boundaries are implemented. Embedded forms and inbox/read are normally pushed; compose/send has final runtime evidence, with hygiene and normal push pending below. Production remains NO-GO; main unchanged. P7-06F stays DEFERRED.
|
||||
|
||||
Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). P7-06 has three sequential implementation boundaries: embedded forms → inbox/read review → compose/send. Optional P7-06F remains **DEFERRED** until its exact mode→draft/review action and fallback are decided.
|
||||
The exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries) governs the sequence: embedded forms → inbox/read review → compose/send. No generic Documents persistence, new provider capability, withheld AI review or Phase8 feature was introduced.
|
||||
|
||||
## Current checkpoint status
|
||||
|
||||
| Boundary | Current result | Evidence and remaining work |
|
||||
| Boundary | Outcome | Evidence |
|
||||
|---|---|---|
|
||||
| Embedded Application forms | Implemented-scope **CONDITIONAL GO**; normal push verified | [Forms report](FORMS.md), [portable results](evidence/forms-results.json). Commit `4c3254c472871d1cac05abcef502feb4a1be435b`; RD11 and manual qualifications remain |
|
||||
| Inbox/read review | Bounded **CONDITIONAL GO**; static checks and normal push pending | [Inbox report](INBOX.md), [entry evidence](evidence/inbox-entry.json). Latest affected units **42/5 PASS**, final native **8/8 PASS**, app/browser TypeScript, lint and build pass; manual/provider/reliability qualifications remain |
|
||||
| Compose/send | **Not implemented at this boundary** | Repair the [confirmed safety dependency](../DEBT.md#p7-06-confirmed-compose-safety-entry-dependency) coherently after the inbox slice. Preserve draft revisions, explicit confirmation, uncertain delivery and deliberate failed-attempt recovery |
|
||||
| Embedded forms | Implemented-scope CONDITIONAL GO; pushed4c3254c472871d1cac05abcef502feb4a1be435b | [Forms report](FORMS.md), [results](evidence/forms-results.json); affected55/4, backend30; final native9PASS/1FAIL retains strict RD11 language204→500. Current Jobs5/P2-D01ordinary1 yields15/1 relevant16; earlier CV4/Career11 yields30/1 qualified31. All23/111 current forms images reviewed;55qualifiedaxe0/741 |
|
||||
| Inbox/read | Implemented-scope CONDITIONAL GO; pusheda791e353b062cc91e10429cd767350ec9f0aaef2 | [Inbox report](INBOX.md), [results](evidence/inbox-results.json); affected42/5, existing backend25, native8/8,20reviewed images/20 axe0/84. Owned trashed-parent messages56/page50+6/detail200 preserved; foreign/anonymous denied |
|
||||
| Compose/send | Implemented-scope CONDITIONAL GO; final hygiene/normal push pending | [Compose report](COMPOSE.md), [results](evidence/compose-results.json). Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. |
|
||||
|
||||
The forms push was observed on **2026-10-13 at 21:27:19.353249 UTC**: clean local HEAD = origin = live `codex/ui-redesign-phase4`. Local/origin/live `main` remained `27657f31131cd95d42f0e1290853ec9c9b3959a4`. This receipt establishes source synchronization; it does not add human or production acceptance.
|
||||
## Final compose behavior
|
||||
|
||||
## Completed forms evidence
|
||||
Captured draft identity and synchronous operation ownership prevent duplicate or conflicting actions. Complete recipient/subject/body dirty state survives replacement Cancel, conflicts and definitive-failure preparation. Protected delivery states preserve the sending identity; generic409 remains uncertain, with no implicit resend/new attempt. Only an authoritative failed attempt permits deliberate server-side identity rotation. Confirmed send and captured-revision cleanup remain explicit.
|
||||
|
||||
The forms slice preserves the seven-section workspace, CV cardinality and associations, existing letter/answer encoding, additive history, owned attachments and frozen package bytes. It adds generation/identity guards, durable GET-only recovery, deliberate dirty Restore confirmation and synchronous Save/AI Apply ownership. The later aggregate repair prevents one job's drafts from populating another job while its read is pending.
|
||||
Draft/setup errors use durable GET-only Retry. A successful write followed by a failed list refresh retains actual saved identity and never repeats the write. Late completions cannot mutate a different or unmounted draft. Current-locale feedback, shared headings/density/focus and character-counter contrast use existing infrastructure. Four DTO date expressions mark existing UTC values consistently after SQLite round trips; ticks, stored data, ownership, revisions and content are unchanged.
|
||||
|
||||
Latest affected frontend tests pass **55 cases/4 suites**; the unchanged backend subset passes **30 cases** on the same Release DLL with `--no-build`. Final forms build `9sM6acEkOGxgN5fTRZXdE` records **9 pass/1 fail of 10** native cases. The strict actual Bokmål language save expected HTTP204 and received HTTP500: **RD11 remains an ordinary failure**. Fresh Jobs5 and ordinary P2-D01 give **15 pass/1 fail of 16** on that build. Qualified earlier-build CV4/Career11 extend the relevant observation to **30 pass/1 fail of 31**; they are not fresh current-build executions.
|
||||
All failed unit/native attempts remain in the compose report. Two fixture corrections establish independent delivery-state documents and actual destination rendering before unmount assertions; retries/skips/expected-failure markers and weakened data assertions were not used.
|
||||
|
||||
All **23/111 latest forms PNGs** were displayed and reviewed; the other 88 retained consumer images were not reviewed in that continuation. Across the qualified forms/Jobs/CV/Career lanes, **55 recorded axe scans** contain **0 violations/741 incomplete nodes**. Raw vectors and source/build limits remain in the forms report. Missing locale captures and changed scan scope do not establish contrast improvement. App/browser TypeScript, lint, build and finite static checks passed before the verified forms push.
|
||||
## Count and preservation reconciliation
|
||||
|
||||
## Inbox/read scope
|
||||
| Inventory | P7-06 entry | Forms | Inbox | Compose | Derived total |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Frontend cases/suites | 706/94 | +11/0 | +12/0 | +10/0 | **739/94** |
|
||||
| Browser cases | 273 | +10 | +8 | +10 | **301** |
|
||||
| Backend cases | 1034 | +0 | +0 | +7 | **1041** |
|
||||
|
||||
The second slice retains existing owner-paginated correspondence, detail/provider fallback, URL filters/pages, legacy alias and job Back context. It reuses shared surfaces, semantic headings, feedback and focus patterns. Read ownership prevents late or unrelated results from changing the active query; explicit recovery retains drafts and does not replay a saved decision POST.
|
||||
These inventories are not fresh full-suite pass counts. Actual whole573/89frontend,1033backend and49sidecar remain inherited until P7-12. Relevant preserved contracts include W01/W05–W10, M01–M06 and E08/E09; all57wholegroups remain56PARTIAL/C11DEFERRED,32routes retained. OrdinaryP2-D01 freshly passes; pagination renderer and frozen bytes remain protected.
|
||||
|
||||
The saved correspondence contract includes owned messages associated with jobs in Trash. The native oracle preserves all **56 owned messages**, exact ordering, pages **50/6**, owned trashed-message detail and foreign-resource denial. The earlier expected total 55 was a false policy assumption, retained as failed evidence rather than represented as a backend regression.
|
||||
M01–M12, provider grants/delivery, physical touch/software keyboard, human reader/zoom, production fonts/photo/print and release evidence remain unverified. Latest strict language204 passes do not diagnose earlier RD11. RD21 and other [debt](../DEBT.md) remain. P5-13/C11 stays withheld32cases/366required/0calls/0human;06F awaits its exact action/fallback decision.
|
||||
|
||||
See [INBOX.md](INBOX.md) for meaningful RED/GREEN progression, the final-run boundary and provider-fixture limits. Compose persistence and send behavior remain the third slice.
|
||||
Next approved checkpoint is **P7-07**: metadata-only authenticated paginated contract commit first, then Documents UI. Do not start Phase8 or deploy.
|
||||
|
||||
Final inbox build `uhKavi8_u6PrcEm2rZ1On` ran all eight native cases on 2026-10-14 at 05:09:27.486825–05:10:16.147321 UTC, with zero failed/skipped/flaky cases. All 16 captured pre/post sources match. All 20 latest PNGs were displayed and reviewed; all20 all-enabled axe scans record 0 violations/84 incomplete nodes. The incompletes require human contrast assessment, and full-page image review retains narrow/sticky/zoom/device limits. Strict real language HTTP204 passes in this fixture; separately reproduced RD11 remains intermittent and undiagnosed.
|
||||
|
||||
## Count reconciliation
|
||||
|
||||
| Inventory | Entry | Forms additions | Inbox additions | Current derived total |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Frontend cases/suites | 706/94 | +11 cases/+0 suites | +12 cases/+0 suites | **729/94** |
|
||||
| Browser cases | 273 | +10 | +8 | **291** |
|
||||
| Backend cases | 1,034 | +0 | +0 | **1,034** |
|
||||
|
||||
These are **derived inventories**, not a fresh integrated pass count. Latest actual whole frontend **573/89**, backend **1,033** and sidecar **49** remain inherited. P7-12 requires fresh integrated regression and an explanation of every changed count.
|
||||
|
||||
## Preservation and remaining gates
|
||||
|
||||
All **57 preservation groups remain 56 PARTIAL / PRES-C11 DEFERRED**, with **32 routes** retained. Current forms and inbox evidence narrows particular workflow risks; it does not mark entire groups complete. Preserve Career Profile/CV scope, frozen packages, original paging/search semantics and server-side authorization. Bodyless restore without ExpectedVersion and partial-write behavior remain under RD21.
|
||||
|
||||
Compose/send must establish captured draft identity, synchronous Save–Send protection, complete dirty-field handling, revision/conflict recovery, explicit confirmed send, uncertain-delivery protection and a deliberate new attempt after definitive failure. Do not automatically send or resend. No generic Documents persistence or second renderer belongs in this boundary.
|
||||
|
||||
M01–M12 remain **NOT VERIFIED**. Actual screen-reader, physical touch/software keyboard, real zoom, provider, fonts/photo/native print and release evidence remain qualified. RD11 stays open; P5-13 stays withheld; P7-06F stays deferred. **Production: NO-GO; main unchanged. Phase 8 implementation: none.**
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
Final compose hygiene PASS observed2026-10-14 11:01:54.856146UTC: secret/working/staged/phase whitespace gates pass,16 docs/486 local links resolve,57 groups56PARTIAL/C11DEFERRED and32original routes retained. All47finalimages actually reviewed. Implemented scope CONDITIONAL GO; normal development push pending, productionNO-GO/mainunchanged.
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"checkpoint": "P7-06",
|
||||
"slice": "Compose/send",
|
||||
"recordedUtc": "2026-10-14T05:19:18.411345+00:00",
|
||||
"precedingInboxPush": {
|
||||
"observedUtc": "2026-10-14T05:14:53.828571+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"originDevelopment": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"liveDevelopment": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"baseline": {
|
||||
"actual": {
|
||||
"numPassedTests": 42,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTodoTests": 0,
|
||||
"numPassedTestSuites": 5,
|
||||
"numFailedTestSuites": 0
|
||||
},
|
||||
"asRun": {
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"npm.cmd",
|
||||
"test",
|
||||
"--",
|
||||
"--runInBand",
|
||||
"--watchAll=false",
|
||||
"--json",
|
||||
"--outputFile=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\unit\\p706-inbox-history-green-20261014-review-20261014T050844Z.json",
|
||||
"src/correspondence-inbox-page.test.tsx",
|
||||
"src/gmail-review-page.test.tsx",
|
||||
"src/correspondence-gmail-import.test.tsx",
|
||||
"src/correspondence-search-context.test.ts",
|
||||
"src/email-provider-connections.test.tsx"
|
||||
],
|
||||
"startedUtc": "2026-10-14T05:08:44.577597+00:00",
|
||||
"finishedUtc": "2026-10-14T05:09:06.324638+00:00",
|
||||
"exitCode": 0,
|
||||
"counts": {
|
||||
"numPassedTests": 42,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTodoTests": 0,
|
||||
"numPassedTestSuites": 5,
|
||||
"numFailedTestSuites": 0
|
||||
}
|
||||
},
|
||||
"sourceEquivalence": "All14 earlierGREEN frozenasrun bytes equalpublished a791 GitblobsunderUTF8/LF. RawWindowsCRLF hashes retained separately; not a newexecution atcleancommit. Original42 mandatoryinexpandedRED/GREEN.",
|
||||
"sources": [
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-inbox-page.test.tsx",
|
||||
"sha256": "494e4e7da11a5910cd55b742837ca250ff0fa3efa9d28994eda15e98371b7ddf",
|
||||
"bytes": 50069
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/gmail-review-page.test.tsx",
|
||||
"sha256": "e67ca4f4f5bbd38043c4420159124dd98cad9789ebbfc380ebc1c2772a46b2a9",
|
||||
"bytes": 16559
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-gmail-import.test.tsx",
|
||||
"sha256": "fbe0592502fa180ea32cbe18c700894f5e0da36b58f9320e0954dcb39d3c4cc0",
|
||||
"bytes": 16504
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-search-context.test.ts",
|
||||
"sha256": "91dfb843add49d1d68f083f22c5106dffa04b1322decb88f79de2ca140ac23ad",
|
||||
"bytes": 986
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/email-provider-connections.test.tsx",
|
||||
"sha256": "b8e090601bc7790bb1457e574d7bcf54c20d876c664f82d5b47176536494ff27",
|
||||
"bytes": 3517
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/CorrespondenceInboxPage.tsx",
|
||||
"sha256": "0929313b17ef18e3397251329c975ea87d151d03b17b5b372dfa2dc07bff382d",
|
||||
"bytes": 43643
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/GmailReviewPage.tsx",
|
||||
"sha256": "b3674c1d5e0776a86af6996eecfe7ef9f707fe5e9d7f9ac94891ea93d3bd5d9e",
|
||||
"bytes": 19744
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/EmailProviderConnections.tsx",
|
||||
"sha256": "174eab8de4f89e6aa69b954de5d06d6361d6a7b77116ab8b47133f3c768042ed",
|
||||
"bytes": 9715
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/Correspondence.tsx",
|
||||
"sha256": "e0869cf484c202ab7faa508a9d5db720d4b46ac8c99a6f0eeb355cf7fb38b31b",
|
||||
"bytes": 41057
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/types.ts",
|
||||
"sha256": "a45779b0b7868c4a69ed4a17316899074416fc8c6f8f6c083827e949f790d7b1",
|
||||
"bytes": 13452
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/translations.ts",
|
||||
"sha256": "de67f2ef3f83dad4f70310a1cdb517a4423f3826cc9dabfe6bbf041f3c09a2cc",
|
||||
"bytes": 317202
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/ViewStateNotice.tsx",
|
||||
"sha256": "fd4bb314346a1603d9e01a15fd02f01576b9fd959e83f8ff0b1dfe5ec94378e9",
|
||||
"bytes": 2340
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/hooks/useViewResource.ts",
|
||||
"sha256": "8c73a2fd845b8bc4c4c10b7c69f8d12dae8e83c434c537558a78b74377511b27",
|
||||
"bytes": 3292
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/I18nProvider.tsx",
|
||||
"sha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113",
|
||||
"bytes": 3184
|
||||
}
|
||||
],
|
||||
"publishedCanonicalLfMatches": [
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-inbox-page.test.tsx",
|
||||
"sha256Lf": "9e50b3cf9b5111d762d7947a4e45ac7cd4b4f3ecea5f95e07314fa679497c391",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/gmail-review-page.test.tsx",
|
||||
"sha256Lf": "667bdfe4aa2cae9669777a066a8df2b7b7bf1fbb1fe719f20204bf3360231790",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-gmail-import.test.tsx",
|
||||
"sha256Lf": "3ee780a8036b24859982e5f07bd5789f093b17bd79a428d4baee8d9a2c2695e3",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/correspondence-search-context.test.ts",
|
||||
"sha256Lf": "8fc52ff3e10827e96194d6eb293e4872f65d4c73213b0b29e275bad9bbac5b15",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/email-provider-connections.test.tsx",
|
||||
"sha256Lf": "6924ea09580a11871cf5abd4548e0f89078963306a09d2001a160dca517d66fb",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/CorrespondenceInboxPage.tsx",
|
||||
"sha256Lf": "ad7978b0833129fb74e99120285010c21c721547350e4ad886eaec62590b496a",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/views/GmailReviewPage.tsx",
|
||||
"sha256Lf": "5e87cad1c5f5118fdd6d62d3aae35b83b3ab10d8d60722ed18c1dbb4487f9890",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/EmailProviderConnections.tsx",
|
||||
"sha256Lf": "c6bfd306580688d5aca274b48f33bf8a283c37c3d059953624fe2a7e4953acde",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/Correspondence.tsx",
|
||||
"sha256Lf": "e0fc56591a1ccc88e4bbe68b119e989cc011aa1f754a1870bbdb2cc33ffc72eb",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/types.ts",
|
||||
"sha256Lf": "d7b551f669415f752fdc38850367fd89257e3f6d6c9d8b459cbf1a1ad91f6a85",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/translations.ts",
|
||||
"sha256Lf": "de67f2ef3f83dad4f70310a1cdb517a4423f3826cc9dabfe6bbf041f3c09a2cc",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/components/ViewStateNotice.tsx",
|
||||
"sha256Lf": "7b6c3e6c311860897eb5327936dbb129e860781929cfd9a4d4e17f81c607c70c",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/hooks/useViewResource.ts",
|
||||
"sha256Lf": "5912131c525ba85ae6994efc38306a10ec2e673cc0082686226b59f00df5da99",
|
||||
"publishedGitBlobMatches": true
|
||||
},
|
||||
{
|
||||
"path": "job-tracker-ui/src/i18n/I18nProvider.tsx",
|
||||
"sha256Lf": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113",
|
||||
"publishedGitBlobMatches": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contract": "DraftserverUUID/rev1;PUT/DELETEcapturedrevision;Failed-onlyatomicnewattempt;confirmedownedUUIDpayloadhashsend. StoredDraftDTOhasnoattemptstatus. AuthSMTPfixture isnotGmail/Graphcompose. NoAPI/schema/provider/inference/productionchange.",
|
||||
"requiredProof": "SynchronouscapturedSave-Send-discard-prepare-replacementconfirmation;completefieldsdirty;noimplicituncertain/terminalrotation;authoritativeFailedexplicitnewattemptonly;generic409notFailed;GETonlyRetry;unmountidentity;realHTTPdraftrevisiontenant/safedisconnectedsend;UIstatusfixturesqualified.",
|
||||
"acceptance": "PENDING_RED_IMPLEMENTATION_RUNTIME_VISUAL_STATIC_PUSH",
|
||||
"preservation": "57groups56PARTIAL/C11DEFERRED32routes;M04next06FdeferredP513withheldmainunchangedproductionNO-GO"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-06",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-14T11:01:54.856146+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-14T11:01:51.634615+00:00",
|
||||
"finishedUtc": "2026-10-14T11:01:54.166683+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261014T110151Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-14T11:01:54.167191+00:00",
|
||||
"finishedUtc": "2026-10-14T11:01:54.254003+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261014T110151Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-14T11:01:54.254003+00:00",
|
||||
"finishedUtc": "2026-10-14T11:01:54.346969+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261014T110151Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-14T11:01:54.347469+00:00",
|
||||
"finishedUtc": "2026-10-14T11:01:54.500329+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261014T110151Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 16,
|
||||
"relativeLinks": 486,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-06",
|
||||
"slice": "Inbox and read review",
|
||||
"recordedUtc": "2026-10-14T05:12:56.311091+00:00",
|
||||
"acceptance": "INBOX_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_PENDING",
|
||||
"acceptance": "INBOX_IMPLEMENTED_SCOPE_CONDITIONAL_GO_HYGIENE_PASS_NORMAL_PUSH_VERIFIED",
|
||||
"precedingFormsCommit": "4c3254c472871d1cac05abcef502feb4a1be435b",
|
||||
"immutableFinalCapsule": {
|
||||
"path": ".artifacts/phase7/p7-06/inbox/history-final/results.json",
|
||||
@@ -1208,5 +1208,112 @@
|
||||
"sha256": "e70dd865d5ce871c6f44fbc55aedeae8b97dd5ff9348ea5fc20276c68aec5a46",
|
||||
"bytes": 3166,
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"commit": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"normalPushReceipt": {
|
||||
"path": ".artifacts/phase7/p7-06/inbox/push-receipt.json",
|
||||
"sha256": "37f78d5ca2c1d640f0140f88f9eadc4ebf66e051c4438f28a09ca8de80e0510c",
|
||||
"bytes": 2849,
|
||||
"hashPolicy": "Exact raw bytes"
|
||||
},
|
||||
"normalPushObserved": {
|
||||
"observedUtc": "2026-10-14T05:14:53.828571+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"originDevelopment": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"liveDevelopment": "a791e353b062cc91e10429cd767350ec9f0aaef2",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "a791e353b062cc91e10429cd767350ec9f0aaef2\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene PASS and bfa0ab6 normal push verified; sandbox frame-tested12 and human/device/production qualifications retained.
|
||||
- [x] P7-05 Discovery, Companies, Offers and Reminders: Discovery21/1,new8+existing4native/backend14/types/lint/build pass;18 actual new PNG reviews,81+52 axe incompletes retained. Discovery hygiene PASS/normal push34a4d6d verified. Companies30/4,new6+old2+named4native/backend2/types/lint/build pass;20actual PNG reviews,91+163incompletes qualified. Companies hygiene PASS/normal pusha8aba85 verified; Offers9/1,new4native/backend6/types/lint/build pass;14actual PNG reviews,15incompletes qualified. Offers hygiene PASS/normal pushc723a4e verified; Reminders17/3,new6native/backend6/types/lint/build pass;17actual PNG reviews/114incompletes qualified. P7-05 implemented CONDITIONAL GO, hygiene PASS/normal627955a push directly verified2026-10-13 11:49:32.473200UTC, clean localHEAD=origin=live/main unchanged.
|
||||
- [ ] P7-06 [remaining Application forms and global correspondence](../redesign/phase-7/p7-06/README.md) ongoing: [embedded forms](../redesign/phase-7/p7-06/FORMS.md) CONDITIONAL GO pending final static/push. Latest54/4 units/backend30 no-build; current native8/1 only strict language-save204→HTTP500 EN (RD11), current Jobs5/P2-D01ordinary1 give14pass/1fail of15. Previous-build CV4/Career11 give29pass/1fail relevant30 with qualifiers. PageDown passes; failure retained without rerun/waiver. Derived716/94 frontend/282browser cases only. Preserve oneCV/letter-history/Notes/frozen packages; next separate inbox/read review and compose/send commits,06F deferred.
|
||||
- [x] P7-06 [remaining Application forms and global correspondence](../redesign/phase-7/p7-06/README.md): all three mandatoryboundaries implemented CONDITIONAL GO; forms4c325/inboxa791 pushed, compose final hygiene/normal push pending. Compose affected52/5 and fresh backend25 PASS; native10+inbox8+ordinaryP2-D01=19 PASS on LFCrTZzDtX5hNBSRBj5Xx; app/browser TypeScript, lint and build PASS. Actual image review 47/47; all-enabled44scans0violations/330incompletes triaged, human/device/provider qualifications remain. +10front/+0suites,+10browser,+7backend derive739/94,301,1041 only; no integrated full-suite claim. Preserve57/32,RD11/RD21/manual/provider/output/release debt,06Fdeferred/P513withheld/mainunchanged. NextP7-07backend contract first.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
- [ ] P7-07 owner-scoped metadata projection, then Documents interface.
|
||||
- [ ] P7-08 existing notifications and operations.
|
||||
@@ -108,3 +108,5 @@ Forms committed and normal push verified **4c3254c472871d1cac05abcef502feb4a1be4
|
||||
Forms4c325 is pushed. [Inbox/read](../redesign/phase-7/p7-06/INBOX.md) now has42/5 units,25 backend unchanged-DLL/no-build and8/8 fresh native, plus types/lint/build and20 reviewed screenshots/20axe0/84 incomplete. Bounded CONDITIONAL GO; final hygiene/normal push pending. Derived729/94 frontend/291browser only. Complete the mandatory compose/send safety and presentation boundary next, then P7-07 projection before Documents.06F/P513 stay deferred/withheld, all57/32 and manual/release gates retained; main unchanged/production NO-GO/Phase8 not started.
|
||||
|
||||
Final inbox hygiene PASS observed2026-10-14 05:14:23UTC: secret and working/staged/phase whitespace gates pass,15 docs/474 local links resolve,57 groups and32 routes retained. Normal development push pending; main unchanged.
|
||||
|
||||
Inbox/read committed and normal push verified **a791e353b062cc91e10429cd767350ec9f0aaef2** at2026-10-14T05:14:53.828571UTC: cleanHEAD=origin=live development; local/origin/live main27657f3 unchanged. Second slice implemented CONDITIONAL GO; overallP7-06 ongoing. Mandatory compose/send follows; no production or Phase8 action.
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type Route, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type Job = { id: number; companyId: number; jobTitle: string };
|
||||
type Draft = { id: string; jobApplicationId: number; provider: string; to: string; subject: string; bodyText: string; threadId: string | null; clientRequestId: string; revision: number; createdAtUtc: string; updatedAtUtc: string };
|
||||
type Send = { attemptId: string; status: 'pending' | 'sending' | 'sent' | 'failed' | 'uncertain'; duplicate: boolean; failureCategory?: string | null };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
|
||||
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const boundary = 'Real owner job and email-draft create/list/detail/update/delete/new-attempt contracts use isolated disposable SQLite. Browser provider capability is an explicitly labelled presentation fixture; no provider grant or connection is installed. One case permits the real email/send request only after independently proving the fresh owner has no Gmail connection: the server records failed/reauthorization_required before delivery, and its failed-only new-attempt response is real. Every other send endpoint is blocked or fulfilled by explicitly labelled browser fixtures, never a provider delivery. Intercepted uncertain/pending/sending/sent/generic409 states prove UI safeguards, not provider persistence, delivery, model or production qualification. Free accounts and disabled delivery/model/alert workers remain required. No Gmail sync/import/create/unlink, external provider/model, raw SQL/schema, production or deployment action. Receipt bodies contain fixture fields and identities only; no credentials/CSRF/session headers. All enabled axe rules and target-size, complete incomplete vectors, finite keyboard/reflow images and strict language HTTP204 assertions do not replace human reader, device/software keyboard, real zoom, font/photo or production evidence.';
|
||||
function interpolate(template: string, params: Record<string, string | number>) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
function waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); }
|
||||
function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); }
|
||||
function reads(session: Session) { return session.requests.filter(value => value.method === 'GET' && /^\/api\/(?:email\/drafts|email\/providers|jobapplications\/choices|correspondence\/page)(?:\/|$)/.test(value.path)); }
|
||||
function noUnrelatedWrites(session: Session) { expect(session.requests.filter(value => value.method !== 'GET' && /^\/api\/(?:gmail|ai)(?:\/|$)/.test(value.path))).toEqual([]); }
|
||||
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
function draft(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].emailInboxDraftTitle, exact: true }); }
|
||||
function setup(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].emailInboxCompose, exact: true }); }
|
||||
function fields(page: Page, language: Sample['language'] = 'en') { const section = draft(page, language); const t = translations[language]; return {
|
||||
to: section.getByRole('textbox', { name: t.emailInboxRecipient, exact: true }), subject: section.getByRole('textbox', { name: t.emailInboxSubject, exact: true }), body: section.getByRole('textbox', { name: t.emailInboxMessage, exact: true }),
|
||||
}; }
|
||||
function save(page: Page, language: Sample['language'] = 'en') { const t = translations[language]; return draft(page, language).getByRole('button', { name: new RegExp(`^(?:${t.emailInboxSaveDraft}|${t.profileSaveChanges})$`) }); }
|
||||
function send(page: Page, language: Sample['language'] = 'en') { return draft(page, language).getByRole('button', { name: translations[language].emailInboxReviewSend, exact: true }); }
|
||||
function retry(page: Page, message?: string) { const alerts = page.getByRole('alert'); return (message ? alerts.filter({ hasText: message }) : alerts.filter({ has: page.getByRole('button', { name: translations.en.retry, exact: true }) })).getByRole('button', { name: translations.en.retry, exact: true }); }
|
||||
async function ownedFocus(section: Locator) { await expect.poll(() => section.evaluate(node => node.contains(document.activeElement) && document.activeElement !== document.body && document.activeElement?.isConnected === true)).toBe(true); }
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
async function account(admin: APIRequestContext, owner: Owner) {
|
||||
const email = `p706-compose-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email;
|
||||
}
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
|
||||
page.on('request', request => { const url = new URL(request.url());
|
||||
if (/^\/api\/(?:correspondence|email|gmail|ai|jobapplications)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null });
|
||||
});
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator));
|
||||
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
|
||||
await context.route('**/*', route => { const url = new URL(route.request().url());
|
||||
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue();
|
||||
blocked.push(url.origin + url.pathname); return route.abort();
|
||||
});
|
||||
await page.route(`${apiUrl}/email/send`, route => route.abort('blockedbyclient'));
|
||||
await provide({ owner, administrator, admin, requests });
|
||||
noUnrelatedWrites({ owner, administrator, admin, requests });
|
||||
} finally { await info.attach('correspondence-compose-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded compose job — æøå', company?: number) {
|
||||
const companyId = company ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded compose company — æøå' } }), 201)).id;
|
||||
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved' } }), 201);
|
||||
}
|
||||
async function stored(api: APIRequestContext, owner: Owner, parent: Job, subject = 'Recorded saved draft — æøå', bodyText = 'Recorded private draft body.\nKeep line breaks and Æ Ø Å.') {
|
||||
return json<Draft>(await api.post(`${apiUrl}/email/drafts`, { headers: owner.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: 'recorded-recipient@example.test', subject, bodyText, threadId: null } }), 201);
|
||||
}
|
||||
async function getDraft(api: APIRequestContext, value: Draft) { return json<Draft>(await api.get(`${apiUrl}/email/drafts/${value.id}`)); }
|
||||
async function capableProjection(page: Page) { await page.route(`${apiUrl}/email/providers`, route => route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'recorded-owner@example.test', canRead: true, canSend: true }] })); }
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop) {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/correspondence?source=phase7-compose&marker=preserved#draft');
|
||||
const current = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (current.language !== sample.language || current.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
}
|
||||
async function resume(page: Page, value: Draft, language: Sample['language'] = 'en') {
|
||||
await keyboard(page, page.getByRole('button', { name: interpolate(translations[language].emailInboxResumeDraft, { name: value.subject }), exact: true })); await expect(fields(page, language).body).toHaveValue(value.bodyText);
|
||||
}
|
||||
async function compose(page: Page, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('button', { name: translations[language].emailInboxCompose, exact: true })); await expect(setup(page, language)).toBeVisible(); await keyboard(page, setup(page, language).getByRole('button', { name: translations[language].emailInboxStartDraft, exact: true })); await expect(draft(page, language)).toBeVisible(); }
|
||||
async function fill(page: Page, value: { to: string; subject: string; bodyText: string }, language: Sample['language'] = 'en') { const controls = fields(page, language); await controls.to.fill(value.to); await controls.subject.fill(value.subject); await controls.body.fill(value.bodyText); }
|
||||
async function confirmSend(page: Page, language: Sample['language'] = 'en') { await keyboard(page, send(page, language)); const dialog = page.getByRole('dialog', { name: translations[language].emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toBeVisible(); await keyboard(page, dialog.getByRole('button', { name: translations[language].emailInboxSendEmail, exact: true })); }
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), name: document.activeElement?.getAttribute('aria-label') },
|
||||
characterHelpers: Array.from(document.querySelectorAll<HTMLElement>('.MuiFormHelperText-root')).filter(node => node.textContent?.includes('200000') && node.getClientRects().length > 0 && getComputedStyle(node).visibility === 'visible').map(node => { const style = getComputedStyle(node); const paper = node.closest('.MuiPaper-root'); return { text: node.textContent, foreground: style.color, paperBackground: paper ? getComputedStyle(paper).backgroundColor : null, fontSize: style.fontSize, fontWeight: style.fontWeight, disabledClass: node.classList.contains('Mui-disabled') }; }),
|
||||
}));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p706-compose-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite isolated Chromium states, not every width/theme/language/state combination.', 'Every incomplete vector retained; no human reader, touch/software keyboard, real zoom, font/photo or production acceptance.', 'Full-page images can be resized; viewport capture separately records focused controls where relevant.', 'Provider capability and delivery-result projections are controlled fixtures; real owner draft persistence and one disconnected failure remain distinct.'] }), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-06 compose] real server-owned identity, revisions, stale conflict, reload and confirmed delete preserve exact draft content', async ({ page, session }, info) => {
|
||||
test.setTimeout(180_000); const parent = await job(page.request, session.owner); await capableProjection(page); await enter(page, session.owner); await compose(page);
|
||||
const initial = { to: 'recorded-recipient@example.test', subject: 'Created from UI — æøå', bodyText: 'Actual owner text\n<script>plain text marker</script>\nÆ Ø Å' }; await fill(page, initial);
|
||||
const createdResponse = waitResponse(page, 'POST', '/api/email/drafts'); await keyboard(page, save(page)); const created = await json<Draft>(await createdResponse, 201);
|
||||
expect(created).toMatchObject({ ...initial, jobApplicationId: parent.id, provider: 'gmail', revision: 1 }); expect(created.id).toMatch(uuid); expect(created.clientRequestId).toMatch(uuid); expect(created.id).not.toBe(created.clientRequestId);
|
||||
expect(count(session, 'POST', '/api/email/drafts')).toEqual([{ method: 'POST', path: '/api/email/drafts', query: {}, body: { ...initial, jobApplicationId: parent.id, provider: 'gmail', threadId: null } }]); expect(await getDraft(page.request, created)).toEqual(created);
|
||||
const changed = 'Reviewed owner change\nPreserve exact body and æøå.'; await fields(page).body.fill(changed); const updatedResponse = waitResponse(page, 'PUT', `/api/email/drafts/${created.id}`); await keyboard(page, save(page)); const updated = await json<Draft>(await updatedResponse);
|
||||
expect(updated).toMatchObject({ ...initial, bodyText: changed, id: created.id, clientRequestId: created.clientRequestId, revision: 2 }); expect(count(session, 'PUT', `/api/email/drafts/${created.id}`)[0].body).toEqual({ revision: 1, to: initial.to, subject: initial.subject, bodyText: changed });
|
||||
const remote = await json<Draft>(await page.request.put(`${apiUrl}/email/drafts/${created.id}`, { headers: session.owner.headers, data: { revision: 2, to: initial.to, subject: initial.subject, bodyText: 'Other tab saved revision — æøå' } })); expect(remote.revision).toBe(3);
|
||||
const local = 'Retain this conflicting local text — æøå'; await fields(page).body.fill(local); const conflict = waitResponse(page, 'PUT', `/api/email/drafts/${created.id}`); await keyboard(page, save(page)); expect((await conflict).status()).toBe(409); await expect(page.getByText(translations.en.emailInboxDraftChangedElsewhere, { exact: true })).toBeVisible(); await expect(fields(page).body).toHaveValue(local); expect((await getDraft(page.request, created)).bodyText).toBe(remote.bodyText);
|
||||
await capture(page, info, desktop, 'actual-stale409-local-retained'); await page.reload(); await resume(page, remote); expect(await getDraft(page.request, created)).toEqual(remote);
|
||||
const deletes = count(session, 'DELETE', `/api/email/drafts/${created.id}`).length; const discard = draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true }); await keyboard(page, discard);
|
||||
const dialog = page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }); await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true })); await expect(discard).toBeFocused(); expect(count(session, 'DELETE', `/api/email/drafts/${created.id}`)).toHaveLength(deletes);
|
||||
await keyboard(page, discard); const deleted = waitResponse(page, 'DELETE', `/api/email/drafts/${created.id}`); await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true })); expect((await deleted).status()).toBe(204);
|
||||
await expect(draft(page)).toHaveCount(0); expect(count(session, 'DELETE', `/api/email/drafts/${created.id}`)).toEqual([{ method: 'DELETE', path: `/api/email/drafts/${created.id}`, query: { revision: '3' }, body: null }]); expect((await page.request.get(`${apiUrl}/email/drafts/${created.id}`)).status()).toBe(404); expect(await json<Draft[]>(await page.request.get(`${apiUrl}/email/drafts`))).toEqual([]); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[P7-06 compose] anonymous and another owner cannot read, update, delete, prepare or associate a private draft', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const other = await login(foreign, await account(session.admin, session.administrator)); const otherJob = await job(foreign, other, 'Other owner compose job'); const privateOther = await stored(foreign, other, otherJob, 'Other owner private draft');
|
||||
expect(await json<Draft[]>(await page.request.get(`${apiUrl}/email/drafts`))).toEqual([original]); expect(await json<Draft[]>(await foreign.get(`${apiUrl}/email/drafts`))).toEqual([privateOther]);
|
||||
for (const api of [anonymous, foreign]) { expect((await api.get(`${apiUrl}/email/drafts/${original.id}`)).status()).toBe(api === anonymous ? 401 : 404); }
|
||||
expect((await anonymous.get(`${apiUrl}/email/drafts`)).status()).toBe(401); expect((await foreign.put(`${apiUrl}/email/drafts/${original.id}`, { headers: other.headers, data: { revision: original.revision, to: original.to, subject: original.subject, bodyText: 'Forbidden overwrite' } })).status()).toBe(404);
|
||||
expect((await foreign.delete(`${apiUrl}/email/drafts/${original.id}`, { headers: other.headers, params: { revision: original.revision } })).status()).toBe(404); expect((await foreign.post(`${apiUrl}/email/drafts/${original.id}/new-attempt`, { headers: other.headers, data: { revision: original.revision } })).status()).toBe(404);
|
||||
expect((await foreign.post(`${apiUrl}/email/drafts`, { headers: other.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: '', subject: '', bodyText: '' } })).status()).toBe(404); expect((await page.request.get(`${apiUrl}/email/drafts/${privateOther.id}`)).status()).toBe(404); expect(await getDraft(page.request, original)).toEqual(original);
|
||||
await capableProjection(page); await enter(page, session.owner); await resume(page, original); await expect(page.getByRole('button', { name: interpolate(translations.en.emailInboxResumeDraft, { name: privateOther.subject }), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'actual-owner-only-draft'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
|
||||
} finally { await foreign.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-06 compose] failed owned job choices provide GET-only Retry without claiming there are no jobs', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const parent = await job(page.request, session.owner); await capableProjection(page); let attempts = 0; const gate = hold(); const reached = hold();
|
||||
await page.route(`${apiUrl}/jobapplications/choices*`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); reached.release(); await gate.pending; return route.fulfill({ response: await route.fetch() }); });
|
||||
try {
|
||||
await enter(page, session.owner); await expect(retry(page)).toBeVisible(); await expect(page.getByText(translations.en.emailInboxAddJobFirst, { exact: true })).toHaveCount(0); const before = reads(session).filter(value => value.path !== '/api/jobapplications/choices').length;
|
||||
await keyboard(page, retry(page)); await reached.pending; expect(attempts).toBe(2); expect(reads(session).filter(value => value.path !== '/api/jobapplications/choices')).toHaveLength(before); expect(count(session, 'POST', '/api/email/drafts')).toHaveLength(0); gate.release();
|
||||
await expect(page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeEnabled(); await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })); await expect(setup(page).getByRole('combobox', { name: translations.en.emailInboxJob, exact: true })).toHaveValue(new RegExp(parent.jobTitle)); await capture(page, info, desktop, 'actual-job-choices-recovered'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
|
||||
} finally { gate.release(); await page.unroute(`${apiUrl}/jobapplications/choices*`); }
|
||||
});
|
||||
|
||||
test('[P7-06 compose] saved-draft GET Retry and committed Save/list503 retain identity and never repeat the write', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); await capableProjection(page); let attempts = 0; let failNext = false;
|
||||
await page.route(`${apiUrl}/email/drafts`, async route => { if (route.request().method() !== 'GET') return route.continue(); attempts += 1; if (attempts === 1 || failNext) { failNext = false; return route.fulfill({ status: 503, json: {} }); } return route.fulfill({ response: await route.fetch() }); });
|
||||
await enter(page, session.owner); await keyboard(page, retry(page, translations.en.emailInboxDraftsLoadFailed)); await resume(page, original); expect(attempts).toBe(2); const edited = 'Successful write with failed following list read — æøå'; await fields(page).body.fill(edited); failNext = true;
|
||||
const written = waitResponse(page, 'PUT', `/api/email/drafts/${original.id}`); await keyboard(page, save(page)); const current = await json<Draft>(await written); expect(current).toMatchObject({ id: original.id, revision: 2, clientRequestId: original.clientRequestId, bodyText: edited }); await expect(retry(page, translations.en.emailInboxDraftsLoadFailed)).toBeVisible(); await expect(fields(page).body).toHaveValue(edited); expect(await getDraft(page.request, original)).toEqual(current);
|
||||
const writes = count(session, 'PUT', `/api/email/drafts/${original.id}`).length; const creates = count(session, 'POST', '/api/email/drafts').length; await keyboard(page, retry(page, translations.en.emailInboxDraftsLoadFailed)); await expect(page.getByText(translations.en.emailInboxDraftsLoadFailed, { exact: true })).toHaveCount(0); expect(attempts).toBe(4); expect(count(session, 'PUT', `/api/email/drafts/${original.id}`)).toHaveLength(writes); expect(count(session, 'POST', '/api/email/drafts')).toHaveLength(creates); expect(await getDraft(page.request, original)).toEqual(current); await capture(page, info, desktop, 'committed-save-list-get-only-recovery'); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[P7-06 compose] held captured Save locks conflicting controls and late unmounted completion preserves both owned drafts', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const firstJob = await job(page.request, session.owner, 'Held save owner A'); const secondJob = await job(page.request, session.owner, 'Unmount destination owner B', firstJob.companyId); const first = await stored(page.request, session.owner, firstJob, 'Held saved draft A'); const second = await stored(page.request, session.owner, secondJob, 'Independent saved draft B'); await capableProjection(page); await enter(page, session.owner); await resume(page, first);
|
||||
const text = 'Captured A Save stays on A after unmount — æøå'; await fields(page).body.fill(text); const gate = hold(); const reached = hold(); const path = `/api/email/drafts/${first.id}`;
|
||||
await page.route(`${apiUrl}/email/drafts/${first.id}`, async route => { if (route.request().method() !== 'PUT') return route.continue(); reached.release(); await gate.pending; return route.fulfill({ response: await route.fetch() }); });
|
||||
try {
|
||||
await keyboard(page, save(page)); await reached.pending; for (const control of Object.values(fields(page))) await expect(control).toBeDisabled(); await expect(send(page)).toBeDisabled(); await expect(draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: interpolate(translations.en.emailInboxResumeDraft, { name: second.subject }), exact: true })).toBeDisabled(); await ownedFocus(draft(page));
|
||||
expect(count(session, 'PUT', path)).toEqual([{ method: 'PUT', path, query: {}, body: { revision: first.revision, to: first.to, subject: first.subject, bodyText: text } }]); expect(count(session, 'POST', '/api/email/send')).toHaveLength(0); await capture(page, info, desktop, 'held-save-conflict-controls-locked'); const origin = await page.evaluate(() => performance.timeOrigin);
|
||||
await keyboard(page, page.getByRole('button', { name: new RegExp(translations.en.quickSearch) })); const commands = page.getByRole('dialog', { name: translations.en.quickSearch }); await commands.getByRole('textbox', { name: translations.en.quickSearch }).fill(secondJob.jobTitle); await keyboard(page, commands.getByRole('button').filter({ hasText: secondJob.jobTitle })); await expect(page).toHaveURL(new RegExp(`/jobs/${secondJob.id}$`));
|
||||
await expect(page.getByRole('heading', { level: 2, name: secondJob.jobTitle, exact: true })).toBeVisible(); await expect(page.getByRole('tablist', { name: translations.en.workspaceSections, exact: true })).toBeVisible(); await expect(page.locator('[aria-labelledby="email-draft-title"]')).toHaveCount(0); expect(await page.evaluate(() => performance.timeOrigin)).toBe(origin);
|
||||
const completed = waitResponse(page, 'PUT', path); gate.release(); const saved = await json<Draft>(await completed); expect(saved).toMatchObject({ id: first.id, bodyText: text, revision: 2, clientRequestId: first.clientRequestId }); expect(await getDraft(page.request, second)).toEqual(second); await page.goBack(); await resume(page, second); await expect(fields(page).body).toHaveValue(second.bodyText); expect(await getDraft(page.request, first)).toEqual(saved); await capture(page, info, desktop, 'late-a-save-b-draft-preserved');
|
||||
} finally { gate.release(); await page.unroute(`${apiUrl}/email/drafts/${first.id}`); }
|
||||
});
|
||||
|
||||
test('[P7-06 compose] recipient-only and subject-only draft Cancel retain edits and explicit send Cancel makes zero POSTs', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); await job(page.request, session.owner); await capableProjection(page); await enter(page, session.owner); await compose(page); const controls = fields(page); await controls.to.fill('recipient-only@example.test'); const discard = draft(page).getByRole('button', { name: translations.en.emailInboxDiscardDraft, exact: true }); await keyboard(page, discard);
|
||||
await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxDiscardDraftTitle, exact: true }).getByRole('button', { name: translations.en.cancel, exact: true })); await expect(controls.to).toHaveValue('recipient-only@example.test'); await expect(discard).toBeFocused(); await controls.to.fill(''); await controls.subject.fill('Subject-only dirty draft — æøå');
|
||||
await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxCompose, exact: true })); await keyboard(page, setup(page).getByRole('button', { name: translations.en.emailInboxStartDraft, exact: true })); await keyboard(page, page.getByRole('dialog', { name: translations.en.emailInboxReplaceDraftTitle, exact: true }).getByRole('button', { name: translations.en.cancel, exact: true })); await expect(controls.subject).toHaveValue('Subject-only dirty draft — æøå'); await keyboard(page, setup(page).getByRole('button', { name: translations.en.cancel, exact: true }));
|
||||
const reviewed = { to: 'reviewed-recipient@example.test', subject: 'Explicit reviewed subject — æøå', bodyText: 'Review this exact body.\nNo delivery on Cancel.' }; await fill(page, reviewed); await keyboard(page, send(page)); const dialog = page.getByRole('dialog', { name: translations.en.emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toContainText(reviewed.to); await expect(dialog).toContainText(reviewed.subject); await capture(page, info, desktop, 'explicit-send-review-before-cancel'); await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true })); await expect(send(page)).toBeFocused(); await expect(controls.to).toHaveValue(reviewed.to); await expect(controls.subject).toHaveValue(reviewed.subject); await expect(controls.body).toHaveValue(reviewed.bodyText); expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('[P7-06 compose] real failed-attempt preparation changes only server identity and preserves all unsaved local fields for the next reviewed send', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); const realProviders = await json<{ provider: string; connected: boolean; canSend: boolean }[]>(await page.request.get(`${apiUrl}/email/providers`)); expect(realProviders.find(value => value.provider === 'gmail')).toMatchObject({ connected: false, canSend: false }); await capableProjection(page); await enter(page, session.owner); await resume(page, original);
|
||||
const local = { to: 'unsaved-local-recipient@example.test', subject: 'Unsaved local subject survives new attempt — æøå', bodyText: 'Unsaved local body survives identity preparation.\nPreserve line breaks and Æ Ø Å.' }; await fill(page, local); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible(); let reviewedRequestId = original.clientRequestId;
|
||||
const safeDisconnected = async (route: Route) => { expect(route.request().method()).toBe('POST'); expect(route.request().postDataJSON()).toEqual({ ...local, jobApplicationId: parent.id, provider: 'gmail', clientRequestId: reviewedRequestId, threadId: null, confirmed: true }); return route.continue(); }; await page.route(`${apiUrl}/email/send`, safeDisconnected);
|
||||
try {
|
||||
const attempted = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const result = await json<Send>(await attempted, 409); expect(result).toMatchObject({ status: 'failed', duplicate: false, failureCategory: 'reauthorization_required' }); expect(result.attemptId).toMatch(uuid); await expect(page.getByText(translations.en.emailInboxDeliveryFailed, { exact: true })).toBeVisible(); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); expect(await getDraft(page.request, original)).toEqual(original);
|
||||
const prepared = waitResponse(page, 'POST', `/api/email/drafts/${original.id}/new-attempt`); await keyboard(page, page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })); const replacement = await json<Draft>(await prepared); expect(replacement).toMatchObject({ id: original.id, revision: 2, to: original.to, subject: original.subject, bodyText: original.bodyText }); expect(replacement.clientRequestId).toMatch(uuid); expect(replacement.clientRequestId).not.toBe(original.clientRequestId); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toEqual([{ method: 'POST', path: `/api/email/drafts/${original.id}/new-attempt`, query: {}, body: { revision: 1 } }]); expect(await getDraft(page.request, original)).toEqual(replacement); await expect(send(page)).toBeEnabled(); await expect(save(page)).toBeEnabled(); expect(count(session, 'POST', '/api/email/send')).toHaveLength(1);
|
||||
await expect(fields(page).to).toHaveValue(local.to); await expect(fields(page).subject).toHaveValue(local.subject); await expect(fields(page).body).toHaveValue(local.bodyText); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible(); expect(count(session, 'PUT', `/api/email/drafts/${original.id}`)).toHaveLength(0); await capture(page, info, desktop, 'real-new-attempt-unsaved-fields-preserved');
|
||||
expect((await page.request.post(`${apiUrl}/email/drafts/${original.id}/new-attempt`, { headers: session.owner.headers, data: { revision: replacement.revision } })).status()).toBe(409); expect(await getDraft(page.request, original)).toEqual(replacement); reviewedRequestId = replacement.clientRequestId;
|
||||
const reviewedAgain = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const secondResult = await json<Send>(await reviewedAgain, 409); expect(secondResult).toMatchObject({ status: 'failed', duplicate: false, failureCategory: 'reauthorization_required' }); expect(secondResult.attemptId).not.toBe(result.attemptId); expect(count(session, 'POST', '/api/email/send').map(value => value.body)).toEqual([original.clientRequestId, replacement.clientRequestId].map(clientRequestId => ({ ...local, jobApplicationId: parent.id, provider: 'gmail', clientRequestId, threadId: null, confirmed: true }))); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toHaveLength(1); expect(await getDraft(page.request, original)).toEqual(replacement); await expect(fields(page).to).toHaveValue(local.to); await expect(fields(page).subject).toHaveValue(local.subject); await expect(fields(page).body).toHaveValue(local.bodyText); await expect(draft(page).getByText(translations.en.assetsUnsavedChanges, { exact: true })).toBeVisible();
|
||||
await info.attach('real-disconnected-failed-attempt', { body: JSON.stringify({ realProviders, result, secondResult, original, replacement, unsavedLocalFields: local, delivery: 'Both actual requests fail before provider.SendAsync because the account has no connection. No provider grant, sync or delivery installed; draft content remains the separately verified persisted snapshot.' }), contentType: 'application/json' });
|
||||
} finally { await page.unroute(`${apiUrl}/email/send`, safeDisconnected); }
|
||||
});
|
||||
|
||||
test('[P7-06 compose] labelled uncertain pending sending sent and generic409 fixtures retain state with no implicit identity or resend', async ({ page, session }, info) => {
|
||||
test.setTimeout(240_000); const parent = await job(page.request, session.owner); await capableProjection(page);
|
||||
for (const status of ['uncertain', 'pending', 'sending', 'sent', 'generic409'] as const) {
|
||||
const original = await stored(page.request, session.owner, parent, `Controlled ${status} draft — æøå`); await enter(page, session.owner);
|
||||
// Independent delivery fixtures require a fresh document; same hash URL preserves current draft.
|
||||
if (status !== 'uncertain') { const origin = await page.evaluate(() => performance.timeOrigin); await page.reload(); expect(await page.evaluate(() => performance.timeOrigin)).not.toBe(origin); }
|
||||
await resume(page, original); const before = count(session, 'POST', '/api/email/send').length;
|
||||
const fixture = async (route: Route) => { expect(route.request().postDataJSON()).toMatchObject({ clientRequestId: original.clientRequestId, jobApplicationId: parent.id, confirmed: true }); return route.fulfill(status === 'generic409' ? { status: 409, json: { title: 'Idempotency conflict', detail: 'Controlled conflict without definitive provider failure.' } } : { status: status === 'sent' ? 200 : 409, json: { attemptId: original.clientRequestId, status, duplicate: status !== 'sent', failureCategory: 'controlled_ui_fixture' } }); }; await page.route(`${apiUrl}/email/send`, fixture);
|
||||
try {
|
||||
const responded = waitResponse(page, 'POST', '/api/email/send'); await confirmSend(page); const result = await responded; expect(result.status()).toBe(status === 'sent' ? 200 : 409); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })).toHaveCount(0);
|
||||
if (status !== 'generic409') await expect(page.getByText(status === 'sent' ? translations.en.emailInboxSentSuccess : translations.en.emailInboxDeliveryUncertain, { exact: true })).toBeVisible(); for (const control of Object.values(fields(page))) await expect(control).toBeDisabled(); await expect(send(page)).toBeDisabled(); await expect(save(page)).toBeDisabled(); await expect(page.getByRole('button', { name: translations.en.emailInboxPrepareAttempt, exact: true })).toHaveCount(0);
|
||||
expect(count(session, 'POST', '/api/email/send')).toHaveLength(before + 1); expect(count(session, 'POST', `/api/email/drafts/${original.id}/new-attempt`)).toHaveLength(0); if (status === 'sent') { expect((await page.request.get(`${apiUrl}/email/drafts/${original.id}`)).status()).toBe(404); expect(count(session, 'DELETE', `/api/email/drafts/${original.id}`)).toEqual([{ method: 'DELETE', path: `/api/email/drafts/${original.id}`, query: { revision: '1' }, body: null }]); } else expect(await getDraft(page.request, original)).toEqual(original);
|
||||
await info.attach(`controlled-${status}-send-result`, { body: JSON.stringify({ qualification: 'Browser response fixture; does not record a real delivery attempt or establish provider status.', original, responseStatus: result.status(), responseBody: await result.text(), sendRequestCount: before + 1 }), contentType: 'application/json' }); if (status === 'uncertain' || status === 'generic409') await capture(page, info, desktop, `controlled-${status}-identity-protected`);
|
||||
} finally { await page.unroute(`${apiUrl}/email/send`, fixture); }
|
||||
}
|
||||
});
|
||||
|
||||
test('[P7-06 compose] strict actual language204 preserves all dirty fields and saved identity without draft or setup rereads', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const original = await stored(page.request, session.owner, parent); await capableProjection(page); await enter(page, session.owner); await resume(page, original); const edited = { to: 'edited-local@example.test', subject: 'Unsaved bilingual subject — æøå', bodyText: 'Unsaved bilingual body\nÆ Ø Å' }; await fill(page, edited); const readCount = reads(session).length;
|
||||
const changed = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true })); const actual = await changed; await info.attach('compose-first-language-http', { body: JSON.stringify({ status: actual.status(), body: await actual.text(), qualification: 'Ordinary strict204 assertion; existing RD11 remains a failure if the real backend returns500.' }), contentType: 'application/json' }); expect(actual.status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(fields(page, 'nb').to).toHaveValue(edited.to); await expect(fields(page, 'nb').subject).toHaveValue(edited.subject); await expect(fields(page, 'nb').body).toHaveValue(edited.bodyText); expect(reads(session)).toHaveLength(readCount); expect(await getDraft(page.request, original)).toEqual(original); await capture(page, info, { ...desktop, language: 'nb' }, 'strict-language-dirty-draft');
|
||||
const restored = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true })); const actualRestored = await restored; await info.attach('compose-second-language-http', { body: JSON.stringify({ status: actualRestored.status(), body: await actualRestored.text() }), contentType: 'application/json' }); expect(actualRestored.status()).toBe(204); await expect(fields(page).body).toHaveValue(edited.bodyText); expect(reads(session)).toHaveLength(readCount); expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('[P7-06 compose] eight finite bilingual theme widths retain long drafts and keyboard setup review Cancel with complete axe vectors', async ({ page, session }, info) => {
|
||||
test.setTimeout(300_000); const parent = await job(page.request, session.owner, 'Samfunnskritisk plattformutvikling med internasjonale samarbeidspartnere Æ Ø Å'); const original = await stored(page.request, session.owner, parent, 'Langt e-postemne om samfunnskritiske tjenester og inkluderende arbeidsprosesser Æ Ø Å', ('Reviewed application message line; keep readable text and line breaks Æ Ø Å.\n').repeat(24)); await capableProjection(page);
|
||||
const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
|
||||
for (const sample of samples) {
|
||||
await enter(page, session.owner, sample); await resume(page, original, sample.language); const t = translations[sample.language]; const controls = fields(page, sample.language); await controls.body.focus(); await expect(controls.body).toBeFocused(); await expect(controls.body).toHaveAttribute('maxlength', '200000'); await capture(page, info, sample, 'long-draft-keyboard');
|
||||
if ([320, 768, 1440].includes(sample.width)) { await keyboard(page, page.getByRole('button', { name: t.emailInboxCompose, exact: true })); const choice = setup(page, sample.language).getByRole('combobox', { name: t.emailInboxJob, exact: true }); await choice.focus(); await expect(choice).toBeFocused(); await capture(page, info, sample, 'owned-setup-keyboard'); await keyboard(page, setup(page, sample.language).getByRole('button', { name: t.cancel, exact: true })); await expect(controls.body).toHaveValue(original.bodyText); }
|
||||
if ([390, 1280].includes(sample.width)) { await keyboard(page, send(page, sample.language)); const dialog = page.getByRole('dialog', { name: t.emailInboxConfirmSendTitle, exact: true }); await expect(dialog).toContainText(original.to); await capture(page, info, sample, 'send-review-keyboard'); await keyboard(page, dialog.getByRole('button', { name: t.cancel, exact: true })); await expect(send(page, sample.language)).toBeFocused(); await send(page, sample.language).scrollIntoViewIfNeeded(); const position = await send(page, sample.language).boundingBox(); expect(position).not.toBeNull(); expect(position!.y).toBeGreaterThanOrEqual(0); expect(position!.y + position!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p706-compose-${sample.language}-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' }); }
|
||||
}
|
||||
expect(session.requests.filter(value => value.method !== 'GET' && value.path.startsWith('/api/email/'))).toEqual([]); expect(await getDraft(page.request, original)).toEqual(original);
|
||||
});
|
||||
@@ -66,6 +66,42 @@ function HistoryBackControl() {
|
||||
return <button onClick={() => navigate(-1)}>History Back</button>;
|
||||
}
|
||||
|
||||
const composeUuid = '00000000-0000-4000-8000-000000000101';
|
||||
const replacementUuid = '00000000-0000-4000-8000-000000000202';
|
||||
|
||||
function savedComposeDraft(overrides: Record<string, unknown> = {}) {
|
||||
return { id: 'compose-owned', revision: 4, jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Owned compose draft', bodyText: 'Owned saved body', threadId: 'thread-1', clientRequestId: 'server-owned-attempt', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z', ...overrides };
|
||||
}
|
||||
|
||||
function composeReads(stored: ReturnType<typeof savedComposeDraft>[] = []) {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/providers') return Promise.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }] } as any);
|
||||
if (url === '/email/drafts') return Promise.resolve({ data: stored } as any);
|
||||
if (url === '/jobapplications/choices') return Promise.resolve({ data: [{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' }] } as any);
|
||||
return original!(url, config);
|
||||
});
|
||||
const uuid = globalThis.crypto.randomUUID as jest.Mock;
|
||||
uuid.mockReset().mockReturnValueOnce(composeUuid).mockReturnValue(replacementUuid);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
function composeField(key: string) {
|
||||
return within(screen.getByRole('region', { name: copy('emailInboxDraftTitle') })).getByRole('textbox', { name: copy(key) });
|
||||
}
|
||||
|
||||
async function openReply(bodyText = '') {
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxReply') }));
|
||||
if (bodyText) fireEvent.change(composeField('emailInboxMessage'), { target: { value: bodyText } });
|
||||
}
|
||||
|
||||
async function closeConfirmation(actionKey: string) {
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: copy(actionKey) }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
function renderPage(initialPath = '/') {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
@@ -834,4 +870,299 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('held draft Save captures one write and locks edits, Send, Discard and every draft replacement entry', async () => {
|
||||
composeReads([savedComposeDraft()]);
|
||||
const save = deferred<any>();
|
||||
mockedApi.post.mockReturnValue(save.promise);
|
||||
renderPage();
|
||||
await openReply('Captured save body');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxCompose') })).toBeEnabled());
|
||||
const button = screen.getByRole('button', { name: copy('emailInboxSaveDraft') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body', threadId: 'thread-1' });
|
||||
for (const key of ['emailInboxRecipient', 'emailInboxSubject', 'emailInboxMessage']) expect(composeField(key)).toBeDisabled();
|
||||
for (const key of ['emailInboxReviewSend', 'emailInboxDiscardDraft', 'emailInboxCompose', 'emailInboxReply']) expect(screen.getByRole('button', { name: copy(key) })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) })).toBeDisabled();
|
||||
await act(async () => { save.resolve({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body' }) }); await save.promise; });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeEnabled());
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Captured save body');
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('double Review creates one confirmation, Cancel sends nothing and explicit double Send owns one captured attempt', async () => {
|
||||
const uuid = composeReads();
|
||||
const send = deferred<any>();
|
||||
mockedApi.post.mockReturnValue(send.promise);
|
||||
renderPage();
|
||||
await openReply('Captured confirmed reply');
|
||||
const review = screen.getByRole('button', { name: copy('emailInboxReviewSend') });
|
||||
review.focus();
|
||||
act(() => { fireEvent.click(review); fireEvent.click(review); });
|
||||
expect(await screen.findAllByRole('dialog')).toHaveLength(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
await closeConfirmation('cancel');
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(review).toHaveFocus());
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Captured confirmed reply');
|
||||
fireEvent.click(review);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const confirm = within(dialog).getByRole('button', { name: copy('emailInboxSendEmail') });
|
||||
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: composeUuid, to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured confirmed reply', threadId: 'thread-1', confirmed: true });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxSending') })).toBeDisabled();
|
||||
expect(uuid).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { send.resolve({ data: { attemptId: 'server-pending', status: 'pending', duplicate: false } }); await send.promise; });
|
||||
expect(await screen.findByText(copy('emailInboxDeliveryUncertain'))).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('recipient, subject and body edits cannot erase any recorded delivery state or rotate its attempt identity', async () => {
|
||||
for (const status of ['uncertain', 'pending', 'sending', 'sent', 'failed'] as const) {
|
||||
const uuid = composeReads();
|
||||
mockedApi.post.mockReset();
|
||||
const result = { attemptId: `recorded-${status}`, status, duplicate: false, failureCategory: status === 'failed' ? 'provider_rejected' : null };
|
||||
mockedApi.post.mockImplementation(() => status === 'failed' ? Promise.reject({ response: { status: 502, data: result } }) : Promise.resolve({ data: result } as any));
|
||||
const mounted = renderPage();
|
||||
await openReply('Recorded attempt body');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
const stateKey = status === 'sent' ? 'emailInboxSentSuccess' : status === 'failed' ? 'emailInboxDeliveryFailed' : 'emailInboxDeliveryUncertain';
|
||||
await screen.findByText(copy(stateKey));
|
||||
for (const [key, value] of [['emailInboxRecipient', 'changed@acme.test'], ['emailInboxSubject', 'Changed subject'], ['emailInboxMessage', 'Changed body']]) {
|
||||
fireEvent.change(composeField(key), { target: { value } });
|
||||
expect(screen.getByText(copy(stateKey))).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
|
||||
expect(uuid).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
if (status === 'failed') expect(screen.getByRole('button', { name: copy('emailInboxPrepareAttempt') })).toBeInTheDocument();
|
||||
else expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
|
||||
mounted.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test('generic 409 cannot prepare a new attempt while an authoritative failed result requires one explicit server rotation', async () => {
|
||||
let uuid = composeReads([savedComposeDraft()]);
|
||||
mockedApi.post.mockRejectedValue({ response: { status: 409, data: { detail: 'Draft or delivery conflict' } } });
|
||||
let mounted = renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
await screen.findByText(copy('emailInboxDeliveryUncertain'));
|
||||
expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
|
||||
expect(uuid).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-owned-attempt' }));
|
||||
mounted.unmount();
|
||||
|
||||
uuid = composeReads([savedComposeDraft()]);
|
||||
mockedApi.post.mockReset();
|
||||
const rotate = deferred<any>();
|
||||
let sends = 0;
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/email/send') return ++sends === 1 ? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-server-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } }) : Promise.resolve({ data: { attemptId: 'new-server-attempt', status: 'pending', duplicate: false } } as any);
|
||||
if (url === '/email/drafts/compose-owned/new-attempt') return rotate.promise;
|
||||
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
||||
});
|
||||
mounted = renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
const prepare = await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
act(() => { fireEvent.click(prepare); fireEvent.click(prepare); });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
|
||||
expect(uuid).not.toHaveBeenCalled();
|
||||
await act(async () => { rotate.resolve({ data: savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-attempt' }) }); await rotate.promise; });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-rotated-attempt', confirmed: true }));
|
||||
expect(uuid).not.toHaveBeenCalled();
|
||||
mounted.unmount();
|
||||
});
|
||||
|
||||
test('recipient-only and subject-only unsaved changes require replacement and discard confirmation whose Cancel retains the draft', async () => {
|
||||
for (const [key, value] of [['emailInboxRecipient', 'edited-recipient@acme.test'], ['emailInboxSubject', 'Edited subject only']]) {
|
||||
composeReads([savedComposeDraft()]);
|
||||
const mounted = renderPage();
|
||||
await openReply();
|
||||
fireEvent.change(composeField(key), { target: { value } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
expect(await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') })).toBeInTheDocument();
|
||||
await closeConfirmation('cancel');
|
||||
expect(composeField(key)).toHaveValue(value);
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxDiscardDraft') }));
|
||||
expect(await screen.findByRole('dialog', { name: copy('emailInboxDiscardDraftTitle') })).toBeInTheDocument();
|
||||
await closeConfirmation('cancel');
|
||||
expect(composeField(key)).toHaveValue(value);
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('');
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
mounted.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test('saved-draft initial read and post-save refresh failures recover with GET-only Retry without replaying the successful Save', async () => {
|
||||
composeReads();
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const retry = deferred<any>();
|
||||
let draftReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/drafts') {
|
||||
draftReads += 1;
|
||||
if (draftReads === 1 || draftReads === 3) return Promise.reject({ response: { status: 503 } });
|
||||
if (draftReads === 2) return retry.promise;
|
||||
return Promise.resolve({ data: [savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' })] } as any);
|
||||
}
|
||||
return original!(url, config);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' }) } as any);
|
||||
renderPage();
|
||||
await screen.findByText(copy('emailInboxDraftsLoadFailed'));
|
||||
const button = retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(draftReads).toBe(2);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
await act(async () => { retry.resolve({ data: [] }); await retry.promise; });
|
||||
await openReply('New captured save');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
|
||||
await waitFor(() => expect(draftReads).toBe(3));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('New captured save');
|
||||
expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeInTheDocument();
|
||||
fireEvent.click(retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
await waitFor(() => expect(draftReads).toBe(4));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a late Save after inbox unmount cannot refresh drafts or attach its result to another page', async () => {
|
||||
composeReads();
|
||||
const save = deferred<any>();
|
||||
mockedApi.post.mockReturnValue(save.promise);
|
||||
const mounted = renderPage();
|
||||
await openReply('Old owner save');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
mounted.unmount();
|
||||
await act(async () => { save.resolve({ data: savedComposeDraft({ bodyText: 'Old owner save' }) }); await save.promise; });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.queryByRole('region', { name: copy('emailInboxDraftTitle') })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(copy('emailInboxDraftSaved'))).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a held managed-draft 409 retains recipient, subject, body and revision with current-language original Save recovery', async () => {
|
||||
const uuid = composeReads([savedComposeDraft()]);
|
||||
const save = deferred<any>();
|
||||
mockedApi.put.mockReturnValueOnce(save.promise).mockRejectedValueOnce({ response: { status: 409 } });
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'local@acme.test' } });
|
||||
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Local conflict subject' } });
|
||||
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Local conflict body' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
|
||||
await act(async () => { save.reject({ response: { status: 409 } }); await save.promise.catch(() => undefined); });
|
||||
await screen.findByText(copy('emailInboxDraftChangedElsewhere'));
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.getByText(copy('emailInboxDraftChangedElsewhere'))).toBeInTheDocument();
|
||||
expect(composeField('emailInboxRecipient')).toHaveValue('local@acme.test');
|
||||
expect(composeField('emailInboxSubject')).toHaveValue('Local conflict subject');
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Local conflict body');
|
||||
expect(uuid).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.put).toHaveBeenLastCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('server attempt rotation preserves three unsaved fields and their dirty state while the next send uses the new identity', async () => {
|
||||
const uuid = composeReads([savedComposeDraft()]);
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
let serverDraft = savedComposeDraft();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? Promise.resolve({ data: [serverDraft] } as any) : original!(url, config));
|
||||
let sends = 0;
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/email/send') return ++sends === 1
|
||||
? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-owned-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } })
|
||||
: Promise.resolve({ data: { attemptId: 'rotated-owned-attempt', status: 'pending', duplicate: false } } as any);
|
||||
if (url === '/email/drafts/compose-owned/new-attempt') {
|
||||
serverDraft = savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-owned-key' });
|
||||
return Promise.resolve({ data: serverDraft } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected POST ${url}`));
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'edited-recipient@acme.test' } });
|
||||
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Unsaved edited subject' } });
|
||||
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Unsaved edited message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
|
||||
expect(composeField('emailInboxRecipient')).toHaveValue('edited-recipient@acme.test');
|
||||
expect(composeField('emailInboxSubject')).toHaveValue('Unsaved edited subject');
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
|
||||
const dialog = await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') });
|
||||
expect(within(dialog).getByText(copy('emailInboxUnsavedDraftLost'))).toBeInTheDocument();
|
||||
await closeConfirmation('cancel');
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
|
||||
await closeConfirmation('emailInboxSendEmail');
|
||||
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: 'server-rotated-owned-key', to: 'edited-recipient@acme.test', subject: 'Unsaved edited subject', bodyText: 'Unsaved edited message', threadId: 'thread-1', confirmed: true });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(3);
|
||||
expect(uuid).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a committed Save starts a fresh draft-list read despite an older pending GET and rejects the late old snapshot', async () => {
|
||||
composeReads();
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const oldRead = deferred<any>();
|
||||
const freshRead = deferred<any>();
|
||||
let reads = 0;
|
||||
const created = savedComposeDraft({ id: 'newly-saved-owned', revision: 1, clientRequestId: 'newly-saved-server-key', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Freshly saved message' });
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? ++reads === 1 ? oldRead.promise : freshRead.promise : original!(url, config));
|
||||
mockedApi.post.mockResolvedValue({ data: created } as any);
|
||||
renderPage();
|
||||
await openReply('Freshly saved message');
|
||||
expect(reads).toBe(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(reads).toBe(2));
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/email/drafts');
|
||||
await act(async () => { freshRead.resolve({ data: [created] }); await freshRead.promise; });
|
||||
expect(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
|
||||
await act(async () => { oldRead.resolve({ data: [savedComposeDraft({ id: 'outdated-owned', subject: 'Stale old draft' })] }); await oldRead.promise; });
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Stale old draft' }) })).not.toBeInTheDocument();
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('Freshly saved message');
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,9 @@ export const translations = {
|
||||
emailInboxJobNumber: "job",
|
||||
emailInboxJob: "Job",
|
||||
emailInboxSearchJobs: "Search every job",
|
||||
emailInboxJobsLoadFailed: "Could not load jobs for this draft.",
|
||||
emailInboxNoJobMatches: "No matching jobs",
|
||||
emailInboxDraftDiscardFailed: "Could not discard the draft. Your draft is still open.",
|
||||
emailInboxSendingProvider: "Sending provider",
|
||||
emailInboxStartDraft: "Start draft",
|
||||
emailInboxSearchPlaceholder: "Company, role, recruiter, subject",
|
||||
@@ -124,7 +127,7 @@ export const translations = {
|
||||
emailInboxReplaceDraft: "Replace draft",
|
||||
emailInboxYourMessage: "Your message",
|
||||
emailInboxCurrentDraftSaved: "Your current draft is already saved and can be resumed later.",
|
||||
emailInboxUnsavedDraftLost: "Your current unsaved draft text will be lost.",
|
||||
emailInboxUnsavedDraftLost: "Your unsaved changes to this draft will be lost.",
|
||||
emailInboxSavedDraft: "Saved email draft",
|
||||
emailInboxReconnectBeforeSending: "Reconnect before sending",
|
||||
emailInboxDiscardDraftTitle: "Discard email draft?",
|
||||
@@ -2727,6 +2730,9 @@ export const translations = {
|
||||
emailInboxJobNumber: "jobb",
|
||||
emailInboxJob: "Jobb",
|
||||
emailInboxSearchJobs: "Søk i alle jobber",
|
||||
emailInboxJobsLoadFailed: "Kunne ikke laste inn jobber for dette utkastet.",
|
||||
emailInboxNoJobMatches: "Ingen jobber samsvarer med søket",
|
||||
emailInboxDraftDiscardFailed: "Kunne ikke forkaste utkastet. Utkastet er fortsatt åpent.",
|
||||
emailInboxSendingProvider: "Avsenderkonto",
|
||||
emailInboxStartDraft: "Start utkast",
|
||||
emailInboxSearchPlaceholder: "Bedrift, rolle, rekrutterer, emne",
|
||||
@@ -2781,7 +2787,7 @@ export const translations = {
|
||||
emailInboxReplaceDraft: "Erstatt utkast",
|
||||
emailInboxYourMessage: "Meldingen din",
|
||||
emailInboxCurrentDraftSaved: "Det gjeldende utkastet er lagret og kan fortsettes senere.",
|
||||
emailInboxUnsavedDraftLost: "Teksten i det ulagrede utkastet går tapt.",
|
||||
emailInboxUnsavedDraftLost: "Ulagrede endringer i dette utkastet vil gå tapt.",
|
||||
emailInboxSavedDraft: "Lagret e-postutkast",
|
||||
emailInboxReconnectBeforeSending: "Koble til på nytt før sending",
|
||||
emailInboxDiscardDraftTitle: "Forkast e-postutkast?",
|
||||
|
||||
@@ -114,6 +114,8 @@ type EmailSendResult = {
|
||||
failureCategory?: string | null;
|
||||
};
|
||||
|
||||
type DraftMutationError = ViewResourceError & { action: "save" | "discard" | "prepare" };
|
||||
|
||||
function readError(error: any): ViewResourceError {
|
||||
const status = error?.response?.status as number | undefined;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
@@ -138,6 +140,11 @@ export default function CorrespondenceInboxPage() {
|
||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||
const [jobsLoaded, setJobsLoaded] = useState(false);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [jobsError, setJobsError] = useState<ViewResourceError | null>(null);
|
||||
const [loadedJobQuery, setLoadedJobQuery] = useState<string | null>(null);
|
||||
const jobsRequest = useRef(0);
|
||||
const pendingJobs = useRef<string | null>(null);
|
||||
const jobsPanel = useRef<HTMLDivElement>(null);
|
||||
const [jobQuery, setJobQuery] = useState("");
|
||||
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||
@@ -171,13 +178,25 @@ export default function CorrespondenceInboxPage() {
|
||||
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
|
||||
const [reviewVisited, setReviewVisited] = useState(view === "review");
|
||||
const mounted = useRef(true);
|
||||
const [draftsReadError, setDraftsReadError] = useState(false);
|
||||
const [draftsReadError, setDraftsReadError] = useState<ViewResourceError | null>(null);
|
||||
const [draftsLoading, setDraftsLoading] = useState(false);
|
||||
const draftsPending = useRef(false);
|
||||
const draftsRequest = useRef(0);
|
||||
const draftsPanel = useRef<HTMLDivElement>(null);
|
||||
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const draftBaseline = useRef<EmailDraft | null>(null);
|
||||
const draftPanel = useRef<HTMLDivElement>(null);
|
||||
const composeOperation = useRef(false);
|
||||
const [composeBusy, setComposeBusy] = useState(false);
|
||||
const latestT = useRef(t);
|
||||
latestT.current = t;
|
||||
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const [draftError, setDraftError] = useState<string | null>(null);
|
||||
const [draftError, setDraftError] = useState<DraftMutationError | null>(null);
|
||||
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<ViewResourceError | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
||||
const unlinkPending = useRef(false);
|
||||
@@ -196,7 +215,7 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; };
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; jobsRequest.current += 1; draftsRequest.current += 1; draftsPending.current = false; };
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -275,26 +294,47 @@ export default function CorrespondenceInboxPage() {
|
||||
}, []);
|
||||
useEffect(() => { void loadProviders(); }, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setJobsLoading(true);
|
||||
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
|
||||
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => {
|
||||
setJobsLoaded(true);
|
||||
setJobsLoading(false);
|
||||
});
|
||||
}, jobQuery.trim() ? 250 : 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
const loadJobs = useCallback(async () => {
|
||||
const key = jobQuery.trim();
|
||||
if (pendingJobs.current === key) return;
|
||||
const request = ++jobsRequest.current;
|
||||
pendingJobs.current = key;
|
||||
setJobsLoading(true);
|
||||
setJobsError(null);
|
||||
try {
|
||||
const response = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: key || undefined, limit: 20 } });
|
||||
if (!mounted.current || request !== jobsRequest.current) return;
|
||||
setJobs(Array.isArray(response.data) ? response.data : []);
|
||||
setLoadedJobQuery(key);
|
||||
setJobsLoaded(true);
|
||||
} catch (error) {
|
||||
if (!mounted.current || request !== jobsRequest.current) return;
|
||||
setJobsError(readError(error));
|
||||
} finally {
|
||||
if (request === jobsRequest.current) { pendingJobs.current = null; if (mounted.current) setJobsLoading(false); }
|
||||
}
|
||||
}, [jobQuery]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadJobs(), jobQuery.trim() ? 250 : 0);
|
||||
return () => { window.clearTimeout(timer); jobsRequest.current += 1; pendingJobs.current = null; };
|
||||
}, [jobQuery, loadJobs]);
|
||||
|
||||
const loadStoredDrafts = useCallback(async () => {
|
||||
const loadStoredDrafts = useCallback(async (afterWrite = false) => {
|
||||
if (!mounted.current || (draftsPending.current && !afterWrite)) return;
|
||||
draftsPending.current = true;
|
||||
const request = ++draftsRequest.current;
|
||||
setDraftsLoading(true);
|
||||
try {
|
||||
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
||||
if (mounted.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); }
|
||||
} catch {
|
||||
if (mounted.current) setDraftsReadError(true);
|
||||
if (mounted.current && request === draftsRequest.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(null); }
|
||||
} catch (error) {
|
||||
if (mounted.current && request === draftsRequest.current) {
|
||||
const failure = readError(error);
|
||||
setDraftsReadError(failure);
|
||||
if (failure.kind === "unauthorized") setStoredDrafts([]);
|
||||
}
|
||||
} finally {
|
||||
if (request === draftsRequest.current) { draftsPending.current = false; if (mounted.current) setDraftsLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -356,225 +396,238 @@ export default function CorrespondenceInboxPage() {
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
if (!messageDetail || !item.provider) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
if (!provider?.canSend) return;
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
const hasDraftContent = (current: EmailDraft | null) => !!current && !!(current.to.trim() || current.subject.trim() || current.bodyText.trim());
|
||||
const draftDirty = !!draft && (!draftBaseline.current || draft.to !== draftBaseline.current.to || draft.subject !== draftBaseline.current.subject || draft.bodyText !== draftBaseline.current.bodyText);
|
||||
const jobsCurrent = loadedJobQuery === jobQuery.trim() && jobsError?.kind !== "unauthorized";
|
||||
const draftErrorMessage = draftError ? (draftError.status === 409 ? "" : draftError.message) || t(draftError.action === "discard"
|
||||
? draftError.status === 409 ? "emailInboxDraftChangedDiscard" : "emailInboxDraftDiscardFailed"
|
||||
: draftError.action === "prepare" ? draftError.status === 409 ? "emailInboxNewAttemptConflict" : "emailInboxNewAttemptFailed"
|
||||
: draftError.status === 409 ? "emailInboxDraftChangedElsewhere" : "emailInboxDraftSaveFailed") : "";
|
||||
const openDraft = (next: EmailDraft | null) => {
|
||||
draftRef.current = next;
|
||||
draftBaseline.current = next;
|
||||
setDraft(next);
|
||||
};
|
||||
const isCurrentDraft = (captured: EmailDraft) => mounted.current && draftRef.current?.clientRequestId === captured.clientRequestId
|
||||
&& draftRef.current?.id === captured.id && draftRef.current?.jobApplicationId === captured.jobApplicationId && draftRef.current?.provider === captured.provider;
|
||||
const finishComposeOperation = () => {
|
||||
composeOperation.current = false;
|
||||
if (mounted.current) { setComposeBusy(false); setSavingDraft(false); setSending(false); }
|
||||
};
|
||||
const replaceDraftApproved = () => {
|
||||
const current = draftRef.current;
|
||||
if (!hasDraftContent(current) && !sendResult) return null;
|
||||
const saved = !!current?.id && !draftDirty;
|
||||
const unresolved = sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status);
|
||||
return confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: t("emailInboxReplaceReplyMessage"),
|
||||
message: (unresolved ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t(saved ? "emailInboxCurrentDraftSaved" : "emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
const subject = messageDetail.subject.trim();
|
||||
setDraft({
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: item.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
||||
bodyText: "",
|
||||
threadId: messageDetail.threadId || item.externalThreadId || "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
destructive: !saved,
|
||||
});
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
const detail = messageDetail;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
if (!detail || !item.provider || !provider?.canSend || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
const subject = detail.subject.trim();
|
||||
openDraft({
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: item.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: item.direction === "outbound" ? detail.to : detail.from,
|
||||
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
||||
bodyText: "",
|
||||
threadId: detail.threadId || item.externalThreadId || "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const resumeDraft = async (stored: StoredEmailDraft) => {
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: !draft.id,
|
||||
}))) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
||||
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
||||
setDraft({
|
||||
...stored,
|
||||
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
|
||||
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
});
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
if (composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
||||
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
||||
openDraft({
|
||||
...stored,
|
||||
companyName: item?.companyName || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}`,
|
||||
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
});
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const openComposeSetup = () => {
|
||||
if (composeOperation.current) return;
|
||||
setComposeJobId((current) => current || jobs[0]?.id || "");
|
||||
setComposeProvider((current) => current || sendCapableProviders[0]?.provider || "");
|
||||
setComposeSetupOpen(true);
|
||||
};
|
||||
|
||||
const startNewMessage = async () => {
|
||||
const job = jobs.find((candidate) => candidate.id === composeJobId);
|
||||
const job = jobsCurrent ? jobs.find((candidate) => candidate.id === composeJobId) : undefined;
|
||||
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
|
||||
if (!job || !provider) return;
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxReplaceDraftTitle"),
|
||||
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
|
||||
confirmLabel: t("emailInboxReplaceDraft"),
|
||||
destructive: !draft.id,
|
||||
}))) return;
|
||||
setDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: "",
|
||||
subject: "",
|
||||
bodyText: "",
|
||||
threadId: "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setComposeSetupOpen(false);
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
if (!job || !provider || jobsLoading || jobsError || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
const approval = replaceDraftApproved();
|
||||
if ((approval && !(await approval)) || !mounted.current) return;
|
||||
openDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
||||
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
||||
to: "", subject: "", bodyText: "", threadId: "",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
});
|
||||
setComposeSetupOpen(false);
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
||||
setDraft((current) => current ? {
|
||||
...current,
|
||||
...changes,
|
||||
clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId,
|
||||
} : null);
|
||||
if (sendResult) setSendResult(null);
|
||||
const current = draftRef.current;
|
||||
if (!current || composeOperation.current || sendResult) return;
|
||||
const next = { ...current, ...changes };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
setSendError(null);
|
||||
};
|
||||
|
||||
const discardDraft = async () => {
|
||||
if (draft?.bodyText.trim() && !(await confirm({
|
||||
title: t("emailInboxDiscardDraftTitle"),
|
||||
message: t("emailInboxDiscardDraftMessage"),
|
||||
confirmLabel: t("emailInboxDiscardDraft"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
if (draft?.id && draft.revision) {
|
||||
try {
|
||||
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
||||
await loadStoredDrafts();
|
||||
} catch (error) {
|
||||
setDraftError(getApiErrorMessage(error, t("emailInboxDraftChangedDiscard")));
|
||||
return;
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
if ((captured.id || hasDraftContent(captured) || sendResult) && !(await confirm({
|
||||
title: t("emailInboxDiscardDraftTitle"),
|
||||
message: (sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status) ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t("emailInboxDiscardDraftMessage"),
|
||||
confirmLabel: t("emailInboxDiscardDraft"), destructive: true,
|
||||
}))) return;
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
setComposeBusy(true);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
setDraftError(null);
|
||||
if (captured.id && captured.revision) await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
inboxPanel.current?.focus({ preventScroll: true });
|
||||
openDraft(null);
|
||||
setSendResult(null); setSendError(null);
|
||||
if (captured.id) {
|
||||
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
||||
await loadStoredDrafts(true);
|
||||
}
|
||||
}
|
||||
setDraft(null);
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setDraftError(null);
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "discard" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!draft || savingDraft || sendResult) return;
|
||||
setSavingDraft(true);
|
||||
setDraftError(null);
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult) return;
|
||||
composeOperation.current = true;
|
||||
setComposeBusy(true); setSavingDraft(true); setDraftError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
try {
|
||||
const response = draft.id && draft.revision
|
||||
? await api.put<StoredEmailDraft>(`/email/drafts/${draft.id}`, {
|
||||
revision: draft.revision,
|
||||
to: draft.to,
|
||||
subject: draft.subject,
|
||||
bodyText: draft.bodyText,
|
||||
})
|
||||
: await api.post<StoredEmailDraft>("/email/drafts", {
|
||||
jobApplicationId: draft.jobApplicationId,
|
||||
provider: draft.provider,
|
||||
to: draft.to,
|
||||
subject: draft.subject,
|
||||
bodyText: draft.bodyText,
|
||||
threadId: draft.threadId || null,
|
||||
});
|
||||
setDraft((current) => current ? { ...current, ...response.data } : null);
|
||||
await loadStoredDrafts();
|
||||
toast(t("emailInboxDraftSaved"), "success");
|
||||
} catch (error: any) {
|
||||
setDraftError(error?.response?.status === 409
|
||||
? t("emailInboxDraftChangedElsewhere")
|
||||
: getApiErrorMessage(error, t("emailInboxDraftSaveFailed")));
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
const response = captured.id && captured.revision
|
||||
? await api.put<StoredEmailDraft>(`/email/drafts/${captured.id}`, { revision: captured.revision, to: captured.to, subject: captured.subject, bodyText: captured.bodyText })
|
||||
: await api.post<StoredEmailDraft>("/email/drafts", { jobApplicationId: captured.jobApplicationId, provider: captured.provider, to: captured.to, subject: captured.subject, bodyText: captured.bodyText, threadId: captured.threadId || null });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
openDraft({ ...captured, ...response.data });
|
||||
setStoredDrafts((current) => [response.data, ...current.filter((stored) => stored.id !== response.data.id)]);
|
||||
await loadStoredDrafts(true);
|
||||
if (mounted.current) toast(latestT.current("emailInboxDraftSaved"), "success");
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "save" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const sendDraft = async () => {
|
||||
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
|
||||
const approved = await confirm({
|
||||
title: t("emailInboxConfirmSendTitle"),
|
||||
message: t("emailInboxConfirmSendMessage", { provider: draft.providerName, from: draft.fromAddress, to: draft.to.trim(), subject: draft.subject.trim(), thread: draft.threadId || t("emailInboxNewMessage") }),
|
||||
confirmLabel: t("emailInboxSendEmail"),
|
||||
});
|
||||
if (!approved) return;
|
||||
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult || !captured.to.trim() || !captured.subject.trim() || !captured.bodyText.trim()) return;
|
||||
composeOperation.current = true;
|
||||
try {
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxConfirmSendTitle"),
|
||||
message: t("emailInboxConfirmSendMessage", { provider: captured.providerName, from: captured.fromAddress, to: captured.to.trim(), subject: captured.subject.trim(), thread: captured.threadId || t("emailInboxNewMessage") }),
|
||||
confirmLabel: t("emailInboxSendEmail"),
|
||||
})) || !isCurrentDraft(captured)) return;
|
||||
setComposeBusy(true); setSending(true); setSendError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
const response = await api.post<EmailSendResult>("/email/send", {
|
||||
jobApplicationId: draft.jobApplicationId,
|
||||
provider: draft.provider,
|
||||
clientRequestId: draft.clientRequestId,
|
||||
to: draft.to.trim(),
|
||||
subject: draft.subject.trim(),
|
||||
bodyText: draft.bodyText,
|
||||
threadId: draft.threadId || null,
|
||||
confirmed: true,
|
||||
jobApplicationId: captured.jobApplicationId, provider: captured.provider, clientRequestId: captured.clientRequestId,
|
||||
to: captured.to.trim(), subject: captured.subject.trim(), bodyText: captured.bodyText, threadId: captured.threadId || null, confirmed: true,
|
||||
});
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
setSendResult(response.data);
|
||||
if (response.data.status === "sent") {
|
||||
toast(t("emailInboxSentSaved"), "success");
|
||||
if (draft.id && draft.revision) {
|
||||
toast(latestT.current("emailInboxSentSaved"), "success");
|
||||
if (captured.id && captured.revision) {
|
||||
try {
|
||||
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
|
||||
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
|
||||
await loadStoredDrafts();
|
||||
await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
openDraft({ ...captured, id: undefined, revision: undefined });
|
||||
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
||||
} catch {
|
||||
toast(t("emailInboxSentNewerDraft"), "warning");
|
||||
if (!mounted.current) return;
|
||||
toast(latestT.current("emailInboxSentNewerDraft"), "warning");
|
||||
}
|
||||
if (mounted.current) await loadStoredDrafts(true);
|
||||
}
|
||||
await load();
|
||||
if (mounted.current) await latestLoad.current();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const result = error?.response?.data;
|
||||
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) {
|
||||
setSendResult(result as EmailSendResult);
|
||||
} else if (!error?.response || error.response.status >= 500) {
|
||||
setSendResult({ attemptId: draft.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
||||
} else {
|
||||
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
|
||||
}
|
||||
setSendError(getApiErrorMessage(error, t("emailInboxSendFailed")));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) setSendResult(result as EmailSendResult);
|
||||
else if (!error?.response || error.response.status >= 500 || error.response.status === 409)
|
||||
setSendResult({ attemptId: captured.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
||||
setSendError(readError(error));
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const prepareNewAttempt = async () => {
|
||||
if (!draft || sendResult?.status !== "failed") return;
|
||||
setDraftError(null);
|
||||
if (draft.id && draft.revision) {
|
||||
try {
|
||||
const response = await api.post<StoredEmailDraft>(`/email/drafts/${draft.id}/new-attempt`, { revision: draft.revision });
|
||||
setDraft((current) => current ? { ...current, ...response.data } : null);
|
||||
await loadStoredDrafts();
|
||||
} catch (error: any) {
|
||||
setDraftError(error?.response?.status === 409
|
||||
? t("emailInboxNewAttemptConflict")
|
||||
: getApiErrorMessage(error, t("emailInboxNewAttemptFailed")));
|
||||
return;
|
||||
const captured = draftRef.current;
|
||||
if (!captured || composeOperation.current || sendResult?.status !== "failed") return;
|
||||
composeOperation.current = true;
|
||||
setComposeBusy(true); setDraftError(null);
|
||||
draftPanel.current?.focus({ preventScroll: true });
|
||||
try {
|
||||
if (captured.id && captured.revision) {
|
||||
const response = await api.post<StoredEmailDraft>(`/email/drafts/${captured.id}/new-attempt`, { revision: captured.revision });
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const stored = { ...captured, ...response.data };
|
||||
openDraft({ ...stored, to: captured.to, subject: captured.subject, bodyText: captured.bodyText });
|
||||
draftBaseline.current = stored;
|
||||
setStoredDrafts((current) => [response.data, ...current.filter((item) => item.id !== response.data.id)]);
|
||||
} else {
|
||||
if (!isCurrentDraft(captured)) return;
|
||||
const next = { ...captured, clientRequestId: globalThis.crypto.randomUUID() };
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
}
|
||||
} else {
|
||||
setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null);
|
||||
}
|
||||
setSendResult(null);
|
||||
setSendError(null);
|
||||
setSendResult(null); setSendError(null);
|
||||
if (captured.id) await loadStoredDrafts(true);
|
||||
} catch (error) {
|
||||
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "prepare" });
|
||||
} finally { finishComposeOperation(); }
|
||||
};
|
||||
|
||||
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
||||
@@ -626,9 +679,9 @@ export default function CorrespondenceInboxPage() {
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={composeBusy || jobsLoading || !jobsCurrent || jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -646,17 +699,21 @@ export default function CorrespondenceInboxPage() {
|
||||
</Box>
|
||||
<ViewStateNotice compact loading={providersLoading} title={t("emailInboxProviderStatusUnavailable")} error={providerError ? { ...providerError, message: providerError.message || t("emailInboxProviderStatusUnavailable") } : null} onRetry={() => { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
<Box ref={jobsPanel} role="group" aria-label={t("emailInboxJob")} tabIndex={-1}>
|
||||
<ViewStateNotice compact loading={jobsLoading} title={t("emailInboxJobsLoadFailed")} error={jobsError ? { ...jobsError, message: jobsError.message || t("emailInboxJobsLoadFailed") } : null} onRetry={() => { jobsPanel.current?.focus({ preventScroll: true }); return loadJobs(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobsCurrent && !jobsLoading && !jobsError && !jobQuery.trim() && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</Alert> : null}
|
||||
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||
{draftsReadError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxDraftsLoadFailed")}</Alert> : null}
|
||||
<Box ref={draftsPanel} role="group" aria-label={t("emailInboxSavedDraft")} tabIndex={-1}>
|
||||
<ViewStateNotice compact loading={draftsLoading} title={t("emailInboxDraftsLoadFailed")} error={draftsReadError ? { ...draftsReadError, message: draftsReadError.message || t("emailInboxDraftsLoadFailed") } : null} onRetry={() => { draftsPanel.current?.focus({ preventScroll: true }); return loadStoredDrafts(); }} />
|
||||
</Box>
|
||||
|
||||
{storedDrafts.length > 0 ? (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
|
||||
<Typography variant="body2">{t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })}</Typography>
|
||||
{storedDrafts.slice(0, 5).map((stored) => (
|
||||
<Button key={stored.id} size="small" onClick={() => void resumeDraft(stored)}>
|
||||
<Button key={stored.id} size="small" disabled={composeBusy} onClick={() => void resumeDraft(stored)}>
|
||||
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
|
||||
</Button>
|
||||
))}
|
||||
@@ -665,12 +722,14 @@ export default function CorrespondenceInboxPage() {
|
||||
) : null}
|
||||
|
||||
{composeSetupOpen ? (
|
||||
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
|
||||
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
||||
<Typography id="compose-new-title" component="h3" variant="h6" sx={{ mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||
<Autocomplete
|
||||
options={jobs}
|
||||
options={jobsCurrent ? jobs : []}
|
||||
loading={jobsLoading}
|
||||
disabled={composeBusy}
|
||||
noOptionsText={t("emailInboxNoJobMatches")}
|
||||
value={jobs.find((job) => job.id === composeJobId) ?? null}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === "input" || reason === "clear") setJobQuery(value);
|
||||
@@ -682,13 +741,13 @@ export default function CorrespondenceInboxPage() {
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="compose-provider-label">{t("emailInboxSendingProvider")}</InputLabel>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} disabled={composeBusy} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || t("emailInboxConnectedAccount")}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
||||
<Button disabled={composeBusy} onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void startNewMessage()} disabled={composeBusy || jobsLoading || !!jobsError || !jobsCurrent || !jobs.some((job) => job.id === composeJobId) || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -719,37 +778,38 @@ export default function CorrespondenceInboxPage() {
|
||||
</Box>
|
||||
|
||||
{draft ? (
|
||||
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||
<Paper ref={draftPanel} tabIndex={-1} component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
<Typography id="email-draft-title" component="h3" variant="h6">{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
{draftDirty ? <Chip size="small" variant="outlined" color="warning" label={t("assetsUnsavedChanges")} sx={{ mt: 1 }} /> : null}
|
||||
</Box>
|
||||
<Button onClick={() => void discardDraft()} disabled={sending}>{t("emailInboxDiscardDraft")}</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={composeBusy}>{t("emailInboxDiscardDraft")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
||||
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxThread")} value={draft.threadId || t("emailInboxNewMessage")} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
|
||||
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 998 }} />
|
||||
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={composeBusy || !!sendResult} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1", "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
|
||||
</Box>
|
||||
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>{t("emailInboxSentSuccess")}</Alert> : null}
|
||||
{sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? (
|
||||
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("emailInboxDeliveryUncertain")}</Alert>
|
||||
) : null}
|
||||
{sendResult?.status === "failed" ? (
|
||||
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
||||
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" disabled={composeBusy} onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
||||
{t("emailInboxDeliveryFailed")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftError}</Alert> : null}
|
||||
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : null}
|
||||
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftErrorMessage}</Alert> : null}
|
||||
{sendError && sendResult?.status !== "uncertain" ? <Typography role="alert" variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError.message || t("emailInboxSendFailed")}</Typography> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => void saveDraft()} disabled={savingDraft || sending || !!sendResult}>
|
||||
<Button variant="outlined" onClick={() => void saveDraft()} disabled={composeBusy || !!sendResult}>
|
||||
{savingDraft ? t("saving") : draft.id ? t("profileSaveChanges") : t("emailInboxSaveDraft")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||
<Button variant="contained" onClick={() => void sendDraft()} disabled={composeBusy || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
||||
{sending ? t("emailInboxSending") : t("emailInboxReviewSend")}
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -802,7 +862,7 @@ export default function CorrespondenceInboxPage() {
|
||||
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || t("emailInboxAttachment")} variant="outlined" />)}
|
||||
</Box> : null}
|
||||
{item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? (
|
||||
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
||||
<Button sx={{ mt: 1.5 }} variant="outlined" disabled={composeBusy} onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
||||
) : item.provider && item.provider !== "manual" ? (
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user