Skip to content

feat(newsletters): native block-composer editor with template selection - #1201

Open
dealako wants to merge 3 commits into
mainfrom
feat/LFXV2-2386-newsletter-composer
Open

feat(newsletters): native block-composer editor with template selection#1201
dealako wants to merge 3 commits into
mainfrom
feat/LFXV2-2386-newsletter-composer

Conversation

@dealako

@dealako dealako commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Restores #1133 (reverted from main via #1200 to unblock scheduled releases).

This is a draft — stays off the release path until testing completes on this branch. Content is identical to the original #1133 (cherry-picked, authorship preserved).

Refs: LFXV2-2386

…on (#1133)

* feat(newsletters): add shared layout and manifest types

Add NewsletterLayout, block/brick instances, and template-manifest interfaces, plus an optional body_layout on the newsletter DTOs, for the spec-004 editor.

LFXV2-2377

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): add block-composer editor shell + manifest

First increment of the native-Angular Puck-style block editor: a block-composer component (palette + CDK drag-drop canvas with recursive container nesting) emitting a NewsletterLayout; a block manifest generated from the real templates (scripts/build-newsletter-manifest.mjs -> public/assets/newsletter-block-manifest.json, 16 blocks); a manifest loader service; and a viewable /newsletters/composer-preview route. Additive — the existing content-step/bodyHtml wizard flow is untouched. Phase-1 blocks carry empty content (per-block field editing is a later ticket).

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): add per-block field editing to the composer

Adds block selection + a Fields panel that renders one control per field from the block's manifest schema (text/textarea/richtext via Tiptap/number/array), two-way bound to the block content; edits patch the block immutably and re-emit the layout. Typed the field-schema interfaces and added a humanizeFieldKey util.

LFXV2-2382

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): render composer blocks visually (client-side)

First-pass in-canvas visual rendering: each block renders styled like the real email (gatewaze block styles ported from gatewaze-modules _shared.ts), inside the wrapper chrome, with a floating per-block toolbar (select/drag/remove) and reactive re-render on Fields-panel edits. New newsletter-renderer.service parses + binds the declarative templates (now bundled into the manifest); content injected via bypassSecurityTrustHtml (authenticated author's own content, allowlisted inert HTML subset). Refinements pending: banner image, rich-text body styling, responsive card width.

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): editor rail, Blocks/Fields auto-switch, banner block

Reworks the composer toward Gatewaze-Puck parity: a left icon rail (Blocks/Fields/Outline) where selecting a block auto-switches to the Fields editor with a 'Page > <block>' breadcrumb; an Outline tab; per-block padding/margin spacing controls applied to the rendered preview; and a logo_header banner block (real MLOps logo + brand label). Also fixes rendered-block styling: the preview classes now pierce view encapsulation (:host ::ng-deep) so the [innerHTML]-injected blocks pick up the card border/eyebrow/title/body styles.

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): inline block editing + persistent fields sidebar

Inline-edit blocks directly on the canvas and keep the Blocks library open
while editing:

- contentEditable inline editing with a floating toolbar (B/I/U/link),
  cursor-stable via a per-block render-freeze + commit-on-blur.
- Empty inline-editable fields render a clickable placeholder
  (data-nl-placeholder + a CSS :empty rule) so a freshly-dragged block is
  editable without opening the panel.
- Per-item editing inside each= arrays (e.g. job-of-week): markers carry the
  indexed path (jobs.0.company) and commit writes into the nested array.
- Two-way canvas<->fields-panel sync: an inline edit patches the panel form in
  place (not a rebuild) so the Tiptap rich-editor reflects external changes.
- Move the Fields editor out of the left rail into a persistent right sidebar
  that swaps on selection, so the Blocks library stays open alongside it.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): harden setAtPath paths and inline-link URLs

Address general code-review findings on the inline-editing commit:

- setAtPath rejects reserved path segments (__proto__/constructor/prototype)
  so a crafted field/each key can't pollute Object.prototype.
- setAtPath now creates a missing container as an array when the next segment
  is numeric (was always an object), matching its documented array indexing.
- applyLink gates the entered URL through the shared isValidUrl, blocking
  javascript:/data:/vbscript: schemes from being stored in the richtext.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): validate passthrough URLs, drop template fn calls

Address the full-branch reviewer-trio sweep findings:

- CRITICAL (learnings): the renderer's passthrough() now gates href/src through
  the shared isValidUrl, dropping javascript:/data: values bound from content
  fields. Only the inline-link path was validated before; this output is
  bypassSecurityTrustHtml'd, so Angular's URL sanitizer never runs on it.
- conventions: replace template method calls renderedBlock()/blockSpacingStyle()
  with computed-map reads (renderedBlocks().get(id) / blockSpacingStyles().get(id))
  per the frontend checklist's no-template-functions rule.
- tests: add an isValidUrl unit spec (the shared gate both URL paths rely on).
- remove the dead 'fields' member from NewsletterComposerTab (Fields moved to the
  persistent right sidebar).
- document the deliberate effect() in the fields panel (imperative form/Tiptap
  orchestration, the checklist's allowed exception).

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): wire block composer into creation wizard

Replace the rich-text editor in the wizard Content step with the block
composer (LFXV2-2381), so the ED composes a structured body_layout instead
of raw HTML. body_layout is now the authored source of truth; body_html is
derived server-side (render-on-write) and synced back on save so the preview
drawer and test-send use the authoritative MJML render.

- Add a bodyLayout form control; persist body_layout in create/update and
  restore it when editing a draft.
- Dedup autosave on the serialized body_layout (body_html no longer changes
  until save, so hashing it would suppress composer edits).
- Content-step now hosts the composer and drops the AI generate drawer; AI
  returns in a later phase once it emits blocks rather than HTML.
- Remove the /newsletters/composer-preview dev route and component, plus its
  dedicated e2e (it drove the now-removed preview surface). A focused
  composer-in-wizard e2e lands as a follow-up.

LFXV2-2385

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): guard body_layout save and rendered-html surfaces

Address post-commit review findings on the composer wizard integration.

Critical:
- The BFF newsletter controller hard-required a non-empty body_html on
  create/update, so a composer-only draft (empty body_html, content in
  body_layout) was rejected before reaching the service that renders
  body_layout to body_html. Accept a non-empty body_layout as satisfying
  the body requirement.
- Preview, test-send, and send read body_html, which is server-derived and
  only current after a save round-trip. Gate those surfaces on the rendered
  body_html being present AND the draft not being dirty, so none acts on
  stale or empty HTML (a test email could otherwise go out empty). Seed the
  saved snapshot on draft load so reopened drafts read as clean.

Also:
- bodyFilled accepts raw body_html too, so drafts authored before the
  composer landed stay sendable.
- Associate the composer's Body caption via aria-labelledby.
- Note serializeLayout's stable-key-order assumption.
- Add a wizard-integration e2e (reopen hydrates the composer; adding a
  block persists body_layout on save), restoring composer coverage after
  the dev-surface spec was removed.

LFXV2-2385

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): canonicalize body_layout serialization for dirty check

isDirty and the autosave dedup compare serialized layouts across the
composer-to-server boundary, where key order can differ. Sort object keys
recursively before stringifying so a content-equal layout serializes
identically regardless of key order, removing the unenforced stable-key-order
assumption (a reopened draft could otherwise read dirty and gate preview/send
off until the first autosave). Array order is preserved — block order is
meaningful.

LFXV2-2385

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): gate send on rendered body + cap body_layout size

- canSend now requires bodyRendered() (server-derived body_html present),
  matching canSendTest/canPreview — closes the edge where a save returning
  empty body_html left full-send enabled while the test-send safety net was
  blocked.
- Bound the serialized body_layout size in the BFF create/update validator
  (body_html already had a cap); an empty body_html plus an oversized
  body_layout otherwise bypassed the only body size guard at the proxy.

LFXV2-2385

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): drop tenant-specific defaults from logo_header block

The logo_header platform block shipped a specific project's masthead as its
field defaults: a Customer.io CDN image, an mlops.community link, and an
'MLOps Community x The Linux Foundation' brand label. Because new blocks seed
these scalar defaults into content, every LFX newsletter with a fresh Logo
Header block would carry that third-party asset unless the author overrode it.

Clear the defaults to empty — this is a generic platform block, so the author
supplies the banner, link, and label per edition. The template already guards
each field with if=, so an unfilled block renders nothing. Tenant-specific
assets belong in per-project/template config, not the shared platform palette.

LFXV2-2385

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): load the editor palette from service templates

Switch the block composer's manifest source from the build-time static
asset to the newsletter service's embedded template sets, making the
service the single source of truth for the palette and the render:

- Shared: NewsletterTemplateInfo/NewsletterTemplatesResponse types and a
  NEWSLETTER_DEFAULT_TEMPLATE_KEY constant (the full AAIF set, matching the
  service's render superset).
- BFF: proxy GET .../newsletters/templates and
  .../templates/:templateKey/manifest (client + service + controller +
  routes, static segments registered before /:newsletterUid).
- Frontend: NewsletterManifestService fetches per template key through the
  BFF (per-key cached streams, project uid from ProjectContextService,
  browser-only as before); the composer takes a templateKey input
  defaulting to the shared constant.
- e2e: manifest stub targets the new API URL.

The static asset and its generator script are now unconsumed; removing
them is a follow-up once the service path has soaked.

Depends on the lfx-v2-newsletter-service branch feat/newsletter-embedded-templates
(embedded per-key template sets + manifest endpoints + Heimdall viewer rules).

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): log manifest load failures before fallback

