diff --git a/docker-compose.yml b/docker-compose.yml index 921f6fd..996ff36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,8 @@ services: # @note override for a real deployment: the site url is baked into the # build by Next.js SITE_URL: ${SITE_URL:-http://localhost:3000} + SPACE_APEX: ${SPACE_APEX:-space.localhost} + PORTAL_APEX: ${PORTAL_APEX:-portal.localhost} ports: - '3000:3000' environment: @@ -78,6 +80,8 @@ services: PORT: 3000 SITE_URL: ${SITE_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + SPACE_APEX: ${SPACE_APEX:-space.localhost} + PORTAL_APEX: ${PORTAL_APEX:-portal.localhost} # @note left empty, the image generates these secrets on first boot and # persists them in the platform-data volume - see docker/entrypoint.sh; # set explicitly to override diff --git a/docker/Dockerfile b/docker/Dockerfile index 3177baf..2681e0b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -91,6 +91,21 @@ ENV NODE_OPTIONS="--max-old-space-size=$NODE_HEAP_MB --require /app/platform/scr ARG SITE_URL=http://localhost:3000 ENV SITE_URL=$SITE_URL +# @note apex host rewrites are generated at build time, so the image bakes a +# `.localhost` pair browsers resolve to loopback without DNS: space sites at +# `.space.localhost`, portals at `.portal.localhost`. The runtime +# environment must name the same apexes (the compose files do). +# @todo move the apex host rewrites out of next.config.d into a runtime proxy +# so one image digest serves any domain without a rebuild +ARG SPACE_APEX=space.localhost +ENV SPACE_APEX=$SPACE_APEX +ARG PORTAL_APEX=portal.localhost +ENV PORTAL_APEX=$PORTAL_APEX +ARG APP_APEX= +ENV APP_APEX=$APP_APEX +ARG PARTNERS_APEX= +ENV PARTNERS_APEX=$PARTNERS_APEX + # @note source maps ship without source content by default; pass 'full' # explicitly to embed the source for debuggable self-hosted images ARG BUILD_SOURCEMAPS=nosources diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index a6744a8..0962dd9 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -61,6 +61,11 @@ services: PORT: 3000 SITE_URL: ${SITE_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + # @note deployment-issued subdomains; must match the apexes baked into + # the image (docker/Dockerfile). Browsers resolve `*.localhost` to + # loopback, so `acme.space.localhost:3000` works with no DNS setup + SPACE_APEX: ${SPACE_APEX:-space.localhost} + PORTAL_APEX: ${PORTAL_APEX:-portal.localhost} # @note left empty, the image generates these secrets on first boot and # persists them in the platform-data volume; set explicitly to override NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} diff --git a/docs/architecture.md b/docs/architecture.md index be1b0f4..e4a77b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,8 +56,9 @@ ignored. [Module defaults](./module-defaults.md) lists the rest. The public defaults differ in what "default" means, and the difference is -deliberate. `@chatbotkit-dev/email` logs messages to the console - a working, -if noisy, delivery path. The plan catalogue (`@/config/limits`, read from the +deliberate. `@chatbotkit-dev/email` delivers through Resend, SendGrid or SES +when it finds a credential, and otherwise logs messages to the console - a +working, if noisy, delivery path. The plan catalogue (`@/config/limits`, read from the LIMITS_CONFIG environment variable) defaults to empty, which the platform reads as "this deployment has no plan concept": every entitlement resolves without limits and no interface renders a plan name. Defaults describe a diff --git a/docs/configuration.md b/docs/configuration.md index 266014c..18dbe2c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -209,6 +209,11 @@ SPACE_APEX=example.site PARTNERS_APEX=example.partners ``` +The apex host rewrites are generated when Next builds, so the runtime values +must match the ones the image was built with. The community image bakes +`SPACE_APEX=space.localhost` and `PORTAL_APEX=portal.localhost`; see +[Deployment](./deployment.md#production-boundary). + ## App shell origins Two scalar origins identify the canonical app-shell endpoints. An origin must diff --git a/docs/deployment.md b/docs/deployment.md index a5c28f2..9c68045 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -210,12 +210,19 @@ parts of host and subscription configuration, is therefore not baked into the and keep secrets out of image layers. The current community image deliberately bakes the neutral single-host -topology: `SITE_URL=http://localhost:3000`, with no app-shell origins, apexes or -external zones. Runtime service variables such as the database, Redis, Qdrant -and S3-compatible storage endpoints remain configurable. Deployment identity -that Next currently exposes through `next.config.js` is still frozen at build -time; do not present the same digest as portable across arbitrary public domains -until that migration is complete. +topology: `SITE_URL=http://localhost:3000`, with no app-shell origins or +external zones. Two apexes are baked alongside it so deployment-issued +subdomains work out of the box: `SPACE_APEX=space.localhost` and +`PORTAL_APEX=portal.localhost`. Browsers resolve any `*.localhost` name to +loopback, so a space site published as `acme` answers at +`http://acme.space.localhost:3000` with no DNS or hosts-file setup (`curl` +needs `--resolve`). The runtime `SPACE_APEX` and `PORTAL_APEX` must name the +same apexes as the build, which the compose files ensure; a different apex +needs a rebuild with the matching build arguments. Runtime service variables +such as the database, Redis, Qdrant and S3-compatible storage endpoints remain +configurable. Deployment identity that Next currently exposes through +`next.config.js` is still frozen at build time; do not present the same digest +as portable across arbitrary public domains until that migration is complete. ## API endpoint diff --git a/docs/getting-started.md b/docs/getting-started.md index 1465ac8..1f1c789 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -143,6 +143,24 @@ REDIS_URL=redis://localhost:6379 QDRANT_URL=http://localhost:6333 ``` +## Configure email delivery + +Without an email vendor, sign-in codes and invitations are printed to the +server log. To deliver real mail, set one vendor's credential and a verified +sender; the module detects the vendor from the credential: + +```bash +EMAIL_FROM="Login " + +RESEND_API_KEY=re_... +# or SENDGRID_API_KEY=SG.... +# or SES_AWS_REGION=eu-west-1 SES_AWS_ACCESS_KEY_ID=... SES_AWS_SECRET_ACCESS_KEY=... +``` + +`EMAIL_PROVIDER` pins a vendor when more than one credential is present, and +`EMAIL_REPLY_TO` and `EMAIL_ACTIONS_FROM` are optional. The email module's +README has the full reference. + ## Protect stored credentials Before storing real credentials, configure `PRISMA_FIELD_ENCRYPTION_KEY`. An diff --git a/docs/module-defaults.md b/docs/module-defaults.md index aba4a4d..892dfd0 100644 --- a/docs/module-defaults.md +++ b/docs/module-defaults.md @@ -51,9 +51,17 @@ is set. Embedding still requires a configured model provider, such as ### Email -The public email module writes delivery information to the console. This makes -local email-code sign-in usable without SMTP but does not deliver external -mail. +The public email module delivers through whichever vendor it finds credentials +for - Resend (`RESEND_API_KEY`), SendGrid (`SENDGRID_API_KEY`) or Amazon SES +(`SES_AWS_ACCESS_KEY_ID` with its region and secret) - detected in that order, +or pinned with `EMAIL_PROVIDER`. A vendor also needs `EMAIL_FROM`, a sender on +a domain verified with that vendor; `assertConfigured` fails until both are +present. + +With no credentials it writes delivery information to the console, text body +included. This makes local email-code sign-in usable without a vendor but does +not deliver external mail. Inbound mail is not supported by any of the vendors; +an inbound implementation replaces the module. ### Sandbox diff --git a/packages/email/README.md b/packages/email/README.md index 3ff9d97..682c693 100644 --- a/packages/email/README.md +++ b/packages/email/README.md @@ -1,11 +1,54 @@ # @chatbotkit-dev/email -The **community email provider**. It does not deliver mail: it writes a line to -the console describing what would have been sent, so a deployment runs and is -observable without an email vendor configured. +The **community email provider**. It delivers through whichever vendor it finds +credentials for - Resend, SendGrid or Amazon SES - and with none configured it +writes a line to the console describing what would have been sent, so a +deployment runs and is observable without an email vendor at all. -The message body is deliberately never logged. Notification mail routinely -carries login links, and action mail carries conversation content. +When printing, the text body is included: the console is delivery there, and +sign-in codes and invitations reach the operator nowhere else. Once a vendor is +configured nothing is logged. + +## Environment + +The vendor is detected from its credential, in this order, and `EMAIL_PROVIDER` +pins one when that is not what you want: + +| `EMAIL_PROVIDER` | Detected from | Also needs | +| ---------------- | ----------------------- | ----------------------------------------------------------------------------- | +| `resend` | `RESEND_API_KEY` | | +| `sendgrid` | `SENDGRID_API_KEY` | | +| `ses` | `SES_AWS_ACCESS_KEY_ID` | `SES_AWS_REGION`, `SES_AWS_SECRET_ACCESS_KEY`; optional `SES_AWS_SESSION_TOKEN`, `SES_AWS_ENDPOINT` | +| `print` | nothing set | | + +Every vendor sends as the deployment's own identity: + +| Variable | Purpose | +| -------------------- | --------------------------------------------------------------------------------------- | +| `EMAIL_FROM` | Sender of the deployment's own mail, e.g. `Login `. Required with a vendor | +| `EMAIL_REPLY_TO` | Where replies to that mail go, when the message does not say | +| `EMAIL_ACTIONS_FROM` | Default mailbox agents and integrations write from; falls back to `EMAIL_FROM` | + +The sending domain has to be verified with the vendor. SES credentials are the +module's own rather than the storage module's, so configuring object storage +does not silently switch mail delivery on. + +Nothing is read at import. `assertConfigured` resolves with nothing set, and +with a vendor detected it fails on any missing credential or a missing +`EMAIL_FROM`, so a deployment that calls it at startup finds out then rather +than when a user fails to receive a login link. + +## What each vendor does with the contract + +Notification mail marked `essential` bypasses SendGrid list management and +tracking; action mail always does, because its recipient never subscribed to +anything. Resend and SES have no list management to bypass. A `messageId` on +action mail is set as `Message-ID`, `In-Reply-To` and `References` with every +vendor. + +Inbound mail is not supported: integration inboxes derive from `SITE_URL` and +`parseInboundEmail` logs and returns null. An implementation with an inbound +vendor replaces this package. ## Providing your own diff --git a/packages/email/package.json b/packages/email/package.json index 6c2b057..b859de0 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@chatbotkit-dev/email-spec": "workspace:*", + "@chatbotkit-dev/fetch": "workspace:*", "@types/node": "^24.0.0" } } diff --git a/packages/email/src/identity.test.js b/packages/email/src/identity.test.js new file mode 100644 index 0000000..a3831f7 --- /dev/null +++ b/packages/email/src/identity.test.js @@ -0,0 +1,132 @@ +import { + actionFrom, + defaultReplyTo, + formatAddress, + notificationFrom, + parseAddress, + threadingHeaders, +} from './identity' + +const ENV = ['EMAIL_FROM', 'EMAIL_ACTIONS_FROM', 'EMAIL_REPLY_TO'] + +describe('parseAddress', () => { + it('splits a display name from the mailbox', () => { + expect(parseAddress('Login ')).toEqual({ + name: 'Login', + email: 'noreply@example.com', + }) + }) + + it('handles a quoted display name', () => { + expect(parseAddress('"Acme, Inc." ')).toEqual({ + name: 'Acme, Inc.', + email: 'hello@acme.example', + }) + }) + + it('handles a bare mailbox', () => { + expect(parseAddress('noreply@example.com')).toEqual({ + email: 'noreply@example.com', + }) + }) + + it('handles an angle-bracketed mailbox with no name', () => { + expect(parseAddress('')).toEqual({ + email: 'noreply@example.com', + }) + }) + + it('trims surrounding whitespace', () => { + expect(parseAddress(' Login ')).toEqual({ + name: 'Login', + email: 'noreply@example.com', + }) + + expect(parseAddress(' noreply@example.com ')).toEqual({ + email: 'noreply@example.com', + }) + }) +}) + +describe('formatAddress', () => { + it('round-trips through parseAddress', () => { + for (const source of [ + 'Login ', + 'noreply@example.com', + ]) { + expect(formatAddress(parseAddress(source))).toBe(source) + } + }) + + it('omits the brackets without a name', () => { + expect(formatAddress({ email: 'a@b.c' })).toBe('a@b.c') + }) +}) + +describe('threadingHeaders', () => { + it('sets all three threading headers to the id', () => { + expect(threadingHeaders('')).toEqual({ + 'Message-ID': '', + 'In-Reply-To': '', + References: '', + }) + }) +}) + +describe('sending identity', () => { + beforeEach(() => { + for (const name of ENV) { + delete process.env[name] + } + }) + + afterEach(() => { + for (const name of ENV) { + delete process.env[name] + } + }) + + it('reads EMAIL_FROM', () => { + process.env.EMAIL_FROM = 'Login ' + + expect(notificationFrom()).toBe('Login ') + }) + + it('throws without EMAIL_FROM, saying what to set', () => { + expect(() => notificationFrom()).toThrow(/EMAIL_FROM is not set/) + }) + + it('treats an empty EMAIL_FROM as unset', () => { + process.env.EMAIL_FROM = '' + + expect(() => notificationFrom()).toThrow(/EMAIL_FROM/) + }) + + it('falls back from EMAIL_ACTIONS_FROM to EMAIL_FROM', () => { + process.env.EMAIL_FROM = 'noreply@example.com' + + expect(actionFrom()).toBe('noreply@example.com') + + process.env.EMAIL_ACTIONS_FROM = 'agents@example.com' + + expect(actionFrom()).toBe('agents@example.com') + }) + + it('does not accept EMAIL_ACTIONS_FROM as the notification identity', () => { + process.env.EMAIL_ACTIONS_FROM = 'agents@example.com' + + expect(() => notificationFrom()).toThrow(/EMAIL_FROM/) + }) + + it('has no reply-to unless EMAIL_REPLY_TO is set', () => { + expect(defaultReplyTo()).toBeUndefined() + + process.env.EMAIL_REPLY_TO = '' + + expect(defaultReplyTo()).toBeUndefined() + + process.env.EMAIL_REPLY_TO = 'support@example.com' + + expect(defaultReplyTo()).toBe('support@example.com') + }) +}) diff --git a/packages/email/src/identity.ts b/packages/email/src/identity.ts new file mode 100644 index 0000000..d9ad2b6 --- /dev/null +++ b/packages/email/src/identity.ts @@ -0,0 +1,90 @@ +// @note the sending identity and the one message shape every vendor module +// delivers. Credentials live with their vendor; the from and reply-to +// addresses are shared because a verified sending domain is a property of the +// deployment, not of whichever API delivers for it. + +/** + * A rendered message with its sending identity resolved, ready for a vendor. + */ +export interface OutboundMessage { + /** RFC 5322 address, `Name ` or bare. */ + from: string + to: string + subject: string + text: string + html: string + replyTo?: string + messageId?: string + + /** + * Must reach the recipient regardless of subscription state, and carries no + * tracking: essential notifications and all third-party action mail, whose + * recipient never subscribed to anything. Vendors without list management + * have nothing to do here. + */ + essential?: boolean +} + +export interface Address { + name?: string + email: string +} + +export function parseAddress(source: string): Address { + const match = source.match(/^\s*(?:"?(.*?)"?\s*)?<([^<>]+)>\s*$/) + + if (!match) { + return { email: source.trim() } + } + + const name = match[1]?.trim() + + return { ...(name ? { name } : null), email: match[2].trim() } +} + +export function formatAddress({ name, email }: Address): string { + return name ? `${name} <${email}>` : email +} + +/** + * Headers that thread a message against the id the platform minted for it. + */ +export function threadingHeaders(messageId: string): Record { + return { + 'Message-ID': messageId, + 'In-Reply-To': messageId, + References: messageId, + } +} + +/** + * The address this deployment's own mail is sent from. + * + * @throws {Error} when `EMAIL_FROM` is not set. + */ +export function notificationFrom(): string { + const from = process.env.EMAIL_FROM + + if (!from) { + throw new Error( + 'EMAIL_FROM is not set, so there is no address to send this ' + + "deployment's mail from. Set it to a sender the email vendor has " + + 'verified, e.g. "Login ".' + ) + } + + return from +} + +/** + * The default mailbox agents and integrations write from. + * + * @throws {Error} when neither `EMAIL_ACTIONS_FROM` nor `EMAIL_FROM` is set. + */ +export function actionFrom(): string { + return process.env.EMAIL_ACTIONS_FROM || notificationFrom() +} + +export function defaultReplyTo(): string | undefined { + return process.env.EMAIL_REPLY_TO || undefined +} diff --git a/packages/email/src/index.test.js b/packages/email/src/index.test.js index 13ce64f..10294ad 100644 --- a/packages/email/src/index.test.js +++ b/packages/email/src/index.test.js @@ -1,4 +1,26 @@ -import provider, { sendEmailAction, sendEmailNotification } from './index' +import provider, { + assertConfigured, + createEmailTransport, + detectVendor, + formatIntegrationInbox, + formatIntegrationMessageId, + parseInboundEmail, + sendEmailAction, + sendEmailNotification, +} from './index' + +const ENV = [ + 'EMAIL_PROVIDER', + 'EMAIL_FROM', + 'EMAIL_ACTIONS_FROM', + 'EMAIL_REPLY_TO', + 'RESEND_API_KEY', + 'SENDGRID_API_KEY', + 'SES_AWS_REGION', + 'SES_AWS_ACCESS_KEY_ID', + 'SES_AWS_SECRET_ACCESS_KEY', + 'SITE_URL', +] describe('community email provider', () => { let logged @@ -13,16 +35,26 @@ describe('community email provider', () => { console.log = (...args) => { logged.push(args.map(String).join(' ')) } + + for (const name of ENV) { + delete process.env[name] + } }) afterEach(() => { // eslint-disable-next-line no-console console.log = original + + for (const name of ENV) { + delete process.env[name] + } }) it('satisfies the provider contract', () => { expect(typeof provider.sendEmailNotification).toBe('function') expect(typeof provider.sendEmailAction).toBe('function') + expect(typeof provider.createEmailTransport).toBe('function') + expect(typeof provider.assertConfigured).toBe('function') }) it('reports a notification without delivering it', async () => { @@ -64,4 +96,203 @@ describe('community email provider', () => { expect(logged.join('\n')).toContain('┌') expect(logged.join('\n')).toContain('└') }) + + it('reports a transport send as the identity it was created with', async () => { + await createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'hello', + html: '

