Skip to main content
Version: 57.2.0

@terreno/comms

Provider-agnostic transactional communications for Terreno backends. The core package defines mail, SMS, push, and verification contracts without installing SendGrid, Twilio, or Expo SDKs.

Install

bun add @terreno/comms

Peer dependency: mongoose ^8.0.0 || ^9.0.0.

Register the plugin

import {
CommsApp,
ConsoleMailProvider,
ConsolePushProvider,
ConsoleSmsProvider,
ConsoleVerificationProvider,
} from "@terreno/comms";
import {TerrenoApp} from "@terreno/api";

new TerrenoApp({userModel: User})
.register(
new CommsApp({
defaultFrom: "notifications@example.com",
mail: new ConsoleMailProvider(),
push: new ConsolePushProvider(),
sms: new ConsoleSmsProvider(),
verification: new ConsoleVerificationProvider(),
})
)
.start();

Console providers log only non-sensitive metadata such as message lengths and recipient counts. They never log message content, recipient identifiers, push tokens, or verification codes.

Send messages

Use the registered service from routes, jobs, or other plugins:

import {getCommsService} from "@terreno/comms";

await getCommsService().sendMail({
subject: "Welcome",
text: "Thanks for joining.",
to: "person@example.com",
});

await getCommsService().sendSms({
body: "Your appointment is tomorrow.",
to: "+15555550100",
});

sendPushToUser() resolves active device tokens and deactivates tokens when errorClass is "permanent" or isPermanentFailure is true. Provider throws become errorClass: "transient" with errorCode: "provider-throw" and never reject the CommsService promise. Transient failures retry once (onRetry with context.attempt === 2); push retries only the failed tokens. checkVerification() does not retry.

beforeSend may mutate the message or cancel (status: "cancelled", no provider call). A throwing beforeSend is logged and treated as no-op (send continues). Adapters later call recordDeliveryEvent() / recordOptOut() to update the log and fire onDeliveryEvent / onOptOut.

Each send stores one CommsMessage row with attempts[]. Rendered payloads are retained for retainPayloadDays (default 30, 0 disables) after redactPayload. Mail payloads keep to, from, subject, text, html, replyTo, templateId, and dynamicTemplateData. SMS payloads keep to and body. Push payloads omit tokens. Verification start stores {channel} only; verification checks store no payload. recordDeliveryEvent writes status, errorCode, and errorClass onto the matching row (opened does not change status). Expired payloads are unset, not deleted.

Provider contracts

InterfaceMethod
MailProvidersendMail(message)
SmsProvidersendSms(message)
PushProvidersendPush(message) — one SendResult per token
VerificationProviderstartVerification(options), checkVerification(options)

Concrete SendGrid, Twilio, and Expo providers are separate adapter packages/subpath exports. Core @terreno/comms has no concrete provider SDK dependencies.

SendGrid mail adapter

bun add @sendgrid/mail
import {CommsApp} from "@terreno/comms";
import {SendGridMailProvider} from "@terreno/comms/adapters/sendgrid";

new TerrenoApp({userModel: User})
.register(
new CommsApp({
defaultFrom: "notifications@example.com",
mail: new SendGridMailProvider({
// apiKey defaults to process.env.SENDGRID_API_KEY (required)
fromEmail: "notifications@example.com",
fromName: "Terreno",
// sandboxMode defaults to true when NODE_ENV === "test"
}),
onError: async (_context, result) => {
console.error("mail failed", result.errorCode, result.errorClass);
},
})
)
.start();

SendGridMailProvider fails fast at construction when SENDGRID_API_KEY (or apiKey) is missing. Send-time failures never throw through sendMail; they return accepted: false with errorCode / errorClass (permanent | transient | config). Transient failures are retried once by CommsService. Accepted sends store the SendGrid x-message-id and a metadata.consoleUrl Email Activity deep link on the CommsMessage row.

Sender verification checklist (SendGrid):

  1. Create an API key with Mail Send permission.
  2. Verify the from domain (or single sender) in SendGrid.
  3. Confirm the from address matches a verified identity.
  4. Use sandbox mode in CI/tests so no real mail is delivered.

Configuration

interface CommsAppOptions {
basePath?: string; // default: "/comms"
beforeSend?: (context: CommsHookContext) =>
Promise<{cancel?: boolean; message?: CommsHookMessage} | undefined>;
defaultFrom?: string;
logMessages?: boolean; // default: true
mail?: MailProvider;
onDeliveryEvent?: (event: DeliveryEvent) => Promise<void>;
onError?: (context: CommsHookContext, result: SendResult) => Promise<void>;
onOptOut?: (event: OptOutEvent) => Promise<void>;
onRetry?: (context: CommsHookContext, result: SendResult) => Promise<void>;
onSend?: (context: CommsHookContext, result: SendResult) => Promise<void>;
push?: PushProvider;
redactPayload?: (context: CommsHookContext, payload: unknown) => unknown;
redactRecipients?: boolean; // default: true
retainPayloadDays?: number; // default: 30; 0 stores no payload
sms?: SmsProvider;
verification?: VerificationProvider;
}

When a channel is unconfigured:

  • non-production environments use the matching console provider and emit a warning;
  • production throws a 501 APIError titled Comms channel not configured.

Delivery attempts are stored in CommsMessage. Recipient values are stored as [redacted] unless redactRecipients is explicitly false. Rendered payloads are retained for retainPayloadDays (default 30) after redactPayload; expired payloads are unset without deleting the log row. Mail payloads keep to, from, subject, text, html, replyTo, templateId, and dynamicTemplateData. SMS payloads keep to and body. Verification start keeps {channel} only; verification checks store no payload. recordDeliveryEvent writes status, errorCode, and errorClass onto the matching row (opened does not change status).

beforeSend may replace the message or cancel the send (status: "cancelled"). onSend and onError fire after every channel outcome. onRetry fires once before the inline retry when errorClass is "transient" (context.attempt === 2; shipped signature is (context, result)). Throwing hooks are logged and never change the send outcome. Exception text stays in logs; metadata.hookErrors records only hook-threw per hook name. Adapters should call recordDeliveryEvent() and recordOptOut() rather than invoking those hooks directly.

Provider throws become {accepted: false, errorClass: "transient", errorCode: "provider-throw"}. Permanent and config failures are not retried. Push retries re-send only the tokens whose first result was transient; tokens are deactivated when errorClass is "permanent" or isPermanentFailure is true. Each push token gets its own hook context (attempt, isRetry, messageId).

Routes

The default basePath is /comms.

MethodPathAccessPurpose
POST/comms/pushTokensAuthenticatedRegister or refresh a device token
GET/comms/pushTokensAuthenticated ownerList the current user's tokens
GET/comms/pushTokens/:idOwnerRead one token
DELETE/comms/pushTokens/:idOwnerDeactivate a token
GET/comms/messagesAdminFiltered, paginated delivery log

An active token cannot be claimed by another user. After its owner deactivates it, another authenticated user on the same device may register it.

Templates

renderTemplate() replaces top-level {{variable}} placeholders in subject, text, and html. Missing or inherited properties render as empty strings.

const message = renderTemplate({
data: {name: "Ada"},
template: {
subject: "Welcome, {{name}}",
text: "Hello {{name}}",
},
});