Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
# `<slug>.space.localhost`, portals at `<slug>.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
Expand Down
5 changes: 5 additions & 0 deletions docker/distro/community/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@example.com>"

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
Expand Down
14 changes: 11 additions & 3 deletions docs/module-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 48 additions & 5 deletions packages/email/README.md
Original file line number Diff line number Diff line change
@@ -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 <noreply@example.com>`. 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

Expand Down
1 change: 1 addition & 0 deletions packages/email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"@chatbotkit-dev/email-spec": "workspace:*",
"@chatbotkit-dev/fetch": "workspace:*",
"@types/node": "^24.0.0"
}
}
132 changes: 132 additions & 0 deletions packages/email/src/identity.test.js
Original file line number Diff line number Diff line change
@@ -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 <noreply@example.com>')).toEqual({
name: 'Login',
email: 'noreply@example.com',
})
})

it('handles a quoted display name', () => {
expect(parseAddress('"Acme, Inc." <hello@acme.example>')).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('<noreply@example.com>')).toEqual({
email: 'noreply@example.com',
})
})

it('trims surrounding whitespace', () => {
expect(parseAddress(' Login <noreply@example.com> ')).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>',
'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('<id@example.com>')).toEqual({
'Message-ID': '<id@example.com>',
'In-Reply-To': '<id@example.com>',
References: '<id@example.com>',
})
})
})

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 <noreply@example.com>'

expect(notificationFrom()).toBe('Login <noreply@example.com>')
})

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')
})
})
Loading