Eloquent

Documentation

<!-- AUTO-GENERATED from packages/@elqnt/notifications/SKILL.md — do not edit here. Run `pnpm docs:sync`. -->
npm install @elqnt/notifications

Notifications — Sending Email from a Custom Frontend

Eloquent's notifications package wraps the platform's outbound notification surface. Today it exposes one thing: sending a pre-rendered notification email through the API Gateway to the integrations service. This skill is only about driving that through the @elqnt/notifications package.

Package is @elqnt/notifications. It exports exactly one hook: useEmail. The body you send is already rendered{ to, subject, body }. The package does not template, queue, schedule, or fan-out; for bulk/newsletter flows the gateway has separate routes (/api/v1/email/bulk-send, /api/v1/newsletter/...) not yet wrapped by this package.


⛔ The contract — read this before writing any code

This skill ships inside the package, so the contract is not a separate file to keep in sync — it is the package's own published type declarations (@elqnt/notificationsdist/**/*.d.ts). Because your app imports the real hook, the TypeScript compiler enforces the contract for you: a wrong param or a misused return value won't compile.

The single source of truth, in order:

  1. import { useEmail } from "@elqnt/notifications/hooks" — the only hook.
  2. import type { UseEmailOptions, UseEmailReturn } from "@elqnt/notifications/hooks" — the named, exact surface of the hook (every method, its params, its return type). The table below is a human-readable mirror of these.
  3. import type { SendEmailRequest, SendEmailResponse } from "@elqnt/notifications/models" — the request/response DTOs. (Also re-exported from the root @elqnt/notifications for type-only/SSR imports.)

Rules (do not drift):

  1. Use only the exported hook and only the method it exposes (send). Do not invent hooks, methods, params, or return shapes — if it's not on UseEmailReturn, it doesn't exist.
  2. Let the compiler check you. Build with tsc; never as any / @ts-ignore your way around the hook's types.
  3. Never bypass the hook. No fetch/axios to /api/v1/email/..., no NATS, no direct service calls.
  4. Wrap, don't scatter. The hook is imported in exactly one app file (hooks/use-<domain>.ts); see "The domain layer".
  5. body is already rendered. The package sends what you give it; rendering HTML/markdown is the app's job.

The prose/table below explains the method; the package's shipped .d.ts is what your code type-checks against.


Architecture — the one and only path

A component never calls the package hook directly. It only sees your domain intent (e.g. "notify these users"), served by your app hook, which is the only place that wraps useEmail.