Both failure branches in NewsletterManifestService.load() set the error
signal silently. Log the missing-project-context skip (console.warn) and
the HTTP failure (console.error with the template key) before falling back,
per the frontend checklist's no-silent-catchError rule. Also note that the
wizard flow resolves project context before the composer mounts, so the
no-context branch is a guard, not an expected path.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): editor blocks search + outline reorder, remove AI tab

Editor parity with the current Gatewaze composer:
- Remove the AI copilot tab (not needed for now); drop 'ai' from the
  composer tab type.
- Add a blocks search box that collapses the palette into a flat list
  of blocks matching label or type (Gatewaze block-search parity).
- Make the Outline tab drag-to-reorder top-level blocks (Gatewaze
  DraggableOutline parity — root blocks only; children reorder on the
  canvas as before).

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): lfx-input-text for blocks search, correct outline reorder

Addresses the post-commit reviewer findings on the editor left-panel work:
- The blocks search box now uses lfx-input-text (FormControl-backed,
  mirrored to a signal) instead of a raw <input>, per the frontend
  wrapper convention.
- The outline reorder previously mis-computed the target index for
  downward drags (dropping a block one slot short). The outline now
  renders only top-level blocks as draggable rows with container
  children nested and static, so CDK's indices map 1:1 onto the blocks
  array and the reorder is a plain moveItemInArray.

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): select field type in the block-composer fields panel

Add a 'select' field type so schema-driven blocks can expose a dropdown.
The field's schema 'options' (label/value pairs) drive an lfx-select in
both the top-level and array-item field switches. Gatewaze field-type
parity (Gatewaze has select in its Puck fields).

LFXV2-2382

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): preview viewport switch + light/dark backdrop

Add preview-frame chrome to the block composer (Gatewaze parity):
- A desktop/mobile viewport toggle that constrains the email column to
  682px / 375px, matching Gatewaze's preview widths.
- A light/dark backdrop toggle behind the email card, for previewing how
  the edition reads on each.

LFXV2-2384

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): drop orphaned NewsletterOutlineEntry type

The outline now iterates top-level blocks directly (children nested), so
the flattened-outline computed and its NewsletterOutlineEntry view-model
were removed. This deletes the now-unreferenced shared interface the
full-branch sweep flagged.

LFXV2-2381

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): HTML-source view + email-size indicator in the composer

Two more editor-parity items (Gatewaze parity):
- An HTML-source toggle that swaps the live canvas for a read-only view
  of the full rendered email HTML (assembled the same way a send is,
  via renderNewsletter with editMode off).
- A live email-size indicator below the preview showing the rendered
  byte size, warning as it approaches and passes Gmail's ~102 KB
  clipping limit (warn at 90 KB, clip at 102 KB).

LFXV2-2384

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): guard email-size indicator while the manifest loads

The size indicator showed a misleading 0.0 KB before the manifest
loaded (fullHtml is empty until then). Show a placeholder while loading,
per the frontend loading-state convention.

LFXV2-2384

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): inline-sync, single toolbar, collapsible panels

Three block-composer editor fixes for LFXV2-2386:

- Inline edits now commit on every `input`, not just on blur, so the
  Fields panel mirrors typing in the live preview instantly (the
  panel->canvas direction already updated live). Safe under the caret:
  the edited block stays frozen, so its re-render reuses cached HTML
  while the separate sidebar form patches its controls in place.

- Removes the duplicate block toolbar. The floating dark action bar
  (label + duplicate / delete / rich-text controls) is the single
  selected-block chrome; the white per-block chip is reduced to just
  the CDK drag handle, which must live inside the cdkDrag element.

- Side panels collapse to widen the squeezed center preview: a second
  click on the active rail tab hides the Blocks/Outline panel (the
  icon rail stays), and the Fields sidebar has a minimize control that
  shrinks it to a thin re-open strip.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): drop hover-only tooltips on the drag-handle icons

The drag-handle icons are non-focusable `<i>` hosts, so a `title`
tooltip is unreachable by keyboard. Each handle already carries an
`aria-label` for its accessible name and a grip is self-evident, so
the redundant `title` is removed rather than adding tabindex/role to a
control CDK doesn't make keyboard-operable here.

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): block-library picker in the composer

Adds a "Library" selector to the composer's Blocks panel so an author
can choose which embedded template set (block library) drives the
palette and the emitted layout, for LFXV2-2386.

- NewsletterLayout gains an optional `template_key`, emitted in the
  layout and re-seeded on draft reopen so a newsletter remembers its
  library. Optional for back-compat; consumers fall back to the default.
- NewsletterManifestService.loadTemplates() fetches the library catalog
  (GET .../newsletters/templates) with graceful degradation: on an
  empty/absent catalog the picker synthesizes a single entry for the
  active key, so it always renders the current library.
- Switching libraries confirms first and clears the canvas, since block
  types can differ between sets and the renderer hard-fails on an
  unknown type; cancelling reverts the picker.

Note: a real multi-library experience still needs upstream
newsletter-service work — today the service embeds a single set and
renders off wrapper_key only (template_key is not yet honored server
side), so the picker currently offers one working library.

Signed-off-by: Dan Baker <me@danb.co>

* test(newsletters): composer coverage for picker, collapse, and fields

- Fixes the active-tab regression: the add-block test clicked the
  already-active Blocks tab, which now toggles the panel collapsed and
  hid the palette. It asserts palette visibility instead.
- Adds a template-catalog stub (two libraries) alongside the manifest
  stub so the picker is deterministic.
- Covers the new behaviors: rail-tab collapse/re-open, Fields sidebar
  minimize/restore, and library-switch clearing the canvas after
  confirmation.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): container-draft data loss, cached library switch-back, array track

Addresses full-branch review findings on the composer work:

- Critical: `hydrate()` decided container-ness from the manifest, but it
  runs in ngOnInit before the manifest resolves, so a reopened container
  draft hydrated as a leaf and dropped its nested children on the next
  save. Container-ness is now derived from the persisted `blocks` array,
  independent of manifest timing. Adds an e2e regression test.

- Important: the manifest signal wasn't refreshed when switching BACK to
  an already-loaded library — the signal write sat upstream of
  `shareReplay`, so a cached replay skipped it, leaving the palette on
  the wrong library. Moved the write downstream of `shareReplay`.

- Important: the fields panel's array-item `@for` tracked by `$index`;
  removing a mid-list item then rebound the rich-editor to the wrong
  item. Tracks by the item's stable FormGroup identity instead.

- Moves the composer's form declarations into the documented Forms slot.

Signed-off-by: Dan Baker <me@danb.co>

* fix(newsletters): stop emitting template_key so saves don't 400

The upstream newsletter-service decodes body_layout with
DisallowUnknownFields and its struct is { wrapper_key, blocks } only,
so the composer emitting template_key made every draft save and send
fail with `400 unknown field "template_key"`.

Drop template_key from the emitted layout; the library picker keeps the
selected set as client-side session state. The interface field stays as
a documented reservation for when upstream accepts a per-newsletter
template_key.

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): explain why a draft can't be saved yet

The Save-as-Draft button was silently disabled until every requirement
was met, giving no feedback. It's now clickable, and a click while
incomplete shows a toast naming exactly what's still missing (audience,
subject, content, or reply-to email). The button still shows its
loading state while a save is in flight.

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): toggle between the block composer and a simple editor

Re-introduces the pre-composer body editor (Tiptap rich-text + AI
generation) alongside the block composer, switchable per newsletter
from the Content step. Requested so authors who prefer the simpler
body-field + AI flow aren't forced into blocks.

- Content step hosts both editors behind a Blocks/Simple toggle. The
  two are mutually exclusive (blocks author body_layout, simple authors
  body_html); switching clears the other representation so only one
  body source is ever authoritative, with a confirm when the outgoing
  editor holds content. Mode is inferred on load from the saved draft.
- Re-wires the AI generate drawer + onGenerated handler into the manage
  component (context inputs, generated output).
- Autosave now tracks body_html as well as body_layout so simple-editor
  edits persist. The snapshot's body_html is taken from the save
  RESPONSE (the server-rendered value in blocks mode), so the post-save
  state compares equal and never re-triggers.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): editor-toggle data loss, stale gates, and mid-edit revert

Addresses the reviewer trio's findings on the Blocks/Simple toggle:

- Critical: switching Blocks->Simple read `initialLayout()`, a computed
  frozen at the draft's initial layout (the FormGroup identity never
  changes), so blocks built in-session counted as zero — the discard
  confirm was skipped and the work was silently cleared. setMode now
  reads the live control, and initialLayout re-reads it on toggle via a
  version bump, so the composer re-seeds from the real current layout
  (fixing the symmetric stale-resurrection on switch back).

- body_html is now mirrored from the form control in initFormMirrors, so
  the simple editor's live typing registers as content/dirtiness (it was
  only set from server responses, leaving canSend/canSaveDraft stale
  until an autosave round-trip).

- syncDerivedBodyHtml no longer overwrites body_html on simple-mode saves
  (the response only echoes the request-time value, so it reverted
  keystrokes typed during an in-flight save); it now runs only when the
  save rendered a layout.