hello

', + }) + + expect(logged).toHaveLength(1) + expect(logged[0]).toContain( + '[email:transport from=Acme ]' + ) + expect(logged[0]).toContain('to=user@example.com') + }) + + it('creates a transport without touching the environment', () => { + process.env.EMAIL_PROVIDER = 'postmark' + + // @note a configuration catalogue constructs transports at import, so + // even a broken vendor pin must not surface until send + + expect(() => createEmailTransport('Acme ')).not.toThrow() + }) + + it('never logs the html part', async () => { + await sendEmailNotification({ + to: 'user@example.com', + subject: 'Sign in', + content: { text: 'plain', html: '

plain

' }, + }) + + expect(logged.join('\n')).not.toContain('only-in-html') + }) + + describe('integration inboxes', () => { + it('derives the inbox domain from SITE_URL', () => { + process.env.SITE_URL = 'https://app.example.com/some/path' + + expect(formatIntegrationInbox('abc123')).toBe( + 'abc123@integration.app.example.com' + ) + }) + + it('falls back to localhost without a usable SITE_URL', () => { + expect(formatIntegrationInbox('abc123')).toBe( + 'abc123@integration.localhost' + ) + + process.env.SITE_URL = 'not a url' + + expect(formatIntegrationInbox('abc123')).toBe( + 'abc123@integration.localhost' + ) + }) + + it('mints a distinct RFC 5322 message id on the same domain', () => { + process.env.SITE_URL = 'https://app.example.com' + + const first = formatIntegrationMessageId('abc123') + const second = formatIntegrationMessageId('abc123') + + expect(first).toMatch(/^<[0-9a-f-]{36}@integration\.app\.example\.com>$/) + expect(second).not.toBe(first) + }) + + it('declines inbound mail and says so', async () => { + const form = new FormData() + + form.set('from', 'someone@example.com') + form.set('to', 'abc123@integration.localhost') + form.set('subject', 'Hello') + + await expect(parseInboundEmail(form)).resolves.toBeNull() + + expect(logged).toHaveLength(1) + expect(logged[0]).toContain('[email:inbound]') + }) + }) + + describe('detectVendor', () => { + it('prints when no credentials are present', () => { + expect(detectVendor()).toBe('print') + }) + + it('picks Resend from its key', () => { + process.env.RESEND_API_KEY = 'x' + + expect(detectVendor()).toBe('resend') + }) + + it('picks SendGrid from its key', () => { + process.env.SENDGRID_API_KEY = 'x' + + expect(detectVendor()).toBe('sendgrid') + }) + + it('picks SES from its access key id', () => { + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + + expect(detectVendor()).toBe('ses') + }) + + it('prefers vendors in a fixed order when several are configured', () => { + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + process.env.SENDGRID_API_KEY = 'x' + process.env.RESEND_API_KEY = 'x' + + expect(detectVendor()).toBe('resend') + }) + + it('lets EMAIL_PROVIDER pin one', () => { + process.env.RESEND_API_KEY = 'x' + process.env.SENDGRID_API_KEY = 'x' + process.env.EMAIL_PROVIDER = 'sendgrid' + + expect(detectVendor()).toBe('sendgrid') + + process.env.EMAIL_PROVIDER = 'print' + + expect(detectVendor()).toBe('print') + }) + + it('rejects an EMAIL_PROVIDER it does not know', () => { + process.env.EMAIL_PROVIDER = 'postmark' + + expect(() => detectVendor()).toThrow(/EMAIL_PROVIDER="postmark"/) + }) + }) + + describe('assertConfigured', () => { + it('resolves with nothing configured', async () => { + await expect(assertConfigured()).resolves.toBeUndefined() + }) + + it('needs a sending identity once a vendor is detected', async () => { + process.env.RESEND_API_KEY = 'x' + + await expect(assertConfigured()).rejects.toThrow(/EMAIL_FROM/) + }) + + it('resolves with a vendor and an identity', async () => { + process.env.RESEND_API_KEY = 'x' + process.env.EMAIL_FROM = 'Login ' + + await expect(assertConfigured()).resolves.toBeUndefined() + }) + + it('names every SES variable that is missing', async () => { + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + process.env.EMAIL_FROM = 'noreply@example.com' + + await expect(assertConfigured()).rejects.toThrow( + /SES_AWS_REGION, SES_AWS_SECRET_ACCESS_KEY are not set/ + ) + }) + + it('resolves with SendGrid and an identity', async () => { + process.env.SENDGRID_API_KEY = 'x' + process.env.EMAIL_FROM = 'noreply@example.com' + + await expect(assertConfigured()).resolves.toBeUndefined() + }) + + it('resolves with a complete SES configuration', async () => { + process.env.SES_AWS_REGION = 'eu-west-1' + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + process.env.SES_AWS_SECRET_ACCESS_KEY = 'y' + process.env.EMAIL_FROM = 'noreply@example.com' + + await expect(assertConfigured()).resolves.toBeUndefined() + }) + + it('checks the credential before the identity', async () => { + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + + await expect(assertConfigured()).rejects.toThrow(/SES_AWS_REGION/) + }) + + it('rejects an unknown EMAIL_PROVIDER', async () => { + process.env.EMAIL_PROVIDER = 'postmark' + + await expect(assertConfigured()).rejects.toThrow(/EMAIL_PROVIDER/) + }) + + it('needs nothing when pinned to print, whatever else is set', async () => { + process.env.EMAIL_PROVIDER = 'print' + process.env.SES_AWS_ACCESS_KEY_ID = 'x' + + await expect(assertConfigured()).resolves.toBeUndefined() + }) + + it('fails a pinned vendor whose credentials are absent', async () => { + process.env.EMAIL_PROVIDER = 'sendgrid' + process.env.EMAIL_FROM = 'noreply@example.com' + + await expect(assertConfigured()).rejects.toThrow(/SENDGRID_API_KEY/) + }) + }) }) diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index a5c7f7f..8a32262 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -6,65 +6,166 @@ import type { NotificationEmail, } from '@chatbotkit-dev/email-spec' +import type { OutboundMessage } from './identity' +import { + actionFrom, + defaultReplyTo, + formatAddress, + notificationFrom, + parseAddress, +} from './identity' +import * as print from './print' +import * as resend from './resend' +import * as sendgrid from './sendgrid' +import * as ses from './ses' + export type * from '@chatbotkit-dev/email-spec' -// @note the community implementation does not deliver mail. It writes what it -// would have sent to the console, text body included, so a deployment runs and -// stays usable without an email vendor configured - the console IS delivery -// here: sign-in codes and invitations reach the operator nowhere else. Replace -// this package to deliver for real (and to keep bodies out of logs). +// @note the community email provider picks its delivery vendor from whichever +// credentials are present, and with none present it writes what it would have +// sent to the console so a deployment runs without an email vendor at all. +// +// Detection is by credential, in the order below, and EMAIL_PROVIDER pins one +// when that is not what an operator wants. Everything is resolved at send +// time: nothing that merely imports this package needs any of it configured. + +export type EmailVendor = 'print' | 'resend' | 'sendgrid' | 'ses' + +interface Vendor { + isConfigured(): boolean + assertEnv(): void + send(message: OutboundMessage): Promise +} + +const VENDORS: Record, Vendor> = { + resend, + sendgrid, + ses, +} -// @note the body is framed with an open left rail rather than a closed box: -// every line stands alone, so long URLs never break the frame and per-line -// log timestamps do not mangle it -function describe( +/** + * The vendor mail is currently delivered through. + * + * @throws {Error} when `EMAIL_PROVIDER` names something that is not one. + */ +export function detectVendor(): EmailVendor { + const pinned = process.env.EMAIL_PROVIDER + + if (pinned) { + if (pinned === 'print' || pinned in VENDORS) { + return pinned as EmailVendor + } + + throw new Error( + `EMAIL_PROVIDER=${JSON.stringify(pinned)} is not one of print, ${Object.keys(VENDORS).join(', ')}` + ) + } + + for (const [name, vendor] of Object.entries(VENDORS)) { + if (vendor.isConfigured()) { + return name as EmailVendor + } + } + + return 'print' +} + +// @note the message is built only once a vendor is chosen: resolving the +// sending identity throws without one, and printing needs none +async function deliver( kind: string, - email: { to: string; subject: string }, - text: string -): void { - const rule = '─'.repeat(50) + preview: { to: string; subject: string; text: string }, + message: () => OutboundMessage +): Promise { + const vendor = detectVendor() - const body = text - .trim() - .split('\n') - .map((line) => `│ ${line}`) - .join('\n') + if (vendor === 'print') { + print.describe(kind, preview, preview.text) - // eslint-disable-next-line no-console - console.log( - `[email:${kind}] to=${email.to} subject=${JSON.stringify(email.subject)} (not delivered: no email provider configured)\n┌${rule}\n${body}\n└${rule}` - ) + return + } + + await VENDORS[vendor].send(message()) } export async function sendEmailNotification( email: NotificationEmail ): Promise { - describe('notification', email, email.content.text) + const { to, subject, content, replyTo, essential = false } = email + + await deliver('notification', { to, subject, text: content.text }, () => ({ + from: notificationFrom(), + to, + subject, + text: content.text, + html: content.html, + + replyTo: replyTo ?? defaultReplyTo(), + + essential, + })) } export async function sendEmailAction(email: ActionEmail): Promise { - describe('action', email, email.content.text) + const { to, subject, content, from, name, replyTo, messageId } = email + + await deliver('action', { to, subject, text: content.text }, () => { + const base = parseAddress(actionFrom()) + + return { + from: formatAddress({ + name: name || base.name, + email: from || base.email, + }), + to, + subject, + text: content.text, + html: content.html, + + replyTo, + messageId, + + // @note the recipient never subscribed to anything and must not land on + // a suppression list by replying + essential: true, + } + }) } /** - * @note the community implementation delivers nothing, so a transport is the - * same console line as anything else - with the identity it would have sent as, - * because that is the whole point of asking for one. + * @note the vendor is resolved on send, not here. A configuration catalogue + * constructs transports at import, and nothing that merely imports one should + * need this deployment's credentials present. */ export function createEmailTransport(source: string): EmailTransport { return { - async send({ to, subject, text }) { - describe(`transport from=${source}`, { to, subject }, text) + async send({ to, subject, text, html }) { + await deliver(`transport from=${source}`, { to, subject, text }, () => ({ + from: source, + to, + subject, + text, + html, + })) }, } } /** - * @note the community provider needs no configuration, so there is nothing that - * can be misconfigured. + * @throws {Error} when a vendor is selected but its credentials or the sending + * identity are incomplete. With no vendor configured there is nothing to + * check: printing needs nothing. */ export async function assertConfigured(): Promise { - // pass + const vendor = detectVendor() + + if (vendor === 'print') { + return + } + + VENDORS[vendor].assertEnv() + + notificationFrom() } // @note the community implementation hosts no sending domain, so integration @@ -86,14 +187,14 @@ export function formatIntegrationMessageId(_integrationId: string): string { return `<${crypto.randomUUID()}@integration.${integrationHostname()}>` } -// @note no inbound vendor means no inbound mail - describe and decline, the -// same posture as outbound delivery above +// @note none of the outbound vendors above receives mail for us, so inbound +// mail is described and declined regardless of which one is delivering export async function parseInboundEmail( _form: FormData ): Promise { // eslint-disable-next-line no-console console.log( - '[email:inbound] inbound message ignored (not parsed: no email provider configured)' + '[email:inbound] inbound message ignored (not parsed: this email provider has no inbound vendor)' ) return null diff --git a/packages/email/src/print.ts b/packages/email/src/print.ts new file mode 100644 index 0000000..1b07ac2 --- /dev/null +++ b/packages/email/src/print.ts @@ -0,0 +1,25 @@ +// @note delivery to the console, for a deployment with no email vendor +// configured. The text body is included because the console IS delivery here: +// sign-in codes and invitations reach the operator nowhere else. + +// @note the body is framed with an open left rail rather than a closed box: +// every line stands alone, so long URLs never break the frame and per-line +// log timestamps do not mangle it +export function describe( + kind: string, + email: { to: string; subject: string }, + text: string +): void { + const rule = '─'.repeat(50) + + const body = text + .trim() + .split('\n') + .map((line) => `│ ${line}`) + .join('\n') + + // eslint-disable-next-line no-console + console.log( + `[email:${kind}] to=${email.to} subject=${JSON.stringify(email.subject)} (not delivered: no email provider configured)\n┌${rule}\n${body}\n└${rule}` + ) +} diff --git a/packages/email/src/resend.ts b/packages/email/src/resend.ts new file mode 100644 index 0000000..b8b5b21 --- /dev/null +++ b/packages/email/src/resend.ts @@ -0,0 +1,55 @@ +import { fetch, getFetchError } from '@chatbotkit-dev/fetch' + +import type { OutboundMessage } from './identity' +import { threadingHeaders } from './identity' + +export const RESEND_API = 'https://api.resend.com/emails' + +export function isConfigured(): boolean { + return Boolean(process.env.RESEND_API_KEY) +} + +/** + * @throws {Error} when `RESEND_API_KEY` is not set. + */ +export function assertEnv(): void { + if (!process.env.RESEND_API_KEY) { + throw new Error( + 'RESEND_API_KEY is not set, so mail cannot be delivered through Resend' + ) + } +} + +/** + * @throws {Error} when the API rejects the message; the response body carries + * Resend's own reason, typically an unverified sending domain. + */ +export async function send(message: OutboundMessage): Promise { + assertEnv() + + const { from, to, subject, text, html, replyTo, messageId } = message + + const response = await fetch(RESEND_API, { + method: 'POST', + + headers: { + Authorization: `Bearer ${process.env.RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + + body: JSON.stringify({ + from, + to, + subject, + text, + html, + + ...(replyTo ? { reply_to: replyTo } : null), + ...(messageId ? { headers: threadingHeaders(messageId) } : null), + }), + }) + + if (!response.ok) { + throw await getFetchError(response, { vendor: 'resend', from }) + } +} diff --git a/packages/email/src/sendgrid.ts b/packages/email/src/sendgrid.ts new file mode 100644 index 0000000..f028ad3 --- /dev/null +++ b/packages/email/src/sendgrid.ts @@ -0,0 +1,77 @@ +import { fetch, getFetchError } from '@chatbotkit-dev/fetch' + +import type { OutboundMessage } from './identity' +import { parseAddress, threadingHeaders } from './identity' + +export const SENDGRID_API = 'https://api.sendgrid.com/v3/mail/send' + +export function isConfigured(): boolean { + return Boolean(process.env.SENDGRID_API_KEY) +} + +/** + * @throws {Error} when `SENDGRID_API_KEY` is not set. + */ +export function assertEnv(): void { + if (!process.env.SENDGRID_API_KEY) { + throw new Error( + 'SENDGRID_API_KEY is not set, so mail cannot be delivered through SendGrid' + ) + } +} + +/** + * @throws {Error} when the API rejects the message. + */ +export async function send(message: OutboundMessage): Promise { + assertEnv() + + const { from, to, subject, text, html, replyTo, messageId, essential } = + message + + const response = await fetch(SENDGRID_API, { + method: 'POST', + + headers: { + Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`, + 'Content-Type': 'application/json', + }, + + body: JSON.stringify({ + from: parseAddress(from), + + ...(replyTo ? { reply_to: { email: replyTo } } : null), + + subject, + + content: [ + { type: 'text/plain', value: text }, + { type: 'text/html', value: html }, + ], + + personalizations: [{ to: [{ email: to }] }], + + ...(messageId ? { headers: threadingHeaders(messageId) } : null), + + // @note account defaults apply otherwise; only essential mail overrides + // them, because it must reach an unsubscribed recipient and is not + // marketing + ...(essential + ? { + tracking_settings: { + click_tracking: { enable: false }, + open_tracking: { enable: false }, + subscription_tracking: { enable: false }, + }, + mail_settings: { + bypass_list_management: { enable: true }, + }, + } + : null), + }), + }) + + if (!response.ok) { + throw await getFetchError(response, { vendor: 'sendgrid', from }) + } +} diff --git a/packages/email/src/ses.ts b/packages/email/src/ses.ts new file mode 100644 index 0000000..3265c38 --- /dev/null +++ b/packages/email/src/ses.ts @@ -0,0 +1,123 @@ +// @note Amazon SES through its v2 JSON API, signed locally - see ./sigv4.ts. +// The credentials are SES's own rather than the storage module's: sharing +// them would make configuring object storage silently switch mail delivery on. +import { fetch, getFetchError } from '@chatbotkit-dev/fetch' + +import type { OutboundMessage } from './identity' +import { threadingHeaders } from './identity' +import { sign } from './sigv4' + +interface Env { + region: string + accessKeyId: string + secretAccessKey: string + sessionToken?: string + endpoint: string +} + +export function isConfigured(): boolean { + return Boolean(process.env.SES_AWS_ACCESS_KEY_ID) +} + +/** + * @throws {Error} naming every required variable that is missing. + */ +function getEnv(): Env { + const { + SES_AWS_REGION, + SES_AWS_ACCESS_KEY_ID, + SES_AWS_SECRET_ACCESS_KEY, + SES_AWS_SESSION_TOKEN, + SES_AWS_ENDPOINT, + } = process.env + + const missing = Object.entries({ + SES_AWS_REGION, + SES_AWS_ACCESS_KEY_ID, + SES_AWS_SECRET_ACCESS_KEY, + }) + .filter(([, value]) => !value) + .map(([name]) => name) + + if (missing.length) { + throw new Error( + `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not set, so mail cannot be delivered through SES` + ) + } + + return { + region: SES_AWS_REGION as string, + accessKeyId: SES_AWS_ACCESS_KEY_ID as string, + secretAccessKey: SES_AWS_SECRET_ACCESS_KEY as string, + sessionToken: SES_AWS_SESSION_TOKEN || undefined, + endpoint: + SES_AWS_ENDPOINT?.replace(/\/+$/, '') || + `https://email.${SES_AWS_REGION}.amazonaws.com`, + } +} + +/** + * @throws {Error} when the configuration is incomplete. + */ +export function assertEnv(): void { + getEnv() +} + +/** + * @throws {Error} when the API rejects the message. + */ +export async function send(message: OutboundMessage): Promise { + const { region, accessKeyId, secretAccessKey, sessionToken, endpoint } = + getEnv() + + const { from, to, subject, text, html, replyTo, messageId } = message + + const url = `${endpoint}/v2/email/outbound-emails` + + const body = JSON.stringify({ + FromEmailAddress: from, + + Destination: { ToAddresses: [to] }, + + ...(replyTo ? { ReplyToAddresses: [replyTo] } : null), + + Content: { + Simple: { + Subject: { Data: subject, Charset: 'UTF-8' }, + + Body: { + Text: { Data: text, Charset: 'UTF-8' }, + Html: { Data: html, Charset: 'UTF-8' }, + }, + + ...(messageId + ? { + Headers: Object.entries(threadingHeaders(messageId)).map( + ([Name, Value]) => ({ Name, Value }) + ), + } + : null), + }, + }, + }) + + const headers = sign({ + method: 'POST', + url, + headers: { 'content-type': 'application/json' }, + body, + + region, + service: 'ses', + + accessKeyId, + secretAccessKey, + sessionToken, + }) + + const response = await fetch(url, { method: 'POST', headers, body }) + + if (!response.ok) { + throw await getFetchError(response, { vendor: 'ses', from }) + } +} diff --git a/packages/email/src/sigv4.test.js b/packages/email/src/sigv4.test.js new file mode 100644 index 0000000..c3ef569 --- /dev/null +++ b/packages/email/src/sigv4.test.js @@ -0,0 +1,215 @@ +import { sign } from './sigv4' + +// @note the worked example from the AWS Signature Version 4 documentation, so +// the signer is checked against a published vector rather than against itself + +const credentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', +} + +describe('sign', () => { + it('reproduces the documented IAM ListUsers signature', () => { + const headers = sign({ + method: 'GET', + url: 'https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + }, + body: '', + + region: 'us-east-1', + service: 'iam', + + ...credentials, + + date: new Date('2015-08-30T12:36:00Z'), + }) + + expect(headers['x-amz-date']).toBe('20150830T123600Z') + + expect(headers.authorization).toBe( + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7' + ) + }) + + it('defaults the signing time to now', () => { + const before = new Date() + + const headers = sign({ + method: 'POST', + url: 'https://email.eu-west-1.amazonaws.com/v2/email/outbound-emails', + headers: {}, + body: '{}', + + region: 'eu-west-1', + service: 'ses', + + ...credentials, + }) + + const stamp = headers['x-amz-date'] + + expect(stamp).toMatch(/^\d{8}T\d{6}Z$/) + expect(stamp.slice(0, 8)).toBe( + before.toISOString().slice(0, 10).replace(/-/g, '') + ) + expect(headers.authorization).toContain( + `Credential=AKIDEXAMPLE/${stamp.slice(0, 8)}/eu-west-1/ses/aws4_request` + ) + }) + + it('changes with the payload', () => { + const base = { + method: 'POST', + url: 'https://email.eu-west-1.amazonaws.com/v2/email/outbound-emails', + headers: { 'content-type': 'application/json' }, + + region: 'eu-west-1', + service: 'ses', + + ...credentials, + + date: new Date('2025-01-02T03:04:05Z'), + } + + const one = sign({ ...base, body: '{"a":1}' }).authorization + const two = sign({ ...base, body: '{"a":2}' }).authorization + + expect(one).not.toBe(two) + expect(sign({ ...base, body: '{"a":1}' }).authorization).toBe(one) + }) + + it('lowercases header names and sorts them into SignedHeaders', () => { + const headers = sign({ + method: 'POST', + url: 'https://example.com/', + headers: { + 'X-Custom': 'value', + 'Content-Type': 'application/json', + }, + body: '', + + region: 'us-east-1', + service: 'ses', + + ...credentials, + }) + + expect(headers['content-type']).toBe('application/json') + expect(headers['x-custom']).toBe('value') + expect(headers.authorization).toContain( + 'SignedHeaders=content-type;host;x-amz-date;x-custom,' + ) + }) + + it('canonicalizes header whitespace without altering what is sent', () => { + const base = { + method: 'POST', + url: 'https://example.com/', + body: '', + + region: 'us-east-1', + service: 'ses', + + ...credentials, + + date: new Date('2025-01-02T03:04:05Z'), + } + + const spaced = sign({ ...base, headers: { 'x-custom': ' a b ' } }) + const tight = sign({ ...base, headers: { 'x-custom': 'a b' } }) + + expect(spaced.authorization).toBe(tight.authorization) + expect(spaced['x-custom']).toBe(' a b ') + }) + + it('sorts query parameters by name, then value', () => { + const base = { + method: 'GET', + headers: {}, + body: '', + + region: 'us-east-1', + service: 'iam', + + ...credentials, + + date: new Date('2015-08-30T12:36:00Z'), + } + + const ordered = sign({ + ...base, + url: 'https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08', + }) + + const shuffled = sign({ + ...base, + url: 'https://iam.amazonaws.com/?Version=2010-05-08&Action=ListUsers', + }) + + expect(shuffled.authorization).toBe(ordered.authorization) + }) + + it('treats a path as case- and encoding-sensitive', () => { + const base = { + method: 'GET', + headers: {}, + body: '', + + region: 'us-east-1', + service: 'iam', + + ...credentials, + + date: new Date('2015-08-30T12:36:00Z'), + } + + const a = sign({ ...base, url: 'https://example.com/a/b' }) + const b = sign({ ...base, url: 'https://example.com/a/B' }) + const c = sign({ ...base, url: 'https://example.com/a%2Fb' }) + + expect(a.authorization).not.toBe(b.authorization) + expect(a.authorization).not.toBe(c.authorization) + }) + + it('does not hand host back, since fetch sets it from the URL', () => { + const headers = sign({ + method: 'POST', + url: 'https://email.eu-west-1.amazonaws.com/v2/email/outbound-emails', + headers: { 'content-type': 'application/json' }, + body: '{}', + + region: 'eu-west-1', + service: 'ses', + + ...credentials, + }) + + expect(headers.host).toBeUndefined() + expect(headers['content-type']).toBe('application/json') + expect(headers.authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/eu-west-1\/ses\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/ + ) + }) + + it('signs the session token along with everything else', () => { + const headers = sign({ + method: 'POST', + url: 'https://email.eu-west-1.amazonaws.com/v2/email/outbound-emails', + headers: { 'content-type': 'application/json' }, + body: '{}', + + region: 'eu-west-1', + service: 'ses', + + ...credentials, + sessionToken: 'token', + }) + + expect(headers['x-amz-security-token']).toBe('token') + expect(headers.authorization).toContain( + 'SignedHeaders=content-type;host;x-amz-date;x-amz-security-token,' + ) + }) +}) diff --git a/packages/email/src/sigv4.ts b/packages/email/src/sigv4.ts new file mode 100644 index 0000000..043d40f --- /dev/null +++ b/packages/email/src/sigv4.ts @@ -0,0 +1,137 @@ +// @note AWS Signature Version 4 over a plain fetch request. Small enough to +// carry here: pulling in an AWS SDK client for one JSON endpoint would make +// the community default's install several times larger than the rest of it. +import { createHash, createHmac } from 'node:crypto' + +export interface SignOptions { + method: string + url: string + headers: Record + body: string + + region: string + service: string + + accessKeyId: string + secretAccessKey: string + sessionToken?: string + + /** Signing time, defaults to now. */ + date?: Date +} + +function sha256(data: string): string { + return createHash('sha256').update(data, 'utf8').digest('hex') +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac('sha256', key).update(data, 'utf8').digest() +} + +function encode(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +// @note the pathname is already percent-encoded once by URL parsing, and +// every service except S3 expects each segment encoded a second time +function canonicalPath(pathname: string): string { + return pathname.split('/').map(encode).join('/') || '/' +} + +function canonicalQuery(params: URLSearchParams): string { + return [...params.entries()] + .map(([key, value]) => [encode(key), encode(value)]) + .sort(([ak, av], [bk, bv]) => + ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk) + ) + .map(([key, value]) => `${key}=${value}`) + .join('&') +} + +/** + * Signs a request and returns the headers to send it with, the caller's own + * plus `x-amz-date`, `x-amz-security-token` when a session token is in play, + * and `authorization`. + */ +export function sign(options: SignOptions): Record { + const { + method, + url, + body, + region, + service, + accessKeyId, + secretAccessKey, + sessionToken, + date = new Date(), + } = options + + const { host, pathname, searchParams } = new URL(url) + + const amzDate = date + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}Z$/, 'Z') + + const dateStamp = amzDate.slice(0, 8) + + const headers: Record = { + ...Object.fromEntries( + Object.entries(options.headers).map(([key, value]) => [ + key.toLowerCase(), + value, + ]) + ), + + host, + 'x-amz-date': amzDate, + + ...(sessionToken ? { 'x-amz-security-token': sessionToken } : null), + } + + const signedHeaderNames = Object.keys(headers).sort() + + const canonicalHeaders = signedHeaderNames + .map((name) => `${name}:${headers[name].trim().replace(/\s+/g, ' ')}\n`) + .join('') + + const signedHeaders = signedHeaderNames.join(';') + + const canonicalRequest = [ + method.toUpperCase(), + canonicalPath(pathname), + canonicalQuery(searchParams), + canonicalHeaders, + signedHeaders, + sha256(body), + ].join('\n') + + const scope = `${dateStamp}/${region}/${service}/aws4_request` + + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + scope, + sha256(canonicalRequest), + ].join('\n') + + const signingKey = hmac( + hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), + 'aws4_request' + ) + + const signature = hmac(signingKey, stringToSign).toString('hex') + + // @note host is signed but not returned: fetch derives it from the URL and + // refuses to have it set by hand + const { host: _host, ...rest } = headers + + return { + ...rest, + + authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + } +} diff --git a/packages/email/src/vendors.test.js b/packages/email/src/vendors.test.js new file mode 100644 index 0000000..9a3178a --- /dev/null +++ b/packages/email/src/vendors.test.js @@ -0,0 +1,590 @@ +// @note the vendor modules are exercised through the provider entry point, the +// way the platform reaches them, with the network mocked at the fetch seam +import { jest } from '@jest/globals' + +const fetch = jest.fn() +const getFetchError = jest.fn(async () => new Error('vendor rejected it')) + +jest.unstable_mockModule('@chatbotkit-dev/fetch', () => ({ + fetch, + getFetchError, +})) + +const { createEmailTransport, sendEmailAction, sendEmailNotification } = + await import('./index') + +const ENV = [ + 'EMAIL_PROVIDER', + 'EMAIL_FROM', + 'EMAIL_ACTIONS_FROM', + 'EMAIL_REPLY_TO', + 'RESEND_API_KEY', + 'SENDGRID_API_KEY', + 'SES_AWS_REGION', + 'SES_AWS_ACCESS_KEY_ID', + 'SES_AWS_SECRET_ACCESS_KEY', + 'SES_AWS_SESSION_TOKEN', + 'SES_AWS_ENDPOINT', +] + +const notification = { + to: 'user@example.com', + subject: 'Sign in', + content: { text: 'plain text', html: '

