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
55 changes: 55 additions & 0 deletions .changeset/smtp-transport-plugin-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/plugin-email": minor
"@objectstack/service-settings": patch
"@objectstack/cli": patch
---

feat(plugin-email): real SMTP delivery — `SmtpTransport`, settings hot-swap, and a `mail/test` that actually sends (#5087)

The **Mail Delivery** settings page has always defaulted to SMTP and offered a
full host / port / TLS / username / password form. Nothing behind it delivered:
`applyMailSettings` treated `provider: 'smtp'` as a no-op ("transport
unchanged"), `mail/test` answered `ok: true, "Configuration looks valid … Wire
@objectstack/plugin-mail for actual delivery"` — a success toast for a message
nobody sent, naming a package that has never existed — and the code pointed
operators at `@objectstack/plugin-mail-smtp`, which is not in this repo or on
npm. A workspace that selected SMTP got a green form, a green test button, and
mail that only ever reached the log and the `sys_email` table. For deployments
in China this left **no** working channel at all: Resend and Postmark are
overseas HTTPS SaaS with unreliable reach and deliverability to QQ / 163 /
enterprise mailboxes, where SMTP is the normal path (Aliyun DirectMail, Tencent
SES, corporate mail servers).

**`SmtpTransport` now ships in `@objectstack/plugin-email`** (ADR-0012: SMTP in
core, implemented with `nodemailer`). `nodemailer` is a real dependency but is
imported **lazily on the first send**, so deployments that never select SMTP —
and non-Node runtimes — never load `node:net` / `node:tls`.

Three doors reach it, all sharing one options reader so they cannot drift:

- **Settings → Mail** (`smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` /
`smtp_password`) hot-swaps the live transport on save, no restart.
- **`os serve`** via `OS_EMAIL_PROVIDER=smtp` plus the new `OS_EMAIL_SMTP_HOST` /
`_PORT` / `_SECURE` / `_USER` / `_PASSWORD` (or `config.email.options`).
- **Constructor**: `new EmailServicePlugin({ provider: 'smtp', providerOptions:
{ host, port, secure, user, password } })`.

TLS is one toggle with the wire behaviour derived from the port, as providers
document it: on `465` implicit TLS (SMTPS); on any other port a **required**
STARTTLS upgrade, so a server that refuses to upgrade fails the send instead of
leaking credentials over a cleartext socket; `secure: false` connects in the
clear and upgrades only when STARTTLS is offered.

**Failure is loud everywhere, because a silent fallback is the bug this fixes.**
On the construction path (CLI / plugin options) a `smtp` provider with no host
**throws** and the boot fails — it no longer degrades into a LogTransport that
reports every send as successful. On the settings hot-swap path a save can never
kill a running server, so the previous transport is kept — but the failure is
logged at `error` naming the consequence and the fix, and **`mail/test` now
performs a real delivery** through the settings on screen and reports the SMTP
server's own words (`535 … authentication failed`) instead of a green toast. The
built-in fallback `mail/test` handler (used only when no email plugin is
mounted) answers `ok: false` and says plainly that nothing was sent.

Nothing to migrate: `log`, `resend` and `postmark` behave exactly as before, and
a deployment that never selects `smtp` is unaffected.
27 changes: 20 additions & 7 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,29 @@ Auth settings precedence:

| Variable | Type | Default | Description |
|:---|:---|:---|:---|
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. Real SMTP delivery requires the separate `@objectstack/plugin-mail-smtp` package. |
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `smtp` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. |
| `OS_EMAIL_API_KEY` | string | — | API key for `resend` / `postmark`. |
| `OS_EMAIL_FROM` | email | — | Default `From:` address. |
| `OS_EMAIL_RETRIES` | number | `0` | Retry count for transient send failures (`0` = no retry). |

> No SMTP transport ships in the open-core runtime — `OS_EMAIL_PROVIDER`
> only materialises `log`, `resend`, or `postmark`. Real SMTP delivery
> requires the separate `@objectstack/plugin-mail-smtp` package, which reads
> its own SMTP settings (configured in **Settings → Mail** or via the
> plugin's constructor options).
| `OS_EMAIL_SMTP_HOST` | string | — | SMTP server hostname. **Required** when `OS_EMAIL_PROVIDER=smtp` — a boot without it fails loudly rather than starting with a transport that logs mail instead of sending it. |
| `OS_EMAIL_SMTP_PORT` | number | `587` | SMTP port. `465` selects implicit TLS (SMTPS). |
| `OS_EMAIL_SMTP_SECURE` | boolean | `true` | Require TLS. On port `465` that means implicit TLS; on any other port a **required** STARTTLS upgrade (a server that will not upgrade fails the send instead of leaking credentials in the clear). `false` connects in the clear and upgrades only when STARTTLS is offered. |
| `OS_EMAIL_SMTP_USER` | string | — | SMTP AUTH username. Omit for servers that accept unauthenticated relay. |
| `OS_EMAIL_SMTP_PASSWORD` | string | — | SMTP AUTH password. |

> SMTP delivery ships in `@objectstack/plugin-email` (ADR-0012) and is
> implemented with `nodemailer`, imported lazily so deployments that never
> select SMTP — and non-Node runtimes — never load `node:net` / `node:tls`.
> Any provider that speaks SMTP works through it: a corporate mail server,
> Aliyun DirectMail, Tencent SES, QQ / 163 enterprise mailboxes.
>
> These variables configure the transport at **boot**. The same connection
> can be configured at runtime in **Settings → Mail** (namespace `mail`,
> keys `smtp_host` / `smtp_port` / `smtp_secure` / `smtp_user` /
> `smtp_password`), which hot-swaps the live transport without a restart and
> whose **Send test email** button performs a real delivery. Settings-page
> values are overridden by the namespace's own `OS_MAIL_*` env door
> (e.g. `OS_MAIL_SMTP_HOST`), which locks the field in the UI.

---

Expand Down
109 changes: 109 additions & 0 deletions packages/cli/src/commands/serve-email-capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* framework#5087 — what `EmailServicePlugin` is constructed with on the
* `os serve` path, and specifically what happens when SMTP is selected.
*
* `OS_EMAIL_PROVIDER=smtp` used to be unreachable: the plugin knew three
* providers (`log`/`resend`/`postmark`), so `smtp` fell into the "no apiKey"
* arm and was silently rewritten to `log`. The server then booted "fine",
* every send was recorded in `sys_email` as sent, and nothing left the box.
* These pin the opposite: a complete SMTP configuration reaches the plugin,
* and an incomplete one fails the boot instead of degrading into a transport
* that reports success.
*/

import { describe, it, expect } from 'vitest';
import { resolveEmailCapabilityArg } from './serve.js';

describe('resolveEmailCapabilityArg', () => {
it('defaults to the log provider when nothing is configured', () => {
const { options, warning } = resolveEmailCapabilityArg({}, {});
expect(options).toMatchObject({ provider: 'log' });
expect(options).not.toHaveProperty('providerOptions');
expect(warning).toBeUndefined();
});

it('assembles the SMTP connection from OS_EMAIL_SMTP_*', () => {
const { options, warning } = resolveEmailCapabilityArg({}, {
OS_EMAIL_PROVIDER: 'smtp',
OS_EMAIL_SMTP_HOST: ' smtp.exmail.qq.com ',
OS_EMAIL_SMTP_PORT: '465',
OS_EMAIL_SMTP_SECURE: 'true',
OS_EMAIL_SMTP_USER: 'ops@example.cn',
OS_EMAIL_SMTP_PASSWORD: 'sekrit',
OS_EMAIL_FROM: 'Acme <no-reply@example.cn>',
});
expect(warning).toBeUndefined();
expect(options).toMatchObject({
provider: 'smtp',
providerOptions: {
host: 'smtp.exmail.qq.com',
port: 465,
secure: true,
user: 'ops@example.cn',
password: 'sekrit',
},
defaultFrom: { name: 'Acme', address: 'no-reply@example.cn' },
});
});

it('reads OS_EMAIL_SMTP_SECURE=false as plain-connect', () => {
const { options } = resolveEmailCapabilityArg({}, {
OS_EMAIL_PROVIDER: 'smtp',
OS_EMAIL_SMTP_HOST: 'smtp.x',
OS_EMAIL_SMTP_SECURE: 'false',
});
expect((options.providerOptions as any).secure).toBe(false);
expect(resolveEmailCapabilityArg({}, {
OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_HOST: 'smtp.x', OS_EMAIL_SMTP_SECURE: '0',
}).options.providerOptions).toMatchObject({ secure: false });
});

it('layers env over config.email.options', () => {
const { options } = resolveEmailCapabilityArg(
{ provider: 'smtp', options: { host: 'smtp.config', port: 25 } },
{ OS_EMAIL_SMTP_HOST: 'smtp.env' },
);
expect(options.providerOptions).toEqual({ host: 'smtp.env', port: 25 });
});

it('accepts an SMTP host declared only in config.email.options', () => {
const { options } = resolveEmailCapabilityArg({ provider: 'smtp', options: { host: 'smtp.config' } }, {});
expect(options).toMatchObject({ provider: 'smtp', providerOptions: { host: 'smtp.config' } });
});

it('THROWS on provider=smtp without a host — never a silent LogTransport', () => {
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'smtp' }))
.toThrow(/no SMTP host is configured/);
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_PORT: '587' }))
.toThrow(/OS_EMAIL_SMTP_HOST/);
});