- generateDrawerVisible is a plain signal (internal-only), not model().

Signed-off-by: Dan Baker <me@danb.co>

* test(newsletters): editor-toggle discards blocks only after confirming

Regression test for the frozen initial-layout bug: opens a draft with
no saved body (empty initial layout), builds a block in-session, then
switches to the simple editor and asserts the discard confirm appears
before the blocks are cleared — the exact case a frozen initial-layout
read would silently drop.

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): persist the selected block library via template_key

Now that the newsletter-service accepts and renders from a per-newsletter
template_key (LFXV2-2747), the composer emits the selected library in the
saved layout, so the email renders from that library server-side and a
reopened draft returns to the same library. An empty/omitted key still
renders from the default library.

This reverses the earlier client-only stopgap (template_key was stripped
from the payload to avoid the upstream DisallowUnknownFields 400); the
service now treats it as a known field.

Requires the newsletter-service template_key change to be deployed first.

Signed-off-by: Dan Baker <me@danb.co>

* test(newsletters): assert the emitted template_key equals the default

Strengthen the persist assertion (all three reviewers noted it): check
the emitted template_key equals NEWSLETTER_DEFAULT_TEMPLATE_KEY rather
than just truthy, so it guards against emitting the wrong library, not
only against the field being absent.

Signed-off-by: Dan Baker <me@danb.co>

* feat(newsletters): surface the template picker and show it on review

The block-library picker lived inside the composer's collapsible Blocks
panel, so it vanished when the panel was collapsed or the Outline tab was
active — authors couldn't find how to choose a template.

- Move the picker to an always-visible bar at the top of the editor
  (outside the collapsible panel) and relabel it "Template". Selection
  logic is unchanged; only the markup moved.
- Show the selected template's name on the review screen's Content card
  (blocks-mode drafts only; html-only drafts have no template).

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): show the review template name from the catalog label

All three reviewers flagged that the review screen humanized template_key
while the composer picker shows the catalog's curated label, so the two
could differ if the backend returns a custom label. The review now looks
the label up from the same catalog (NewsletterManifestService.templates),
humanizing the key only as a fallback when the catalog hasn't loaded.

Signed-off-by: Dan Baker <me@danb.co>

* fix(newsletters): keep supported blocks when switching templates

Switching the template previously wiped the whole canvas, and an empty
newsletter can't be saved — so a switch never persisted and the preview
kept showing the old template. Authors couldn't actually change template.

Now a switch loads the new library's manifest and keeps the blocks it
supports (they re-render in the new template's styling), dropping only
the blocks the new library doesn't define, with a note of how many were
removed. Compatible content carries over and autosaves, so the preview
reflects the chosen template. Updates the e2e to assert keep + drop
(a key-specific mock manifest makes one block incompatible).

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): guard template switch against a failed manifest load

The general reviewer flagged that retainSupportedBlocks read the shared
manifest signal, which stays on the OLD library when the new library's
manifest fails to load (ensureLoaded emits null, the service tap skips
the signal on null). The canvas would then filter against the stale
library, drop nothing, and silently autosave orphaned blocks under the
new template_key — the 422 case this is meant to prevent.

- onLibraryChange now uses the manifest value the stream emits and bails
  on null: it reverts the picker to the current library, alerts the
  author, and emits nothing. selectedTemplateKey is only set on success.
- retainSupportedBlocks takes the manifest as a parameter (no shared-
  signal re-read), which also closes the rapid-successive-switch race.
- Moves the new private helpers into the Private Helpers section per
  component-organization §4.

Signed-off-by: Dan Baker <me@danb.co>

* fix(newsletters): let blocks nest into container drop zones

Dragging a palette block onto a container's "nest" zone dropped it at the
top level instead of inside the container. CDK routes a drop to the first
connected drop list whose rect contains the pointer, and the canvas rect
encloses every (nested) container — so with the canvas listed first in
paletteConnectedTo / containerConnectedTo it always won, swallowing drops
meant for a container.

Order the container lists BEFORE the canvas in both, so a drop over a
container's nest zone matches the container first and the canvas is only
the fallback for drops outside every container.

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): sanitize richtext and clear branch-sweep findings

Address the pre-PR full-branch review sweep:

- Sanitize the richtext field through DomSanitizer before it enters the
  bypassSecurityTrustHtml'd preview string. body_layout is server-loaded
  and shared across a project's privileged personas, so an unsanitized
  richtext field was a stored cross-user XSS vector, not just self-XSS.
- Remove the unreferenced generated newsletter-block-manifest.json asset
  (the manifest is fetched from the API at runtime); the
  `yarn newsletter:manifest` generator stays for local use.
- Precompute per-container drop-list wiring into a signal map and replace
  the isActiveTab / isSelected / containerConnectedTo / containerListId
  template method calls with signal reads - satisfies the no-template-
  functions rule and stops a fresh connected-to array allocating on every
  change-detection pass.
- Add takeUntilDestroyed to the composer's manifest-load subscribes
  (loadTemplates + both ensureLoaded) for consistent lifecycle management.
- Mark the block-fields effect() as a sanctioned exception to the
  no-effect() rule (imperative FormGroup/Tiptap orchestration a derived
  pipe can't express).
- Move newsletter-manage's private helpers below the protected methods to
  satisfy member-ordering.
- Normalize pre-existing prettier drift the sweep surfaced (mostly
  composer template re-indentation).

The test-coverage finding is not actioned: apps/lfx-one has no unit-test
harness (no angular.json test target, zero app specs - the app is e2e-only
via Playwright), so a lone Karma spec would be dead code. The richtext
sanitization leans on Angular's DomSanitizer.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): address PR bot findings (correctness, test-send, email size, a11y)

Post-open bot review (Copilot / CodeRabbit / cursor / CodeQL) on #1133:

Correctness:
- body_layout tri-state: emit explicit null (not undefined) when a blocks
  draft switches to the simple editor, so the service clears the stored
  layout instead of preserving it. Shared DTO widened to accept null.
- Library-switch race: a monotonic token drops a stale manifest response so a
  slow earlier switch can't overwrite the palette/canvas/key; a failed switch
  re-activates the current library and clears the "could not load" error
  (a cache-hit now resets the manifest error signal).
- Reconcile empty containers after the manifest loads (an empty container
  returns without a blocks array and hydrates before the manifest, so it would
  otherwise render without its drop zone).
- Review step counts body_layout blocks in hasBody, so a blocks draft isn't
  shown as empty before render-on-write syncs body_html.

Test-send:
- Send is_layout: true for block-composer drafts so the service dispatches the
  emitter email as-is instead of double-wrapping it in the legacy chrome (the
  upstream TestSend already honors the flag).

Email size + source:
- The email-size indicator and HTML-source view now use the server MJML render
  (new render-preview BFF proxy to the service's render-preview endpoint), so
  they reflect the ACTUAL sent email, not the client preview estimate.
  Debounced; falls back to the client render until the first response.

A11y + housekeeping:
- Associate block-fields labels with their controls (role=group +
  aria-labelledby; ariaLabelledBy on selects; wrapping labels on spacing).
- Loop the manifest comment-strip until stable (CodeQL: incomplete
  multi-character sanitization).
- Refresh the stale NEWSLETTER_DEFAULT_TEMPLATE_KEY comment.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): coderabbit nits — scss empty comments, array-item test ids

- Give the two empty `//` separator lines in the composer stylesheet content
  (scss/comment-no-empty).
- Use the aliased array-item index (not the shadowed nested-field $index) in
  the nested field dataTest ids so they stay unique across array items.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): address second-wave PR bot findings

Deeper Copilot / CodeRabbit / cursor pass on the composer:

Correctness:
- Reject a present-but-empty body_layout: bodyFilled branches on layout
  presence (an empty layout is not content), validateCommonPayload rejects a
  present-but-empty layout, and the simple-body check strips markup - so a
  0-block draft can't autosave/persist a wrapper-only email.
- Clear body_html on the blocks->simple switch: it held the complete emitter
  document, which the send path would otherwise double-wrap in legacy chrome.
- Library-switch race, service level: a stale manifest response can no longer
  overwrite the shared manifestSignal/loading/error - gated on the latest
  requested key (the component seq token only guarded local actions).
- Send body_layout on test-send so the service recompiles with the unsubscribe
  footer suppressed (no dangling empty Unsubscribe row).

UX / a11y:
- Show per-block spacing controls only for top-level blocks (upstream applies
  spacing there only; child spacing would show in canvas but drop from email).
- canPreview / canSendTest no longer require a save in the simple editor (both
  read the live form body_html); the real send stays strict.
- Keyboard-accessible reorder: focusable move up/down controls in the Outline
  (drag was pointer-only).

Housekeeping:
- Log render-preview size as UTF-8 bytes (Buffer.byteLength).
- Remove the obsolete newsletter-manifest generator + package script.

Documented follow-up (not fixed): a container's live child drop zone renders
below the container chrome rather than at a deeply-nested template slot. The
SENT email renders children at the slot correctly - a canvas placement
discrepancy only; a correct fix needs a fragile slot-split of the container
render.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): blocks-mode gate on layout presence + reconcile emits

