Skip to content

Add "To:" line and "Send to me" to the shared email composer - #1801

Merged
peterdrier merged 2 commits into
mainfrom
claude/hopeful-albattani-yfsz4k
Sep 22, 2026
Merged

peterdrier merged 2 commits into
mainfrom
claude/hopeful-albattani-yfsz4k

Conversation

@peterdrier

Copy link
Copy Markdown
Owner

What

_EmailComposer gets a read-only "To:" line and a "Send to me" button, so every compose form shows who it reaches and can send a real branded test to the sender.

Why

Recipient counts and send-to-self existed only on some pages and looked different on each. The rule is "same everywhere".

Existing surface checked

  • Extended EmailComposerViewModel / _EmailComposer / _EmailPreviewModal JS. No new component.
  • The new endpoint sits on the existing EmailPreviewController beside PreviewMarkdown.
  • New section-internal ComposerSelfSendService (scoped). EmailPreviewService is a singleton, so it can't take IEmailService/audit. Not a public surface.
  • Reused IUserEmailService.GetNotificationTargetEmailsAsync (same as Surveys' self-send), SanitizedMarkdownRenderer, IEmailService.SendAsync and IAuditLogService.
  • New: AuditAction.EmailComposerSelfTestSent, plus the EmailComposerSelfSend rate-limit policy (registered in Email's Section.cs).

UI changes / screenshots

Check it on the preview deploy. The Widget Gallery entry shows a 42-recipient warning example.

  • "To:" line: a name via <vc:human> (profile message, feedback reply, issue comment to reporter), a summary (camp, rota, team), and/or a count. At 25 or more recipients it turns into alert-warning and the host form asks for confirmation on submit.
  • "Send to me" sits beside Preview. It shows an inline status: queued, rate-limited, no address, or error.

Checklist

  • Section labeled
  • Targeting main on peterdrier/Humans
  • Branched off origin/main
  • Issue refs are qualified
  • EF migrations: none (AuditAction is an enum value only)
  • NuGet packages updated?: no
  • New project rule?: no
  • Reuse-first checked (see above)
  • Build + test pass locally: full solution, 0 failures
  • Nav coverage: no new pages
  • No magic strings
  • Dates/times via NodaTime, icons via Font Awesome 6

Reviewer notes

  • RecipientCount is nullable, not required. The survey invitation builder and the campaign form edit templates. Their audience is chosen later, so they show only a summary. Everything else passes a count.
  • Issues: a handler's comment emails the reporter, so the line shows the reporter. A reporter's comment only notifies handlers in-app, so the line says that.
  • Confirm bypass: the large-send confirm skips submit buttons marked formnovalidate. That covers the team-rotas "refresh recipients" button, which submits the same form.
  • I kept the rota pages' own count/name blocks. The team-rotas sidebar lists names, which the To line doesn't.
  • The self-send is MessageCategory.System, so there is no unsubscribe footer. As the issue accepts, sections that wrap the body in extra content (rota table, survey link) won't show that content.

Closes

Closes #1793

🤖 Generated with Claude Code

https://claude.ai/code/session_01M1CXvfQwWKLEYUcvUHtUf5


Generated by Claude Code

Closes #1793.

- EmailComposerViewModel: RecipientCount/RecipientSummary/RecipientUserId and
  WarnRecipientCount (default 25). Large sends render as a warning and the
  host form's submit asks for confirmation naming the count.
- Send to me: POST /Email/SendMarkdownToSelf (any authenticated human,
  rate-limited 5/min per human) queues one composer_self_test System email
  to the caller's own address and audits EmailComposerSelfTestSent.
- All eight composer call sites and the Widget Gallery pass their audience.
- Strings in all six cultures; Email/AuditLog docs updated; tests cover the
  happy, signed-out and no-address paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1CXvfQwWKLEYUcvUHtUf5
@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown

PR Surface Report

Compared 82f3aea1...697c9579. Scored with reforge 0.29.0.

Summary: 46 changed file(s) | EF migrations: 0 added file(s), max 0/1 per context

Reforge Surface Score

metric base head delta
total 20495 20520 +25
surface 16876 16901 +25
internal complexity 3619 3619 0

Section Deltas

section base head delta
Email 514 535 +21
Base 712 716 +4

Section Size & Complexity Deltas

section LOC delta files delta classes delta cognitive p95 delta
AuditLog +3 0 0 0
Base +23 0 0 0
Email +84 +1 +1 0
Web +2 0 0 0
Corpus +112 +1 +1 0

Corpus is the solution-wide rollup (same source as Corpus Size & Complexity below), not a sum of the section rows above - cognitive p95 in particular doesn't compose across sections.

Rule Deltas

rule base head delta
crossSectionFullService 1736 1752 +16
dtoScalarProperty 1751 1755 +4
diRegistration 2082 2085 +3
crossSectionReadInterface 632 634 +2

Corpus Size & Complexity

metric base head delta
locProd 186439 186551 +112
methods 6769 6771 +2
classes 2936 2937 +1
files 2171 2172 +1

At head: largest class SurveyService, most complex method DevelopmentDashboardSeeder.SeedAsync.

Published Write Surface

14 of 48 sections publish write capability, 23 interfaces (0).

Interface Surface

No new interfaces or interface methods.

Diff Size

bucket added deleted
code 262 10
tests 124 0
docs 16 3
other 84 0

New Files

  • src/Sections/Humans.Email/Services/ComposerSelfSendService.cs
  • tests/Humans.Email.Tests/Controllers/ComposerSelfSendTests.cs

Comment thread src/Sections/Humans.Email/Section.cs
Comment on lines +16 to +44
internal sealed class ComposerSelfSendService(
IUserEmailService userEmailService,
IUserServiceRead userService,
IEmailService emailService,
IAuditLogService audit,
IStringLocalizer<EmailResource> localizer,
ILogger<ComposerSelfSendService> logger)
{
public const string TemplateName = "composer_self_test";

/// <summary>
/// Queues one outbox row to <paramref name="userId"/> and audits it. Returns the address it was
/// queued to, or null when the human has no notification address (nothing is queued).
/// </summary>
public async Task<string?> SendToSelfAsync(
Guid userId, string? subject, string? markdownBody, CancellationToken ct = default)
{
var emails = await userEmailService.GetNotificationTargetEmailsAsync([userId], ct);
if (!emails.TryGetValue(userId, out var email) || string.IsNullOrWhiteSpace(email))
return null;

var user = await userService.GetUserInfoAsync(userId, ct);
var fullSubject = string.IsNullOrWhiteSpace(subject)
? localizer["Email_ComposerSelfTest_DefaultSubject"].Value
: string.Format(CultureInfo.CurrentCulture, localizer["Email_ComposerSelfTest_Subject"].Value, subject.Trim());

await emailService.SendAsync(new EmailMessage(
email, user?.BurnerName, fullSubject, SanitizedMarkdownRenderer.Render(markdownBody),
TemplateName, MessageCategory.System, UserId: userId), ct);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCK — builds a second template directly in the Email crosscut

memory/architecture/email-templates-live-in-sender.md: "Adding or editing an email template: build it in the sending section... never in the Email crosscut... FacilitatedMessage is the one template that stays in Email — a generic person-to-person relay with no section vocabulary... Never add a method to IEmailMessageFactory, and never add an Email_* key to EmailResource.*.resx."

This PR adds Email_ComposerSelfTest_Subject / Email_ComposerSelfTest_DefaultSubject to EmailResource.*.resx (all six cultures) and has ComposerSelfSendService, living in Humans.Email itself, build and send a second self-contained EmailMessage/template (composer_self_test) straight from the crosscut — exactly the pattern the atom says is closed off, with FacilitatedMessage as the sole, deliberately-signed-off exception (#1651).

If the composer's "Send to me" is meant to be a second sanctioned exception (it does have some claim to being generic, composer-widget infrastructure rather than section vocabulary, similar to the existing PreviewMarkdown seam), that needs the same explicit design sign-off FacilitatedMessage got, plus an update to this atom recording the new exception — not a silent second instance. As written, the rule forbids this.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left open for Peter: a design call, not something to decide in a review round. The finding is literally true: email-templates-live-in-sender forbids new Email_* keys in EmailResource and names FacilitatedMessage as the only template that stays in Email. But the "sending section" here is Email itself. The composer widget and EmailPreviewController live there, and the copy is generic ([Test] {subject}) with no section vocabulary, so there is no other owner to move it to. @peterdrier, choose one: (a) sign off on composer_self_test as a second exception and have the atom record it, or (b) rework it, e.g. send without a subject prefix so no new keys are needed.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, a path for send to me is fine as a centralized exception to the rule. otherwise each section would need that, duplicating code.

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Reviewed commit cf3301f. 2 inline finding(s) posted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf3301f29c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Sections/Humans.Email/Section.cs
Comment thread src/Sections/Humans.Email/Services/ComposerSelfSendService.cs
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-22T20:46:48.541294Z cf3301f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

UseRateLimiter ran before UseAuthentication, so partition keys never saw
the signed-in NameIdentifier: the composer's per-human EmailComposerSelfSend
policy collapsed into one site-wide "anonymous" bucket (and the global
limiter keyed every signed-in human by IP). Move it after authentication.

Review-round: 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnqiSqXV7Abo153ieThBq
@peterdrier
peterdrier merged commit 79fc8db into main Sep 22, 2026
6 checks passed
@peterdrier
peterdrier deleted the claude/hopeful-albattani-yfsz4k branch September 22, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add recipient "To:" line and send-to-self to the shared email composer

2 participants