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/notifications → dist/**/*.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:
import { useEmail } from "@elqnt/notifications/hooks"— the only hook.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.import type { SendEmailRequest, SendEmailResponse } from "@elqnt/notifications/models"— the request/response DTOs. (Also re-exported from the root@elqnt/notificationsfor type-only/SSR imports.)
Rules (do not drift):
- 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 onUseEmailReturn, it doesn't exist. - Let the compiler check you. Build with
tsc; neveras any/@ts-ignoreyour way around the hook's types. - Never bypass the hook. No
fetch/axiosto/api/v1/email/..., no NATS, no direct service calls. - Wrap, don't scatter. The hook is imported in exactly one app file
(
hooks/use-<domain>.ts); see "The domain layer". bodyis 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 → browserApiRequest → getGatewayToken() 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-Productfrom 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
| Var | Used by | Purpose |
|---|---|---|
JWT_SECRET | /api/gateway-token route + createServerClient | sign the gateway JWT (same value the gateway validates with) |
API_GATEWAY_URL_INTERNAL | createServerClient gatewayUrl | in-cluster gateway URL (server) |
API_GATEWAY_URL_PUBLIC | SSR layout → AppConfigProvider → browser baseUrl | public gateway URL, read at request time (not NEXT_PUBLIC_*) |
ORG_ID | token route / app config | the org all requests are scoped to |
Headers the API layer sets per request
| From hook option | Header |
|---|---|
| auto token | Authorization: Bearer <token> |
orgId | X-Org-ID |
userId | X-User-ID |
userEmail | X-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 exposesloading/errorflags. On failuresendreturnsnulland setserror— 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):
| Member | Signature | Resolves to | Endpoint |
|---|---|---|---|
loading | boolean | — | — |
error | string | 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 editmodels/email.tsmanually.
/** 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 inventsendBulk/useNewsletter. bodyis pre-rendered. The package/service does not template. Render the HTML/text in your app before callingsend.senddoesn't throw. It returnsnulland setserroron failure (and a non-null result may still havesuccess: false— check it).- Hook is browser-only (
"use client"). For SSR/server actions, POST/api/v1/email/sendviacreateServerClient— not the hook. - Root import is models-only.
@elqnt/notificationsre-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. productmatters. The gateway resolves product from the JWT claim (server) andX-Product(browser). It scopes which org/provider config is used.