Two follow-up findings from the re-review:
- isBlocksMode keys on layout PRESENCE, not block count: a present-but-empty
  layout (every block removed) is still blocks mode, so the preview/test gate
  keeps requiring a clean snapshot instead of treating it as simple HTML and
  showing stale server-rendered body_html.
- reconcileContainers now emit()s after promoting a seeded block to a container,
  so the parent form layout matches the canvas. Empty-container blocks are
  omitted from the emitted layout (toLayoutBlock), matching the service's
  omitempty round-trip, so this emit produces a layout identical to the seed and
  does not spuriously mark the draft dirty.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): third-wave regressions from the layout-authoritative fixes

- Manifest service: clear loadingSignal on a cache-hit too, so switching to a
  cached library while another fetch is in flight no longer leaves the palette
  stuck on "Loading blocks…" (the superseded finalize is gated out by isLatest).
- Review step: hasBody is now layout-authoritative (a present layout counts only
  with blocks), and templateLabel falls back to the default library when a blocks
  draft has a layout but no template_key (was hiding the Template row).
- Content-step: infer Blocks mode from layout PRESENCE, not block count, so a
  saved blocks draft with its last block removed doesn't re-open as Simple.
- Manage: syncDerivedBodyHtml skips the write when the form layout is now null
  (author switched to Simple mid-save), so a late blocks-save response can't
  clobber the freshly-cleared body_html.
- Canvas: container children no longer render outer spacing (upstream applies
  _spacing_* only to top-level; the Fields panel already hides child spacing).

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): gate send on bodyFilled + refresh stale composer doc

- canSend / canSendTest now also require bodyFilled, not just bodyRendered: the
  latter is a trim() check, so a markup-only simple draft (e.g. an empty
  rich-text "<p></p>") passed it; bodyFilled strips markup, so a visually-empty
  body can't be sent or test-sent.
- Refresh the composer class doc: per-field editing (schema-driven Fields panel
  + inline canvas editing) is implemented — the "Phase-1: empty content, later
  ticket" note was stale.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* docs(newsletters): correct stale build-time-manifest comments

Manifests are fetched at runtime from the newsletter service per selected block
library — the build-time generator was removed. Update the composer class doc,
the templateKey input doc (a saved layout's template_key takes precedence; this
is the initial/fallback key), and the NewsletterTemplateManifest type doc.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): sanitize pasted HTML in inline richtext editing

commitInlineEdit read a richtext contenteditable's innerHTML straight into
block.content with no sanitization at commit, and the renderer's sanitizer only
runs on the next re-render (frozen while editing) — so pasted markup was parsed
into the live DOM, executing embedded handlers like <img onerror> in the
author's own session before anything sanitized it. Narrow (self-XSS, author's
session only; other viewers load sanitized renderer output), but avoidable.

Add a paste handler in wireEditable: richtext fields insert the pasted HTML run
through the same Angular DomSanitizer the renderer uses; plain-text fields
insert text only, so no pasted markup is parsed into the DOM at all. Mirrors the
defense-in-depth the Tiptap editor already applies elsewhere in the composer.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): clear inline-edit freeze when the edited block is deleted

Removing a block cleared selection and the floating toolbar but never reset
editingBlockId. If the block was deleted while one of its contenteditable fields
still had focus, blur may not fire, leaving a stale inline-edit freeze id that
keeps re-renders frozen until another field is focused. clearSelectionIfRemoved
(the shared hook for both the toolbar delete and child removal) now also clears
editingBlockId and the richtext toolbar flag when the edited block is removed.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): persist emptied block canvas; sanitize dropped HTML

Two cursor findings:

- Cannot persist a cleared canvas: bodyFilled counts a present layout only when
  blocks.length > 0, and the save gates (canSaveDraft, hasAnythingToSave) reused
  it — so after removing every block the emptied layout never reached the server
  and reverted on reload. Add bodyPersistable (a present layout counts even with
  zero blocks; else fall back to body_html content) and use it in the save gates.
  Send gates keep bodyFilled, so an empty layout can be saved but not sent.

- Inline richtext drop bypassed the sanitizer: the paste handler sanitized
  clipboard HTML, but dropped HTML was inserted raw and copied into body_layout.
  Route paste and drop through a shared insertSanitizedTransfer so both sanitize
  before anything reaches the contenteditable DOM (drag-drop is the same
  untrusted-HTML vector as paste).

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): keyless layout stays keyless; manifest race; interface docs

Four review findings on the newsletter editor:

- Keyless layout rebranded to the default library: toLayout always emitted
  template_key = selectedTemplateKey, so a keyless legacy (or new) layout was
  stamped with the default palette's key (aaif-user-community) and permanently
  rebranded with that chrome. Track hasExplicitTemplateKey (the saved layout had
  a key, or the author picked one) and emit template_key only when explicit; the
  default palette still drives editing, but a keyless layout persists keyless and
  the service renders it with neutral chrome over the block superset.

- Manifest loader race: the cached branch cleared loadingSignal even when the
  cached stream was still in flight, so a rapid A->B->C->B switch could show a
  stale palette as ready and allow adding incompatible blocks. Track resolvedKeys
  and only clear loading for a resolved key; a still-pending cached key keeps the
  loading state until its shared stream lands.

- Shared interfaces: mark NewsletterTestSendPayload.is_layout @deprecated/ignored
  (body_layout is the sole layout trigger upstream), and document that an omitted
  NewsletterLayout.template_key renders neutral chrome over the superset, not a
  specific library's branding.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): align draft hint + wizard e2e with keyless/save behavior

Follow-ups to the previous review commit:

- missingDraftRequirements used bodyFilled to warn about missing content, but
  canSaveDraft now gates on bodyPersistable — so an author with an emptied block
  layout (saveable) but a missing audience was wrongly told to add content. Use
  bodyPersistable so the warning matches the save gate.

- The wizard e2e asserted a keyless draft emits the default template_key; the
  composer now keeps a keyless layout keyless (omits template_key). Update the
  assertion to expect no template_key and drop the now-unused constant import.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): allow saving an emptied block layout (blocks: [])

The frontend now persists an emptied canvas (bodyPersistable), but the BFF's
validateCommonPayload rejected any present-but-empty layout with 400 — so the
save failed and the emptied draft reverted to its old content on reload. Relax
draft validation to require a present layout be STRUCTURALLY valid (blocks is an
array) while permitting blocks: []; a wrapper-only layout renders fine upstream
and is a valid draft state. The non-empty requirement stays on the send gate
(canSend / bodyFilled), not draft validation — so an empty newsletter can be
saved but not sent. validateCommonPayload gates create/update only; send does
not use it.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): escape paste/drop text; layout-aware size caps; neutral label

Four review findings:

- Security: insertSanitizedTransfer passed the text/plain fallback (and the
  sanitizer-returned-null case) straight to insertHTML, so a plain-text
  <img onerror=...> payload was parsed into the live contenteditable DOM,
  defeating the sanitization. Only sanitized HTML now goes to insertHTML; all
  plain text goes through insertText (inserted as text, never markup).

- Draft autosave: the 100k body_html cap was applied in blocks mode too, but
  upstream ignores request body_html and re-derives it from body_layout — so a
  large composed email the composer only WARNS about (the ~102 KB Gmail
  threshold) failed to autosave. Cap body_html only on the HTML-only path.

- Test-send: same 100k body_html cap blocked test-sending a large layout. Make
  validateTestSendPayload layout-aware — require/cap body_html only when there's
  no valid layout; size-cap the layout itself otherwise.

- Review screen: a keyless layout was labelled with the default library's name
  ("AAIF User Community"), misstating what is sent (keyless renders neutral).
  Show "Default (neutral)" for a keyless layout.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* style(newsletters): prettier-format test-send validation

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): enforce container allowed_block_types; rebuild fields on schema swap

Three review findings on the composer:

- Dropping a block into a container (both palette drop-in and existing-block
  transfer) only rejected nested containers, ignoring the container's
  allowed_block_types allowlist — so an unsupported leaf could be placed into a
  restricted container, producing a layout the selected library can't render.
  Add childAllowedInContainer and gate both drop paths on it.

- The block-fields panel rebuilt its FormGroup only when the selected block ID
  changed. Switching libraries keeps the same block ID but swaps the block's
  manifest schema, so the form kept the previous library's controls (stale
  added/removed fields). Track a field-set signature (key+type) and rebuild when
  it changes even if the ID does not.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): sync emptied-layout body_html; clear stale manifest on load

Two more review findings, both fallout from the empty-canvas and manifest work:

- syncDerivedBodyHtml keyed on non-empty blocks, so after clearing the canvas
  and saving, the emptied-but-present layout skipped syncing its server-rendered
  (wrapper-only) body_html — the form kept the old HTML and the draft stayed
  dirty with preview disabled until reload. Detect layout PRESENCE instead.

- The manifest loader left the previous library's manifest published while the
  latest uncached load was in flight, and the failure path only sets errorSignal
  — so renderers/field lookups kept using the prior library, breaking the 'null
  on failure' contract. Clear the active manifest when starting the latest
  uncached load (gated on isLatest).

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): send ed_reply_email on layout test-send; ack save during autosave

Two review findings:

- The layout test-send payload omitted ed_reply_email, so a layout test email
  lost the reply-to header and the wrapper's 'To reply, email …' row that the
  real send includes (the service recompiles the wrapper from it). Add
  ed_reply_email to NewsletterTestSendPayload and the composer's test-send call.