html

' }, +} + +function request() { + const [url, init] = fetch.mock.calls[0] + + return { url, init, body: JSON.parse(init.body) } +} + +describe('vendors', () => { + // eslint-disable-next-line no-console + const original = console.log + + beforeEach(() => { + fetch.mockReset() + fetch.mockResolvedValue({ ok: true }) + getFetchError.mockClear() + + // eslint-disable-next-line no-console + console.log = jest.fn() + + for (const name of ENV) { + delete process.env[name] + } + + process.env.EMAIL_FROM = 'Login ' + }) + + afterEach(() => { + // eslint-disable-next-line no-console + console.log = original + + for (const name of ENV) { + delete process.env[name] + } + }) + + describe('resend', () => { + beforeEach(() => { + process.env.RESEND_API_KEY = 'resend-key' + }) + + it('posts a notification as the configured identity', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailNotification(notification) + + const { url, init, body } = request() + + expect(url).toBe('https://api.resend.com/emails') + expect(init.headers.Authorization).toBe('Bearer resend-key') + + expect(body).toEqual({ + from: 'Login ', + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + reply_to: 'support@example.com', + }) + + // eslint-disable-next-line no-console + expect(console.log).not.toHaveBeenCalled() + }) + + it('lets the message choose where replies go', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailNotification({ + ...notification, + replyTo: 'billing@example.com', + }) + + expect(request().body.reply_to).toBe('billing@example.com') + }) + + it('threads an action against its message id', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Re: enquiry', + content: notification.content, + from: 'agent@partner.example', + name: 'Agent', + messageId: '', + }) + + const { body } = request() + + expect(body.from).toBe('Agent ') + expect(body.headers).toEqual({ + 'Message-ID': '', + 'In-Reply-To': '', + References: '', + }) + }) + + it('sends an action from the actions identity when the caller names none', async () => { + process.env.EMAIL_ACTIONS_FROM = 'Agents ' + + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + }) + + expect(request().body.from).toBe('Agents ') + }) + + it('keeps the default display name when only the mailbox is given', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + from: 'agent@partner.example', + }) + + expect(request().body.from).toBe('Login ') + }) + + it('sends a transport as the identity it was created with', async () => { + await createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + }) + + expect(request().body.from).toBe('Acme ') + }) + + it('throws what the API said when it rejects the message', async () => { + fetch.mockResolvedValue({ ok: false, status: 422 }) + + await expect(sendEmailNotification(notification)).rejects.toThrow( + 'vendor rejected it' + ) + + expect(getFetchError).toHaveBeenCalledWith(expect.anything(), { + vendor: 'resend', + from: 'Login ', + }) + }) + + it('refuses to send without a sending identity', async () => { + delete process.env.EMAIL_FROM + + await expect(sendEmailNotification(notification)).rejects.toThrow( + /EMAIL_FROM/ + ) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('sends a transport without any sending identity configured', async () => { + delete process.env.EMAIL_FROM + + await createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + }) + + expect(request().body.from).toBe('Acme ') + }) + + it('sends nothing vendor-specific for essential mail', async () => { + await sendEmailNotification({ ...notification, essential: true }) + + expect(Object.keys(request().body).sort()).toEqual([ + 'from', + 'html', + 'subject', + 'text', + 'to', + ]) + }) + + it('omits reply_to and headers when there is nothing to say', async () => { + await sendEmailNotification(notification) + + const { body } = request() + + expect(body).not.toHaveProperty('reply_to') + expect(body).not.toHaveProperty('headers') + }) + + it('passes an action reply-to through untouched', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + replyTo: 'agent@partner.example', + }) + + expect(request().body.reply_to).toBe('agent@partner.example') + }) + + it('does not give an action the notification reply-to', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + }) + + expect(request().body).not.toHaveProperty('reply_to') + }) + + it('throws when a transport is rejected, naming the source', async () => { + fetch.mockResolvedValue({ ok: false, status: 403 }) + + await expect( + createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + }) + ).rejects.toThrow('vendor rejected it') + + expect(getFetchError).toHaveBeenCalledWith(expect.anything(), { + vendor: 'resend', + from: 'Acme ', + }) + }) + }) + + describe('sendgrid', () => { + beforeEach(() => { + process.env.SENDGRID_API_KEY = 'sendgrid-key' + }) + + it('posts a notification in the v3 mail shape', async () => { + await sendEmailNotification(notification) + + const { url, init, body } = request() + + expect(url).toBe('https://api.sendgrid.com/v3/mail/send') + expect(init.headers.Authorization).toBe('Bearer sendgrid-key') + + expect(body.from).toEqual({ name: 'Login', email: 'noreply@example.com' }) + expect(body.personalizations).toEqual([ + { to: [{ email: 'user@example.com' }] }, + ]) + expect(body.content).toEqual([ + { type: 'text/plain', value: 'plain text' }, + { type: 'text/html', value: '

