@thejob/email-client
v0.2.5
Published
Typed TypeScript/JavaScript client for the thejob-email-service HTTP API.
Readme
@thejob/email-client
Typed TypeScript/JavaScript client for the thejob-email-service HTTP API.
Other services (and the Svelte UI) use this instead of hand-building fetch
requests.
The contract is small. A consumer does two things:
- Send mail with
send()(raw subject + body) orsendTemplate()(a named server-side template). - Inspect with
getEmail()/listEmails()to read a send's delivery status from the audit log.
The client owns the base URL, JSON plumbing, auth headers, the send envelope, and
typed errors. Zero runtime dependencies. Uses the global fetch (Node 18+, Deno,
browsers, edge).
Auth
Pass either credential to the constructor:
apiKey-> sent asx-api-key, for backend-to-backend callers.token-> sent asAuthorization: Bearer <token>, for user-facing sends.
The audit reads (getEmail, listEmails) require the API key on the server.
Install
npm install @thejob/email-clientUsage
import { EmailClient, EmailClientError } from '@thejob/email-client';
// backend-to-backend
const email = new EmailClient({
baseUrl: 'http://localhost:3085',
apiKey: process.env.EMAIL_SERVICE_API_KEY,
});
// raw send (to/cc/bcc accept a string or an array)
const rec = await email.send({
to: '[email protected]',
subject: 'Hi',
text: 'Hello',
idempotencyKey: 'welcome:user-123', // safe to retry
});
console.log(rec.status, rec.providerMessageId);
// template send
await email.sendTemplate({
to: '[email protected]',
template: 'welcome',
data: { name: 'Sam' },
});
// audit / delivery status
const page = await email.listEmails({ status: 'bounced', limit: 50 });
const one = await email.getEmail(rec.shortId);User-facing send (Bearer JWT instead of the API key):
const email = new EmailClient({ baseUrl, token: userJwt });
await email.send({ to: user.email, subject: 'Hi', html: '<p>Hello</p>' });Errors
Any non-2xx response throws EmailClientError with .status and the parsed
.body:
try {
await email.send({ to: 'bad', subject: 'x', text: 'y' });
} catch (err) {
if (err instanceof EmailClientError) {
console.error(err.status, err.body); // e.g. 400 { error, errors: [...] }
}
}Events (Redis Stream)
Beyond the HTTP client, this package ships the email-events contract + Redis
Streams transport consumed by downstream services. email-service publishes
bounce / complaint / unsubscribe / delivery events to thejob:email.events;
consumers mirror them (e.g. into a suppression list). Three subpaths:
@thejob/email-client/events— the zero-dep contract (EmailEvent,EmailEventType,parseEmailEvent,EMAIL_EVENTS_STREAM).@thejob/email-client/events/producer—createEmailEventsProducer(email-service).@thejob/email-client/events/consumer—createEmailEventsConsumer(downstream).
The producer/consumer require ioredis (a dependency of this package) and an
injected Redis URL; the bare EmailClient import pulls in neither. Durability
mirrors @thejob/auth/events: a consumer
group gives at-least-once delivery, so handlers must be idempotent.
// downstream consumer
import { createEmailEventsConsumer } from '@thejob/email-client/events/consumer';
const consumer = createEmailEventsConsumer({ url: EMAIL_REDIS_URL, group: 'my-service' });
consumer.on('email.bounced', async (e) => suppress(e.email));
consumer.on('email.complained', async (e) => suppress(e.email));
consumer.on('email.unsubscribed', async (e) => suppress(e.email));
await consumer.start();Keeping types in sync
This is a separate package from the service, so a route change will not break the
client's typecheck. Treat them as one change: when a request/response shape changes
in thejob-email-service/src/routes/emails.ts, update the matching type in
src/types.ts by hand.