- Clicking Save during a background autosave was silently ignored: the button's
  spinner binds to manualSaving (manual saves only), so during an autosave it
  looks idle yet the click does nothing. Acknowledge the click with a 'Saving…'
  info message instead of dropping it silently.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* test(newsletters): cover legacy html-only draft opening in simple editor

Backward-compat regression guard for pre-blocks newsletters (raised in review):
a draft authored before the block composer has body_html and no body_layout. The
test seeds such a draft and asserts it reopens in the SIMPLE editor (rich-text
visible, blocks composer absent, Simple toggle active), the authored body is
present, and a save round-trips as html-only (PUT carries body_html with a null
body_layout, so the service takes the legacy chrome path).

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

* fix(review): validate ed_reply_email on test-send; address round-4 findings

Round-4 review follow-ups (David + Cursor/Copilot):

- validateTestSendPayload now requires a valid ed_reply_email (non-empty, '@'),
  matching validateCommonPayload — the client sends it on every test send and a
  layout test recompiles the wrapper reply-to row from it (David, blocking).
- renderPreview: moved requireProjectUid/startOperation inside the try so an
  Express-4 synchronous throw reaches next(error) instead of hanging.
- Preview renderer: allow mailto: for href (src stays http(s)-only) so the
  wrapper's mailto:{{edition.reply_email}} link is not dropped from the preview.
- Content step: entering Blocks from a null layout seeds a non-null empty layout
  so a confirmed discard of a simple body is persistable and does not revert on
  reload.
- Dropped the deprecated, upstream-ignored is_layout flag from the test-send
  payload and corrected the comment (body_layout is the sole trigger).
- Legacy-draft spec: assert body_layout toBeNull (the tri-state clear signal the
  form actually sends), not the ?? null coalesce.

LFXV2-2386

Signed-off-by: Dan Baker <me@danb.co>

---------

Signed-off-by: Dan Baker <me@danb.co>
Signed-off-by: David Deal <ddeal@linuxfoundation.org>
Co-authored-by: David Deal <ddeal@linuxfoundation.org>
(cherry picked from commit d641613)
Copilot AI balanced review requested due to automatic review settings July 27, 2026 15:32
@dealako dealako added the ai-assisted A task or activity that was supported by AI, such as CoPilot, ChatGPT, or other AI technology. label Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a newsletter block-composer flow with structured layouts, template manifests, rendering APIs, Angular editing UI, persistence integration, and Playwright coverage for modern and legacy drafts.

Changes

Newsletter block composer

Layer / File(s) Summary
Layout contracts and template APIs
packages/shared/src/interfaces/newsletter.interface.ts, packages/shared/src/constants/newsletter.constants.ts, apps/lfx-one/src/server/..., apps/lfx-one/src/app/shared/services/newsletter-manifest.service.ts, apps/lfx-one/src/app/shared/services/newsletter.service.ts
Adds structured layout types, template manifest endpoints, render-preview APIs, payload validation, size limits, and cached manifest loading.
Declarative newsletter renderer
apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts, packages/shared/src/utils/*
Parses templates, resolves bindings and slots, renders editable previews, sanitizes rich text and URLs, and supports wrapper chrome.
Block composer and field editing
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/*, apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/*
Adds palette, outline, drag-and-drop nesting, preview controls, inline editing, spacing controls, and reactive block fields.
Editor modes and newsletter persistence
apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/*, apps/lfx-one/src/app/modules/newsletters/newsletter-manage/*, apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/*
Connects Blocks and Simple modes, hydrates and saves body_layout, synchronizes rendered HTML, updates preview and save gating, and displays template metadata.
Wizard flow coverage
apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts
Covers layout hydration, composition, mode switching, legacy drafts, panel controls, and template-library compatibility.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor as NewsletterBlockComposerComponent
  participant Manifest as NewsletterManifestService
  participant API as NewsletterService
  participant Server as NewsletterController
  participant Manage as NewsletterManageComponent

  Editor->>Manifest: Load template manifest
  Manifest->>API: Request manifest
  API->>Server: GET template manifest
  Editor->>API: Request render preview
  API->>Server: POST body_layout
  Server-->>API: Return rendered body_html
  API-->>Editor: Return preview response
  Editor-->>Manage: Emit layoutChange
  Manage->>Server: Save body_layout and body_html
Loading

Possibly related PRs

Suggested labels: do-not-merge

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding a native newsletter block-composer editor with template selection.
Description check ✅ Passed The description identifies this PR as a restoration of the newsletter block-composer work and explains its draft status and tracking reference.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/LFXV2-2386-newsletter-composer

Comment @coderabbitai help to get the list of available commands.

@dealako
dealako marked this pull request as ready for review July 27, 2026 15:34
@dealako
dealako requested a review from a team as a code owner July 27, 2026 15:34
@cursor

cursor Bot commented Jul 27, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large new editor surface changes newsletter authoring, save semantics, and send/preview gating; mitigated by E2E coverage and explicit layout/html mode handling.

Overview
Reintroduces the newsletter block composer in the creation wizard Content step (restoring work from #1133), so authors build body_layout instead of only rich-text body_html.

The Content step now defaults to Blocks (lfx-newsletter-block-composer) with a Simple toggle for the legacy rich-text + AI path. Switching modes confirms when content would be discarded and keeps a single authoritative body (body_layout vs body_html, including explicit null to clear a stored layout).

newsletter-manage gains a bodyLayout form control, persists body_layout on create/update, syncs server-rendered body_html after save in blocks mode, and tightens preview/send/test gates until layout edits are saved and rendered. Review shows template/neutral library labeling and treats composed blocks as “has body” before body_html catches up.

New UI includes manifest-driven palette/canvas (drag-drop, containers, template library switch with unsupported blocks dropped), schema-driven Fields panel, live preview/source/Gmail size hints, and a large Playwright spec for hydration, persistence, legacy drafts, and template switching.

Reviewed by Cursor Bugbot for commit 2f8bc9c. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Restores the draft newsletter block-composer feature with runtime template selection and server-rendered layouts.

Changes:

  • Adds structured newsletter layout contracts and BFF endpoints.
  • Introduces block composition, inline editing, previews, and template switching.
  • Updates draft persistence, validation, review UI, and E2E coverage.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/shared/src/utils/url.utils.spec.ts Tests safe URL validation.
packages/shared/src/utils/string.utils.ts Adds field-key humanization.
packages/shared/src/interfaces/newsletter.interface.ts Defines layout and manifest contracts.
packages/shared/src/constants/newsletter.constants.ts Adds spacing and template defaults.
apps/lfx-one/src/server/services/newsletter.service.ts Exposes template and preview operations.
apps/lfx-one/src/server/services/newsletter-service.client.ts Proxies new upstream endpoints.
apps/lfx-one/src/server/routes/newsletters.route.ts Registers template and preview routes.
apps/lfx-one/src/server/controllers/newsletter.controller.ts Validates and handles layout requests.
apps/lfx-one/src/app/shared/services/newsletter.service.ts Adds client preview rendering.
apps/lfx-one/src/app/shared/services/newsletter-manifest.service.ts Loads and caches runtime manifests.
apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts Renders declarative templates safely.
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts Integrates layout persistence and gating.
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.html Updates preview and draft actions.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.ts Adds layout-aware review details.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.html Displays template and preview state.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts Implements editor-mode switching.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.html Hosts composer and simple editors.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts Builds schema-driven field forms.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.html Renders block and spacing controls.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts Implements composer behavior.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.scss Styles rendered email previews.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html Defines composer palette, canvas, and sidebars.
apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts Covers hydration, persistence, and switching.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +569 to +571
if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) {
fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required';
}
</lfx-input-text>
</label>
</div>
<p class="text-xs text-gray-400">CSS shorthand, e.g. <code>12px</code> or <code>8px 16px</code>.</p>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts (1)

504-510: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Close formatMissing before the next method
Missing the closing } before protected onAddAudienceEmail leaves newsletter-manage.component.ts unparsable and blocks the TypeScript build.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts`
around lines 504 - 510, Close the private formatMissing method with a closing
brace before protected onAddAudienceEmail, preserving its existing formatting
and return behavior so the component parses correctly.

Source: Linters/SAST tools

🧹 Nitpick comments (6)
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts (1)

214-224: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Orphaned doc comment.

The Build the reactive form… one-liner at Line 214 now sits above fieldsKey's own doc block, so buildForm (Line 224) reads as undocumented and fieldsKey appears to have two descriptions. Move it directly above buildForm.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts`
around lines 214 - 224, Move the “Build the reactive form for a block and wire
its value-change emit” doc comment from above fieldsKey to directly above
buildForm. Keep fieldsKey’s existing schema-signature documentation immediately
above fieldsKey.
apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts (2)

1017-1032: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc block is attached to the wrong method.

The JSDoc at Lines 1017-1024 describes container reconciliation but sits above moveTopLevelBlock; reconcileContainers (Line 1034) is left undocumented.

♻️ Move the doc block
-  /**
-   * After the manifest resolves, promote any top-level block the manifest
-   * declares a container but that hydrated as a leaf. An empty (childless)
-   * container comes back from the service without a `blocks` array (omitempty),
-   * and `hydrate` runs before the manifest is available, so it can't classify it
-   * — left unreconciled it would render without its drop zone and can't accept
-   * nested blocks. Containers never nest, so only the top level needs this.
-   */
+  /** Move a top-level block from one index to another, then re-emit. */
   private moveTopLevelBlock(from: number, to: number): void {

and re-attach the container text above reconcileContainers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts`
around lines 1017 - 1032, Move the existing container-reconciliation JSDoc from
above moveTopLevelBlock to directly above reconcileContainers, leaving
moveTopLevelBlock without that documentation and preserving the text unchanged.

431-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Prefer the app's notification/dialog services over window.alert / window.prompt.

Native modals are blocking, unstyled, and inconsistent with the MessageService/ConfirmationService pattern used elsewhere in the newsletter module (see newsletter-manage.component.ts). The link prompt in particular would be better as an inline popover so the contentEditable selection isn't disturbed.

Also applies to: 577-595

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts`
around lines 431 - 446, Replace the native window.alert calls in the newsletter
block composer, including the failure branch around ensureLoaded and the
additional block-removal notification, with the newsletter module’s established
MessageService or dialog notification pattern used by
newsletter-manage.component.ts. Preserve the existing messages and control flow
while avoiding blocking native modals; apply the same service-based approach to
the link prompt range noted in the review, using an inline popover that does not
disrupt the contentEditable selection.
apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts (1)

315-320: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated draft-GET override into a shared helper.

The same "override the per-newsletter GET route with a modified draft, fallback otherwise" block is repeated three times (container draft, empty-layout draft, legacy draft). Consider a small helper to keep the intent obvious and avoid drift as more scenarios are added.

♻️ Proposed helper extraction
+async function stubDraftGetOverride(page: Page, overrides: Partial<Newsletter> = {}): Promise<void> {
+  await page.route(`**/api/projects/${MOCK_FOUNDATION_UID}/newsletters/${MOCK_NEWSLETTER_ID}`, (route) => {
+    if (route.request().method() === 'GET') {
+      return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(buildDraft(overrides)) });
+    }
+    return route.fallback();
+  });
+}

