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
6 changes: 4 additions & 2 deletions docs/architecture/template-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ the body.
Consumer repository
content/email-templates/<dir>/{template.json, metadata.json, preview.json}
|
| edit (by hand today; the editor package is not implemented — see #5)
| edit (by hand or via @singleton-sd/post-kit-editor in a consumer admin)
v
Pull request in the consumer repository
|
Expand Down Expand Up @@ -158,4 +158,6 @@ surfaces as `404 TEMPLATE_NOT_FOUND`. See

Template source may be authored by hand or with
[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor/README.md)
(published on npmjs; epic [#5](https://github.com/singleton-sd/post-kit/issues/5)).
(published on npmjs). Embed pattern:
[`examples/admin-editor`](../../examples/admin-editor/) and
[`guides/editor-integration.md`](../guides/editor-integration.md).
6 changes: 4 additions & 2 deletions docs/guides/editor-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ Some teams stage drafts outside the publish branch:
Keep staging credentials and PostKit send credentials on the server. The
browser only talks to your admin API with the user’s session.

See [`examples/admin-editor/`](../../examples/admin-editor/) for an
in-memory load/save adapter that mirrors the callback contract without I/O.
See [`examples/admin-editor/`](../../examples/admin-editor/) for list/load from
`content/email-templates/`, a filesystem save stub (with PR reminder), an
in-memory UI adapter, and a server-only Send-test BFF
(`handleSendTest` + `POSTKIT_API_KEY`).

## Preview vs send-time Handlebars

Expand Down
5 changes: 3 additions & 2 deletions docs/onboarding/tenant-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,5 +376,6 @@ Deeper operational triage is being written under
| Onboarding automation (token minting, tenant bootstrap CLI) | None. Every step above that touches configuration is manual. |

`@singleton-sd/post-kit-client` and `@singleton-sd/post-kit-editor` are
published on npmjs. Further editor epic work (if any) is tracked by
[#5](https://github.com/singleton-sd/post-kit/issues/5).
published on npmjs. Admin embedding (list/load/save + Send-test BFF) is
demonstrated in [`examples/admin-editor`](../../examples/admin-editor/);
onboarding epic: [#7](https://github.com/singleton-sd/post-kit/issues/7).
139 changes: 112 additions & 27 deletions examples/admin-editor/App.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,128 @@
/**
* Minimal React embedding for `@singleton-sd/post-kit-editor`.
* Reference React host for `@singleton-sd/post-kit-editor`.
*
* Persistence is the in-memory adapter under `./src/memory-persistence.ts`.
* No PostKit credentials, no network, no Send-test chrome.
* - List/load: seed from the filesystem store in Node tests / server wiring;
* this component takes a preloaded catalog for the embedding demo.
* - Save: `onSave` → your API → Git/PR (here: in-memory adapter for the demo).
* - Send-test: pass `sendTest` only when your BFF is configured; omitting it
* hides Send-test chrome (see `postSendTestToBff` + README).
*
* Never pass API keys into this module or the editor props.
*/
import { useEffect, useRef, useState } from 'react';
import {
EmailTemplateEditor,
loadTemplateSource,
type SerializedTemplateSource,
type TemplateSourceFiles,
type SendTestResult,
} from '@singleton-sd/post-kit-editor';

import { createMemoryPersistence, toOnSave } from './src/memory-persistence';
import {
createMemoryPersistence,
reconcileSelectedKey,
toOnSave,
type MemoryPersistence,
} from './src/memory-persistence';

export interface AdminEditorExampleProps {
/** Catalog of templates the admin may open (from Git / your list API). */
templates: TemplateSourceFiles[];
/**
* Optional Send-test callback. Omit when the trusted BFF / env is not
* configured so the editor hides Send-test. Use {@link postSendTestToBff}
* once `POSTKIT_API_*` is available server-side.
*/
sendTest?: (
serialized: SerializedTemplateSource,
files: TemplateSourceFiles,
recipient: string,
) => Promise<SendTestResult | void> | SendTestResult | void;
}

/** Browser helper that POSTs to the consumer Send-test BFF (no secrets). */
export async function postSendTestToBff(
_serialized: SerializedTemplateSource,
files: TemplateSourceFiles,
recipient: string,
): Promise<SendTestResult> {
const res = await fetch('/api/email-templates/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateKey: files.metadata.key,
to: recipient,
variables: files.previewData,
}),
});
if (!res.ok) {
let detail = 'Send-test failed.';
try {
const body = (await res.json()) as { error?: string };
if (typeof body.error === 'string' && body.error.length > 0) {
detail = body.error;
}
} catch {
// keep generic message
}
return { ok: false, message: detail };
}
return { ok: true };
}

export function AdminEditorExample({ templates, sendTest }: AdminEditorExampleProps) {
if (templates.length === 0) {
throw new Error('AdminEditorExample requires at least one template.');
}

import templateJson from './sample/template.json';
import metadata from './sample/metadata.json';
import previewData from './sample/preview.json';
const catalogKeys = templates.map((t) => t.metadata.key);
const persistenceRef = useRef<MemoryPersistence | null>(null);
if (persistenceRef.current === null) {
const seed: Record<string, TemplateSourceFiles> = {};
for (const t of templates) {
seed[t.metadata.key] = t;
}
persistenceRef.current = createMemoryPersistence(seed);
} else {
persistenceRef.current.syncCatalog(templates);
}
const persistence = persistenceRef.current;

const seed: TemplateSourceFiles = loadTemplateSource({
templateJson,
metadata,
previewData,
});
const [selectedKey, setSelectedKey] = useState(() => catalogKeys[0]!);

const persistence = createMemoryPersistence({ [seed.metadata.key]: seed });
useEffect(() => {
setSelectedKey((current) => reconcileSelectedKey(current, catalogKeys));
}, [catalogKeys.join('\0')]);

export function AdminEditorExample() {
const template = persistence.load(seed.metadata.key);
const effectiveKey = reconcileSelectedKey(selectedKey, catalogKeys);
const template = persistence.load(effectiveKey);
const availableVariables = template.metadata.variables.map((name) => ({
name,
label: name,
}));

return (
<EmailTemplateEditor
template={template}
availableVariables={[
{
name: 'name',
label: 'Recipient name',
description: 'Synthetic display name only',
},
]}
onSave={toOnSave(persistence)}
/>
<div className="pk-admin-editor-example">
<label>
Template{' '}
<select
value={effectiveKey}
onChange={(event) => setSelectedKey(event.target.value)}
aria-label="Select template"
>
{templates.map((t) => (
<option key={t.metadata.key} value={t.metadata.key}>
{t.metadata.name} ({t.metadata.key})
</option>
))}
</select>
</label>
<EmailTemplateEditor
key={effectiveKey}
template={template}
availableVariables={availableVariables}
onSave={toOnSave(persistence)}
{...(sendTest !== undefined ? { onSendTest: sendTest } : {})}
/>
</div>
);
}
122 changes: 94 additions & 28 deletions examples/admin-editor/README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,106 @@
# Example: admin editor embedding
# Example: admin editor + Send-test BFF

Minimal, testable host for
[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor): a React
embedding plus an in-memory persistence adapter. Not a full admin app — no
router, auth UI, or HTTP server.
Reference host for embedding
[`@singleton-sd/post-kit-editor`](../../packages/post-kit-editor) in a
**consumer** admin app (InkAds back-office, etc.). PostKit does not host an
admin CMS — your app owns list/load/save auth and Git; this example shows the
wiring.

Guide: [`docs/guides/editor-integration.md`](../../docs/guides/editor-integration.md).

## Topology (1:1 with a real admin)

```text
Admin browser
→ EmailTemplateEditor (onSave / onSendTest callbacks only)
→ Your admin API (session / SSO — not PostKit API keys)
├─ list/load/save → content/email-templates/… (or open a PR)
└─ POST …/send-test → PostKitClient + POSTKIT_API_KEY → PostKit API
Consumer CI
→ post-kit-publish → Blob (template must be published before Send-test works)
```

Never put `POSTKIT_API_KEY` in a browser bundle.

## What this proves

- `App.tsx` mounts `EmailTemplateEditor` with synthetic sample sources
(`jane@example.com` only).
- `createMemoryPersistence` implements load/save around the package’s real
contract: `onSave(serialized, files)` and structured `TemplateSourceFiles`.
- The adapter spec covers load after seed, save round-trip, and a save failure
surfaced to the caller — no browser / jsdom.
| Piece | Location |
| --- | --- |
| List / load / save on disk | `src/template-store.ts` + seeded `content/email-templates/` |
| Save stub + PR reminder | `src/save-stub.ts` |
| React host (list select + editor) | `App.tsx` (in-memory save for the UI demo) |
| Send-test BFF handler | `src/send-test-handler.ts` |
| Env → `PostKitClient` | `src/create-client-from-env.ts` |
| In-memory adapter (UI contract) | `src/memory-persistence.ts` |

## What this does not do
## Environment (server only)

| Variable | Purpose |
| --- | --- |
| `POSTKIT_API_BASE_URL` | PostKit API base URL |
| `POSTKIT_API_KEY` | Bearer credential from the **consumer** secret store |

Send-test targets the Blob templates for the tenant/environment bound to that
key. Draft-only Git files are not sendable until publish CI has run.

## Wire the BFF (Express-style sketch)

- No PostKit send credentials or `@singleton-sd/post-kit-client`
- No Git, Blob, or network I/O
- No `onSendTest` (Send-test chrome stays hidden)
Gate the route (and the editor callback) on the same env boundary. When env is
incomplete, omit `sendTest` on `AdminEditorExample` so Send-test chrome stays
hidden — do not default the browser handler on.

```ts
import express from 'express';
import {
createPostKitClientFromEnv,
isSendTestEnvConfigured,
} from './create-client-from-env';
import { handleSendTest } from './send-test-handler';

const app = express();
app.use(express.json());

app.post('/api/email-templates/send-test', async (req, res) => {
if (!isSendTestEnvConfigured()) {
res.status(503).json({ error: 'Send-test is not configured.' });
return;
}
const client = createPostKitClientFromEnv();
const result = await handleSendTest(req.body, {
client,
logError: (event, detail) => console.error(event, detail),
});
res.status(result.status).json(result.body);
});
```

When the BFF is configured, pass `sendTest={postSendTestToBff}` into
`AdminEditorExample` (POST `{ templateKey, to, variables }` — no secrets).

## Map to InkAds (or any) admin

1. Embed `EmailTemplateEditor` on an authenticated admin route (your SSO/RBAC).
2. List/load from your Git tree or admin API (`createFsTemplateStore` pattern).
3. `onSave` → trusted server → commit or GitHub App PR (this example writes
locally and logs a PR reminder via `toFsOnSave`).
4. `onSendTest` → your BFF → `handleSendTest` + `createPostKitClientFromEnv`.
5. Publish CI: adapt [`docs/examples/publish-email-templates.yml`](../../docs/examples/publish-email-templates.yml).
6. Per-environment keys so Send-test hits the right Blob prefix.

## Layout

```text
examples/admin-editor/
App.tsx # EmailTemplateEditor + memory adapter
sample/ # synthetic template / metadata / preview
App.tsx
sample/ # single-template fixture for memory tests
content/email-templates/ # multi-template list/load/save seed
src/
memory-persistence.ts # in-memory load/save
memory-persistence.spec.ts
package.json # private
README.md
memory-persistence.ts
template-store.ts
save-stub.ts
send-test-handler.ts
create-client-from-env.ts
*.spec.ts
```

## Run the tests
Expand All @@ -43,11 +111,9 @@ From the repository root:
pnpm --filter @singleton-sd/example-admin-editor test
```

`pnpm test` at the root runs it too.

## Copy into a real host
## What this does not do

1. Copy `App.tsx` (or the pattern) into your React admin route.
2. Replace `createMemoryPersistence` with a server-backed load/save that
writes `content/email-templates/<key>/` (or opens a PR / staging store).
3. Optionally add `onSendTest` that POSTs to **your** trusted server only.
- Real GitHub App / PR creation
- Consumer SSO/RBAC
- Live HTTP server in this package (handler is framework-agnostic)
- Browser-held PostKit credentials
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"key": "auth.password-reset",
"name": "Password Reset",
"subject": "Reset your password",
"description": "Sent when a user requests a password reset link",
"variables": ["name", "resetUrl"],
"schemaVersion": "1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Jane Doe",
"resetUrl": "https://app.example.com/reset?token=preview-placeholder"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"root": {
"type": "EmailLayout",
"data": {
"backdropColor": "#F8F8F8",
"canvasColor": "#FFFFFF",
"textColor": "#242424",
"fontFamily": "MODERN_SANS",
"childrenIds": ["block-greeting", "block-action"]
}
},
"block-greeting": {
"type": "Text",
"data": {
"style": {
"fontWeight": "normal",
"padding": { "top": 24, "bottom": 8, "right": 24, "left": 24 }
},
"props": {
"text": "Hi {{name}}, we received a request to reset your password."
}
}
},
"block-action": {
"type": "Text",
"data": {
"style": {
"fontWeight": "normal",
"padding": { "top": 8, "bottom": 24, "right": 24, "left": 24 }
},
"props": {
"text": "Open {{resetUrl}} to choose a new password. The link expires shortly and can be used once. If you did not request this, no action is needed."
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"key": "demo.welcome",
"name": "Welcome",
"subject": "Hello {{name}}",
"variables": ["name"],
"schemaVersion": "1"
}
Loading
Loading