it('does not apply the apiKey fallback to smtp', () => {
// The `resend`/`postmark` arm degrades to `log` when the key is missing;
// smtp must never reach it (it needs no apiKey at all).
const { options, warning } = resolveEmailCapabilityArg({}, {
OS_EMAIL_PROVIDER: 'smtp',
OS_EMAIL_SMTP_HOST: 'smtp.x',
});
expect(options.provider).toBe('smtp');
expect(warning).toBeUndefined();
});

it('keeps the pre-existing resend/postmark behaviour', () => {
const withKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'resend', OS_EMAIL_API_KEY: 're_x' });
expect(withKey.options).toMatchObject({ provider: 'resend', apiKey: 're_x' });
expect(withKey.warning).toBeUndefined();

const noKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'postmark' });
expect(noKey.options.provider).toBe('log');
expect(noKey.warning).toMatch(/no apiKey found/);
});

it('still derives the fallback from-address and template context', () => {
const { options } = resolveEmailCapabilityArg({}, { OS_APP_NAME: 'Acme CRM' }, 'ignored');
expect(options.defaultTemplateContext).toMatchObject({ appName: 'Acme CRM' });
expect(options.defaultFrom).toEqual({ name: 'Acme CRM', address: 'no-reply@acme-crm.local' });
});
});
152 changes: 105 additions & 47 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,53 +2269,13 @@ export default class Serve extends Command {
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
arg = { cubes };
} else if (cap === 'email') {
// Compose EmailServicePlugin options from config.email + OS_EMAIL_* env.
// Env precedence: env beats config so operators can override per-environment.
const cfgEmail = (config as any).email ?? {};
const envProvider = process.env.OS_EMAIL_PROVIDER;
const provider = (envProvider || cfgEmail.provider || 'log').toLowerCase();
const apiKey = process.env.OS_EMAIL_API_KEY || cfgEmail.apiKey;
const envFrom = process.env.OS_EMAIL_FROM;
// OS_EMAIL_FROM supports either "addr@x" or "Name <addr@x>".
let defaultFrom = cfgEmail.defaultFrom;
if (envFrom) {
const m = envFrom.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/);
if (m) {
const name = (m[1] ?? '').trim();
const address = (m[2] ?? m[3] ?? '').trim();
if (address) defaultFrom = name ? { name, address } : { address };
}
}
const retries = process.env.OS_EMAIL_RETRIES
? Number(process.env.OS_EMAIL_RETRIES)
: cfgEmail.retries;
const defaultTemplateContext = {
appName: process.env.OS_APP_NAME || cfgEmail.appName || (config as any).appName || 'ObjectStack',
...(cfgEmail.defaultTemplateContext || {}),
};
// Provide a sensible fallback `from` so templates can render
// even before operators configure SMTP/SaaS. The log transport
// simply prints to stdout; the address never leaves the box.
if (!defaultFrom) {
const slug = String(defaultTemplateContext.appName || 'objectstack')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'objectstack';
defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` };
}
arg = {
provider,
...(apiKey ? { apiKey } : {}),
defaultFrom,
...(retries != null && !Number.isNaN(retries) ? { retries } : {}),
defaultTemplateContext,
};
if (provider !== 'log' && !apiKey) {
console.warn(chalk.yellow(
` ⚠ Capability "email": provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). Falling back to LogTransport.`,
));
arg.provider = 'log';
}
const emailArg = resolveEmailCapabilityArg(
(config as any).email ?? {},
process.env,
(config as any).appName,
);
arg = emailArg.options;
if (emailArg.warning) console.warn(chalk.yellow(` ⚠ Capability "email": ${emailArg.warning}`));
} else if (cap === 'sms') {
// Compose SmsServicePlugin options from config.sms + OS_SMS_* env
// (#2780). Same precedence as email: env beats config. Provider
Expand Down Expand Up @@ -2851,6 +2811,104 @@ export function resolveStorageCapabilityArg(envRoot?: string): StorageCapability
return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir };
}

/**
* Constructor options for `EmailServicePlugin`, plus an optional warning for
* the caller to print (degraded, but still bootable, configurations).
*/
export interface EmailCapabilityArg {
options: Record<string, unknown>;
warning?: string;
}

/**
* Resolve what `EmailServicePlugin` is constructed with, from `config.email`
* plus `OS_EMAIL_*` env (env wins, so an operator can override per environment).
*
* SMTP (#5087, ADR-0012) is configured through `OS_EMAIL_SMTP_HOST` / `_PORT` /
* `_SECURE` / `_USER` / `_PASSWORD` — the `OS_{DOMAIN}_{FEATURE}_{QUALIFIER}`
* shape of Prime Directive #9, grouped with the email vars rather than the bare
* third-party `SMTP_*` names — layered over `config.email.options`.
*
* `provider='smtp'` with no host **throws**. The capability loop turns that into
* a boot failure, which is the point: the alternative (quietly substituting the
* LogTransport, as this function's `resend`/`postmark` arm still does for a
* missing API key) hands the operator a server that accepts every send, records
* it in `sys_email`, and delivers nothing — the exact declared-but-not-delivered
* gap #5087 closed inside the plugin.
*/
export function resolveEmailCapabilityArg(
cfgEmail: Record<string, any> = {},
env: NodeJS.ProcessEnv = process.env,
configAppName?: string,
): EmailCapabilityArg {
const provider = String(env.OS_EMAIL_PROVIDER || cfgEmail.provider || 'log').toLowerCase();
const apiKey = env.OS_EMAIL_API_KEY || cfgEmail.apiKey;

// OS_EMAIL_FROM supports either "addr@x" or "Name <addr@x>".
let defaultFrom = cfgEmail.defaultFrom;
if (env.OS_EMAIL_FROM) {
const m = env.OS_EMAIL_FROM.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/);
if (m) {
const name = (m[1] ?? '').trim();
const address = (m[2] ?? m[3] ?? '').trim();
if (address) defaultFrom = name ? { name, address } : { address };
}
}
const retries = env.OS_EMAIL_RETRIES ? Number(env.OS_EMAIL_RETRIES) : cfgEmail.retries;
const defaultTemplateContext = {
appName: env.OS_APP_NAME || cfgEmail.appName || configAppName || 'ObjectStack',
...(cfgEmail.defaultTemplateContext || {}),
};
// Provide a sensible fallback `from` so templates can render even before
// operators configure SMTP/SaaS. The log transport simply prints to stdout;
// the address never leaves the box.
if (!defaultFrom) {
const slug = String(defaultTemplateContext.appName || 'objectstack')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'objectstack';
defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` };
}