html

' }, + ]) + + expect(body.tracking_settings).toBeUndefined() + expect(body.mail_settings).toBeUndefined() + }) + + it('bypasses list management and tracking for essential mail', async () => { + await sendEmailNotification({ ...notification, essential: true }) + + const { body } = request() + + expect(body.mail_settings).toEqual({ + bypass_list_management: { enable: true }, + }) + expect(body.tracking_settings.click_tracking).toEqual({ enable: false }) + }) + + it('treats every action as essential', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + messageId: '', + }) + + const { body } = request() + + expect(body.mail_settings).toEqual({ + bypass_list_management: { enable: true }, + }) + expect(body.headers['In-Reply-To']).toBe('') + }) + + it('splits a caller-supplied action identity into name and mailbox', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + from: 'agent@partner.example', + name: 'Agent', + replyTo: 'replies@partner.example', + }) + + const { body } = request() + + expect(body.from).toEqual({ name: 'Agent', email: 'agent@partner.example' }) + expect(body.reply_to).toEqual({ email: 'replies@partner.example' }) + }) + + it('sends a bare mailbox without a name field', async () => { + process.env.EMAIL_FROM = 'noreply@example.com' + + await sendEmailNotification(notification) + + expect(request().body.from).toEqual({ email: 'noreply@example.com' }) + }) + + it('uses the notification reply-to by default', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailNotification(notification) + + expect(request().body.reply_to).toEqual({ email: 'support@example.com' }) + }) + + it('sends a transport as the identity it was created with', async () => { + await createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + }) + + const { body } = request() + + expect(body.from).toEqual({ name: 'Acme', email: 'login@acme.example' }) + expect(body.mail_settings).toBeUndefined() + }) + + it('throws what the API said when it rejects the message', async () => { + fetch.mockResolvedValue({ ok: false, status: 401 }) + + await expect(sendEmailNotification(notification)).rejects.toThrow( + 'vendor rejected it' + ) + + expect(getFetchError).toHaveBeenCalledWith(expect.anything(), { + vendor: 'sendgrid', + from: 'Login ', + }) + }) + + it('refuses to send without a sending identity', async () => { + delete process.env.EMAIL_FROM + + await expect(sendEmailNotification(notification)).rejects.toThrow( + /EMAIL_FROM/ + ) + + expect(fetch).not.toHaveBeenCalled() + }) + }) + + describe('ses', () => { + beforeEach(() => { + process.env.SES_AWS_REGION = 'eu-west-1' + process.env.SES_AWS_ACCESS_KEY_ID = 'AKIDEXAMPLE' + process.env.SES_AWS_SECRET_ACCESS_KEY = 'secret' + }) + + it('posts a signed SendEmail request to the regional endpoint', async () => { + process.env.EMAIL_REPLY_TO = 'support@example.com' + + await sendEmailNotification(notification) + + const { url, init, body } = request() + + expect(url).toBe( + 'https://email.eu-west-1.amazonaws.com/v2/email/outbound-emails' + ) + + expect(init.headers['content-type']).toBe('application/json') + expect(init.headers['x-amz-date']).toMatch(/^\d{8}T\d{6}Z$/) + expect(init.headers.authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/eu-west-1\/ses\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/ + ) + + expect(body).toEqual({ + FromEmailAddress: 'Login ', + Destination: { ToAddresses: ['user@example.com'] }, + ReplyToAddresses: ['support@example.com'], + Content: { + Simple: { + Subject: { Data: 'Sign in', Charset: 'UTF-8' }, + Body: { + Text: { Data: 'plain text', Charset: 'UTF-8' }, + Html: { Data: '

