Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
63 changes: 63 additions & 0 deletions apps/api/src/contact.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DevelopmentEmailProvider } from '@singleton-sd/post-kit-email';
import {
buildContactEmailRequest,
contactCorsHeaders,
isPreviewContactTraffic,
resolveContactEmailProvider,
resolveTrustedContactHost,
submitContactInquiry,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
});

Expand Down
78 changes: 66 additions & 12 deletions apps/api/src/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
} = {},
Expand All @@ -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),
});
Expand All @@ -93,7 +147,7 @@ export async function submitContactInquiry(
export function contactCorsHeaders(requestOrigin: string | null): Record<string, string> {
const headers: Record<string, string> = {
'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;
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/functions/contact.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
├── onboarding/
│ ├── tenant-onboarding.md (new tenant → first email)
│ └── environments.md (dev/staging/prod separation, local dev)
Expand Down Expand Up @@ -69,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 |
115 changes: 115 additions & 0 deletions docs/integrations/inkads-marketing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 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` 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.

**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
allows real sends from preview hosts / preview-marked requests (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`
Loading