Skip to content

feat(emails): move sender/contact settings into Emails screen via Settings modal (NPPD-1566)#162

Open
kmwilkerson wants to merge 7 commits into
nppd-1525-slice-2b-email-previewfrom
nppd-1566-move-transactional-email-settings-to-emails
Open

feat(emails): move sender/contact settings into Emails screen via Settings modal (NPPD-1566)#162
kmwilkerson wants to merge 7 commits into
nppd-1525-slice-2b-email-previewfrom
nppd-1566-move-transactional-email-settings-to-emails

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented May 29, 2026

Summary

Moves the three transactional-email settings (sender name, sender email, contact email) out of their existing home in Audience > Configuration > Transactional Emails prerequisite card and into the unified Emails screen via a "Settings" button + modal in the chip bar header.

Resolves NPPD-1566.

image

Stacking

⚠️ Stacked on slice 2b (#146) and targets its branch, not main. Review order: #137#144#146 → this.

When NPPD-1538 eventually moves the Emails screen from Newspack > Settings to Audience > Configuration, this PR's frontend changes get relocated naturally as part of that move. No coordination needed between this PR and NPPD-1538's work.

Headline design decision: preserve dynamic-default behavior

The most important thing in this PR isn't moving the fields — it's preserving the semantic contract of how those fields work today.

Today, when the three options are unset in wp_options, the system computes defaults dynamically every time they're read:

  • sender_nameget_bloginfo('name') (the current site title)
  • sender_email_addressno-reply@{current domain}
  • contact_email_addressget_option('admin_email') (the current admin email)

This means a publisher who changes their site title, domain, or admin email gets transactional emails updated automatically. The naive "move the fields to a new UI" approach would silently destroy this behavior — the first time a publisher opens the new modal and clicks Save (even with no edits), the dynamically-derived values would be written to wp_options as static rows. Subsequent site-title or admin-email changes would no longer propagate.

This was caught during smoke testing rather than at design time. The fix is the architectural pattern this PR ships:

GET endpoint returns saved values and derived defaults separately:

{
  "sender_name": "",                              // empty if unset
  "sender_email_address": "",
  "contact_email_address": "",
  "defaults": {
    "sender_name": "My Newsroom",                 // computed each request
    "sender_email_address": "no-reply@example.com",
    "contact_email_address": "admin@example.com"
  }
}

Frontend renders saved values as value= and derived defaults as placeholder= on each TextControl, so publishers visually distinguish "what I've explicitly set" from "what the system is computing."

POST endpoint accepts empty values as "revert to derived default" — empty strings trigger delete_option() on the underlying option row, restoring the dynamic-default behavior. Email-format validation only fires when the value is non-empty.

Result: publishers can hit Save with no edits and nothing breaks. The dynamic-default behavior is preserved by construction.

Helper text on each field reinforces the affordance: "Leave blank to use your site title" / "Leave blank to use no-reply@..." / "Leave blank to use your site's admin email."

Architectural notes

New dedicated REST endpoint rather than reusing the existing audience-management POST. Routes registered on Emails_Section under the same REST_BASE constant that NPPD-1535 (#158) introduces:

  • GET /newspack/v1/wizard/newspack-settings/emails/settings — reads via Reader_Activation::get_setting() (defaults flow through site title, no-reply address, admin email helpers)
  • POST /newspack/v1/wizard/newspack-settings/emails/settings — validates, sanitizes, delegates writes to Reader_Activation::update_setting() (option storage location unchanged at newspack_reader_activation_*)

The dedicated endpoint aligns with the discipline NPPD-1535 established: email-related REST routes belong under newspack-settings/emails/*, not in the donations wizard or the audience-management catchall. Reusing the shared POST would have been the smallest-change choice but would have undone that alignment.

Both this PR and #158 declare REST_BASE independently (slice 2b doesn't have it yet). Whichever PR merges first, the other's REST_BASE declaration collapses cleanly on rebase — the declarations are byte-identical with the same docblock.

Validation

Recon found that the three fields currently have no validation at all — no schema-level sanitize, no sanitize_email(), no format checks. Today the system happily persists "not-an-email" as a sender email address, breaking deliverability silently.

This PR adds light validation as part of the move:

  • Email fields (sender_email_address, contact_email_address): type="email" HTML5 attribute on the frontend + server-side sanitize_email() + is_email() validation. Returns 400 with newspack_invalid_sender_email / newspack_invalid_contact_email on failure. Validation fires only when the value is non-empty (since empty = revert).
  • Client-side gating: Save button is disabled when any non-empty email field fails client-side format validation, preventing the submit-fail-resubmit loop that would otherwise occur on partial values like user@.
  • Sender name: server-side sanitize_text_field(). Empty is valid (triggers revert to derived default); no required HTML5 attribute.

The current absence of validation looks like an oversight given these are email fields; the move is the natural moment to add it.

UI changes

Chip bar layout: wrapped in <HStack justify="space-between">. Chips on the left (existing), Settings button on the right (new). Button is text-only ("Settings"), no icon, variant=secondary.

Settings modal: opened on Settings button click. Matches the pattern established by advanced-settings.tsx in the newsletters/premium-newsletters wizard:

  • Title: "Settings"
  • Header text: "Configure the sender details and reply-to address for transactional emails sent to your readers."
  • Body: three TextControl fields with placeholder= showing the derived defaults
  • Footer: Cancel (tertiary) + Save (primary, disabled when not loaded, fetching, not dirty, or client-side-invalid)
  • Dirty detection via JSON.stringify comparison against initial state
  • Cancel/X/Escape/click-outside when dirty: useConfirmDialog from packages/components/src/hooks/use-confirm-dialog.tsx asks confirmation before discarding
  • Save success: addNotice from the newspack/wizards store with type=success → modal closes
  • Save failure: error surfaced inline in the modal, no close

The toast uses addNotice from the Newspack wizards store (which has its WizardSnackbar already mounted at the Wizard level), not createNotice from @wordpress/notices. Matches the existing convention.

Audience-side cleanup

Removes the Transactional Emails prerequisite card from Reader_Activation::get_prerequisites_status() (lines 946-965 deleted). The three fields' settings keys are independently defined in get_settings_config() and survive — only the prereq card's UI definition goes away.

Dual-surface state (leaving the card in place, both UIs editing the same options) was the alternative and would have been confusing: publishers would have two locations editing the same data with no clear winner.

The newspack_reader_activation_emails_skipped option becomes an orphan after this removal. Harmless — no consumer — but could be cleaned up in a follow-up if anyone cares.

Testing

7 new PHP tests (tests/unit-tests/emails-section.php, Bucket F):

  • test_get_settings_returns_three_fields (happy path with saved values)
  • test_get_settings_returns_empty_values_when_no_override_saved (launch-safety lock-in: fresh-install case returns empty values + populated defaults)
  • test_post_settings_persists_three_fields (happy path: saves all three)
  • test_post_settings_empty_value_deletes_option (revert semantics: empty triggers delete_option)
  • test_post_settings_rejects_invalid_email_in_sender (400 + newspack_invalid_sender_email)
  • test_post_settings_rejects_invalid_email_in_contact (400 + newspack_invalid_contact_email)
  • test_settings_endpoint_permission_check (subscriber-level user blocked, both GET and POST)

4 new JS tests (settings-modal.test.js):

  • test_modal_opens_on_settings_button_click
  • test_modal_save_calls_post_endpoint_and_closes_with_notice
  • test_modal_cancel_prompts_when_dirty
  • test_modal_cancel_no_prompt_when_clean

PHP suite: 1330 → 1337 (+7 tests / +39 assertions, no other counts shifted). JS suite: 213 → 217 (+4 tests / +1 suite, no regressions).

Known pre-existing tsc noise

emails.tsx has 4 pre-existing tsc errors on lines 350, 441, 444, 448 — DataViews v14 type narrowness on the reset action's isDestructive and DataViews component prop type widening. Verified pre-existing baseline (errors present before this PR's changes; this PR adds zero new tsc errors). Worth a small cleanup follow-up; out of scope here.

kmwilkerson and others added 4 commits June 1, 2026 12:48
…PPD-1566)

GET + POST /wizard/newspack-settings/emails/settings carry the three
transactional-email settings (sender_name, sender_email_address,
contact_email_address) under the emails namespace, where they belong
architecturally — independent of the legacy shared audience-management
endpoint. Reads + writes preserve the existing
newspack_reader_activation_* wp_options keys.

GET returns saved values at the top-level keys (empty string when no
override exists) plus a `defaults` sub-array with the derived
fallbacks (blog title, no-reply@yourdomain, admin email). Returning
value + defaults separately — rather than collapsing through
`Reader_Activation::get_setting()` — lets the frontend render saved
overrides as the input's `value` and derived fallbacks as `placeholder`,
which keeps publishers from accidentally locking in dynamic defaults
as static option rows on first save (a real launch-safety concern:
once written, the static row no longer tracks later site-title or
admin-email changes).

Default-source computation is inlined rather than abstracted into
Emails::get_from_*() — those helpers resolve to the *saved value when
one exists*, so calling them for the "default" slot would just return
the saved value when present, defeating the purpose of returning both
separately. The sender-email default mirrors Emails::get_from_email()'s
fallback logic (network_home_url host with `www.` prefix stripped).

POST treats empty values as "revert to derived default" — the
corresponding option row is deleted (not written empty), letting the
dynamic-default behavior re-engage. Format validation on email fields
runs only when value is non-empty; empty sender_name is accepted.

Validation: the legacy audience-management endpoint accepted these
fields without sanitization or format validation. This commit adds
server-side sanitization (sanitize_email + sanitize_text_field) plus
re-validation via is_email() in the handler (sanitize_email leaves
partials like "user@" intact). Error codes
newspack_invalid_sender_email and newspack_invalid_contact_email each
return 400. No newspack_invalid_sender_name code — empty is the
intentional revert path.

POST response mirrors the GET shape via api_get_settings() so the
frontend can confirm the saved state without a second round-trip.

REST_BASE: introduces the same REST_BASE constant pattern NPPD-1535
establishes on its parallel branch. Existing routes left on their
string form here to minimize rebase surface — NPPD-1535's diff
refactors those.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…settings (NPPD-1566)

Wraps the chip-bar row with an HStack (justify="space-between") and
adds a "Settings" button on the right. Clicking the button opens a
new SettingsModal that hosts the three transactional-email settings
(sender_name, sender_email_address, contact_email_address)
previously surfaced inside the Reader Activation "Transactional
Emails" prerequisite card.

Modal pattern mirrors src/wizards/newsletters/views/premium-newsletters/
advanced-settings.tsx:
- Newspack Modal wrapper from packages/components/src
- VStack body, HStack footer (Cancel tertiary + Save primary)
- Dirty-detection via JSON.stringify(current) !== JSON.stringify(initial)
- addNotice( 'Saved.', success ) from the newspack/wizards store
- Save disabled when not loaded, fetching, not dirty, or
  client-side-invalid

Renders saved value as the input's `value` and the derived default as
its `placeholder`. The mount-time GET response carries both at
top-level (saved) and under `defaults` (derived); the frontend keeps
them separated so an empty saved value visually shows the
dynamic-default fallback in lighter placeholder text rather than
populating the input with what would become a static override on
save. Helper text reads "Leave blank to use [the default]" to make
the revert affordance discoverable. No `required` HTML5 attribute on
any field — empty is the intentional revert path, not a validation
error.

Client-side validation gates Save: empty values pass for every
field, but email fields with non-empty malformed input keep Save
disabled. Mirrors the server's contract — prevents firing a POST
that would 400, along with the jarring submit/reject/Notice-flash
cycle that would follow.

Discard-confirm via useConfirmDialog({ when: isDirty }) — Cancel, X,
Escape, and click-outside all route through the same handleClose
that pops the dialog only when there are unsaved changes.

Promise rejection handling: useWizardApiFetch's catchCallback
populates errorMessage state THEN re-throws to preserve async-API
semantics. Without an onError stub + trailing .catch(), the
rejection surfaces as "Uncaught (in promise) WizardApiError" in the
console. Empty onError handlers + .catch() chained on both GET
(mount-time fetch) and POST (save) suppress the unhandled-rejection
noise; the error UX (inline Notice rendering errorMessage from the
hook) is unchanged.

SCSS: the 24px horizontal padding that aligned the chip-bar's left
edge with DataViews' search row moves from .newspack-emails__chips
to the new .newspack-emails__chip-bar HStack wrapper, so the
right-side Settings button gets the same 24px inset from the right.

emails.test.js: stubs the SettingsModal as a no-op render. The grid
tests don't exercise modal behavior, and the modal pulls in
@wordpress/data's useDispatch against the wizards store, which
isn't registered in the test env. Modal coverage lives in its own
test file (added in the test commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three transactional-email settings (sender_name,
sender_email_address, contact_email_address) now live in a dedicated
Settings modal on the Newspack > Settings > Emails screen — opened
via the new "Settings" button on the chip-bar row added in the
previous commit. (NPPD-1538 will later move the Emails screen itself
to Audience > Configuration.)

The underlying wp_options keys are unchanged:
- newspack_reader_activation_sender_name
- newspack_reader_activation_sender_email_address
- newspack_reader_activation_contact_email_address

Reads and writes still flow through Reader_Activation::get_setting()
and update_setting() — those continue to work because the three
fields are independently defined in get_settings_config() (lines
382-384), not derived from the prerequisite entry being removed
here.

Removal verified safe via cascade check:
- All callers of get_prerequisites_status() iterate generically
  (no hardcoded 'emails' lookup)
- is_ras_ready_to_configure() (line 854) calls
  is_transactional_email_configured() inline, not through the
  prereq entry — keep working
- Frontend (src/wizards/audience/) iterates prerequisites via
  Object.keys().map / .every() — no key-specific code
- Zero tests reference the 'emails' prereq

Orphaned option: newspack_reader_activation_emails_skipped (written
by the generic skip mechanism on the now-removed prereq) becomes
unreferenced. Harmless — no consumer reads it. Optional cleanup
follow-up if anyone cares to delete the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tings (NPPD-1566)

PHP — Bucket F (Settings endpoint) in tests/unit-tests/emails-section.php:
1. test_get_settings_returns_three_fields — GET returns saved values
   + a `defaults` sub-array of derived fallbacks
2. test_get_settings_returns_empty_values_when_no_override_saved —
   fresh-install: empty top-level values, populated defaults. Locks
   in the launch-safety story (publishers who've never explicitly
   saved should see empty fields with placeholder hints, not
   auto-derived values that could be locked in on first save).
3. test_post_settings_persists_three_fields — POST writes the three
   newspack_reader_activation_* options and returns refreshed shape
4. test_post_settings_rejects_invalid_email_in_sender — 400 +
   newspack_invalid_sender_email
5. test_post_settings_rejects_invalid_email_in_contact — 400 +
   newspack_invalid_contact_email
6. test_post_settings_empty_value_deletes_option — empty value on
   any field deletes the option row, reverting the field to its
   derived default
7. test_settings_endpoint_permission_check — subscriber hits
   Wizard_Section::api_permissions_check() → WP_Error 403. Both GET
   and POST share the same permission callback.

JS — src/wizards/newspack/views/settings/emails/settings-modal.test.js
(matching the sibling .test.js convention used by emails.test.js and
email-preview.test.js — not .test.tsx, which would trigger strict
tsc type-checking that other .test.js files in this repo escape):
1. test_modal_opens_on_settings_button_click — renders <Emails />,
   clicks Settings, asserts dialog appears
2. saves: POSTs payload, dispatches success notice, closes modal —
   verifies the path, payload, addNotice call shape, and close
3. cancel: prompts for confirmation when fields are dirty —
   useConfirmDialog called with when=true, closeModal NOT invoked
4. cancel: closes directly when fields are clean — useConfirmDialog
   called with when=false, closeModal invoked immediately

Mock surface for the JS file is more elaborate than emails.test.js
because the modal touches WIZARD_STORE_NAMESPACE (mocked away to
avoid evaluating createReduxStore against the @wordpress/data stub)
and TextControl/HStack/VStack from @wordpress/components (mocked as
passthroughs since the real barrel fails to evaluate in this jsdom
env when reached through the modal's import chain). useConfirmDialog
is mocked to capture the `when` value and conditionally invoke the
callback — that lets the cancel tests assert the dirty-vs-clean
branch deterministically. SAMPLE_INITIAL carries a `defaults` block
mirroring the server's response shape; the fetch mock returns
Promise.resolve() so the production-side `.catch()` chaining works
in tests.

Verification:
- PHPCS exit 0, ESLint exit 0
- PHP suite: 1336 / 4282 / 5 skipped
- JS suite: 217 tests / 22 suites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson force-pushed the nppd-1566-move-transactional-email-settings-to-emails branch from 838050e to 402217f Compare June 1, 2026 17:53
kmwilkerson and others added 2 commits June 1, 2026 14:19
CORRECTNESS

1. **sanitize_email args callback silently destroyed validation**
   (class-emails-section.php, post-args sanitize chain). The
   `sender_email_address` / `contact_email_address` args used
   `sanitize_email`, which COLLAPSES partial input like 'user@' or
   'newaddress' to '' BEFORE the handler runs. The handler then took
   the '' === $value branch as an "intentional revert" and
   delete_option()'d the publisher's saved override. The
   newspack_invalid_sender_email / _contact_email error codes were
   unreachable for the typo cases they were meant to catch. Switched
   both fields to `sanitize_text_field` — preserves typo input so the
   handler's is_email() guard rejects with a proper 400.

2. **is_ras_ready_to_configure() and the removed prereq UI
   disagreed** (class-reader-activation.php:854). The function still
   AND'd in is_transactional_email_configured(), which requires
   explicit saves of all three options. With the emails prereq UI
   removed, fresh-install publishers could satisfy every visible
   prereq and find RAS stuck-disabled with no in-flow hint pointing
   at the (no-longer-prereq) Emails settings. Dropped the AND clause:
   the three options have valid derived defaults by construction, so
   requiring explicit saves is no longer the right gate.

3. **'no-reply@' surfaced as a publisher-facing default**
   (class-emails-section.php api_get_settings). When wp_parse_url
   can't extract a host from network_home_url (edge multisite, mal-
   formed siteurl), the cast yields '' and the response carried
   `sender_email_address: 'no-reply@'` — visibly broken. Guard the
   empty-host case: emit '' instead of the bare 'no-reply@'.

4. **update_setting silent failure** (class-emails-section.php
   api_update_settings). The write loop ignored the bool return of
   Reader_Activation::update_setting(), then returned api_get_settings()
   regardless. If the key were ever filtered out of get_settings_config()
   via `newspack_reader_activation_settings_config`, the POST would
   return 200 with the stale value — silent data loss with a success
   notice. Now: convert false return into a visible 500 +
   `newspack_settings_write_failed` error code.

UX / CLEANUP

5. **Modal stale state on reopen** (settings-modal.tsx useEffect).
   Reset settings/initial/defaults to EMPTY_SETTINGS at the start of
   each open so the first paint after reopen renders an empty form
   instead of briefly flashing the previous open's values.

6. **`onClose` prop dead code on Modal** (settings-modal.tsx).
   @wordpress/components Modal accepts onRequestClose, not onClose.
   Removed the onClose pass — code now matches the actual contract.

7. **isClientSideValid useMemo was redundant** (settings-modal.tsx).
   The dep `settings` changes ref on every keystroke; the memo never
   hit. Replaced with a plain expression — same cost, less misleading.

8. **delete_option path bypassed update_setting action hook**
   (class-emails-section.php api_update_settings). When clearing a
   field, the action `newspack_reader_activation_update_setting` no
   longer fired, so external subscribers (audit logs, ESP sync)
   couldn't observe revert events. Now fire the action manually with
   empty value alongside the delete_option.

DESIGN

9. **Inner chips div → HStack** (emails.tsx + emails.scss). Replaced
   `<div className="newspack-emails__chips">` with custom `display:
   flex; gap: 8px` CSS with `<HStack className="newspack-emails__chips"
   spacing={2}>`. WP grid unit 2 = 8px = previous gap. Matches the
   design system's "prefer WP layout components over custom flex CSS
   in admin/wizard context" rule.

10. **Unclassed wrapper div** (settings-modal.tsx). The escape-hatch
    div for the ReactNode/ReactElement TS mismatch now has a BEM
    class `.newspack-emails__settings-modal-wrap` — future CSS can
    target it intentionally rather than accidentally catch it via
    unscoped sibling selectors.

NEW TESTS

- test_post_settings_args_use_text_field_sanitizer_for_emails:
  regression guard that introspects the registered POST route's
  args and asserts sanitize_callback is NOT `sanitize_email`.
- test_get_settings_default_sender_email_has_non_empty_host: when
  the default is emitted, it must look like a real email (no bare
  `no-reply@`).
- test_post_settings_returns_error_when_update_setting_fails: 500 +
  newspack_settings_write_failed when update_setting returns false.
- test_post_settings_empty_value_fires_update_setting_action: the
  revert path fires the action hook for subscriber visibility.

Verification: PHPCS exit 0, ESLint exit 0. PHP 1337 → 1341 (+4
tests / +14 assertions, 0 skipped change). JS 217/217 unchanged.

KNOWN FOLLOW-UPS (not addressed in this commit, separate scope):

- Legacy /wizard/newspack-audience/audience-management POST still
  accepts these three keys with no validation. Defense-in-depth gap;
  add validation at Reader_Activation::update_setting() or at the
  legacy handler in a follow-up PR.
- Default-fallback computation duplicates Emails::get_from_email().
  Extracting Emails::get_default_from_email() is a refactor scope;
  drift-risk only if either site changes the fallback logic.
- @wordpress/components / @wordpress/data hand-rolled mocks
  duplicate across emails.test.js and settings-modal.test.js. A
  shared jest setup file is a tooling investment, out of this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Smoketest regression caught by Katie. Item 4 of the previous review-
fix commit (the update_setting() bool-return check) was treating
WordPress's `update_option()` no-op return as a write failure.

REPRO: type a value, save → 200 OK. Re-open modal, click Save without
changing anything → 500 + newspack_settings_write_failed.

ROOT CAUSE: `update_option()` returns false in two distinct cases:
(a) genuine write failure (DB error, etc.) and (b) no-op (new value
=== existing value). The handler's `! Reader_Activation::update_setting(...)`
check conflated the two. The forced-failure test
(test_post_settings_returns_error_when_update_setting_fails) only
exercised case (a) via a filter that drops the key from
get_settings_config, missing case (b).

FIX: pre-check current value via `get_option()` and `continue` past
unchanged fields before the update_setting() call. Same pattern on
the empty-revert path — if the option row doesn't exist, skip the
delete_option + action hook (which would otherwise fire a phantom
"this changed" event with nothing actually changing).

Bonus correctness: the `newspack_reader_activation_update_setting`
action hook is now semantically honest — "this changed" means it
actually changed, not "this was submitted". Subscribers no longer
get noisy re-save events that don't reflect state changes.

NEW TESTS

- test_post_settings_idempotent_when_value_unchanged: POST twice
  with identical values, assert second POST returns 200 (not 500)
  AND the action hook does NOT fire on the no-op re-save.
- test_post_settings_empty_value_idempotent_when_already_empty:
  POST '' for a field with no existing row, assert no 500 AND the
  action hook does NOT fire AND no spurious option row is created.

Verification: PHPCS exit 0. PHP 1341 → 1343 (+2 tests / +9
assertions, no regressions). JS unchanged.

LESSON FILED FORWARD: when adding error-handling around third-party
return values, test boundary cases (no-op, already-in-target-state)
alongside happy path and forced-failure. update_option() and
delete_option() both return false on no-op — those tests would have
caught this before smoketest if they'd been written with the
review-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kmwilkerson kmwilkerson marked this pull request as ready for review June 1, 2026 21:00
Copilot AI review requested due to automatic review settings June 1, 2026 21:00
@kmwilkerson kmwilkerson requested a review from a team as a code owner June 1, 2026 21:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Moves transactional email sender/contact settings into the unified Newspack > Settings > Emails screen via a new “Settings” modal, backed by a dedicated REST endpoint that preserves the existing “dynamic defaults unless explicitly overridden” behavior.

Changes:

  • Add GET/POST /newspack/v1/wizard/newspack-settings/emails/settings to read/write sender/contact settings while preserving unset-as-dynamic-default semantics.
  • Add a Settings button + modal UI on the Emails screen (value vs placeholder rendering, dirty-confirm flow, client/server validation).
  • Remove the Reader Activation “Transactional Emails” prerequisite card and drop transactional-email configuration as an RA readiness prerequisite; add PHP/Jest coverage for the new flow.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
plugins/newspack-plugin/includes/wizards/newspack/class-emails-section.php Adds REST base constant and registers/implements the new settings endpoint (GET defaults + POST persist/revert semantics).
plugins/newspack-plugin/includes/reader-activation/class-reader-activation.php Removes transactional-email configured gating from RA readiness + removes the prereq card definition.
plugins/newspack-plugin/src/wizards/newspack/views/settings/emails/emails.tsx Adds chip-bar layout updates, Settings button, and mounts the new Settings modal.
plugins/newspack-plugin/src/wizards/newspack/views/settings/emails/emails.scss Styles updated chip bar wrapper to accommodate the Settings button alignment.
plugins/newspack-plugin/src/wizards/newspack/views/settings/emails/emails.test.js Stubs the modal so existing grid tests remain focused and stable.
plugins/newspack-plugin/src/wizards/newspack/views/settings/emails/settings-modal.tsx New modal component: fetches settings, placeholder defaults, validation, dirty confirmation, save UX.
plugins/newspack-plugin/src/wizards/newspack/views/settings/emails/settings-modal.test.js New Jest coverage for modal open/save/cancel-confirm behavior and endpoint wiring.
plugins/newspack-plugin/tests/unit-tests/emails-section.php Adds PHPUnit coverage for GET/POST settings endpoint semantics, validation, permissions, and idempotency.

Comment thread plugins/newspack-plugin/includes/wizards/newspack/class-emails-section.php Outdated
Comment thread plugins/newspack-plugin/includes/wizards/newspack/class-emails-section.php Outdated
…mp (NPPD-1566)

Three Copilot findings on PR #162 + one user-reported UX issue from
smoketest. All small polish; bundled into one commit.

COPILOT #1 — api_get_settings default diverges from
Emails::get_from_email()

A previous review-fix commit guarded the empty-host edge in
api_get_settings: when wp_parse_url(network_home_url()) returns no
host, return '' for sender_email_address default instead of the
visibly-broken 'no-reply@'. The intent was avoiding a publisher-
facing broken placeholder.

Copilot caught the divergence: `Emails::get_from_email()` (the
actual send path) does NOT guard the same case — it still returns
the literal 'no-reply@'. Result: on a misconfigured site, the modal
placeholder would show '' while outbound mail would send from
'no-reply@'. Silent UX gap: publishers see one default and get
another.

Aligned the response to match the send path's literal 'no-reply@'.
The broken-looking placeholder is honest signal that the site's
home URL doesn't resolve; hiding it didn't make the underlying
issue go away. Fixing the misconfigured-host fallback at the send
path is a separate follow-up.

Updated the test: `test_get_settings_default_sender_email_has_non_empty_host`
(which asserted no bare `no-reply@`) renamed to
`test_get_settings_default_sender_email_matches_send_path`, now
asserts the contract directly: `Emails::get_from_email()` ===
defaults.sender_email_address. Added
`test_get_settings_defaults_match_send_path_helpers` for the parallel
contract on sender_name and contact_email_address.

COPILOT #2 — awkward help text "no-reply@ your site domain"

Reads like a formatting mistake. Rewrote to natural prose: "Leave
blank to use a no-reply address at your site's domain." Removes the
literal `@ ` break that read as broken.

COPILOT #3 — stale GET-route inline comment

Said the handler "Reads through `Reader_Activation::get_setting()`",
but the Option-A handler intentionally does NOT call get_setting()
(that would collapse value+default into one resolved string, which
is the conflation the modal's placeholder UX exists to avoid).
Rewrote the comment to describe the actual behavior + explicitly
forbid the regression: future refactors must preserve the
value-vs-placeholder split.

USER — description clamp truncating new emails at 2 lines

PR #155's "Card expiry warning" and slice 2a's "Renewal reminder"
have single-sentence descriptions that exceed the 2-line clamp at
12px in the narrow grid card width. The clamp was set in slice 1
when descriptions were shorter; new descriptions outgrew the budget.
Bumped 2 → 3 lines: fits sentence-length descriptions cleanly while
still bounding card height enough to preserve grid uniformity.

Verification: PHPCS exit 0, ESLint exit 0. PHP 1343 → 1344
(+1 test / +2 assertions; one test renamed/rescoped + one new
test). JS 217/217 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants