Sending Email and SMS
import { RelayClient } from '@insureco/relay'
Ruleconfigure
About
import { RelayClient } from '@insureco/relay'
Skill Content
This is the raw markdown that gets installed as a Claude Code rule.
# Sending Email and SMS
## The Short Version
```typescript
import { RelayClient } from '@insureco/relay'
const relay = RelayClient.fromEnv() // reads RELAY_URL + BIO_CLIENT_ID + BIO_CLIENT_SECRET
// Fire-and-forget — NEVER await in request handlers
relay.sendEmail({
template: 'welcome',
to: { email: user.email, name: user.name },
data: { firstName: user.firstName, orgName: org.name },
}).catch((err) => logger.warn({ err }, 'Welcome email failed'))
```
## Setup
Declare relay under `dependencies` in `catalog-info.yaml`. The scoped dependency provisions **both** things the SDK needs: `RELAY_URL` (the Janus proxy URL) **and** the `BIO_CLIENT_ID`/`BIO_CLIENT_SECRET` client-credentials it authenticates with.
```yaml
spec:
dependencies:
- service: relay
scopes: [relay:send]
transport: janus # gas-metered, JWT-verified via Janus /i/relay
charge: service # or callerOrg — who pays gas per send
```
`RelayClient.fromEnv()` reads `RELAY_URL` and `BIO_CLIENT_ID`/`BIO_CLIENT_SECRET` — all injected by the builder from the declaration above. Same-org grants auto-approve on deploy; a cross-org consumer's first deploy opens a scope-grant request that must be approved in Console → Permissions → API Access.
> Do **not** declare relay under `internalDependencies` — that injects `RELAY_URL` but **not** the Bio-ID credentials, so `RelayClient.fromEnv()` throws `No auth configured` at runtime.
For local dev, add to `.env.local`:
```
RELAY_DIRECT_URL=http://localhost:4001
```
## Fire-and-Forget (REQUIRED Pattern)
Email and SMS are **never on the critical path**. A relay outage must not break your API.
```typescript
// ✅ CORRECT: fire-and-forget
relay.sendEmail({ ... }).catch((err) => logger.warn({ err }, 'Email failed'))
// ❌ WRONG: awaiting blocks your response
await relay.sendEmail({ ... }) // your API times out if relay is slow
```
Always include `.catch()`. Silent failures mean lost audit events.
## Sending Email
### With a built-in template
```typescript
await relay.sendEmail({
template: 'welcome', // see Built-In Templates below
to: { email: '[email protected]', name: 'Jane Doe' },
data: { firstName: 'Jane', orgName: 'Acme Corp' },
})
```
### With custom HTML
```typescript
relay.sendEmail({
content: {
subject: 'Your quote is ready',
html: '<h1>Hello {{name}},</h1><p>Your quote is attached.</p>',
text: 'Hello {{name}}, your quote is ready.', // plain text fallback
},
to: { email: '[email protected]', name: 'Jane' },
data: { name: 'Jane' },
options: {
fromName: 'MyProgram', // display name only, NOT the from address
cc: ['[email protected]'],
bcc: '[email protected]',
},
}).catch((err) => logger.warn({ err }, 'Quote email failed'))
```
## Sending SMS
```typescript
relay.sendSMS({
content: { text: 'Your code is {{code}}. Expires in 10 minutes.' },
to: { phone: '+15551234567' }, // E.164 format required: +{country}{number}
data: { code: '123456' },
}).catch((err) => logger.warn({ err }, 'SMS failed'))
```
## Built-In Templates
| Name | Required Data |
|------|--------------|
| `welcome` | `firstName`, `orgName` |
| `invitation` | `inviterName`, `orgName`, `inviteUrl` |
| `password-reset` | `firstName`, `resetUrl` |
| `magic-link` | `firstName`, `magicUrl` |
## CC and BCC
```typescript
relay.sendEmail({
content: { subject: 'Policy bound', html: '...' },
to: { email: '[email protected]' },
options: {
cc: ['[email protected]'], // visible to all recipients
bcc: '[email protected]', // hidden, max 50 addresses each
},
})
```
## With Metadata (for tracing)
```typescript
relay.sendEmail({
template: 'invitation',
to: { email: invitee.email },
data: { inviterName: user.name, orgName: org.name, inviteUrl },
metadata: {
sourceService: 'my-api',
sourceAction: 'user_invited',
correlationId: req.id,
},
}).catch((err) => logger.warn({ err }, 'Invite email failed'))
```
## Delivery Status & Webhooks
`sendEmail()` resolving only means relay **accepted** the message. Final delivery (the provider confirming the recipient's mail server accepted it, or a bounce) arrives later. There are two ways to learn it.
### Pull — status lookup (always available)
```typescript
const status = await relay.getStatus(messageId)
// status.status: 'sent' | 'delivered' | 'bounced' | 'failed'
// status.deliveredAt, status.failureReason
```
Or list/aggregate via `relay.listMessages({ status: 'bounced', sourceService: 'my-api' })`.
### Push — delivery webhooks (real-time)
Register a URL once and relay POSTs a **signed** event whenever one of *your* messages is delivered/bounced/failed (relay learns this from the provider's webhook). Routing is by **verified caller identity** — you only receive events for messages your service actually sent.
**1. Register** (one-time; use your service token — the same `BIO_CLIENT_ID`/`SECRET` the SDK uses):
```bash
POST {RELAY_URL}/webhook-subscriptions
Authorization: Bearer {service-token}
{ "url": "https://my-api.tawa.pro/api/relay/delivery-webhook",
"events": ["delivered", "bounced", "failed"] }
# → { "data": { "id", "url", "events", "secret": "whsec_..." } } ← secret returned ONCE
```
Store the returned `secret` as a config secret (`tawa config set RELAY_WEBHOOK_SECRET=whsec_... --secret`). `GET`/`DELETE {RELAY_URL}/webhook-subscriptions[/:id]` manage existing subscriptions.
**2. Receive & verify** — relay POSTs JSON signed with HMAC-SHA256 over the raw body:
```
X-Relay-Event: delivered
X-Relay-Signature: sha256=<hmac>
{ "event": "delivered", "messageId", "providerMessageId", "channel",
"recipient": { "email" }, "sourceService", "occurredAt", "reason"? }
```
```typescript
import { createHmac, timingSafeEqual } from 'node:crypto'
// Verify over the RAW request body BEFORE JSON-parsing:
const expected = 'sha256=' + createHmac('sha256', process.env.RELAY_WEBHOOK_SECRET!).update(rawBody).digest('hex')
const ok = a.length === b.length && timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
```
The webhook receiver is a **public** endpoint — the HMAC signature is its authentication; do not also gate it behind user auth.
- Delivery POSTs are fire-and-forget with retries; a subscription that fails repeatedly is auto-disabled.
- Keep a **fallback**: webhooks can be missed, so periodically reconcile `pending` rows with `getStatus()` (e.g. a cron) — push is primary, poll is the safety net.
- SDK helper methods for subscription management are not yet exposed — register via the HTTP endpoint above.
## Sandbox Mode
In sandbox and local dev, `RELAY_MODE=sandbox` is set automatically. Relay logs messages instead of sending them — no real emails or SMS are delivered. You never set this manually.
## What NOT to Do
```typescript
// ❌ WRONG: deprecated 'from' field
options: { from: 'My Service' }
// ✅ CORRECT
options: { fromName: 'My Service' }
// ❌ WRONG: legacy JWT auth
new RelayClient({ jwtSecret: process.env.JWT_SECRET, programId: '...' })
// ✅ CORRECT
RelayClient.fromEnv()
// ❌ WRONG: await in request handler
app.post('/api/send', async (req, res) => {
await relay.sendEmail({ ... }) // blocks until delivery
res.json({ ok: true })
})
// ✅ CORRECT
app.post('/api/send', async (req, res) => {
relay.sendEmail({ ... }).catch((err) => logger.warn({ err }, 'Failed'))
res.json({ ok: true }) // responds immediately
})
```
## Key Facts
- `fromName` sets the display name only — the actual sender is always your org's domain or `[email protected]`
- Retries default to 2 — set `retries: 0` if duplicates are unacceptable (e.g. payment receipts)
- Templates use `{{variable}}` Handlebars-style interpolation
- SMS requires E.164 format: `+15551234567` (country code required, no spaces or dashes)
- Max 50 recipients per CC or BCC field
Install
Copy the skill content and save it to:
~/.claude/rules/tawa-relay.mdComing soon via CLI:
tawa chaac install tawa-relayDetails
- Format
- Rule
- Category
- configure
- Version
- 1.0.81116
- Tokens
- ~1,967
- Updated
- 2026-06-24
platformrelayemailsmsnotificationsfire-and-forgetdeliverywebhooks