html

', Charset: 'UTF-8' }, + }, + }, + }, + }) + }) + + it('carries threading headers on actions', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + messageId: '', + }) + + expect(request().body.Content.Simple.Headers).toEqual([ + { Name: 'Message-ID', Value: '' }, + { Name: 'In-Reply-To', Value: '' }, + { Name: 'References', Value: '' }, + ]) + }) + + it('signs a session token and honours a custom endpoint', async () => { + process.env.SES_AWS_SESSION_TOKEN = 'token' + process.env.SES_AWS_ENDPOINT = 'http://localhost:4566/' + + await sendEmailNotification(notification) + + const { url, init } = request() + + expect(url).toBe('http://localhost:4566/v2/email/outbound-emails') + expect(init.headers['x-amz-security-token']).toBe('token') + expect(init.headers.authorization).toContain('x-amz-security-token') + }) + + it('refuses to send with an incomplete configuration', async () => { + delete process.env.SES_AWS_SECRET_ACCESS_KEY + + await expect(sendEmailNotification(notification)).rejects.toThrow( + /SES_AWS_SECRET_ACCESS_KEY is not set/ + ) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('omits ReplyToAddresses and Headers when there is nothing to say', async () => { + await sendEmailNotification(notification) + + const { body } = request() + + expect(body).not.toHaveProperty('ReplyToAddresses') + expect(body.Content.Simple).not.toHaveProperty('Headers') + }) + + it('composes a caller-supplied action identity', async () => { + await sendEmailAction({ + to: 'third@example.com', + subject: 'Hello', + content: notification.content, + from: 'agent@partner.example', + name: 'Agent', + replyTo: 'replies@partner.example', + }) + + const { body } = request() + + expect(body.FromEmailAddress).toBe('Agent ') + expect(body.ReplyToAddresses).toEqual(['replies@partner.example']) + }) + + it('sends a transport as the identity it was created with', async () => { + await createEmailTransport('Acme ').send({ + to: 'user@example.com', + subject: 'Sign in', + text: 'plain text', + html: '

html

