Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/architecture/debt-ledger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
# citing its id in the PR. Shape, `status:` values and `root:` linking:
# memory/process/debt-ledger-additions.md.
version: 1
next_id: 65
next_id: 66
recent_sections: [Campaigns, Feedback, Governance]
themes:
- id: grandfathered-hum0024-nav-strip
Expand Down Expand Up @@ -318,6 +318,10 @@ themes:
memory/process/debt-ledger-additions.md. Count re-measured 2026-09-20
across the central ledger plus every src/Sections/*/Docs/debt.yml.
inbox:
- added: 2026-09-22
id: CENTRAL-65
what: "tests/Humans.Feedback.Tests/Architecture/FeedbackArchitectureTests.cs, tests/Humans.Notifications.Tests/NotificationsArchitectureTests.cs and tests/Humans.Issues.Tests/Architecture/IssuesArchitectureTests.cs each carry the comment 'IRepositoryImplementationsAreSealedRule, which sweeps Humans.Infrastructure only'. Both the rule and that assembly are gone — docs/architecture/roslyn-analysis.md records the rule as retired and says not to repoint it, because HUM0034 plus MA0053 make sealing structural. Email's copy was corrected by /section-doctor on Email 2026-09-22; these three are the same false claim in sections that run did not touch."
review: light
- added: 2026-09-21
id: CENTRAL-63
what: "docs/architecture/design-rules.md §8 still describes the pre-cutover Settings world: it says settings_event 'has no readers yet', that every section reads the app-wide values off the Shifts-owned event_settings row via IBurnSettingsService, and that the table is populated by an operator screen at /Settings/Admin/Carry. The cutover (nobodies-collective/Humans#1630, nobodies-collective/Humans#1631) repointed every reader at settings_event, deleted the carry screen and made /Settings/Admin POST-only. The same paragraph's key list also omits Workgroups:RootDriveFolderId, the third system_settings key. Central regulations, not a section file (/section-doctor on Settings 2026-09-21)."
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/Email.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- freshness:triggers
src/Sections/Humans.Email/**
src/Sections/Humans.Users/Views/Profile/Emails.cshtml
src/Sections/Humans.Users/Controllers/ProfileController.cs
src/Sections/Humans.Users/Views/ProfileEmails/Emails.cshtml
src/Sections/Humans.Users/Controllers/ProfileEmailsController.cs
src/Sections/Humans.GoogleIntegration/Services/EmailProvisioningService.cs
src/Sections/Humans.GoogleIntegration/Services/GoogleWorkspaceUserService.cs
src/Sections/Humans.Users/Services/UserEmailService.cs
Expand Down
274 changes: 274 additions & 0 deletions docs/health/runs/2026-09-22-Email.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ namespace Humans.Email.Contracts;
/// immediately.
/// </summary>
/// <remarks>
/// Followed <see cref="ProcessEmailOutboxJob"/> out of <c>Humans.Infrastructure</c> at
/// G5 lane 5b-1 (nobodies-collective/Humans#866): it names the job's concrete type, and
/// Base cannot reference <c>Humans.Email</c> without a cycle. Shell registers it, so it
/// is public under <c>Contracts/</c>; the job it enqueues is public under <c>Jobs/</c>.
/// It names <see cref="ProcessEmailOutboxJob"/>'s concrete type, and Base cannot reference
/// <c>Humans.Email</c> without a cycle. Shell registers it, so it is public under
/// <c>Contracts/</c>; the job it enqueues is public under <c>Jobs/</c>.
/// </remarks>
public sealed class HangfireImmediateOutboxProcessor(IBackgroundJobClient backgroundJobClient)
: IImmediateOutboxProcessor
Expand Down
5 changes: 2 additions & 3 deletions src/Sections/Humans.Email/Data/EmailOutboxRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,14 +258,13 @@ public async Task AddDailySendCountsAsync(
await ctx.SaveChangesAsync(ct);
}

public async Task<IReadOnlyList<EmailOutboxMessage>> GetSentOrFailedSinceAsync(
public async Task<IReadOnlyList<EmailOutboxMessage>> GetSentSinceAsync(
Instant since, CancellationToken ct = default)
{
await using var ctx = await factory.CreateDbContextAsync(ct);
return await ctx.EmailOutboxMessages
.AsNoTracking()
.Where(m => (m.Status == EmailOutboxStatus.Sent && m.SentAt >= since)
|| (m.Status == EmailOutboxStatus.Failed && m.CreatedAt >= since))
.Where(m => m.Status == EmailOutboxStatus.Sent && m.SentAt >= since)
.ToListAsync(ct);
}
}
16 changes: 8 additions & 8 deletions src/Sections/Humans.Email/Data/IEmailOutboxRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,13 @@ Task AddDailySendCountsAsync(
IReadOnlyList<EmailDailySendCount> rows, CancellationToken ct = default);

/// <summary>
/// Returns every <c>Sent</c> row sent at or after <paramref name="since"/>, plus
/// every <c>Failed</c> row created at or after it, read-only — the backfill's
/// source data. <c>Sent</c> rows are filtered by <see cref="EmailOutboxMessage.SentAt"/>
/// so this matches the retention cutoff (which deletes by <c>SentAt</c>), not
/// <see cref="EmailOutboxMessage.CreatedAt"/>. Queued rows are excluded: they
/// were never attempted.
/// </summary>
Task<IReadOnlyList<EmailOutboxMessage>> GetSentOrFailedSinceAsync(
/// Returns every <c>Sent</c> row sent at or after <paramref name="since"/>,
/// read-only — the backfill's source data. Rows are filtered by
/// <see cref="EmailOutboxMessage.SentAt"/> so this matches the retention cutoff
/// (which deletes by <c>SentAt</c>), not <see cref="EmailOutboxMessage.CreatedAt"/>.
/// Failed and queued rows are excluded: the backfill reconstructs deliveries, and
/// a failure day is only ever counted by the live processor.
/// </summary>
Task<IReadOnlyList<EmailOutboxMessage>> GetSentSinceAsync(
Instant since, CancellationToken ct = default);
}
14 changes: 7 additions & 7 deletions src/Sections/Humans.Email/Docs/Email.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Per design-rules §8, each `system_settings` key is owned by its consuming secti
- Every outgoing email queued through `OutboxEmailService` writes a row to `email_outbox_messages` before any transport attempt — the audit trail for delivery. The single exception is `EmailMessage.DoNotPersist`, which hands the message straight to `IEmailTransport` and writes no row; it is set by exactly one template, `account_deleted`, because its recipient is a human the Article 17 cascade has just erased and a row would re-create their address, name and body. Such a message is never retried: retrying would mean keeping the address in order to retry with it.
- `ProcessEmailOutboxJob` (Hangfire recurring, every minute — `*/1 * * * *`) selects rows with `SentAt IS NULL`, `RetryCount < OutboxMaxRetries`, `NextRetryAt <= now` (or null), and `PickedUpAt < now − 5 min` (or null). The batch is bounded by `OutboxBatchSize` and ordered **time-sensitive templates first, then FIFO by `CreatedAt` within each class** — see the priority invariant below. Selected rows are stamped `PickedUpAt = now` to block concurrent runs from picking the same rows, then sent one at a time through `IEmailTransport.SendAsync` with a 1-second throttle delay between successful sends.
- While `IsEmailSendingPaused = "true"`, the job returns immediately — no rows are picked up.
- **Time-sensitive mail jumps the queue.** `GetProcessingBatchAsync` orders rows whose `TemplateName` is one of `email_verification`, `magic_link_login`, `magic_link_signup`, `workspace_credentials` (`TimeSensitiveTemplates.Names`) ahead of every other row, then by `CreatedAt` within each of the two classes. Without this, a magic-link login lands behind whatever bulk mail is already queued and drains at the 1 send/second throttle — the user is locked out until the backlog clears (nobodies-collective/Humans#1122). No column and no schema change back this: it is ordering only, computed DB-side as a `CASE` in the `ORDER BY`. The immediate drain reads the same list, so the two cannot disagree (peterdrier/Humans#1651 removed the separate `EmailMessage.TriggerImmediate` field; the event lifecycle mail, which used to set it on a template that is not on this list, now waits for the batch run like every other non-time-sensitive template).
- **Time-sensitive mail jumps the queue.** `GetProcessingBatchAsync` orders rows whose `TemplateName` is one of `email_verification`, `magic_link_login`, `magic_link_signup`, `workspace_credentials` (`TimeSensitiveTemplates.Names`) ahead of every other row, then by `CreatedAt` within each of the two classes. Without this, a magic-link login lands behind whatever bulk mail is already queued and drains at the 1 send/second throttle — the user is locked out until the backlog clears (nobodies-collective/Humans#1122). No column and no schema change back this: it is ordering only, computed DB-side as a `CASE` in the `ORDER BY`. The immediate drain reads the same list, so the two cannot disagree.
- On success the row becomes `Status = Sent`, `SentAt = now`, `PickedUpAt = null`. On failure the row becomes `Status = Failed`, `RetryCount += 1`, `LastError = ex.Message` (truncated to 4000 chars), `NextRetryAt = now + 2^(RetryCount+1) minutes`, `PickedUpAt = null`. Failed rows with `RetryCount >= OutboxMaxRetries` stop being picked up by future scans (they remain `Status = Failed` forever unless an admin retries or discards them). The job does not distinguish hard vs soft transport failures — every thrown exception increments the retry counter.
- `Status = Sent` / `SentAt` records SMTP-server acceptance, **not** inbox delivery. Bounce processing is out of scope — a message marked `Sent` may still bounce silently at the recipient's mail server. Admins watching the outbox dashboard see SMTP outcomes, not inbox outcomes.
- Admin retry resets a row to `Status = Queued`, `RetryCount = 0`, `LastError = null`, `NextRetryAt = null`, `PickedUpAt = null`.
Expand All @@ -140,7 +140,7 @@ Per design-rules §8, each `system_settings` key is owned by its consuming secti
- **On enqueue of a message whose `TemplateName` is one of `TimeSensitiveTemplates.Names`:** after the row is added, `IImmediateOutboxProcessor.TriggerImmediate()` is called to run the processor without waiting for the next minute tick. Those four names (`email_verification`, `magic_link_login`, `magic_link_signup`, `workspace_credentials`) get both the immediate run and the batch-priority ordering — one list, so the two cannot drift. See the batch-ordering bullet above.
- **On batch pick-up:** rows in the batch are stamped `PickedUpAt = now` (block window 5 minutes).
- **On successful delivery:** `Status = Sent`, `SentAt = now`, `PickedUpAt = null`. If `CampaignGrantId` is set, `ICampaignService.UpdateGrantEmailStatusAsync(grantId, Sent, now)` mirrors the status onto the grant — a failure there is logged and swallowed, never re-tallying the already-sent message as `Failed`. The job then sleeps 1 second before processing the next message.
- **On delivery failure (any thrown exception):** `Status = Failed`, `RetryCount += 1`, `LastError = ex.Message` (truncated to 4000 chars), `NextRetryAt = now + 2^(RetryCount+1) minutes`, `PickedUpAt = null`. If `CampaignGrantId` is set, the grant is mirrored with `Failed`. Once `RetryCount >= OutboxMaxRetries`, the processor query stops returning the row.
- **On delivery failure (any thrown exception):** `Status = Failed`, `RetryCount += 1`, `LastError = ex.Message` (truncated to 4000 chars), `NextRetryAt = now + 2^(RetryCount+1) minutes`, `PickedUpAt = null`. If `CampaignGrantId` is set, the grant is mirrored with `Failed` — a failure there is logged and swallowed too, so it cannot abort the rest of the batch. Once `RetryCount >= OutboxMaxRetries`, the processor query stops returning the row.
- **On admin pause:** `IsEmailSendingPaused = "true"`. (No audit entry is written by the controller today.)
- **On admin resume:** `IsEmailSendingPaused = "false"`. (No audit entry is written by the controller today.)
- **On admin retry of failed message:** row reset to `Status = Queued`, `RetryCount = 0`, `LastError = null`, `NextRetryAt = null`, `PickedUpAt = null`. (No audit entry today.)
Expand All @@ -163,10 +163,10 @@ Per design-rules §8, each `system_settings` key is owned by its consuming secti
**Owned SystemSetting keys:** `IsEmailSendingPaused`
**Status:** (A) Migrated.

- The section lives at `src/Sections/Humans.Email` with its cross-section surface on the `Humans.Email.Contracts` leaf project (nobodies-collective/Humans#866, G5). Everything else — the entity, the repository, the renderer, the body composer, the SMTP transport, the outbox admin surface — is `internal`.
- `IEmailOutboxRepository` (impl `src/Sections/Humans.Email/Data/EmailOutboxRepository.cs`) is the only file that touches `DbContext.EmailOutboxMessages`. `TimeSensitiveTemplates` (`Humans.Email.Contracts`) holds the template names the repository keys its batch priority ordering off, and `OutboxEmailService` keys its immediate drain off the same list. The `IsEmailSendingPaused` row in `system_settings` is no longer read or written here — `EmailOutboxService` reaches it through `ISettingsService` (Settings section owns the table). Registered Singleton via `IDbContextFactory<EmailDbContext>` (peeled out of `HumansDbContext` in #858) so it can be injected into Application services and the recurring job alike.
- The section lives at `src/Sections/Humans.Email` with its cross-section surface on the `Humans.Email.Contracts` leaf project (nobodies-collective/Humans#866, G5). Everything else — the entity, the repository, the body composer, the SMTP transport, the outbox admin surface — is `internal`.
- `IEmailOutboxRepository` (impl `src/Sections/Humans.Email/Data/EmailOutboxRepository.cs`) is the only file that touches `DbContext.EmailOutboxMessages`. `TimeSensitiveTemplates` (`Humans.Email.Contracts`) holds the template names the repository keys its batch priority ordering off, and `OutboxEmailService` keys its immediate drain off the same list. The `IsEmailSendingPaused` row in `system_settings` is no longer read or written here — `EmailOutboxService` reaches it through `ISettingsService` (Settings section owns the table). Registered Singleton via `IDbContextFactory<EmailDbContext>` so it can be injected into services and the recurring job alike.
- **Decorator decision — no caching decorator.** Outbox is a sequential queue drain, not a hot-path read shape.
- **Cross-domain navs stripped:** `EmailOutboxMessage` carries no navigation properties at all — `UserId` and `CampaignGrantId` are bare Guid columns in `EmailOutboxMessageConfiguration` with no FK constraint and no nav (#992 cut the FK, #996 cut the last navs). A stale id is an accepted orphan on this append-only send log, pruned on age by `DeleteSentOlderThanAsync`. User display data resolves via `IUserService`; grant status mirroring goes through `ICampaignService`.
- **Cross-domain navs stripped:** `EmailOutboxMessage` carries no navigation properties at all — `UserId` and `CampaignGrantId` are bare Guid columns in `EmailOutboxMessageConfiguration` with no FK constraint and no nav. A stale id is an accepted orphan on this append-only send log, pruned on age by `DeleteSentOlderThanAsync`. User display data resolves via `IUserService`; grant status mirroring goes through `ICampaignService`.
- **`Humans.Email.Contracts` — everything consumed from outside the section:**
- `IEmailService` + `EmailMessage` — the one transport entry point, consumed from the other section projects that send mail (Auth, Camps, Campaigns, Consent, Events, Feedback, GoogleIntegration, Governance, Issues, Onboarding, Shifts, Surveys, Teams, Tickets, Users) plus the `Humans.Web` shell.
- `IEmailMessageFactory` — one method, `FacilitatedMessage`: the volunteer-to-volunteer relay, the
Expand All @@ -185,8 +185,8 @@ Per design-rules §8, each `system_settings` key is owned by its consuming secti
outbox, creates no outbox row, and deliberately rejects opt-outable categories whose exact
footer depends on recipient-specific send policy.
- `IEmailOutboxServiceRead` + `EmailOutboxMessageDto` — per-human outbox history for Shell's `/Profile/Me/Outbox` and `/Users/Admin/{id}/Outbox`.
- `IEmailOutboxProcessor` / `IEmailOutboxRetention` — what `ProcessEmailOutboxJob` / `CleanupEmailOutboxJob` drive. Both jobs moved out of Base into the section at G5 lane 5b-1 (initially under `Contracts/`, then into their own `Humans.Email/Jobs/` folder at nobodies-collective/Humans#1353's Jobs/ carve-out), so these two have no consumer outside the section any more and could move inward in a later pass.
- `IImmediateOutboxProcessor` — was on the leaf for the opposite reason: Base *implemented* it. `HangfireImmediateOutboxProcessor` followed the job out of Base at the same lane and still lives under `Humans.Email/Contracts/` — the #1353 Jobs/ carve-out is for `IRecurringJob` implementors and `*Job`-named Hangfire jobs, and this type is neither — so both sides are now section-side.
- `IEmailOutboxProcessor` / `IEmailOutboxRetention` — what `ProcessEmailOutboxJob` / `CleanupEmailOutboxJob` drive. Neither has a consumer outside the section any more, so both could move inward in a later pass.
- `IImmediateOutboxProcessor` — implemented by `HangfireImmediateOutboxProcessor` under `Humans.Email/Contracts/` rather than `Jobs/`: that folder is for `IRecurringJob` implementors and `*Job`-named Hangfire jobs, and this type is neither. Both sides sit inside the section.
- **Section-internal abstractions** (`Humans.Email.Services`): `IEmailOutboxService` (admin surface, consumed only by `EmailController`), `EmailMessageFactory` (`IEmailMessageFactory`), `FacilitatedMessagePreviews` (`IEmailPreviewContributor`), `IEmailBodyComposer` / `BrandedEmailBodyComposer`, `IEmailTransport` / `SmtpEmailTransport` / `StubEmailTransport`, `EmailInlineStyler` (static, AngleSharp-based inline-style pass called from `BrandedEmailTemplate.Wrap`), `EmailPreviewService` (`IEmailPreviewServiceRead`), `EmailPreviewController` (`[Authorize]`, not `AdminOnly` — hosts `POST /Email/PreviewMarkdown`).
- **`EmailSettings` stays in `Humans.Base.Configuration` and is bound in Shell**, not in `Section.Register`: Auth's `MagicLinkUrlBuilder`, Profiles' `UnsubscribeTokenProvider`, `SendReConsentReminderJob` and Email's own `SmtpHealthCheck` all read it. It is Base configuration the section is merely named after.
- **`EmailOutboxStatus` stays in `Humans.Base.Enums`.** Campaigns' `CampaignGrant.LatestEmailStatus` and Surveys' `SurveyInvitation` persist it on their own tables, so it is shared Base vocabulary rather than section-internal; its `Enum_EmailOutboxStatus_*` resource keys stay in `SharedResource` with it.
Expand Down
8 changes: 4 additions & 4 deletions src/Sections/Humans.Email/Docs/data-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ Repository: `IEmailOutboxRepository`.

Implements `IEmailService` — the single `SendAsync(EmailMessage)` send
path (the interface collapsed to one method). Cross-section calls via
`IUserEmailService`, `IEmailBodyComposer`, `IImmediateOutboxProcessor`,
`IHumansMetrics`, `ICommunicationPreferenceService`, plus `IClock`. No
`IMemoryCache`.
`IUserEmailService`, `IImmediateOutboxProcessor`, `IHumansMetrics`,
`ICommunicationPreferenceService`, plus `IClock`; `IEmailBodyComposer` is
section-internal. No `IMemoryCache`.

### EmailMessageFactory (Scoped, internal)

Expand All @@ -70,7 +70,7 @@ No repository. Read-only gallery contributor (`IEmailPreviewContributor`,
`Section.cs:52`) — builds the two facilitated-message samples via
`IEmailMessageFactory` for `/Email/EmailPreview`. No DB access, no cache.

### EmailPreviewService (Scoped)
### EmailPreviewService (Singleton)

No repository — side-effect-free preview only, via the same
`IEmailBodyComposer` the outbox uses to render the send body.
Expand Down
Loading
Loading