Then each test body becomes, e.g.:

-    await page.route(`**/api/projects/${MOCK_FOUNDATION_UID}/newsletters/${MOCK_NEWSLETTER_ID}`, (route) => {
-      if (route.request().method() === 'GET') {
-        return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(buildDraft({ body_layout: CONTAINER_DRAFT_LAYOUT })) });
-      }
-      return route.fallback();
-    });
+    await stubDraftGetOverride(page, { body_layout: CONTAINER_DRAFT_LAYOUT });

Also applies to: 373-378, 406-415

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts` around lines 315 - 320,
Extract the repeated newsletter draft GET route override into a shared helper
near the existing test utilities, parameterized by the draft or layout needed
for each scenario. Update the container, empty-layout, and legacy draft cases to
call this helper while preserving route fallback for non-GET requests.
packages/shared/src/utils/url.utils.spec.ts (1)

15-33: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add the two inputs the renderer can actually hand this gate.

passthrough in apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts (Line 604) passes raw resolved attribute values straight through, and isSafeUrlForAttr tolerates leading whitespace before mailto:. Pinning whitespace-padded and protocol-relative inputs keeps that gate honest.

💚 Suggested extra cases
     expect(isValidUrl('javascript:alert(1)')).toBe(false);
     expect(isValidUrl('JavaScript:alert(1)')).toBe(false); // case-insensitive
+    expect(isValidUrl('  javascript:alert(1)')).toBe(false); // leading whitespace
     expect(isValidUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
     expect(isValidUrl('/relative/path')).toBe(false);
+    expect(isValidUrl('//evil.example.com')).toBe(false); // protocol-relative
     expect(isValidUrl('example.com')).toBe(false); // no protocol
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/utils/url.utils.spec.ts` around lines 15 - 33, Add test
cases in the URL validation spec for a whitespace-padded mailto value and a
protocol-relative URL, asserting both are rejected by isValidUrl. Keep the
coverage focused on inputs passed through the newsletter renderer’s
isSafeUrlForAttr gate.
apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts (1)

123-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated editable-field rules between collectEditableFields and selfEditableField.

Lines 133-143 re-implement the exact anchor/each=/richtext exclusion logic that selfEditableField (Lines 493-504) already owns — the comment there even says it "mirrors" these rules. Two copies that must stay in sync will drift the first time a tag is added to TAG_MAP.

♻️ Proposed refactor
     const walk = (nodes: TemplateNode[]): void => {
       for (const node of nodes) {
         if (node.kind !== 'element') continue;
-        // Skip structural / repeated cases — those aren't single-field text.
-        if (node.attrs['each'] === undefined) {
-          if (node.tag === 'richtext') {
-            const field = node.attrs['field'] ?? bindingKeyFromChildren(node.children);
-            if (field) fields.add(field);
-          } else {
-            const field = singleTextField(node);
-            const mapped = TAG_MAP[node.tag]?.tag;
-            // Mirror renderNode's exclusion of anchors (link text owns an href).
-            if (field && mapped !== 'a' && node.tag !== 'a' && node.tag !== 'link' && node.tag !== 'gw-link' && node.tag !== 'button') {
-              fields.add(field);
-            }
-          }
-        }
+        // Single source of truth for the marking rules (richtext, `each=`, anchors).
+        const self = selfEditableField(node);
+        if (self) fields.add(self.field);
         walk(node.children);
       }
     };

Note: selfEditableField returns a richtext field even under each=, so confirm that difference is acceptable (or move the each= guard ahead of the richtext branch there).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts`
around lines 123 - 150, Consolidate editable-field detection in
collectEditableFields by reusing selfEditableField instead of duplicating its
each=, richtext, and anchor-exclusion rules. Preserve traversal of child nodes
and Set collection, and ensure the shared helper’s each= behavior matches the
intended contract—move the guard before richtext handling if richtext fields
must also be excluded under each=.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html`:
- Around line 376-385: Update the block wrapper divs in the canvas, including
the corresponding instance around the second referenced section, to expose
button semantics with role="button" and tabindex="0". Add keyboard handling so
Enter and Space invoke selectBlock(block.id), while preserving the existing
click selection and aria-pressed state.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts`:
- Around line 165-190: Update applyMode’s simple branch so switching from Blocks
retains a persistable empty bodyLayout instead of setting it to null. Seed the
same empty NewsletterLayout shape used when entering Blocks, while continuing to
clear bodyHtml, so the discarded state can pass bodyPersistable and be saved.

In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts`:
- Around line 565-571: The test-send reply-address contract must be consistent
across validation, documentation, and shared types. Keep the mandatory
ed_reply_email validation in testSend, update its JSDoc to state that a valid
reply address is required, and make ed_reply_email required in the shared
newsletter interface while removing the omission note.
- Around line 170-198: Move projectUid extraction and logger.startOperation
calls inside the try blocks of getTemplates and getTemplateManifest so
synchronous validation or logging errors are caught and passed to next(error),
matching the established handling in renderPreview and deleteOptOut.

---

Outside diff comments:
In
`@apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts`:
- Around line 504-510: Close the private formatMissing method with a closing
brace before protected onAddAudienceEmail, preserving its existing formatting
and return behavior so the component parses correctly.

---

Nitpick comments:
In `@apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts`:
- Around line 315-320: Extract the repeated newsletter draft GET route override
into a shared helper near the existing test utilities, parameterized by the
draft or layout needed for each scenario. Update the container, empty-layout,
and legacy draft cases to call this helper while preserving route fallback for
non-GET requests.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts`:
- Around line 1017-1032: Move the existing container-reconciliation JSDoc from
above moveTopLevelBlock to directly above reconcileContainers, leaving
moveTopLevelBlock without that documentation and preserving the text unchanged.
- Around line 431-446: Replace the native window.alert calls in the newsletter
block composer, including the failure branch around ensureLoaded and the
additional block-removal notification, with the newsletter module’s established
MessageService or dialog notification pattern used by
newsletter-manage.component.ts. Preserve the existing messages and control flow
while avoiding blocking native modals; apply the same service-based approach to
the link prompt range noted in the review, using an inline popover that does not
disrupt the contentEditable selection.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts`:
- Around line 214-224: Move the “Build the reactive form for a block and wire
its value-change emit” doc comment from above fieldsKey to directly above
buildForm. Keep fieldsKey’s existing schema-signature documentation immediately
above fieldsKey.

In
`@apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts`:
- Around line 123-150: Consolidate editable-field detection in
collectEditableFields by reusing selfEditableField instead of duplicating its
each=, richtext, and anchor-exclusion rules. Preserve traversal of child nodes
and Set collection, and ensure the shared helper’s each= behavior matches the
intended contract—move the guard before richtext handling if richtext fields
must also be excluded under each=.

In `@packages/shared/src/utils/url.utils.spec.ts`:
- Around line 15-33: Add test cases in the URL validation spec for a
whitespace-padded mailto value and a protocol-relative URL, asserting both are
rejected by isValidUrl. Keep the coverage focused on inputs passed through the
newsletter renderer’s isSafeUrlForAttr gate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a9a8c03-00fb-44d4-8c21-402d8f0ca933

📥 Commits