', + }) + + expect(request().body.FromEmailAddress).toBe('Acme ') + }) + + it('signs the exact body it sends', async () => { + await sendEmailNotification(notification) + + const { init } = request() + + // @note the signature covers the payload hash, so the body handed to + // fetch must be the very string that was signed - a re-serialization + // with different key order would be rejected upstream + + expect(typeof init.body).toBe('string') + expect(init.method).toBe('POST') + expect(init.headers).not.toHaveProperty('host') + }) + + it('strips a trailing slash from a custom endpoint', async () => { + process.env.SES_AWS_ENDPOINT = 'https://ses.internal.example///' + + await sendEmailNotification(notification) + + expect(request().url).toBe( + 'https://ses.internal.example/v2/email/outbound-emails' + ) + }) + + it('throws what the API said when it rejects the message', async () => { + fetch.mockResolvedValue({ ok: false, status: 400 }) + + await expect(sendEmailNotification(notification)).rejects.toThrow( + 'vendor rejected it' + ) + + expect(getFetchError).toHaveBeenCalledWith(expect.anything(), { + vendor: 'ses', + from: 'Login ', + }) + }) + }) + + describe('EMAIL_PROVIDER', () => { + it('routes to the pinned vendor when several are configured', async () => { + process.env.RESEND_API_KEY = 'resend-key' + process.env.SENDGRID_API_KEY = 'sendgrid-key' + process.env.EMAIL_PROVIDER = 'sendgrid' + + await sendEmailNotification(notification) + + expect(request().url).toBe('https://api.sendgrid.com/v3/mail/send') + }) + + it('prints when pinned to print despite credentials', async () => { + process.env.RESEND_API_KEY = 'resend-key' + process.env.EMAIL_PROVIDER = 'print' + + await sendEmailNotification(notification) + + expect(fetch).not.toHaveBeenCalled() + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledTimes(1) + }) + + it('fails a pinned vendor without its credential instead of falling back', async () => { + process.env.RESEND_API_KEY = 'resend-key' + process.env.EMAIL_PROVIDER = 'sendgrid' + + await expect(sendEmailNotification(notification)).rejects.toThrow( + /SENDGRID_API_KEY/ + ) + + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects an unknown pin at send time', async () => { + process.env.EMAIL_PROVIDER = 'postmark' + + await expect(sendEmailNotification(notification)).rejects.toThrow( + /EMAIL_PROVIDER="postmark"/ + ) + }) + }) + + it('prints instead of fetching when nothing is configured', async () => { + await sendEmailNotification(notification) + + expect(fetch).not.toHaveBeenCalled() + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json index 53607f2..eb04375 100644 --- a/packages/email/tsconfig.json +++ b/packages/email/tsconfig.json @@ -4,25 +4,27 @@ "isolatedModules": true, "rootDir": ".", "noEmit": true, + "composite": true, "target": "es2021", "module": "esnext", "moduleResolution": "bundler", - "types": [ - "node" - ], "lib": [ "DOM", - "DOM.Iterable", "ES2021" ], + "types": [ + "node" + ], "allowJs": true, "checkJs": false, "declaration": true, - "strict": true, - "noImplicitAny": true, + "strict": false, + "strictNullChecks": true, + "noImplicitOverride": true, + "skipLibCheck": true, "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "composite": true + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true }, "include": [ "./src/**/*.ts", diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 805b895..3f63095 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -30,12 +30,11 @@ // // Deduplication *is* honoured, in process and for half an hour, because it is // the one option that can be implemented without outliving anything. - import type { QueueAuthentication, QueueDelivery, - QueuePublishOptions, QueueProvider, + QueuePublishOptions, } from '@chatbotkit-dev/queue-spec' export type * from '@chatbotkit-dev/queue-spec' diff --git a/platform/components/PortalList.jsx b/platform/components/PortalList.jsx index 892f6d1..85c2152 100644 --- a/platform/components/PortalList.jsx +++ b/platform/components/PortalList.jsx @@ -5,7 +5,7 @@ import { useMemo } from 'react' import ResourceList from '@/components/ResourceList' import useGraphQLConnectionListRoute from '@/hooks/useGraphQLConnectionListRoute' -import { usePortalApex } from '@/hooks/useHostname' +import { useApexHostURL, usePortalApex } from '@/hooks/useHostname' import useProjectScope, { scopeListRoute } from '@/hooks/useProjectScope' const DEFAULT_LIST_ROUTE = '/api/v1/portal/list' @@ -72,16 +72,18 @@ export default function PortalList({ }) { const portalApex = usePortalApex() + const toApexHostURL = useApexHostURL() + const { hydrated, scope } = useProjectScope() const resolvedExtraLinks = useMemo( () => extraLinks === undefined ? { - Open: ({ slug }) => `https://${slug}.${portalApex}`, + Open: ({ slug }) => toApexHostURL(slug, portalApex), } : extraLinks, - [extraLinks, portalApex] + [extraLinks, portalApex, toApexHostURL] ) const variables = useMemo( diff --git a/platform/components/PortalList.utest.jsx b/platform/components/PortalList.utest.jsx index c9ac1b8..8b92c6e 100644 --- a/platform/components/PortalList.utest.jsx +++ b/platform/components/PortalList.utest.jsx @@ -43,7 +43,7 @@ describe('PortalList', () => { const { getByTestId } = render() expect(getByTestId('extra-links')).toHaveTextContent( - 'https://test-portal.chatbotkit.agency' + 'http://test-portal.chatbotkit.agency' ) }) }) @@ -214,7 +214,7 @@ describe('PortalList', () => { // Default extraLinks should handle any slug format expect(getByTestId('extra-links')).toHaveTextContent( - 'https://test-portal.chatbotkit.agency' + 'http://test-portal.chatbotkit.agency' ) }) @@ -230,7 +230,7 @@ describe('PortalList', () => { const { getByTestId } = render() expect(getByTestId('extra-links')).toHaveTextContent( - 'https://test-portal.chatbotkit.agency' + 'http://test-portal.chatbotkit.agency' ) }) diff --git a/platform/components/SpaceSiteList.jsx b/platform/components/SpaceSiteList.jsx index ec5e56c..ab6aef1 100644 --- a/platform/components/SpaceSiteList.jsx +++ b/platform/components/SpaceSiteList.jsx @@ -8,7 +8,7 @@ import { useConfirmDelete } from '@/components/Confirm' import List from '@/components/List' import useFetch from '@/hooks/useFetch' -import { useSpaceApex } from '@/hooks/useHostname' +import { useApexHostURL, useSpaceApex } from '@/hooks/useHostname' import usePopup from '@/hooks/usePopup' /** @@ -167,8 +167,11 @@ export default function SpaceSiteList({ readOnly = false, }) { const [sites, setSites] = useState(defaultItems) + const spaceApex = useSpaceApex() + const toApexHostURL = useApexHostURL() + const { fetch: load } = useFetch({ loadingMessage: false, failureMessage: false, @@ -197,12 +200,12 @@ export default function SpaceSiteList({ const handleOpen = useCallback( (site) => { window.open( - `https://${site.slug}.${spaceApex}`, + toApexHostURL(site.slug, spaceApex), '_blank', 'noopener,noreferrer' ) }, - [spaceApex] + [spaceApex, toApexHostURL] ) const handleSave = useCallback( diff --git a/platform/components/ThisSolution.jsx b/platform/components/ThisSolution.jsx index 3f1d1e1..e27d0ee 100644 --- a/platform/components/ThisSolution.jsx +++ b/platform/components/ThisSolution.jsx @@ -17,7 +17,7 @@ import FOC from '@/components/FOC' import Portal from '@/components/Portal' import useDashboardWidgetSend from '@/hooks/useDashboardWidgetSend' -import { usePortalApex } from '@/hooks/useHostname' +import { useApexHostURL, usePortalApex } from '@/hooks/useHostname' import useTeamSwitch from '@/hooks/useTeamSwitch' import useUserSwitch from '@/hooks/useUserSwitch' @@ -343,6 +343,8 @@ export default function ThisSolution({ }) { const portalApex = usePortalApex() + const toApexHostURL = useApexHostURL() + const singleton = useThisSolutionSingleton() const href = function (href, search = {}) { @@ -602,7 +604,7 @@ export default function ThisSolution({ { icon: 'heroicons/link', title: 'Open Portal', - link: `https://${instance.slug}.${portalApex}`, + link: toApexHostURL(instance.slug, portalApex), target: '_blank', }, ] @@ -730,7 +732,7 @@ export default function ThisSolution({ ] : []), ] - }, [updateKey, type, instance, level, toName, portalApex]) + }, [updateKey, type, instance, level, toName, portalApex, toApexHostURL]) const wrapper = useMemo(() => { return portal diff --git a/platform/config/models.ts b/platform/config/models.ts index 67804f3..f868800 100644 --- a/platform/config/models.ts +++ b/platform/config/models.ts @@ -1868,6 +1868,94 @@ export const openrouterLanguageModels: Record< addedDate: '2025-04-18', }, + // meta + + 'muse-spark-1.3': { + description: `Muse Spark 1.3 is Meta's multimodal reasoning model for long-horizon agentic and coding workflows. It improves first-attempt accuracy, tool calling, and context tracking across extended tasks, with native understanding of images and documents and a 1M-token context window.`, + + provider: 'openrouter', + + providerModel: 'meta/muse-spark-1.3', + + family: 'muse', + + features: ['chat', 'functions', 'image', 'file', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + maxTokens: 1_048_576, + maxInputTokens: Math.floor(1_048_576 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(1_048_576 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.2361, + inputTokenRatio: 0.0893, + outputTokenRatio: 0.2361, + inputPrice: 1.25, + outputPrice: 4.25, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: [], + + addedDate: '2026-09-02', + }, + + 'muse-spark-1.3-contributor': { + description: `Muse Spark 1.3 Contributor is Meta's cost-efficient tier for experimentation and early-stage agentic, multi-agent, and coding workflows. It offers dependable tool calling, multimodal perception, and a 1M-token context window. Prompts and outputs may be used to improve Meta's products.`, + + provider: 'openrouter', + + providerModel: 'meta/muse-spark-1.3-contributor', + + family: 'muse', + + features: ['chat', 'functions', 'image', 'file', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + maxTokens: 1_048_576, + maxInputTokens: Math.floor(1_048_576 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(1_048_576 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.0111, + inputTokenRatio: 0.0071, + outputTokenRatio: 0.0111, + inputPrice: 0.1, + outputPrice: 0.2, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: ['training use'], + + addedDate: '2026-09-02', + }, + // moonshotai 'kimi-k3': { @@ -2764,7 +2852,111 @@ export const vercelLanguageModels: Record< addedDate: '2025-04-10', }, - // facebook + // meta + + 'muse-spark-1.3': { + description: `Muse Spark 1.3 is Meta's multimodal reasoning model for long-horizon agentic and coding workflows. It improves first-attempt accuracy, tool calling, and context tracking across extended tasks, with native understanding of images and documents and a 1M-token context window.`, + + provider: 'vercel', + + providerModel: 'meta/muse-spark-1.3', + + providerOptions: { + gateway: { + // @note meta is the only provider serving this model and is not + // ZDR-compliant on the Vercel AI Gateway, so forced ZDR leaves the + // gateway nowhere to route and the request fails + zeroDataRetention: false, + }, + }, + + family: 'muse', + + features: ['chat', 'functions', 'image', 'file', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + maxTokens: 1_048_576, + maxInputTokens: Math.floor(1_048_576 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(1_048_576 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.2361, + inputTokenRatio: 0.0893, + outputTokenRatio: 0.2361, + inputPrice: 1.25, + outputPrice: 4.25, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: [], + + addedDate: '2026-09-02', + }, + + 'muse-spark-1.3-contributor': { + description: `Muse Spark 1.3 Contributor is Meta's cost-efficient tier for experimentation and early-stage agentic, multi-agent, and coding workflows. It offers dependable tool calling, multimodal perception, and a 1M-token context window. Prompts and outputs may be used to improve Meta's products.`, + + provider: 'vercel', + + providerModel: 'meta/muse-spark-1.3-contributor', + + providerOptions: { + gateway: { + // @note meta is the only provider serving this model and is not + // ZDR-compliant on the Vercel AI Gateway, so forced ZDR leaves the + // gateway nowhere to route and the request fails + zeroDataRetention: false, + }, + }, + + family: 'muse', + + features: ['chat', 'functions', 'image', 'file', 'reasoning'], + + region: 'us', + availableRegions: ['us'], + + maxTokens: 1_048_576, + maxInputTokens: Math.floor(1_048_576 * MAX_INPUT_TOKENS_RATIO), + maxOutputTokens: Math.ceil(1_048_576 * MAX_OUTPUT_TOKENS_RATIO), + + pricing: { + tokenRatio: 0.0111, + inputTokenRatio: 0.0071, + outputTokenRatio: 0.0111, + inputPrice: 0.1, + outputPrice: 0.2, + }, + + interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, + + thresholdStrategy: 'truncate', + + visible: true, + deprecated: false, + + temperature: DEFAULT_TEMPERATURE, + + frequencyPenalty: 0, + presencePenalty: 0, + + tags: ['training use'], + + addedDate: '2026-09-02', + }, 'muse-spark-1.2': { description: `Muse Spark 1.2 is a coding-optimized model purpose-built for agentic workflows. It improves on code generation, debugging, and codebase understanding, with a 1M-token context window that handles an entire project in one session.`, diff --git a/platform/hooks/useHostname.tsx b/platform/hooks/useHostname.tsx index 127023e..618598d 100644 --- a/platform/hooks/useHostname.tsx +++ b/platform/hooks/useHostname.tsx @@ -1,5 +1,5 @@ /* eslint-disable custom-eslint-rules/no-restricted-client-imports -- the hostname seam itself - the constants seed first render and the data-* attributes overlay the runtime value */ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { portalApex, spaceApex } from '@/config/apexes' import { @@ -107,6 +107,36 @@ export function useSpaceApex(): string { return apex } +/** + * Builds the URL of a deployment-issued `.` host. Apex hosts are + * served by the same server as the page, so the scheme and port follow the + * document location once hydrated - a Compose stack serves them over plain + * http on the site port - and the site URL before. + */ +export function useApexHostURL(): (slug: string, apex: string) => string { + const [{ protocol, port }, setLocation] = useState<{ + protocol: string + port: string + }>(() => { + const url = new URL(siteUrl) + + return { protocol: url.protocol, port: url.port } + }) + + useHydrationSafeLayoutEffect(() => { + setLocation({ + protocol: window.location.protocol, + port: window.location.port, + }) + }, []) + + return useCallback( + (slug: string, apex: string) => + `${protocol}//${slug}.${apex}${port ? `:${port}` : ''}`, + [protocol, port] + ) +} + /** * The app slug to hostname table with the runtime deployment hosts overlaid. * The build-time constants carry no apex values in the browser - they read diff --git a/platform/hooks/useHostname.utest.js b/platform/hooks/useHostname.utest.js index 08b9df9..8f5531b 100644 --- a/platform/hooks/useHostname.utest.js +++ b/platform/hooks/useHostname.utest.js @@ -5,6 +5,7 @@ import useCookie from './useCookie' import useHostname, { getDocumentHostname, useAPIHostname, + useApexHostURL, useAppSlugToHostnameMap, useAudienceHostname, useCookieHostname, @@ -409,6 +410,53 @@ describe('configured apexes', () => { }) }) +describe('useApexHostURL', () => { + afterEach(() => { + siteUrlValue = 'https://default.example.com' + }) + + it('should follow the document location scheme and port once hydrated', () => { + // @note jsdom serves the test document from http://localhost/ + const { result } = renderHook(() => useApexHostURL()) + + expect(result.current('acme', 'space.localhost')).toBe( + 'http://acme.space.localhost' + ) + }) + + it('should seed the scheme and port from the site url on the server', () => { + siteUrlValue = 'http://localhost:3000' + + let href = '' + + function Probe() { + href = useApexHostURL()('acme', 'space.localhost') + + return null + } + + renderToString() + + expect(href).toBe('http://acme.space.localhost:3000') + }) + + it('should omit the port when the site url has none', () => { + siteUrlValue = 'https://app.example.com' + + let href = '' + + function Probe() { + href = useApexHostURL()('acme', 'space.example.com') + + return null + } + + renderToString() + + expect(href).toBe('https://acme.space.example.com') + }) +}) + describe('useAppSlugToHostnameMap', () => { beforeEach(() => { delete document.documentElement.dataset.appApex diff --git a/platform/hooks/useImageColorPalette.tsx b/platform/hooks/useImageColorPalette.tsx index 654a94e..b7dea7d 100644 --- a/platform/hooks/useImageColorPalette.tsx +++ b/platform/hooks/useImageColorPalette.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react' -import { rgbToHex } from '@/lib/color' - -import ColorThief from 'colorthief' +import { getColor, getPalette } from 'colorthief' interface UseImageColorPaletteResult { error: Error | Event | null @@ -20,32 +18,43 @@ export default function useImageColorPalette( const [colorPalette, setColorPalette] = useState(null) useEffect(() => { - const colorThief = new ColorThief() + let cancelled = false const img = new Image() img.src = url || '' img.crossOrigin = 'Anonymous' - img.onload = () => { + img.onload = async () => { try { setError(null) - const color = rgbToHex(colorThief.getColor(img)) + const [dominant, swatches] = await Promise.all([ + getColor(img), + getPalette(img, { colorCount: 8 }), + ]) + + if (cancelled) { + return + } + + const color = dominant ? dominant.hex() : null setColor(color) - const palette = colorThief - .getPalette(img, 8) - .map((color) => rgbToHex(color)) + const palette = (swatches || []).map((swatch) => swatch.hex()) setPalette(palette) - const colorPalette = Array.from(new Set([color, ...palette])) + const colorPalette = Array.from( + new Set([...(color ? [color] : []), ...palette]) + ) setColorPalette(colorPalette) } catch (e) { - setError(e as Error) + if (!cancelled) { + setError(e as Error) + } } } @@ -54,6 +63,8 @@ export default function useImageColorPalette( } return () => { + cancelled = true + img.onload = null } }, [url]) diff --git a/platform/hooks/useImageColorPalette.utest.js b/platform/hooks/useImageColorPalette.utest.js index 430bb85..eaf1893 100644 --- a/platform/hooks/useImageColorPalette.utest.js +++ b/platform/hooks/useImageColorPalette.utest.js @@ -2,47 +2,38 @@ import useImageColorPalette from './useImageColorPalette' import { renderHook, waitFor } from '@testing-library/react' -import ColorThief from 'colorthief' +import { getColor, getPalette } from 'colorthief' -jest.mock('colorthief', () => { - return jest.fn().mockImplementation(() => ({ - getColor: jest.fn(), - getPalette: jest.fn(), - })) -}) - -jest.mock('@/lib/color', () => ({ - rgbToHex: jest.fn((rgb) => { - // Simple mock implementation for testing - const [r, g, b] = rgb - - return `#${r.toString(16).padStart(2, '0')}${g - .toString(16) - .padStart(2, '0')}${b.toString(16).padStart(2, '0')}` - }), +jest.mock('colorthief', () => ({ + getColor: jest.fn(), + getPalette: jest.fn(), })) +// @note the hook only reads `.hex()` off the colors the library returns +const color = (r, g, b) => ({ + hex: () => + `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`, +}) + describe('useImageColorPalette', () => { let mockColorThief beforeEach(() => { jest.clearAllMocks() - mockColorThief = { - getColor: jest.fn().mockReturnValue([255, 0, 0]), // Red - getPalette: jest.fn().mockReturnValue([ - [255, 0, 0], // Red - [0, 255, 0], // Green - [0, 0, 255], // Blue - [255, 255, 0], // Yellow - [255, 0, 255], // Magenta - [0, 255, 255], // Cyan - [128, 128, 128], // Gray - [0, 0, 0], // Black - ]), - } - - ColorThief.mockImplementation(() => mockColorThief) + mockColorThief = { getColor, getPalette } + + getColor.mockReturnValue(color(255, 0, 0)) // Red + getPalette.mockReturnValue([ + color(255, 0, 0), // Red + color(0, 255, 0), // Green + color(0, 0, 255), // Blue + color(255, 255, 0), // Yellow + color(255, 0, 255), // Magenta + color(0, 255, 255), // Cyan + color(128, 128, 128), // Gray + color(0, 0, 0), // Black + ]) // Mock Image constructor global.Image = class { @@ -108,7 +99,7 @@ describe('useImageColorPalette', () => { expect(result.current.palette).toContain('#0000ff') expect(mockColorThief.getPalette).toHaveBeenCalledWith( expect.any(Object), - 8 + { colorCount: 8 } ) }) @@ -158,7 +149,7 @@ describe('useImageColorPalette', () => { const firstColor = result.current.color // Change to different color - mockColorThief.getColor.mockReturnValue([0, 255, 0]) // Green + mockColorThief.getColor.mockReturnValue(color(0, 255, 0)) // Green rerender({ url: 'https://example.com/image2.jpg' }) @@ -232,7 +223,7 @@ describe('useImageColorPalette', () => { }) it('should handle getPalette errors', async () => { - mockColorThief.getColor.mockReturnValue([255, 0, 0]) + mockColorThief.getColor.mockReturnValue(color(255, 0, 0)) mockColorThief.getPalette.mockImplementation(() => { throw new Error('getPalette failed') }) @@ -397,12 +388,12 @@ describe('useImageColorPalette', () => { it('should remove duplicate colors from colorPalette', async () => { // Make palette include the same color as the dominant color - mockColorThief.getColor.mockReturnValue([255, 0, 0]) + mockColorThief.getColor.mockReturnValue(color(255, 0, 0)) mockColorThief.getPalette.mockReturnValue([ - [255, 0, 0], // Duplicate of dominant color - [255, 0, 0], // Another duplicate - [0, 255, 0], - [0, 0, 255], + color(255, 0, 0), // Duplicate of dominant color + color(255, 0, 0), // Another duplicate + color(0, 255, 0), + color(0, 0, 255), ]) const { result } = renderHook(() => diff --git a/platform/hooks/useImageColorPalette.utest.jsx b/platform/hooks/useImageColorPalette.utest.jsx index ace3b99..3c361de 100644 --- a/platform/hooks/useImageColorPalette.utest.jsx +++ b/platform/hooks/useImageColorPalette.utest.jsx @@ -1,21 +1,20 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ import useImageColorPalette from './useImageColorPalette' import { act, renderHook } from '@testing-library/react' -// Mock colorthief -jest.mock('colorthief', () => { - return jest.fn().mockImplementation(() => ({ - getColor: jest.fn(), - getPalette: jest.fn(), - })) -}) +import { getColor, getPalette } from 'colorthief' -// Mock color lib -jest.mock('@/lib/color', () => ({ - rgbToHex: jest.fn((rgb) => `#${rgb.join('')}`), +jest.mock('colorthief', () => ({ + getColor: jest.fn(), + getPalette: jest.fn(), })) +// @note the hook only reads `.hex()` off the colors the library returns +const color = (r, g, b) => ({ + hex: () => + `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`, +}) + describe('useImageColorPalette', () => { let mockImage let mockColorThief @@ -35,10 +34,7 @@ describe('useImageColorPalette', () => { global.Image = jest.fn(() => mockImage) - // Get mocked ColorThief instance - const ColorThief = require('colorthief') - - mockColorThief = new ColorThief() + mockColorThief = { getColor, getPalette } jest.clearAllMocks() }) @@ -81,7 +77,7 @@ describe('useImageColorPalette', () => { describe('url changes', () => { it('should reload when url changes', async () => { - mockColorThief.getColor.mockReturnValue([255, 0, 0]) + mockColorThief.getColor.mockReturnValue(color(255, 0, 0)) mockColorThief.getPalette.mockReturnValue([]) const { rerender } = renderHook(({ url }) => useImageColorPalette(url), { @@ -122,7 +118,7 @@ describe('useImageColorPalette', () => { }) it('should not throw when unmounted before image loads', async () => { - mockColorThief.getColor.mockReturnValue([255, 0, 0]) + mockColorThief.getColor.mockReturnValue(color(255, 0, 0)) mockColorThief.getPalette.mockReturnValue([]) const { unmount } = renderHook(() => diff --git a/platform/instrumentation.ts b/platform/instrumentation.ts index 66bf3a4..4b5b3cd 100644 --- a/platform/instrumentation.ts +++ b/platform/instrumentation.ts @@ -4,6 +4,7 @@ import { } from '@chatbotkit-dev/observability/next/server' import { BANNER } from '@/lib/banner' +import { startClock } from '@/lib/clock' import { warnlog } from '@/lib/debug' import { isDevelopment } from '@/lib/env' @@ -30,6 +31,10 @@ export async function register() { '. Do not expose this instance publicly.' ) } + + // @note the one place the platform has that outlives a request - see + // lib/clock.ts. + startClock() } return registerObservability() diff --git a/platform/lib/clock.ts b/platform/lib/clock.ts new file mode 100644 index 0000000..4dd8f31 --- /dev/null +++ b/platform/lib/clock.ts @@ -0,0 +1,88 @@ +// @note the platform's own clock, for deployments with nothing outside to tick +// it. +// +// Nothing in the platform outlives a request except the server process itself, +// so that is what publishes the `clock10` tick - always, on every server, as a +// standard part of running the platform. A queue backend that keeps schedules +// of its own may tick the same route from outside as well; the handlers behind +// it are maintenance sweeps and tolerate a second pass. +// +// It publishes rather than running the handlers, so a tick from here takes the +// same path as one from outside: the shared-secret check, the timeout monitor, +// and whatever the installed queue does with a delivery. +// +// Two limits, both inherited from where this runs. A deployment with several +// instances ticks once per instance, and only a queue that deduplicates across +// processes collapses them - the barebone one does not, and says so. A +// serverless host ends the interval with the instance, so a deployment there +// needs its queue backend to keep the schedule. + +import { TEN_MINUTES_IN_MILLISECONDS } from '@chatbotkit-dev/time' + +import debug from '@/lib/debug' +import { captureException } from '@/lib/error' +import { queue } from '@/lib/queue' + +export const CLOCK_ROUTE = '/api/system/clock/queue' + +/** + * @note the literal rather than the route module's constant, because importing + * the route here would load every queue module it fans out to at server start. + * The route's own test asserts the same value. + */ +export const CLOCK10_EVENT_TYPE = 'clock10' + +/** + * @note not configurable. The event is named for its period, and every handler + * behind it sizes its work to ten minutes. + */ +export const CLOCK_INTERVAL = TEN_MINUTES_IN_MILLISECONDS + +/** + * @note one id per ten-minute window, so two instances publishing in the same + * window are collapsed by a queue that deduplicates. + */ +export function getClockDeduplicationId(now: number): string { + return `${CLOCK10_EVENT_TYPE}-${Math.floor(now / CLOCK_INTERVAL)}` +} + +/** + * Publishes one clock tick. A failed publish is reported and swallowed so the + * interval that called it keeps going. + */ +export async function tick(now: number = Date.now()): Promise { + debug(`clock tick`).log('clock.tick') + + try { + await queue( + CLOCK_ROUTE, + { type: CLOCK10_EVENT_TYPE, payload: {} }, + { deduplicationId: getClockDeduplicationId(now) } + ) + } catch (error) { + await captureException(error) + } +} + +/** + * Starts the clock. + * + * @note the first tick lands ten minutes after start, not at start. A container + * in a restart loop would otherwise fan out every maintenance job on each + * crash. The timer is unref'd so it never keeps a stopping process alive. + * + * @returns a function that stops the clock + */ +export function startClock(): () => void { + debug(`clock started`, { interval: CLOCK_INTERVAL }).log('clock.start') + + const timer = setInterval(() => { + void tick() + }, CLOCK_INTERVAL) + + timer.unref?.() + + return () => { + clearInterval(timer) + } +} diff --git a/platform/lib/clock.utest.js b/platform/lib/clock.utest.js new file mode 100644 index 0000000..6426e3f --- /dev/null +++ b/platform/lib/clock.utest.js @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ + +import { + CLOCK_INTERVAL, + CLOCK_ROUTE, + getClockDeduplicationId, + startClock, + tick, +} from '@/lib/clock' + +jest.mock('@/lib/debug', () => ({ + __esModule: true, + + default: () => ({ + log: () => ({}), + }), +})) + +jest.mock('@/lib/error', () => ({ + captureException: jest.fn(async () => undefined), +})) + +jest.mock('@/lib/queue', () => ({ + queue: jest.fn(async () => undefined), +})) + +const { queue } = jest.requireMock('@/lib/queue') +const { captureException } = jest.requireMock('@/lib/error') + +describe('startClock', () => { + let stop + + beforeEach(() => { + jest.clearAllMocks() + + jest.useFakeTimers() + + jest.setSystemTime(0) + }) + + afterEach(() => { + stop?.() + + stop = undefined + + jest.useRealTimers() + }) + + it('ticks every ten minutes', () => { + expect(CLOCK_INTERVAL).toBe(10 * 60 * 1000) + }) + + // @note a container in a restart loop must not fan out every maintenance job + // on each crash + it('does not tick at start', async () => { + stop = startClock() + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL - 1) + + expect(queue).not.toHaveBeenCalled() + }) + + it('publishes a clock10 event to the clock route once per interval', async () => { + stop = startClock() + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL) + + expect(queue).toHaveBeenCalledTimes(1) + + expect(queue).toHaveBeenCalledWith( + CLOCK_ROUTE, + { type: 'clock10', payload: {} }, + { deduplicationId: 'clock10-1' } + ) + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL) + + expect(queue).toHaveBeenCalledTimes(2) + + expect(queue).toHaveBeenLastCalledWith( + CLOCK_ROUTE, + { type: 'clock10', payload: {} }, + { deduplicationId: 'clock10-2' } + ) + }) + + it('stops ticking once stopped', async () => { + stop = startClock() + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL) + + stop() + + stop = undefined + + await jest.advanceTimersByTimeAsync(CLOCK_INTERVAL * 5) + + expect(queue).toHaveBeenCalledTimes(1) + }) +}) + +describe('tick', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('reports a failed publish rather than throwing, so the clock keeps going', async () => { + const failure = new Error('queue is down') + + queue.mockRejectedValueOnce(failure) + + await expect(tick(0)).resolves.toBeUndefined() + + expect(captureException).toHaveBeenCalledWith(failure) + }) +}) + +describe('getClockDeduplicationId', () => { + // @note two instances in the same window produce the same id, which is what + // lets a deduplicating queue collapse them + it('is stable within a ten-minute window and changes across them', () => { + expect(getClockDeduplicationId(0)).toBe('clock10-0') + + expect(getClockDeduplicationId(CLOCK_INTERVAL - 1)).toBe('clock10-0') + + expect(getClockDeduplicationId(CLOCK_INTERVAL)).toBe('clock10-1') + }) +}) diff --git a/platform/package.json b/platform/package.json index 2bc3ea8..258de20 100644 --- a/platform/package.json +++ b/platform/package.json @@ -234,7 +234,7 @@ "canvas": "file:../stubs/canvas", "chroma-js": "^3.1.2", "clsx": "^1.2.1", - "colorthief": "2.6.0", + "colorthief": "3.5.0", "contrast-color": "^1.0.1", "core-js": "^3.37.1", "core-js-pure": "^3.37.1", diff --git a/platform/pages/api/system/clock/_queue.utest.js b/platform/pages/api/system/clock/_queue.utest.js index ef0929a..7eaf944 100644 --- a/platform/pages/api/system/clock/_queue.utest.js +++ b/platform/pages/api/system/clock/_queue.utest.js @@ -7,6 +7,7 @@ import { handleClock10Event, handleEmptyConversations, handleExpiredConversations, + sendEvent, } from './queue' jest.mock('@/lib/debug', () => () => ({ @@ -133,4 +134,17 @@ describe('/api/system/clock/queue', () => { expect.objectContaining({ type: 'empty' }) ) }) + + // @note the clock has its own route, and a tick queued anywhere else is a + // delivery to a path with no handler + it('should queue clock events to the clock route', async () => { + const queue = require('@/lib/queue') + + await sendEvent({ type: 'clock10', payload: {} }) + + expect(queue).toHaveBeenCalledWith('/api/system/clock/queue', { + type: 'clock10', + payload: {}, + }) + }) }) diff --git a/platform/pages/api/system/clock/queue.js b/platform/pages/api/system/clock/queue.js index 0982945..d396a13 100644 --- a/platform/pages/api/system/clock/queue.js +++ b/platform/pages/api/system/clock/queue.js @@ -407,7 +407,7 @@ export async function sendEvent(event) { } } - await queue(`/api/system/queue`, event) + await queue(`/api/system/clock/queue`, event) } /** diff --git a/platform/pages/api/v1/integration/extract/[extractIntegrationId]/queue.js b/platform/pages/api/v1/integration/extract/[extractIntegrationId]/queue.js index 78b1694..8455dc6 100644 --- a/platform/pages/api/v1/integration/extract/[extractIntegrationId]/queue.js +++ b/platform/pages/api/v1/integration/extract/[extractIntegrationId]/queue.js @@ -2,6 +2,7 @@ import prisma from '@/prisma/client' import { Trigger } from '@/prisma/types' +import { setContextConversation } from '@/lib/context.store' import { isAutonomousConversation } from '@/lib/conversation.app' import debug from '@/lib/debug' import { fetchPlusPlus } from '@/lib/egress.fetch' @@ -178,6 +179,11 @@ export async function handleIdleEvent(extractIntegrationId, payload, context) { // Perform the extraction. + // @note the engine resolves message attachments (images) through the + // context conversation; without it any conversation with an upload throws + + setContextConversation(conversation) + // @note usage is recorded by the conversation engine internally via // usageMeta and usageReferences passed here diff --git a/platform/pages/hub/blueprints/[blueprintId]/index.jsx b/platform/pages/hub/blueprints/[blueprintId]/index.jsx index bfcc889..10d35dd 100644 --- a/platform/pages/hub/blueprints/[blueprintId]/index.jsx +++ b/platform/pages/hub/blueprints/[blueprintId]/index.jsx @@ -17,8 +17,12 @@ import Link from '@/components/Link' import List from '@/components/List' import StructuredData from '@/components/StructuredData' +import { + useApexHostURL, + usePortalApex, + useSpaceApex, +} from '@/hooks/useHostname' import useSession from '@/hooks/useSession' -import { usePortalApex } from '@/hooks/useHostname' import faq from '@/content/faqs/platform-blueprints.yaml' @@ -27,6 +31,10 @@ import { UserCircleIcon } from '@heroicons/react/24/solid' export function PageHero({ instance }) { const portalApex = usePortalApex() + const spaceApex = useSpaceApex() + + const toApexHostURL = useApexHostURL() + const { data: session } = useSession() const site = instance.blueprint?.spaces?.flatMap( @@ -36,11 +44,12 @@ export function PageHero({ instance }) { const portal = instance.blueprint?.portals?.[0] // @note sites take priority over portals for the public visit link - const visitHref = site - ? `https://${site.domain}` - : portal - ? `https://${portal.slug}.${portalApex}` - : null + const visitHref = + site && spaceApex + ? toApexHostURL(site.slug, spaceApex) + : portal + ? toApexHostURL(portal.slug, portalApex) + : null return ( <> @@ -299,9 +308,7 @@ Index.getLayout = function (children, { instance }) { data={{ '@context': 'https://schema.org/', '@type': 'SoftwareApplication', - url: `/hub/blueprints/${ - instance.slug || instance.id - }`, + url: `/hub/blueprints/${instance.slug || instance.id}`, name: instance.name, description: instance.description, applicationCategory: 'AI Chatbot', @@ -412,7 +419,7 @@ export async function getServerSideProps(context) { sites: { select: { - domain: true, + slug: true, }, orderBy: { diff --git a/platform/pages/integrations/widget/[widgetIntegrationId]/frame.jsx b/platform/pages/integrations/widget/[widgetIntegrationId]/frame.jsx index b5339de..70782b8 100644 --- a/platform/pages/integrations/widget/[widgetIntegrationId]/frame.jsx +++ b/platform/pages/integrations/widget/[widgetIntegrationId]/frame.jsx @@ -57,7 +57,7 @@ import { isOpaqueColor, legibleTextColor } from '@/lib/color2' import { accessVar } from '@/lib/css.var' import { looksLikeEmail } from '@/lib/email.validation' import { isDevelopment } from '@/lib/env' -import { captureError } from '@/lib/error' +import { SystemError, captureError } from '@/lib/error' import fetch from '@/lib/fetch' import { formToData } from '@/lib/form' import { getHighlighter, highlight } from '@/lib/highlighter' @@ -70,6 +70,7 @@ import { getAccept } from '@/lib/mime' import { equal, merge, pick } from '@/lib/object' import { sleep } from '@/lib/promise' import { isComponent } from '@/lib/react' +import { captureUnknownError, isUnknownError } from '@/lib/response' import { textToEmojiSpans, wordsToSpans } from '@/lib/rehype.plugins' import { saveBlob, saveUrl } from '@/lib/save' import { buildOriginRestrictedCsp } from '@/lib/security.headers' @@ -340,7 +341,7 @@ function useFunctionHandler(handler, deps, name) { // @note prevent unhandled rejection from propagating to global // handler - await captureError(e) + await captureUnknownError(e) } if ('id' in event.data && event.data.id) { @@ -5594,9 +5595,9 @@ export function Conversation({ return { conversationId, token, expiresAt } } } catch (e) { - await captureError(e) + await captureUnknownError(e) - return { error: e.message || 'Token method failed' } + return { error: e.message || 'Token method failed', code: e.code } } } @@ -5614,9 +5615,12 @@ export function Conversation({ }) if (error) { - await captureError(error) + await captureUnknownError(error) - return { error: error.message || error || 'Fetch failed' } + return { + error: error.message || error || 'Fetch failed', + code: error.code, + } } const { conversationId, token, expiresAt } = data @@ -5649,6 +5653,7 @@ export function Conversation({ return conversationId } else { let lastError = null + let lastCode = null for (let attempt = 0; attempt < 3; attempt++) { const { @@ -5656,6 +5661,7 @@ export function Conversation({ token, expiresAt, error, + code, } = await getToken() if (newConversationId && token) { @@ -5669,12 +5675,25 @@ export function Conversation({ return newConversationId } else { lastError = error || 'Empty response' + lastCode = code + + // @note an expected refusal (account limits, auth) will not clear + // on retry + + if (code && !isUnknownError({ code })) { + break + } await sleep(500 * (attempt + 1)) } } - throw new Error(`Failed to get a fresh token: ${lastError}`) + // @note carry the code so expected refusals stay out of Sentry upstream + + throw new SystemError( + `Failed to get a fresh token: ${lastError}`, + lastCode + ) } }, [conversationId, setConversationId, setToken, token, getToken]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc82be9..01b05e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,6 @@ overrides: react-icons: 5.5.0 colorthief>sharp: '-' ndarray-pixels>sharp: '-' - colorthief>file-type: '-' swagger-jsdoc>glob: ^13.0.6 deepmerge-ts: '>=8.0.0' @@ -675,6 +674,9 @@ importers: '@chatbotkit-dev/email-spec': specifier: workspace:* version: link:../email-spec + '@chatbotkit-dev/fetch': + specifier: workspace:* + version: link:../fetch '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -3214,8 +3216,8 @@ importers: specifier: ^1.2.1 version: 1.2.1 colorthief: - specifier: 2.6.0 - version: 2.6.0 + specifier: 3.5.0 + version: 3.5.0(sharp@0.35.4(@types/node@24.13.3)) contrast-color: specifier: ^1.0.1 version: 1.0.1 @@ -6732,9 +6734,6 @@ packages: resolution: {integrity: sha512-CfBK4/EZ73uN/6b5/wOfvWju1Ev/6H+uXqS2Vqd7/dEQTyOsvv4BdOHDqESp4Efa9YyrPPvN3IHU+C4z9FAo3Q==} engines: {node: '>= 18'} - '@lokesh.dhakar/quantize@1.4.0': - resolution: {integrity: sha512-+//cqVWKis//t0YH62EDtwaFSPG/CDtYNg4CZmzNmG2d5W17Iu3fuDAdpQXCDHUDrrU9q0veze4A7tPZXlR/mg==} - '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -8745,9 +8744,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/ndarray@1.0.14': - resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==} - '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -10207,8 +10203,14 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - colorthief@2.6.0: - resolution: {integrity: sha512-yL3B7laeOr4kH9XasFF5rl+9Taz+Pmt/CRbaTI6XepZFyQvk4K/abaGKIAsngVpxKkgFeoJ2IwdRpS228icrig==} + colorthief@3.5.0: + resolution: {integrity: sha512-wV/6Rnkqdx07CAuv8O3WYKBU/j5QdSXbYUfGp26seMIqze5Q40C9ovBSZB9PeVV6XkGe3VWb4zUEtQCYzCfyEA==} + hasBin: true + peerDependencies: + sharp: '*' + peerDependenciesMeta: + sharp: + optional: true combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} @@ -10540,9 +10542,6 @@ packages: csv-stringify@6.8.3: resolution: {integrity: sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==} - cwise-compiler@1.1.3: - resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} - cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -12444,9 +12443,6 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} - iota-array@1.0.0: - resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} - ip-address@10.4.0: resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} @@ -12507,9 +12503,6 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} - is-buffer@1.1.6: - resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} - is-buffer@2.0.5: resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} engines: {node: '>=4'} @@ -14105,15 +14098,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - ndarray-ops@1.2.2: - resolution: {integrity: sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==} - - ndarray-pixels@4.1.0: - resolution: {integrity: sha512-xKPI4zXJ2pkUcVX24zIN1AWqqPWvRWWhRuO6PlY4EdB2VNRauNwA6rDdsAQG/ldQp0sU7nTXgPR/io1duy3Zyg==} - - ndarray@1.0.19: - resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} - negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -16940,9 +16924,6 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - uniq@1.0.1: - resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==} - unist-util-find-after@4.0.1: resolution: {integrity: sha512-QO/PuPMm2ERxC6vFXEPtmAutOopy5PknD+Oq64gGwxKtk4xwo9Z97t9Av1obPmGU0IyTa6EKYUfTrK2QJS3Ozw==} @@ -21012,8 +20993,6 @@ snapshots: '@kikobeats/time-span@1.0.13': {} - '@lokesh.dhakar/quantize@1.4.0': {} - '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@types/mdx': 2.0.14 @@ -23853,8 +23832,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/ndarray@1.0.14': {} - '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.20.1 @@ -25522,10 +25499,9 @@ snapshots: colorette@2.0.20: {} - colorthief@2.6.0: - dependencies: - '@lokesh.dhakar/quantize': 1.4.0 - ndarray-pixels: 4.1.0 + colorthief@3.5.0(sharp@0.35.4(@types/node@24.13.3)): + optionalDependencies: + sharp: 0.35.4(@types/node@24.13.3) combined-stream@1.0.8: dependencies: @@ -25888,10 +25864,6 @@ snapshots: csv-stringify@6.8.3: {} - cwise-compiler@1.1.3: - dependencies: - uniq: 1.0.1 - cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.1): dependencies: cose-base: 1.0.3 @@ -28319,8 +28291,6 @@ snapshots: transitivePeerDependencies: - supports-color - iota-array@1.0.0: {} - ip-address@10.4.0: {} ip-regex@4.3.0: {} @@ -28379,8 +28349,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-buffer@1.1.6: {} - is-buffer@2.0.5: {} is-bun-module@2.0.0: @@ -30748,21 +30716,6 @@ snapshots: natural-compare@1.4.0: {} - ndarray-ops@1.2.2: - dependencies: - cwise-compiler: 1.1.3 - - ndarray-pixels@4.1.0: - dependencies: - '@types/ndarray': 1.0.14 - ndarray: 1.0.19 - ndarray-ops: 1.2.2 - - ndarray@1.0.19: - dependencies: - iota-array: 1.0.0 - is-buffer: 1.1.6 - negotiator@0.6.3: {} negotiator@1.0.0: {} @@ -34215,8 +34168,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - uniq@1.0.1: {} - unist-util-find-after@4.0.1: dependencies: '@types/unist': 2.0.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4acdde0..2f2075c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -390,7 +390,6 @@ overrides: # sharp dependency is dead weight that drags libvips advisories in 'colorthief>sharp': '-' 'ndarray-pixels>sharp': '-' - 'colorthief>file-type': '-' # @note swagger-jsdoc pins deprecated glob 11 although its glob usage remains # compatible with the maintained release 'swagger-jsdoc>glob': ^13.0.6