# EDM internal API

Base: `{CORE_URL}/v1/internal/edm` — local dev `http://127.0.0.1:9005/v1/internal/edm`

Mounted in `apps/core/src/cmd/routes/index.ts`, handlers in
`apps/core/src/routes/edm/edm-internal.route.ts`.

Every route sits behind `apiKeyAuth()` (`apps/core/src/middlewares/apiKeyAuth.ts`).
There is no console session and no RBAC on this group — the key is the whole
credential. It is the same group the SMTP worker and the VS Code extension use.

---

## Authentication

Send header `x-api-payload`, formatted:

```
<iv hex>:<ciphertext hex + auth tag hex>
```

- AES-256-GCM
- Key: `INTERNAL_SERVER_API_KEY`, used as raw UTF-8 bytes — **must be exactly 32 bytes**
- IV: 12 random bytes, fresh per request
- Plaintext: `{"key":"<INTERNAL_SERVER_API_KEY>","ts":<Date.now()>}`
- Auth tag appended to the ciphertext, not sent separately
- `ts` older than 60 000 ms is rejected, so mint per request — never cache

A key of any length other than 32 bytes throws inside `createCipheriv` on the
caller side rather than returning 401.

### Minting the header

```js
import * as crypto from "node:crypto";

function buildApiPayload(secret) {
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(secret, "utf-8"), iv);
  const encrypted = Buffer.concat([
    cipher.update(JSON.stringify({ key: secret, ts: Date.now() }), "utf-8"),
    cipher.final(),
  ]);
  const payload = Buffer.concat([encrypted, cipher.getAuthTag()]);
  return `${iv.toString("hex")}:${payload.toString("hex")}`;
}
```

### curl

```bash
KEY='<32-byte INTERNAL_SERVER_API_KEY>'
PAYLOAD=$(node -e '
  const c=require("node:crypto"), s=process.argv[1];
  const iv=c.randomBytes(12);
  const x=c.createCipheriv("aes-256-gcm", Buffer.from(s,"utf-8"), iv);
  const e=Buffer.concat([x.update(JSON.stringify({key:s,ts:Date.now()}),"utf-8"), x.final()]);
  console.log(iv.toString("hex")+":"+Buffer.concat([e,x.getAuthTag()]).toString("hex"));
' "$KEY")

curl -s http://127.0.0.1:9005/v1/internal/edm/templates \
  -H "x-api-payload: $PAYLOAD"
```

Failure modes: missing header → `401 Unauthorized`; bad ciphertext or wrong key →
`401 Invalid Security Payload`; `ts` over 60 s old → `401 Token Expired`.

---

## Response envelope

All routes return:

```json
{ "success": true, "data": ... }
{ "success": false, "message": "..." }
```

---

## Endpoints

### `GET /templates`

Every template, local store first with SendGrid fall-through for anything not
yet backfilled. Shape matches the console list page.

`data`: `SendGridTemplate[]` — `{ id, name, generation, updated_at, versions[] }`.

`versions[].html_content` is `""` here by design. The list would otherwise cost
one object-storage read per template.

### `GET /templates/:templateId`

The **renderable payload** — what the SMTP worker fetches per send.

```json
{ "success": true,
  "data": { "templateId": "d-...", "versionId": "uuid|null",
            "subject": "Hello {{full_name}}", "html": "<!doctype html>...",
            "plain": "...", "generatePlain": true } }
```

`subject` is Handlebars source, not rendered — the caller merges it. It lives
here because nothing upstream of the worker supplies a subject.

`404` means the template is not in the local store. For the worker that is a
meaningful answer, not an error: fall back to SendGrid's `template_id`.

### `GET /templates/:templateId/full`

The **whole record including html**, for editors. Same shape as
`GET /v1/admin-console/edm/:templateId`.

`data`: `{ id, name, generation, updated_at, versions[] }` with every version's
`html_content`, `plain_content`, `subject`, `active`.

Separate from `/templates/:id` on purpose: the worker would otherwise fetch and
discard every historical version on every send.

### `GET /browse?folder_id=<uuid>`

One folder's own contents. Omit `folder_id` (or pass `root`) for the top level.