Reviewing files that changed from the base of the PR and between 65d9d75 and dd1b923.

📒 Files selected for processing (23)
  • apps/lfx-one/e2e/newsletter-composer-wizard.spec.ts
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.scss
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.html
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-fields/newsletter-block-fields.component.ts
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.html
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.html
  • apps/lfx-one/src/app/modules/newsletters/components/newsletter-review/newsletter-review.component.ts
  • apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.html
  • apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts
  • apps/lfx-one/src/app/modules/newsletters/services/newsletter-renderer.service.ts
  • apps/lfx-one/src/app/shared/services/newsletter-manifest.service.ts
  • apps/lfx-one/src/app/shared/services/newsletter.service.ts
  • apps/lfx-one/src/server/controllers/newsletter.controller.ts
  • apps/lfx-one/src/server/routes/newsletters.route.ts
  • apps/lfx-one/src/server/services/newsletter-service.client.ts
  • apps/lfx-one/src/server/services/newsletter.service.ts
  • packages/shared/src/constants/newsletter.constants.ts
  • packages/shared/src/interfaces/newsletter.interface.ts
  • packages/shared/src/utils/string.utils.ts
  • packages/shared/src/utils/url.utils.spec.ts

Comment on lines +376 to +385
<div
class="group/block relative cursor-pointer rounded-md transition-shadow"
[class.ring-2]="selectedBlockId() === block.id"
[class.ring-blue-500]="selectedBlockId() === block.id"
[style]="blockSpacingStyles().get(block.id)"
cdkDrag
[cdkDragData]="block"
(click)="selectBlock(block.id)"
[attr.data-testid]="'newsletter-composer-block-' + block.block_type"
[attr.aria-pressed]="selectedBlockId() === block.id">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

aria-pressed on a plain div isn't exposed, and canvas selection is pointer-only.

These block wrappers carry (click) + aria-pressed but no role/tabindex, so screen readers ignore the pressed state and keyboard users can't select a block from the canvas. Adding role="button" + tabindex="0" (or relying solely on the Outline for keyboard selection and dropping aria-pressed here) would make the semantics honest.

Also applies to: 427-435

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.html`
around lines 376 - 385, Update the block wrapper divs in the canvas, including
the corresponding instance around the second referenced section, to expose
button semantics with role="button" and tabindex="0". Add keyboard handling so
Enter and Space invoke selectBlock(block.id), while preserving the existing
click selection and aria-pressed state.

Comment on lines +165 to +190
private applyMode(mode: NewsletterEditorMode): void {
if (mode === 'simple') {
// Drop the layout so the server uses the authored html rather than
// rendering blocks over it.
this.form().get('bodyLayout')?.setValue(null);
// Also clear the derived body_html: it holds the layout's COMPLETE
// server-rendered emitter email, which — kept as "simple" content with a
// null layout — the send path would double-wrap in the legacy chrome. The
// discard was already confirmed, so the simple editor starts empty.
this.form().get('bodyHtml')?.setValue('');
} else {
// Drop the authored html; blocks become the source and the server
// re-derives body_html on save.
this.form().get('bodyHtml')?.setValue('');
// Seed a non-null empty layout when entering Blocks from a null layout.
// Without it, a switch from a populated simple draft leaves bodyLayout null
// AND body_html empty, so bodyPersistable is false and neither autosave nor
// manual save can persist the confirmed discard — the old simple body then
// reverts on reload. The composer re-emits with its real manifest
// wrapper_key as soon as a block is added (mirrors its own 'default'
// fallback in toLayout()).
if (this.form().get('bodyLayout')?.value == null) {
const emptyLayout: NewsletterLayout = { wrapper_key: 'default', blocks: [] };
this.form().get('bodyLayout')?.setValue(emptyLayout);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C4 'bodyPersistable|hasAnythingToSave' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts

Repository: linuxfoundation/lfx-self-serve

Length of output: 3089


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the mode-switch logic and save gating around the relevant component.
sed -n '120,230p' apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts
printf '\n---\n'
sed -n '230,340p' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts
printf '\n---\n'
sed -n '1048,1085p' apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts

Repository: linuxfoundation/lfx-self-serve

Length of output: 11019


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'canSaveDraft|saveDraft\(|hasAnythingToSave\(|saveTrigger\$|manual save|Save draft' apps/lfx-one/src/app/modules/newsletters -g '*.ts' -g '*.html'

Repository: linuxfoundation/lfx-self-serve

Length of output: 13067


Blocks → Simple discard can’t be persisted Switching to simple sets bodyLayout = null and bodyHtml = '', but canSaveDraft() and autosave both require bodyPersistable(). The cleared state never reaches the server, so the discarded blocks layout comes back on reload. Seed a persistable empty layout here too, or delay clearing until the author makes a new change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/lfx-one/src/app/modules/newsletters/components/newsletter-content-step/newsletter-content-step.component.ts`
around lines 165 - 190, Update applyMode’s simple branch so switching from
Blocks retains a persistable empty bodyLayout instead of setting it to null.
Seed the same empty NewsletterLayout shape used when entering Blocks, while
continuing to clear bodyHtml, so the discarded state can pass bodyPersistable
and be saved.

Comment on lines +170 to +198
public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
const projectUid = this.requireProjectUid(req);
const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });

try {
const result = await this.newsletterService.getTemplates(req, projectUid);
logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
res.json(result);
} catch (error) {
next(error);
}
}

/**
* GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest
*/
public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> {
const projectUid = this.requireProjectUid(req);
const templateKey = String(req.params['templateKey'] || '').trim();
const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });

try {
const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey);
logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length });
res.json(manifest);
} catch (error) {
next(error);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

New GET handlers throw outside try, the exact hazard renderPreview documents two methods below.

requireProjectUid(req) (and logger.startOperation) run before the try in both getTemplates and getTemplateManifest. On a missing/blank projectUid the synchronous ServiceValidationError rejects the returned promise without reaching next(error) — under Express 4 that leaves the request hanging, which is precisely why renderPreview (Lines 208-211) and deleteOptOut (Lines 351-352) keep these calls inside the try.

🛡️ Proposed fix
   public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
-    const projectUid = this.requireProjectUid(req);
-    const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
-
     try {
+      const projectUid = this.requireProjectUid(req);
+      const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
       const result = await this.newsletterService.getTemplates(req, projectUid);
       logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
       res.json(result);
     } catch (error) {
       next(error);
     }
   }
 
   public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> {
-    const projectUid = this.requireProjectUid(req);
-    const templateKey = String(req.params['templateKey'] || '').trim();
-    const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
-
     try {
+      const projectUid = this.requireProjectUid(req);
+      const templateKey = String(req.params['templateKey'] || '').trim();
+      const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
       const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
const projectUid = this.requireProjectUid(req);
const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
try {
const result = await this.newsletterService.getTemplates(req, projectUid);
logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest
*/
public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> {
const projectUid = this.requireProjectUid(req);
const templateKey = String(req.params['templateKey'] || '').trim();
const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
try {
const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey);
logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length });
res.json(manifest);
} catch (error) {
next(error);
}
}
public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const projectUid = this.requireProjectUid(req);
const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });
const result = await this.newsletterService.getTemplates(req, projectUid);
logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /api/projects/:projectUid/newsletters/templates/:templateKey/manifest
*/
public async getTemplateManifest(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const projectUid = this.requireProjectUid(req);
const templateKey = String(req.params['templateKey'] || '').trim();
const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });
const manifest = await this.newsletterService.getTemplateManifest(req, projectUid, templateKey);
logger.success(req, 'newsletter_template_manifest', startTime, { template_key: templateKey, block_count: manifest.blocks.length });
res.json(manifest);
} catch (error) {
next(error);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines
170 - 198, Move projectUid extraction and logger.startOperation calls inside the
try blocks of getTemplates and getTemplateManifest so synchronous validation or
logging errors are caught and passed to next(error), matching the established
handling in renderPreview and deleteOptOut.

Comment on lines +565 to +571
// Parity with validateCommonPayload: the client sends ed_reply_email on every
// test send, and a layout test recompiles the wrapper's "To reply, email …"
// row (and the email's reply-to) from it, so require a valid address here too
// rather than silently accepting a missing/malformed one.
if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) {
fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Test-send reply-address contract is inconsistent across three sites. The new validator makes ed_reply_email mandatory while the shared type and the route doc still describe it as omittable; one decision needs to propagate to all of them.

  • apps/lfx-one/src/server/controllers/newsletter.controller.ts#L565-L571: keep the requirement (and update the testSend JSDoc at Lines 86-92) or gate it on layoutValid so HTML-only test sends stay accepted without a reply address.
  • packages/shared/src/interfaces/newsletter.interface.ts#L92-L95: make ed_reply_email required and drop the "Omitted for a test send that carries no reply address" note if the server requirement stands.
📍 Affects 2 files
  • apps/lfx-one/src/server/controllers/newsletter.controller.ts#L565-L571 (this comment)
  • packages/shared/src/interfaces/newsletter.interface.ts#L92-L95
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines
565 - 571, The test-send reply-address contract must be consistent across
validation, documentation, and shared types. Keep the mandatory ed_reply_email
validation in testSend, update its JSDoc to state that a valid reply address is
required, and make ed_reply_email required in the shared newsletter interface
while removing the omission note.

…etter-composer

Signed-off-by: Dan Baker <me@danb.co>

# Conflicts:
#	apps/lfx-one/src/server/controllers/newsletter.controller.ts
Copilot AI review requested due to automatic review settings August 2, 2026 19:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/lfx-one/src/server/controllers/newsletter.controller.ts (1)

570-580: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject a malformed supplied layout before falling back to body_html.

If a request contains body_layout: {} and valid body_html, layoutValid is false and this method accepts the request. This differs from validateCommonPayload, which rejects a present layout without a blocks array. Reject a present invalid layout so the test-send path cannot forward an invalid structured payload upstream.

Proposed fix
-    const layoutValid =
-      payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0;
-    if (!layoutValid) {
+    const layoutPresent = payload?.body_layout !== undefined && payload?.body_layout !== null;
+    const layoutValid = layoutPresent && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0;
+    if (layoutPresent && !layoutValid) {
+      fieldErrors['body_layout'] = 'A block layout must have a non-empty blocks array';
+    } else if (!layoutValid) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts` around lines
570 - 580, Update the validation flow around layoutValid so any supplied
body_layout that is not an object with a blocks array is rejected before
body_html fallback, matching validateCommonPayload behavior. Preserve the
existing body_html validation only when body_layout is absent, and continue
applying the serialized size limit to valid layouts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/lfx-one/src/server/controllers/newsletter.controller.ts`:
- Around line 570-580: Update the validation flow around layoutValid so any
supplied body_layout that is not an object with a blocks array is rejected
before body_html fallback, matching validateCommonPayload behavior. Preserve the
existing body_html validation only when body_layout is absent, and continue
applying the serialized size limit to valid layouts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4e8e2547-b1d7-4ec5-96a8-70a3807d9903

📥 Commits

Reviewing files that changed from the base of the PR and between dd1b923 and 6ae82e6.

📒 Files selected for processing (7)
  • apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts
  • apps/lfx-one/src/app/shared/services/newsletter.service.ts
  • apps/lfx-one/src/server/controllers/newsletter.controller.ts
  • apps/lfx-one/src/server/services/newsletter-service.client.ts
  • apps/lfx-one/src/server/services/newsletter.service.ts
  • packages/shared/src/constants/newsletter.constants.ts
  • packages/shared/src/interfaces/newsletter.interface.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/lfx-one/src/app/shared/services/newsletter.service.ts
  • apps/lfx-one/src/server/services/newsletter-service.client.ts
  • apps/lfx-one/src/server/services/newsletter.service.ts
  • packages/shared/src/interfaces/newsletter.interface.ts
  • apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts

})
),
{ initialValue: '' }
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Spurious preview fetch emissions

