Skip to content

Newsletter — Improvement & Growth Plan

Package: capell-app/newsletter · Kind: package · Tier: premium · Product group: Capell Marketing · Bundle: newsletter · Status: Draft

Newsletter is a schema-owning premium package (13 migrations, 12 models, 23 Actions, 12 Data objects, 5 public HTTP controllers, 10 Filament resources + 1 dashboard widget). It captures subscribers from Form Builder submissions, records consent evidence, runs double opt-in via hashed public tokens, syncs to ESPs through a pluggable adapter registry (Mailchimp, Kit, Campaign Monitor, Fake), supports static/dynamic segments, CSV import/export, UTM attribution, a public preference center, and schedules NewsletterSend campaign records. Core domain logic correctly lives in src/Actions; public controllers (src/Http/Controllers) delegate to Actions and the public Blade (resources/views/preference-center.blade.php) renders only a typed PreferenceCenterData view model — no DB queries, no admin leakage. Provider credentials/webhook_secret are encrypted casts (src/Models/ProviderConnection.php). Tests: 82 across 17 Pest files (Integration Actions + Unit + 1 Filament Feature).

Current marketplace summary: “Capture, confirm, and segment newsletter subscribers on every Capell site — with double opt-in, a public preference center, GDPR-grade consent evidence, and one-click sync to Mailchimp, Kit, and Campaign Monitor.” Composer description is aligned around capture/confirm/segment/provider sync without claiming an in-house delivery worker. Screenshot media is reconciled: capell.json lists the extension card plus all 13 committed captures from docs/screenshots.json.

  • Done/Shipped: Marketplace screenshot contract is reconciled. The full 13-shot docs/screenshots.json plan is committed under docs/screenshots/ and listed in capell.json marketplace media with the extension card; manifest coverage pins contract order and file existence. — docs/screenshots.json, docs/screenshots/*, capell.json, tests/Unit/ManifestRequirementsTest.phpS

  • Done/Shipped: Implemented the four advertised health checks. NewsletterHealthCheck now delegates to BuildNewsletterHealthDiagnosticsAction, which supports all checks or a single manifest key and probes Form Builder listener/table wiring, provider sync retry queryability, provider webhook route/action/idempotency storage, and static/dynamic segment evaluation. Diagnostics output is translated and covered. — manifest/behaviour alignment — src/Actions/BuildNewsletterHealthDiagnosticsAction.php, src/Health/NewsletterHealthCheck.php, resources/lang/en/health.php, tests/Feature/NewsletterHealthCheckTest.phpM

  • Harden the Form Builder event binding — the listener is wired by building the event class name from a string and guarding with class_exists: Event::listen($formSubmittedEvent, SubscribeFromFormSubmission::class). If Form Builder renames/moves FormSubmitted, subscription silently stops with no error. Reference the class directly (it is a hard requires dependency) or assert its existence in a health check/test. — silent failure of the headline capability — src/Providers/NewsletterServiceProvider.php:233-236S

  • Closed 2026-06-05 — Restrict or gate the Fake provider adapter in productionFake is hidden from the admin provider select and fake adapter operations fail closed outside local/testing unless capell-newsletter.providers.allow_fake_provider (or the legacy webhook override) is explicitly enabled. Fake webhooks return 403 without mutating subscriber state in production by default. — unauthenticated state mutation — src/Support/Providers/FakeProviderAdapter.php, src/Support/Providers/FakeProviderGuard.php, src/Filament/Resources/ProviderConnections/ProviderConnectionResource.phpS

  • Set token expiry for unsubscribe and preference-center tokensCreateUnsubscribeTokenAction and CreatePreferenceCenterTokenAction create PublicTokens with 'expires_at' => null, so isUsable() treats them as valid forever. Confirm tokens correctly expire (72h). Long-lived unsubscribe/preference links are a standing risk if a URL leaks. Add a configurable expiry (reuse token_expiry_hours or a new key). — non-expiring public credentials — src/Actions/CreateUnsubscribeTokenAction.php, src/Actions/CreatePreferenceCenterTokenAction.php, src/Models/PublicToken.phpS

  • Make public tokens single-use where appropriate — confirm/unsubscribe Actions lock the row (lockForUpdate) and check isUsable(), but I did not observe used_at being stamped after a successful confirm/unsubscribe in the token flow; isUsable() keys off used_at. Verify the token is burned post-use so a confirm link can’t be replayed. — replayable tokens — src/Actions/ConfirmSubscriberAction.php, src/Actions/UnsubscribeSubscriberAction.phpS

  • Done/Shipped: Surface sync-retry exhaustion — provider sync attempts now move to SyncStatus::Exhausted when configured retry delays are spent. Admin overview counts include exhausted attempts, and the newsletter.provider-sync-retry health diagnostic fails with an operator-facing message while exhausted attempts remain. — silent sync data loss — src/Actions/SyncSubscriberToProviderAction.php, src/Actions/BuildNewsletterHealthDiagnosticsAction.php, src/Enums/SyncStatus.phpM

  • Add provider-side rate-limit / 429 handling to adapters — adapters use Http::retry(retry_times, retry_delay_ms) with a flat delay; Mailchimp/Kit/Campaign Monitor return 429 with Retry-After. Honour backoff to avoid hammering ESPs during bulk sync/import. — deliverability + API-ban risk — src/Support/Providers/MailchimpProviderAdapter.php (and Kit/CampaignMonitor siblings) — M

  • Localise the preference-center page and harden CSV import limits — only resources/lang/en exists; the one public HTML surface is English-only. Separately, imports.max_rows = 10000 / max_file_kb = 2048 are enforced but a 10k-row synchronous import in ImportSubscribersAction should be chunked/queued. — i18n gap + import timeout risk — resources/lang/, src/Actions/ImportSubscribersAction.phpM

Tie-back to capabilities[] in capell.json.

  • Done/Shipped: send strategy is explicit external handoff. Newsletter remains the capture, consent, segmentation, scheduling, and ESP-sync layer rather than an in-house delivery worker. BuildNewsletterSendHandoffPayloadAction turns a NewsletterSend into a documented external_handoff payload with send, segment, provider audience, UTM, and metadata data for Email Studio, Campaign Studio, Automation Studio, or ESP-specific workers. README/overview/manifest now state this boundary directly. This closes the capability-vs-reality gap without claiming delivery this package does not own.

  • No campaign analytics (newsletter-utm-attribution exists for capture; nothing for outcomes). No open/click/bounce/unsubscribe-rate reporting per send or per segment. Newsletter-category norm — buyers expect at least delivered/opened/clicked. Differentiator if tied to UTM attribution already captured.

  • Bounce/complaint suppression is modelled but not closed-loop. Subscriber has bounced_at / complained_at / suppressed_at columns and webhooks map cleaned→Bounced, abuse→Complained, but there’s no enforced global suppression list preventing re-subscribe/sync of a hard-bounced or complained address. GDPR/deliverability table-stakes.

  • No preference center beyond segment opt-in/out. The public center only toggles segment membership; no frequency control, no one-click “unsubscribe from all”, no profile fields (name) editing. Table-stakes for a “preference center” capability.

  • No List-Unsubscribe header support / RFC 8058 one-click. Required by Gmail/Yahoo bulk-sender rules. Even without an in-house sender, the package should expose a mailto/HTTP one-click unsubscribe surface for the ESP to reference. Deliverability-critical.

  • No GDPR data-subject export/erasure Action. Consent is recorded (ConsentEvent) but there’s no subscriber-level “export my data” / “forget me” Action despite privateDocsRequested and a consent-evidence model. Differentiator for EU buyers; pairs with the Contacts suite.

  • No webhook coverage for Kit/Campaign Monitor parity beyond Mailchimp, and no scheduled re-sync/audience drift reconciliation (provider is source of truth for unsubscribes that arrive outside webhooks). Provider-sync robustness gap.

  • Automation hooks are thin. newsletter-automation-hooks is advertised but there are no documented domain events (e.g. SubscriberConfirmed, SubscriberUnsubscribed) other packages can subscribe to. Emitting events would make the cross-sell to Campaign/Automation Studio real rather than aspirational.

  • Done/Shipped: critical health checks are real. The four manifest health checks now resolve to keyed diagnostics for form subscription capture, provider sync retry, provider webhooks, and segment evaluation. (See §2.)

  • Closed 2026-06-05 — Fake adapter accepts unsigned webhooks in production. FakeProviderAdapter::verifyWebhook() now fails closed outside local/testing unless an explicit fake-provider override is enabled, and the admin provider select uses the same gate. src/Support/Providers/FakeProviderAdapter.php. (See §2.)

  • Non-expiring unsubscribe/preference tokens. expires_at => null in both create Actions. src/Actions/CreateUnsubscribeTokenAction.php, CreatePreferenceCenterTokenAction.php. (See §2.)

  • Webhook idempotency degrades silently if table missing. HandleProviderWebhookAction::alreadyProcessed()/markProcessed() short-circuit with Schema::hasTable('newsletter_processed_webhook_events') checks; if the migration hasn’t run, duplicate webhooks are reprocessed with no warning. src/Actions/HandleProviderWebhookAction.php. S

  • Mailchimp signature fallback to a query-string shared secret. When no signature header is present, verifyWebhook falls back to hash_equals($secret, $request->query('secret')) — a secret in the URL lands in access logs/referers. Acceptable as documented Mailchimp legacy behaviour, but should be opt-in, not default. src/Support/Providers/MailchimpProviderAdapter.php. S

  • Performance budget is asserted, not enforced. capell.json sets frontendRenderBudgetMs: 20 and adminQueryBudget: 40. No test measures the preference-center render or asserts the admin resource query budget. cacheSafety.cacheable: false is correct (per-subscriber output). No regression guard exists. M

  • Test gaps. 102 package tests now cover Form Builder listener registration and dispatch, public token expiry/replay, Fake provider production blocking, real newsletter health diagnostics, and preference-center public-output safety for anonymous/non-admin HTML. Remaining gap: broader provider retry/backoff coverage. tests/.

  • PII at rest. newsletter_subscribers stores email/first_name/last_name/profile as longText (plaintext) with a separate email_hash for lookup. Consider whether raw email should be encrypted given consent-grade PII handling claims; at minimum document the retention posture. database/migrations/...02_create_newsletter_subscribers_table.php. M

  • i18n. Public preference center + all strings are en only. resources/lang/en. (See §2.)

Done/Shipped: marketplace/composer copy is honest and aligned. capell.json and composer.json now lead with the capture-confirm-segment-sync value proposition and avoid claiming an in-house campaign delivery worker. README and overview copy now describe scheduled send records as delivery-worker inputs rather than completed delivery.

Improved 1-sentence summary:

Capture, confirm, and segment newsletter subscribers on every Capell site — with double opt-in, a public preference center, GDPR-grade consent evidence, and one-click sync to Mailchimp, Kit, and Campaign Monitor.

Improved 3–4 sentence description:

Newsletter turns any Capell form into a consent-compliant audience pipeline: submissions create subscribers, double opt-in is handled through expiring signed links, and every consent change is logged as evidence you can defend. Build static or rule-based segments, let subscribers manage their own preferences from a tokenised public page, and import/export lists by CSV. Connections to Mailchimp, Kit, and Campaign Monitor keep your ESP in sync with durable, auto-retrying jobs and idempotent inbound webhooks. UTM attribution captures where each subscriber came from, and scheduled sends feed Publishing Studio’s editorial calendar. (Note: keep the description honest about delivery — see §3; if no in-house sender ships, say “schedules sends for your ESP/Campaign Studio to deliver.”)

Screenshot/media status: all 13 planned captures are committed and listed in capell.json: subscribers index, create/edit subscriber form, provider connections, provider audiences, provider interest mappings, form mappings, newsletter tags, segments, import batches, sync attempts, overview stats, confirmation route, and unsubscribe route. The public preference center remains a useful future refresh candidate because it is one of the strongest “trust” visuals for a paid marketing add-on.

Pricing / tier / bundle: tier: premium, bundle: newsletter, proposedLicense: paid, first-party, priority support — appropriate if the send or analytics gap is closed; as a capture-and-sync tool only, it sits closer to mid-tier. Strengthen the moat (suppression list, analytics, one-click unsubscribe, GDPR export) to justify premium.

Cross-sell (via dependencies.supports + Extension Suites): ships adapters/feeds for capell-app/contacts (SyncNewsletterSubscriberContactAction) and capell-app/customer-portal (preferences feed) — lead with these as bundle hooks. Natural upsell path: email-studio (own the actual send/delivery the package currently lacks), contacts (unified profile + the contacts source adapter already present), campaign-studio (segments → campaigns + the editorial-calendar contributor already wired). Position Newsletter as the capture & consent layer of the Capell Marketing suite.

Differentiators / value props / target buyer: consent-evidence trail + double opt-in + provider-agnostic sync in one CMS-native package is the differentiator vs. bolt-on ESP embeds. Target buyer: agency/site owner who wants compliant list growth from existing Capell forms without gluing a third-party signup widget onto the front end.

8–12 keywords/tags: newsletter, email-marketing, subscribers, double-opt-in, gdpr-consent, mailchimp, kit-convertkit, campaign-monitor, segmentation, preference-center, unsubscribe, audience-sync.

ItemBucketEffortImpactSection ref
Done/Shipped: Implement real logic for the 4 advertised health checks. Evidence: keyed diagnostics cover form subscription capture, provider sync retry, provider webhooks, and segment evaluation with translated output and focused tests.DoneMHigh§2, §4
Hide/block Fake provider adapter in production (unsigned webhook)DoneSHigh§2, §4
Add expiry + single-use burn (used_at) to unsubscribe/preference tokens — Shipped: unsubscribe/preference tokens use public_tokens.token_expiry_hours; confirm/unsubscribe stamp used_at and replay returns 404; preference-center view/update tokens remain reusable until expiry for self-service UX. Covered by PreferenceCenterActionTest + NewsletterLifecycleActionTest.DoneSHigh§2, §4
Reference FormSubmitted directly + test the listener actually fires — Shipped: NewsletterServiceProvider registers Event::listen(FormSubmitted::class, SubscribeFromFormSubmission::class) directly, and NewsletterLifecycleActionTest asserts the raw Laravel listener registration before dispatching a real FormSubmitted event that creates a subscriber.DoneSHigh§2, §4
Done/Shipped: Honest marketplace summary + composer description (align the two)DoneSMed§5
Done/Shipped: Capture the remaining 12 marketplace screenshotsDoneSMed§1, §5
Done/Shipped: Decide & ship send strategy — reposition as scheduling + external delivery handoffDoneLHigh§3 — BuildNewsletterSendHandoffPayloadAction exposes a concrete external_handoff contract for downstream delivery workers, config declares sends.delivery_strategy, and README/overview/manifest copy now describe scheduled sends as handoff inputs rather than an in-house sender.
Done/Shipped: Global suppression list for bounced/complained (block re-sync/re-subscribe)DoneMHigh§3, §4 — SubscriberStatus::isGloballySuppressed() now treats suppressed, bounced, and complained subscribers as hard blocks. Upserts keep suppressed addresses suppressed even with new consent evidence, queueing skips provider sync attempts, and already-queued sync attempts fail permanently before adapter calls.
Done/Shipped: List-Unsubscribe / RFC 8058 one-click unsubscribe surfaceDoneMHigh§3 — BuildListUnsubscribeHeadersAction emits the one-click and manual unsubscribe URLs plus List-Unsubscribe-Post; capell-newsletter.unsubscribe.one-click accepts CSRF-exempt POSTs, records list_unsubscribe_one_click consent evidence, burns the token, dispatches unsubscribe lifecycle events, and rejects replay.
Shipped 2026-06-06: Public-output safety test for preference-center HTML (anon/non-admin)DoneSMed§4
Done/Shipped: Sync-retry exhaustion: terminal state + admin/health signalDoneMMed§2, §4 — failed provider sync attempts become SyncStatus::Exhausted once retry delays are spent; overview failure stats include exhausted attempts, and newsletter.provider-sync-retry reports exhausted attempts as a failed health diagnostic.
Done/Shipped: Emit domain events (Confirmed/Unsubscribed) for automation hooksDoneMMed§3 — SubscriberConfirmed and SubscriberUnsubscribed dispatch after public confirmation/unsubscribe flows update subscriber state, record consent events, queue provider sync, and sync Contacts.
Campaign analytics (delivered/open/click/unsub per send & segment)LaterLHigh§3
GDPR subject export/erasure ActionLaterMMed§3, §4
ESP 429/Retry-After backoff in adaptersLaterMMed§2
Localise preference center + chunk/queue large CSV importsLaterMLow§2, §4