```json
{ "folder": { "id": "...", "name": "Campaigns", "parent_id": null } | null,
  "breadcrumb": [{ "id": "...", "name": "Campaigns" }],
  "folders":   [ ...direct subfolders ],
  "templates": [{ "id", "name", "source", "folder_id", "updated_at",
                  "active_version_id", "subject", "version_number" }],
  "assets":    [{ "original", "path", "url", "sha256", "usedBy": ["Template A"] }] }
```

Direct children only, not the flattened subtree — descendants are reached by
opening the child folder.

`assets` is derived, not stored. Images are content-addressed objects in one
flat bucket prefix so a shared logo is stored once and a URL already sitting in
someone's inbox never breaks. What is returned is the union of the asset
manifests of this folder's templates, deduped by storage path.

`active_version_id: null` means unpublished — nothing for the mail worker to
render, so the template cannot be sent.

### `POST /scan`

Preflight. Pure analysis, writes nothing, no database access beyond auth.

Request: `{ "html": "<!doctype html>..." }`

```json
{ "total": 12,
  "unresolved": [{ "url": "images/hero.png", "kind": "img-src" }],
  "insecure":   [{ "url": "http://...",      "kind": "css-url" }],
  "localhost":  [],
  "dataUris": 1,
  "external": ["https://cdn..."],
  "ok": false,
  "helpers": [{ "name": "formatDate", "count": 2, "hasArguments": true }] }
```

`kind` is one of `img-src`, `img-srcset`, `background-attr`, `css-url`,
`vml-src` — email hides images in all five, not just `<img>`.

`ok` covers images only. A template with clean images can still have a bad
helper, so check `helpers` separately.

`helpers` lists Handlebars block helpers the renderer does not implement.
`hasArguments: true` can only be a helper call and will make the send throw;
`false` may legitimately be a data field, and if it is not, that block renders
as nothing, silently.

### `POST /assets`

`multipart/form-data`, field `image`. Extensions: png, jpg, jpeg, gif, webp,
svg, ico.

```json
{ "original": "hero.png",
  "path": "console2-edm-assets/1a2b3c4d5e6f7890-hero.png",
  "url": "https://storage.googleapis.com/<bucket>/console2-edm-assets/...",
  "sha256": "..." }
```

Content-addressed, so re-uploading identical bytes returns the same object
rather than duplicating. Assets are **never deleted** — mail already delivered
still fetches these URLs.

### `POST /templates`

Create. Request:

```json
{ "name": "Welcome IN", "subject": "Welcome, {{full_name}}",
  "html_content": "<!doctype html>...", "plain_content": "",
  "active": 1, "generate_plain_content": true, "folder_id": null }
```

`name`, `subject`, `html_content` required. `active: 0` creates the version
without publishing it, so the worker cannot render it.

Returns the full template record. `201` on success.

`422` with `partial: true` means the template row exists but its first version
failed; `data.templateId` carries the id so it is not lost.

### `PATCH /templates/:templateId`

Update. Every field optional; omitted content fields inherit from the current
active version.

```json
{ "name": "...", "subject": "...", "html_content": "...",
  "plain_content": "...", "active": 1, "generate_plain_content": true,
  "folder_id": "uuid|null" }
```

Appends a **new immutable version** and points the template at it — an existing
version is never mutated, so what was sent stays reconstructable. Rollback is
re-pointing at an older version.

`404` if the template is not in the local store and `EDM_STORAGE_MODE` is not
`sendgrid`; run the backfill first.

---

## Relevant env

| Var | Default | Meaning |
|---|---|---|
| `INTERNAL_SERVER_API_KEY` | — | Shared secret. Exactly 32 bytes. |
| `EDM_STORAGE_MODE` | `gcs` | `sendgrid` stores new/edited templates in SendGrid instead |
| `EDM_STORAGE_DRIVER` | `gcs` | `local` writes template bytes to disk, no bucket needed |
| `EDM_LOCAL_STORAGE_DIR` | `.edm-storage` | Root for the local driver |
| `EDM_ASSET_BASE_URL` | — | CDN base for asset URLs instead of storage.googleapis.com |
| `EDM_SENDGRID_WRITES` | off | Allows deleting SendGrid templates / sending through them |

## Security

`apiKeyAuth` grants full trust and has no per-user revocation — the key on a
developer's laptop is the same key the SMTP worker uses, so losing one means
rotating everywhere. Per-user personal access tokens are the intended fix and do
not exist yet.