Medium Severity

initServerHtml feeds toObservable from a computed that returns a fresh { layout, wrapperContent } object on every recompute, with no value-based distinctUntilChanged before switchMap. Angular compares computed results with Object.is, so each upstream notification retriggers the debounced renderPreview request even when the layout payload is unchanged.

Fix in Cursor Fix in Web

Triggered by learned rule: computed() returning objects causes spurious toObservable() emissions — use primitive keys

Reviewed by Cursor Bugbot for commit 6ae82e6. Configure here.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🚀 Deployment Status

Your branch has been deployed to: https://ui-pr-1201.dev.v2.cluster.linuxfound.info

Deployment Details:

  • Environment: Development
  • Namespace: ui-pr-1201
  • ArgoCD App: ui-pr-1201

The deployment will be automatically removed when this PR is closed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

apps/lfx-one/src/app/modules/newsletters/newsletter-manage/newsletter-manage.component.ts:281

  • Blocks-mode test sends are disabled until the draft has been saved: bodyUsable() requires rendered body_html and a clean snapshot. However, this request sends the current body_layout, and the newsletter service recompiles that layout for the test send. A new block draft cannot autosave before an audience is selected, so users cannot test it even though test-send has no audience requirement. Gate this action on the live subject/layout content instead.
    () => this.subjectFilled() && this.bodyUsable() && this.bodyFilled() && this.hasContext() && this.edEmail().length > 0 && !this.testSending()

apps/lfx-one/src/server/controllers/newsletter.controller.ts:580

  • A present but malformed or empty body_layout falls through to the HTML-only branch when body_html is valid, yet the unchanged layout is still forwarded. The upstream contract treats any present body_layout as the sole layout trigger, so this either bypasses the intended non-empty check or produces an upstream render error instead of a local validation response. Reject invalid present layouts; only fall back to body_html when the field is absent/null.
    const layoutValid =
      payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0;
    if (!layoutValid) {

Copilot AI review requested due to automatic review settings August 4, 2026 04:00

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2f8bc9c. Configure here.

// the blur commit re-renders and repositions anyway).
if (!this.editingBlockId()) this.repositionToolbar();
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale inline editors after deselection

Medium Severity

The inline-editing effect only adds contenteditable on the newly selected block and never clears it from the previously selected one. Because renderedBlocks does not depend on selection, a pure selection change leaves the old block editable until some later content-driven re-render replaces its DOM, so more than one block can stay inline-editable at once contrary to the selected-only gating.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2f8bc9c. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Suppressed comments (3)

apps/lfx-one/src/server/controllers/newsletter.controller.ts:210

  • The project UID extraction and logger setup are outside the try, so either synchronous throw bypasses next(error) under Express 4 and can leave the request unresolved. Keep the whole handler setup in the guarded block, matching renderPreview.
    const projectUid = this.requireProjectUid(req);
    const templateKey = String(req.params['templateKey'] || '').trim();
    const startTime = logger.startOperation(req, 'newsletter_template_manifest', { project_uid: projectUid, template_key: templateKey });

apps/lfx-one/src/server/controllers/newsletter.controller.ts:592

  • This unconditional check contradicts the new shared contract, which makes ed_reply_email optional and explicitly permits omission when there is no reply address; the upstream test-send DTO is optional too. As written, valid clients following that contract receive a BFF 400. Validate the address only when it is supplied (or make it required consistently across the interface and upstream contract).
    // Parity with validateCommonPayload: the client sends ed_reply_email on every
    // test send, and a layout test recompiles the wrapper's "To reply, email …"
    // row (and the email's reply-to) from it, so require a valid address here too
    // rather than silently accepting a missing/malformed one.
    if (!payload?.ed_reply_email || typeof payload.ed_reply_email !== 'string' || !payload.ed_reply_email.includes('@')) {
      fieldErrors['ed_reply_email'] = 'A valid ed_reply_email is required';
    }

apps/lfx-one/src/app/modules/newsletters/components/newsletter-block-composer/newsletter-block-composer.component.ts:1353

  • The form/test-send path carries a reply address, but preview wrapper content never supplies edition.reply_email. Both restored upstream wrappers guard their “To reply, email …” row on that value, so the canvas and authoritative server preview omit content that appears in real/test sends; the reported size/source is therefore understated. Pass the current reply address into the composer and include it here.
    return {
      edition: {
        date: 'Newsletter preview',
        view_online_link: '',
        unsubscribe_url: '#',
        manage_subscriptions_url: '#',
      },

Comment on lines +191 to +202
public async getTemplates(req: Request, res: Response, next: NextFunction): Promise<void> {
const projectUid = this.requireProjectUid(req);
const startTime = logger.startOperation(req, 'newsletter_templates_list', { project_uid: projectUid });

try {
const result = await this.newsletterService.getTemplates(req, projectUid);
logger.success(req, 'newsletter_templates_list', startTime, { count: result.templates.length });
res.json(result);
} catch (error) {
next(error);
}
}
Comment on lines +570 to 580
const layoutValid =
payload?.body_layout !== undefined && payload?.body_layout !== null && Array.isArray(payload.body_layout.blocks) && payload.body_layout.blocks.length > 0;
if (!layoutValid) {
if (!payload?.body_html || typeof payload.body_html !== 'string' || payload.body_html.trim().length === 0) {
fieldErrors['body_html'] = 'Body is required';
} else if (payload.body_html.length > NEWSLETTER_BODY_MAX_LENGTH) {
fieldErrors['body_html'] = `Body must be ${NEWSLETTER_BODY_MAX_LENGTH} characters or fewer`;
}
} else if (JSON.stringify(payload.body_layout).length > NEWSLETTER_BODY_LAYOUT_MAX_LENGTH) {
fieldErrors['body_layout'] = `Layout must be ${NEWSLETTER_BODY_LAYOUT_MAX_LENGTH} characters or fewer when serialized`;
}
Comment on lines +169 to +174
this.form().get('bodyLayout')?.setValue(null);
// Also clear the derived body_html: it holds the layout's COMPLETE
// server-rendered emitter email, which — kept as "simple" content with a
// null layout — the send path would double-wrap in the legacy chrome. The
// discard was already confirmed, so the simple editor starts empty.
this.form().get('bodyHtml')?.setValue('');
Comment on lines +48 to +49
public async getTemplates(req: Request, projectUid: string): Promise<NewsletterTemplatesResponse> {
return this.microserviceProxy.proxyRequest<NewsletterTemplatesResponse>(req, 'LFX_V2_SERVICE', `/projects/${projectUid}/newsletters/templates`, 'GET');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted A task or activity that was supported by AI, such as CoPilot, ChatGPT, or other AI technology. deploy-preview

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants