From 2ca40b1a399c293c851296d7f6c7cd3c382cf299 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Tue, 1 Sep 2026 13:51:26 +1000 Subject: [PATCH 1/2] Document InkAds marketing contact integration with PostKit. Adds integration guide with API base URL, host profile, request shape, and PR-preview behaviour for POC-259. Closes #93 Co-authored-by: Cursor --- apps/api/README.md | 5 +- docs/README.md | 2 + docs/integrations/inkads-marketing.md | 86 +++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 docs/integrations/inkads-marketing.md diff --git a/apps/api/README.md b/apps/api/README.md index 31d7474..fd39267 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -22,5 +22,6 @@ pnpm --filter @singleton-sd/post-kit-api test pnpm --filter @singleton-sd/post-kit-api start ``` -See [`docs/email-forward-email.md`](../../docs/email-forward-email.md) and -[`infra/README.md`](../../infra/README.md). +See [`docs/email-forward-email.md`](../../docs/email-forward-email.md), +[`docs/integrations/inkads-marketing.md`](../../docs/integrations/inkads-marketing.md), +and [`infra/README.md`](../../infra/README.md). diff --git a/docs/README.md b/docs/README.md index ba1be69..4e058bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,8 @@ docs/ │ ├── template-publishing.md (post-kit-publish, blob layout, environments) │ ├── api-quickstart.md (send API + client quick start) │ └── public-forms.md (public web forms: trusted server endpoint pattern) +├── integrations/ +│ └── inkads-marketing.md (InkAds PoC site → POST /contact) ├── onboarding/ │ ├── tenant-onboarding.md (new tenant → first email) │ └── environments.md (dev/staging/prod separation, local dev) diff --git a/docs/integrations/inkads-marketing.md b/docs/integrations/inkads-marketing.md new file mode 100644 index 0000000..b3e0b05 --- /dev/null +++ b/docs/integrations/inkads-marketing.md @@ -0,0 +1,86 @@ +# InkAds marketing site — PostKit contact integration + +Consumer: [`singleton-sd/poc-inkads-marketing`](https://github.com/singleton-sd/poc-inkads-marketing) + +ClickUp: [POC-259](https://app.clickup.com/t/86d42mwdr) + +## Public API base URL + +InkAds Astro builds set `PUBLIC_POSTKIT_API_BASE_URL` to the shared PostKit +Function App: + +```text +https://ssd-postkit-api-prod-ae.azurewebsites.net +``` + +The contact form posts JSON to `{PUBLIC_POSTKIT_API_BASE_URL}/contact` with an +`Origin` header matching the page host. PostKit applies CORS, per-IP rate +limits, and routes the message to the InkAds inbox using the host profile below. + +Health check (no auth): + +```bash +curl -fsS "https://ssd-postkit-api-prod-ae.azurewebsites.net/api/health" +``` + +## InkAds host profile (App Configuration) + +Seeded in [`infra/appconfig-seed.json`](../../infra/appconfig-seed.json): + +| Host | From | Inbox | +| --- | --- | --- | +| `inkads.poc.singletonsd.com` | `noreply@mail.inkads.poc.singletonsd.com` | `inkads-support@singletonsd.com` | + +`app:email:origins` includes `*.poc.singletonsd.com`, so production and +`inkads.poc.singletonsd.com/pr-preview/pr-*` previews share the same allowed +origin host. + +## Request shape + +`POST /contact` body (browser → PostKit): + +```json +{ + "name": "Jane Example", + "email": "jane@venue.example", + "subject": "partnership", + "message": "Venue / company: Example Pub\n\nMessage text…" +} +``` + +`subject` must be one of: `general`, `sales`, `support`, `partnership`. + +InkAds maps the contact form role select to these values (venue → +`partnership`, advertiser → `sales`, other → `general`). + +## PR preview behaviour + +PR previews are served on `inkads.poc.singletonsd.com` (subpath previews), not +raw `azurestaticapps.net` hosts. Contact submissions from previews therefore use +the production email provider and the InkAds inbox, subject to PostKit rate +limits (`app:email:rateLimitPerMin`). + +To capture instead of send on localhost dev, use `http://localhost:4321` — PostKit +returns success via `DevelopmentEmailProvider` without outbound email. + +Optional operator override: `EMAIL_ALLOW_PREVIEW_SEND=true` on the Function App +also allows real sends from `azurestaticapps.net` and localhost (see +[`apps/api/src/contact.ts`](../../apps/api/src/contact.ts)). + +## Operator verification + +After deploying App Configuration changes: + +1. `curl -fsS https://ssd-postkit-api-prod-ae.azurewebsites.net/api/health` +2. From an allowed origin, smoke `POST /contact` with a valid body and confirm + delivery to `inkads-support@singletonsd.com` (or dev capture on localhost). + +`/contact` sends plain-text email directly — it does **not** use the +`marketing.contact-us` template or `POST /emails/send`. Template publishing is +only required for authenticated `SendRequest` flows. + +## Related docs + +- [`docs/guides/public-forms.md`](../guides/public-forms.md) +- [`docs/email-forward-email.md`](../email-forward-email.md) +- Platform Kit reference: `plattform-kit` `docs/marketing-astro-decap.md` From dee69ce2334d69f0997e92418f63624f34072735 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 4 Sep 2026 11:19:10 +1000 Subject: [PATCH 2/2] fix(contact): gate same-host PR-preview sends separately from production Origin cannot see /pr-preview/ paths; accept X-PostKit-Contact-Preview (and Referer when present), document the InkAds client contract, and index the guide in the Topic docs table. Co-authored-by: Cursor --- apps/api/src/contact.spec.ts | 63 ++++++++++++++++++++++ apps/api/src/contact.ts | 78 ++++++++++++++++++++++----- apps/api/src/functions/contact.ts | 10 +++- docs/README.md | 1 + docs/integrations/inkads-marketing.md | 41 +++++++++++--- 5 files changed, 173 insertions(+), 20 deletions(-) diff --git a/apps/api/src/contact.spec.ts b/apps/api/src/contact.spec.ts index 5f41c0b..4dfd3a1 100644 --- a/apps/api/src/contact.spec.ts +++ b/apps/api/src/contact.spec.ts @@ -4,6 +4,7 @@ import { DevelopmentEmailProvider } from '@singleton-sd/post-kit-email'; import { buildContactEmailRequest, contactCorsHeaders, + isPreviewContactTraffic, resolveContactEmailProvider, resolveTrustedContactHost, submitContactInquiry, @@ -58,6 +59,67 @@ describe('contact send', () => { assert.equal(provider.name, 'development'); }); + it('forces development provider for same-host PR previews via preview header', () => { + const provider = resolveContactEmailProvider( + 'https://inkads.poc.singletonsd.com', + { + EMAIL_PROVIDER: 'forward-email', + FORWARD_EMAIL_TOKEN: 'secret', + EMAIL_ALLOW_PRODUCTION_SEND: 'true', + }, + { previewHeader: 'true' }, + ); + assert.equal(provider.name, 'development'); + }); + + it('forces development provider when Referer path is /pr-preview/', () => { + const provider = resolveContactEmailProvider( + 'https://inkads.poc.singletonsd.com', + { + EMAIL_PROVIDER: 'forward-email', + FORWARD_EMAIL_TOKEN: 'secret', + EMAIL_ALLOW_PRODUCTION_SEND: 'true', + }, + { + requestReferer: 'https://inkads.poc.singletonsd.com/pr-preview/pr-72/', + }, + ); + assert.equal(provider.name, 'development'); + }); + + it('keeps the configured provider for production origin without preview signals', () => { + const provider = resolveContactEmailProvider('https://inkads.poc.singletonsd.com', { + EMAIL_PROVIDER: 'forward-email', + FORWARD_EMAIL_TOKEN: 'secret', + EMAIL_ALLOW_PRODUCTION_SEND: 'true', + }); + assert.equal(provider.name, 'forward-email'); + assert.equal( + isPreviewContactTraffic('https://inkads.poc.singletonsd.com', { + requestReferer: 'https://inkads.poc.singletonsd.com/contact', + }), + false, + ); + }); + + it('allows real preview sends when EMAIL_ALLOW_PREVIEW_SEND=true', () => { + const provider = resolveContactEmailProvider( + 'https://inkads.poc.singletonsd.com', + { + EMAIL_PROVIDER: 'forward-email', + FORWARD_EMAIL_TOKEN: 'secret', + EMAIL_ALLOW_PRODUCTION_SEND: 'true', + EMAIL_ALLOW_PREVIEW_SEND: 'true', + }, + { previewHeader: '1' }, + ); + assert.equal(provider.name, 'forward-email'); + assert.equal( + isPreviewContactTraffic('https://inkads.poc.singletonsd.com', { previewHeader: '1' }), + true, + ); + }); + it('applies host-based sender profile override when configured', async () => { const email = new DevelopmentEmailProvider({ logMetadata: false }); const result = await submitContactInquiry( @@ -149,6 +211,7 @@ describe('contactCorsHeaders', () => { headers['Access-Control-Allow-Origin'], 'https://plattform-kit.poc.singletonsd.com', ); + assert.match(headers['Access-Control-Allow-Headers'] ?? '', /x-postkit-contact-preview/i); }); }); diff --git a/apps/api/src/contact.ts b/apps/api/src/contact.ts index ee64328..f137d3b 100644 --- a/apps/api/src/contact.ts +++ b/apps/api/src/contact.ts @@ -46,22 +46,69 @@ export function resolveTrustedContactHost( } /** - * Preview SWA hosts must not trigger real outbound email against the shared + * Header marketing PR-preview pages must set so PostKit can tell them apart from + * production on the same host. `Origin` never includes the URL path, and the + * default browser Referrer-Policy strips the path on cross-origin POSTs, so a + * dedicated header is the only reliable same-host preview signal. + */ +export const CONTACT_PREVIEW_HEADER = 'x-postkit-contact-preview'; + +/** + * True when the request should use DevelopmentEmailProvider unless the operator + * has set EMAIL_ALLOW_PREVIEW_SEND=true. + * + * Covers: + * - SWA default / PR hosts (`*.azurestaticapps.net`) + * - localhost + * - same-host path previews that send `X-PostKit-Contact-Preview: 1|true` + * - Referer paths containing `/pr-preview/` when a full Referer URL is present + */ +export function isPreviewContactTraffic( + requestOrigin: string | null | undefined, + options: { + requestReferer?: string | null; + previewHeader?: string | null; + } = {}, +): boolean { + const previewHeader = options.previewHeader?.trim().toLowerCase(); + if (previewHeader === '1' || previewHeader === 'true') { + return true; + } + + if (options.requestReferer) { + try { + const refererPath = new URL(options.requestReferer).pathname; + if (/(?:^|\/)pr-preview(?:\/|$)/.test(refererPath)) { + return true; + } + } catch { + // ignore malformed Referer + } + } + + if (!requestOrigin) return false; + try { + const host = new URL(requestOrigin).host.toLowerCase(); + return host.endsWith('.azurestaticapps.net') || host.startsWith('localhost'); + } catch { + return false; + } +} + +/** + * Preview traffic must not trigger real outbound email against the shared * Function App unless EMAIL_ALLOW_PREVIEW_SEND=true. */ export function resolveContactEmailProvider( requestOrigin: string | null, env: NodeJS.ProcessEnv = process.env, + options: { + requestReferer?: string | null; + previewHeader?: string | null; + } = {}, ): EmailProvider { - if (requestOrigin && env.EMAIL_ALLOW_PREVIEW_SEND !== 'true') { - try { - const host = new URL(requestOrigin).host.toLowerCase(); - if (host.endsWith('.azurestaticapps.net') || host.startsWith('localhost')) { - return new DevelopmentEmailProvider({ logMetadata: true }); - } - } catch { - // fall through to configured provider - } + if (env.EMAIL_ALLOW_PREVIEW_SEND !== 'true' && isPreviewContactTraffic(requestOrigin, options)) { + return new DevelopmentEmailProvider({ logMetadata: true }); } return createEmailProvider(env); } @@ -70,6 +117,8 @@ export async function submitContactInquiry( body: unknown, options: { requestOrigin?: string | null; + requestReferer?: string | null; + previewHeader?: string | null; email?: EmailProvider; env?: NodeJS.ProcessEnv; } = {}, @@ -82,7 +131,12 @@ export async function submitContactInquiry( } const env = options.env ?? process.env; - const email = options.email ?? resolveContactEmailProvider(options.requestOrigin ?? null, env); + const email = + options.email ?? + resolveContactEmailProvider(options.requestOrigin ?? null, env, { + requestReferer: options.requestReferer, + previewHeader: options.previewHeader, + }); const result = await sendContactInquiryEmail(validated.value, email, env, { trustedRequestHost: resolveTrustedContactHost(options.requestOrigin ?? null, env), }); @@ -93,7 +147,7 @@ export async function submitContactInquiry( export function contactCorsHeaders(requestOrigin: string | null): Record { const headers: Record = { 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Accept', + 'Access-Control-Allow-Headers': `Content-Type, Accept, ${CONTACT_PREVIEW_HEADER}`, 'Access-Control-Max-Age': '86400', }; if (!requestOrigin) return headers; diff --git a/apps/api/src/functions/contact.ts b/apps/api/src/functions/contact.ts index 4eeaa5f..a6c3a76 100644 --- a/apps/api/src/functions/contact.ts +++ b/apps/api/src/functions/contact.ts @@ -1,7 +1,7 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { EmailProviderError } from '@singleton-sd/post-kit-email'; import { ensureAppConfiguration } from '../config/app-configuration'; -import { contactCorsHeaders, submitContactInquiry } from '../contact'; +import { CONTACT_PREVIEW_HEADER, contactCorsHeaders, submitContactInquiry } from '../contact'; import { clientIpFromHeaders, getContactRateLimiter } from '../contact-rate-limit'; import { createLogger, resolveCorrelationId } from '../telemetry'; @@ -16,6 +16,8 @@ export async function contactHandler( logger.info('contact.request.received'); const origin = request.headers.get('origin'); + const referer = request.headers.get('referer'); + const previewHeader = request.headers.get(CONTACT_PREVIEW_HEADER); try { await ensureAppConfiguration(); } catch (error) { @@ -73,7 +75,11 @@ export async function contactHandler( try { const body = await request.json().catch(() => null); - const result = await submitContactInquiry(body, { requestOrigin: origin }); + const result = await submitContactInquiry(body, { + requestOrigin: origin, + requestReferer: referer, + previewHeader, + }); const durationMs = Date.now() - startMs; logger.info('contact.request.completed', { outcome: 'sent', durationMs }); return { diff --git a/docs/README.md b/docs/README.md index 4e058bb..ec45df0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,3 +71,4 @@ docs/ | [`operations/troubleshooting.md`](./operations/troubleshooting.md) | `POST /emails/send` error triage, correlation-ID tracing, incident runbooks | | [`operations/send-metrics-queries.md`](./operations/send-metrics-queries.md) | Kusto queries for send volume, success rate, provider failures, latency, duplicates | | [`guides/public-forms.md`](./guides/public-forms.md) | Public web forms (Contact Us, waitlist): trusted server endpoint pattern, credential-exposure anti-patterns, consumer-side validation / abuse / rate-limit duties | +| [`integrations/inkads-marketing.md`](./integrations/inkads-marketing.md) | InkAds PoC site → `POST /contact` integration | diff --git a/docs/integrations/inkads-marketing.md b/docs/integrations/inkads-marketing.md index b3e0b05..ebe1645 100644 --- a/docs/integrations/inkads-marketing.md +++ b/docs/integrations/inkads-marketing.md @@ -55,16 +55,45 @@ InkAds maps the contact form role select to these values (venue → ## PR preview behaviour -PR previews are served on `inkads.poc.singletonsd.com` (subpath previews), not -raw `azurestaticapps.net` hosts. Contact submissions from previews therefore use -the production email provider and the InkAds inbox, subject to PostKit rate -limits (`app:email:rateLimitPerMin`). +PR previews are served on `inkads.poc.singletonsd.com` under `/pr-preview/pr-*`, +not raw `azurestaticapps.net` hosts. The HTTP `Origin` header is only +`scheme://host[:port]` — it never includes that path — so PostKit cannot tell +production and same-host preview apart from `Origin` alone. The default browser +Referrer-Policy also strips the path on cross-origin POSTs to the Function App. -To capture instead of send on localhost dev, use `http://localhost:4321` — PostKit +**Required for preview pages:** the InkAds client must send: + +```http +X-PostKit-Contact-Preview: true +``` + +When that header is present (or when `Origin` is `*.azurestaticapps.net` / +`localhost`), PostKit uses `DevelopmentEmailProvider` and does **not** deliver +to `inkads-support@singletonsd.com`, unless the operator override below is set. +Production pages omit the header and use the configured provider + InkAds inbox +(rate-limited via `app:email:rateLimitPerMin`). + +CORS allows the header (`Access-Control-Allow-Headers` includes +`x-postkit-contact-preview`). Example fetch from a preview page: + +```ts +await fetch(`${import.meta.env.PUBLIC_POSTKIT_API_BASE_URL}/contact`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(location.pathname.includes('/pr-preview/') + ? { 'X-PostKit-Contact-Preview': 'true' } + : {}), + }, + body: JSON.stringify(payload), +}); +``` + +Localhost (`http://localhost:4321`) is always treated as preview traffic and returns success via `DevelopmentEmailProvider` without outbound email. Optional operator override: `EMAIL_ALLOW_PREVIEW_SEND=true` on the Function App -also allows real sends from `azurestaticapps.net` and localhost (see +allows real sends from preview hosts / preview-marked requests (see [`apps/api/src/contact.ts`](../../apps/api/src/contact.ts)). ## Operator verification