const smtpEnv: Record<string, unknown> = {};
const smtpHost = env.OS_EMAIL_SMTP_HOST?.trim();
if (smtpHost) smtpEnv.host = smtpHost;
if (env.OS_EMAIL_SMTP_PORT) smtpEnv.port = Number(env.OS_EMAIL_SMTP_PORT);
if (env.OS_EMAIL_SMTP_SECURE != null) {
const raw = String(env.OS_EMAIL_SMTP_SECURE).trim().toLowerCase();
smtpEnv.secure = raw !== 'false' && raw !== '0';
}
if (env.OS_EMAIL_SMTP_USER) smtpEnv.user = env.OS_EMAIL_SMTP_USER;
if (env.OS_EMAIL_SMTP_PASSWORD) smtpEnv.password = env.OS_EMAIL_SMTP_PASSWORD;
const providerOptions = { ...(cfgEmail.options ?? {}), ...smtpEnv };

const options: Record<string, unknown> = {
provider,
...(apiKey ? { apiKey } : {}),
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
defaultFrom,
...(retries != null && !Number.isNaN(retries) ? { retries } : {}),
defaultTemplateContext,
};

if (provider === 'smtp' && !providerOptions.host) {
throw new Error(
"provider='smtp' selects SMTP delivery but no SMTP host is configured — set OS_EMAIL_SMTP_HOST "
+ '(plus OS_EMAIL_SMTP_PORT / _SECURE / _USER / _PASSWORD) or config.email.options.host, '
+ 'or choose another provider.',
);
}
if (provider !== 'log' && provider !== 'smtp' && !apiKey) {
options.provider = 'log';
return {
options,
warning: `provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). `
+ 'Falling back to LogTransport.',
};
}
return { options };
}

/**
* Best-effort driver introspection.
*
Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/plugin-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/spec": "workspace:*"
"@objectstack/spec": "workspace:*",
"nodemailer": "^9.0.3"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
},
Expand Down
Loading
Loading