┌──────────────────────────────────────────────────────────────────────────────┐
│  YOUR CUSTOM APP (Next.js / React)                                             │
│                                                                                │
│  SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID               │
│       └─► AppConfigProvider { apiGatewayUrl, orgId }                           │
│                                                                                │
│  components/*            ── trigger "send a notification" intents              │
│         │                                                                       │
│         ▼                                                                       │
│  hooks/use-notify.ts     ── app hook: the only file importing @elqnt/notifications│
│         │                                                                       │
│         ▼                                                                       │
│  useEmail               (@elqnt/notifications/hooks)                           │
│         │                                                                       │
│         ▼                                                                       │
│  browserApiRequest      (@elqnt/api-client/browser)                           │
│         │  getGatewayToken() ⇒ GET /api/gateway-token (mint HS256 JWT, JWT_SECRET)│
│         │  attaches: Authorization: Bearer <jwt>, X-Org-ID, X-User-ID, X-Product│
└─────────┼──────────────────────────────────────────────────────────────────────┘
          ▼
   ┌─────────────────┐   verify JWT, stamp X-Org-ID/X-Product, route match
   │   API GATEWAY   │   POST /api/v1/email/send → integrations svc
   └───────┬─────────┘
           ▼
   ┌─────────────────┐   renders nothing — sends the pre-rendered body
   │ integrations(Go)│   via the configured email provider
   └─────────────────┘

Rules: the frontend never calls the integrations service directly and never touches NATS. Every call is HTTP through the gateway carrying an org id

  • gateway token. And components never import @elqnt/notifications — only the app hook does.

The gateway token (the secret) — how the custom app gets it

Every request to the gateway needs Authorization: Bearer <token>: a short-lived HS256 JWT signed with the shared gateway secret. You never hardcode it.

Browser flow (what the hook uses)

The hook → browserApiRequestgetGatewayToken() internally. You do not pass a token to the hook. getGatewayToken() (from @elqnt/api-client/browser) by default does:

fetch("/api/gateway-token")   ⇒  { token, expiresIn }

So your custom app must expose a /api/gateway-token route that mints the JWT server-side (the secret stays on the server, never reaches the browser). The client caches the token and refreshes ~5 min before expiry.

// app/api/gateway-token/route.ts  (Next.js route handler — server only)
import { NextResponse } from "next/server";
import * as jose from "jose";

export async function GET() {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET!);  // SAME secret the gateway validates with
  const token = await new jose.SignJWT({
    org_id:  process.env.ORG_ID!,
    user_id: "system",
    email:   "system@my-app.com",
    role:    "system",
    scopes:  ["write:notifications"],   // OR-matched against the route's required scopes
    product: "my-product",              // gateway resolves product from THIS claim first
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setIssuer("eloquent-gateway")     // must match gateway JWT_ISSUER
    .setAudience("eloquent-api")       // must match gateway JWT_AUDIENCE
    .setExpirationTime("1h")
    .sign(secret);

  return NextResponse.json({ token, expiresIn: 3600 });
}

The gateway re-stamps X-Org-ID / X-User-ID / X-Product from the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative.

Override the token source (mobile/native, or a non-default URL) once at startup:

import { configureAuth } from "@elqnt/api-client/browser";
configureAuth(async () => myTokenProvider());     // URL string or async () => token|null

Server flow (server actions / SSR)

The useEmail hook is browser-only ("use client"). For SSR/server actions, send via @elqnt/api-client/server's createServerClient (mints the JWT itself with JWT_SECRET — no /api/gateway-token hop) and POST /api/v1/email/send directly.

import { createServerClient } from "@elqnt/api-client/server";

const client = createServerClient({
  gatewayUrl: process.env.API_GATEWAY_URL_INTERNAL!,  // in-cluster gateway URL (server-side)
  jwtSecret:  process.env.JWT_SECRET!,                // the shared gateway secret
  defaultProduct: "my-product",                        // gateway reads product from the JWT claim first
  defaultScopes: ["read", "write"],
});

await client.post("/api/v1/email/send",
  { to: ["user@example.com"], subject: "Welcome", body: "<p>Hi</p>" },
  { orgId });

Env vars

VarUsed byPurpose
JWT_SECRET/api/gateway-token route + createServerClientsign the gateway JWT (same value the gateway validates with)
API_GATEWAY_URL_INTERNALcreateServerClient gatewayUrlin-cluster gateway URL (server)
API_GATEWAY_URL_PUBLICSSR layout → AppConfigProvider → browser baseUrlpublic gateway URL, read at request time (not NEXT_PUBLIC_*)
ORG_IDtoken route / app configthe org all requests are scoped to

Headers the API layer sets per request

From hook optionHeader
auto tokenAuthorization: Bearer <token>
orgIdX-Org-ID
userIdX-User-ID
userEmailX-User-Email
product (default "eloquent")X-Product

Hook options

There is no provider/context in this package. You pass options into the hook call. UseEmailOptions is the shared ApiClientOptions:

interface ApiClientOptions {
  baseUrl: string;     // API Gateway base URL — required
  orgId: string;       // required → X-Org-ID
  userId?: string;     // → X-User-ID
  userEmail?: string;  // → X-User-Email
  product?: string;    // → X-Product, defaults to "eloquent"
  headers?: Record<string, string>;
}

type UseEmailOptions = ApiClientOptions;

Imperative, not auto-fetching. The hook does not send anything on mount. You call send(...) on demand. The hook exposes loading / error flags. On failure send returns null and sets error — it does not throw.


Hook: useEmail

import { useEmail } from "@elqnt/notifications/hooks";

const email = useEmail({ baseUrl, orgId, product: "my-product" });

Returns { loading, error, send } (UseEmailReturn):

MemberSignatureResolves toEndpoint
loadingboolean
errorstring | null
send(request: SendEmailRequest) => Promise<SendEmailResponse | null>null on error (sets error)POST /api/v1/email/send
const res = await email.send({
  to: ["user@example.com", "ops@example.com"],
  subject: "Your report is ready",
  body: "<p>Hi — your weekly report is attached.</p>",   // already rendered
});
if (!res || !res.success) {
  // email.error is set; surface it
}

Types (@elqnt/notifications/models)

Generated from Go via tygo generate. Never edit models/email.ts manually.

/** Request for sending a pre-rendered notification email. */
interface SendEmailRequest {
  to: string[];      // one or more recipient addresses
  subject: string;
  body: string;      // already-rendered HTML/text
}

/** Response from sending an email. */
interface SendEmailResponse {
  success: boolean;
  message?: string;  // provider/error detail when present
}

The domain layer

Wrap the hook in one app file; components speak your intent, not SendEmailRequest.

// hooks/use-notify.ts
"use client";
import { useEmail } from "@elqnt/notifications/hooks";
import { useAppConfig } from "@/contexts/app-config-context";

export function useNotify() {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const email = useEmail({ baseUrl: apiGatewayUrl, orgId, product: "my-product" });

  return {
    loading: email.loading,
    error: email.error,
    async notifyReportReady(to: string[], reportName: string): Promise<boolean> {
      const res = await email.send({
        to,
        subject: `Your report "${reportName}" is ready`,
        body: renderReportEmail(reportName),   // your app renders the body
      });
      return !!res?.success;
    },
  };
}

export type UseNotifyReturn = ReturnType<typeof useNotify>;

{ apiGatewayUrl, orgId } come from an AppConfigProvider fed by SSR (API_GATEWAY_URL_PUBLIC read at request time — never a NEXT_PUBLIC_* var). See the @elqnt/entity SKILL.md for the full AppConfigProvider pattern.


Gotchas

  • One hook, one method. Only useEmail().send. Bulk-send and newsletter flows exist on the gateway but are not wrapped here yet — don't invent sendBulk/useNewsletter.
  • body is pre-rendered. The package/service does not template. Render the HTML/text in your app before calling send.
  • send doesn't throw. It returns null and sets error on failure (and a non-null result may still have success: false — check it).
  • Hook is browser-only ("use client"). For SSR/server actions, POST /api/v1/email/send via createServerClient — not the hook.
  • Root import is models-only. @elqnt/notifications re-exports only the types; the hook is reached only via @elqnt/notifications/hooks. Importing the hook from the root would drag "use client" into server components.
  • product matters. The gateway resolves product from the JWT claim (server) and X-Product (browser). It scopes which org/provider config is used.