Add daily-digest Phase A: per-package SMTP send + admin test endpoint (v0.1.0:75)

Groundwork for the daily activity digest: give the CRM an outbound mail path.
Today nothing leaves the box (Gmail capture + drafts only), so this adds a
dedicated, per-package SMTP account independent of any StartOS system-wide SMTP.

- configureDigestSmtp Start9 action: writes host/port/from/username/password/
  security to /data/secrets/smtp/* (password piped over stdin, never argv/env;
  per-field files, owner-only) — mirrors the setAnthropicApiKey pattern.
- docker_entrypoint.sh reads those at boot and exports SMTP_* (operator env wins).
- backend/smtp_send.py: stdlib smtplib wrapper reading SMTP_* (one code path for
  dev .env and the box); starttls/tls/none modes.
- POST /api/admin/digest/test-email (admin-only): proves the pipe. Recipients are
  restricted to the active-admin set — an arbitrary `to` is rejected, so the
  endpoint is not an open relay; send failures are logged, not echoed (an SMTP
  auth error can carry the credential).
- Tests: test_smtp_send.py (sender), test_smtp_endpoint.py (gating + relay
  restriction + no-leak). 18/18 backend green; s9pk typechecks.

Analysis/summarization for the digest body (Phase B) will run on Spark, never
Claude — the digest is deliberately un-anonymized. Decisions + Phase B plan in
ROADMAP.md.
This commit is contained in:
Keysat
2026-06-15 18:33:06 -05:00
parent ecfc5d968a
commit 2758ac81d3
13 changed files with 765 additions and 14 deletions
@@ -0,0 +1,202 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { DATA_MOUNT_PATH, IMAGE_ID } from '../utils'
/**
* "Configure Digest SMTP" action.
*
* The CRM sends a daily activity digest by email. This is the package's OWN
* dedicated SMTP account (per-package custom SMTP) — it is independent of any
* StartOS system-wide SMTP account, so the digest can go through a mailbox of
* its own without one being configured server-wide.
*
* Mirrors setAnthropicApiKey: the credentials are written to files under
* /data/secrets/smtp/{host,port,from,username,password,security}
* and docker_entrypoint.sh reads them ONCE at boot and exports SMTP_* into the
* server process. The backend only ever reads SMTP_* env, so dev (.env) and the
* box share one code path. A service RESTART is required to pick up changes.
*
* Security: the password is piped over STDIN (never argv/env) so it can't appear
* in a process listing — exactly the property setAnthropicApiKey relies on. The
* other fields (host/port/from/username/security) are not secret and go via env.
* Each field lands in its own file so the entrypoint reads it with a plain `cat`
* (no shell-eval of operator-supplied values).
*/
const { InputSpec, Value } = sdk
const SMTP_DIR = `${DATA_MOUNT_PATH}/secrets/smtp`
// Always (re)writes host/port/from/username/security from env. The password is
// rewritten only when one was entered (piped on stdin), so re-saving to change
// the host doesn't wipe a previously stored password.
function writeScript(includePassword: boolean): string {
// umask 077 makes every file owner-only at creation; the explicit chmods are a
// belt-and-suspenders that name each file (matches setAnthropicApiKey, and
// avoids a glob that could mis-expand on an empty dir).
const steps = [
'set -eu',
'umask 077',
'mkdir -p "$DATA_DIR/secrets/smtp"',
'chmod 700 "$DATA_DIR/secrets" "$DATA_DIR/secrets/smtp" 2>/dev/null || true',
'printf %s "$SMTP_HOST" > "$DATA_DIR/secrets/smtp/host"',
'printf %s "$SMTP_PORT" > "$DATA_DIR/secrets/smtp/port"',
'printf %s "$SMTP_FROM" > "$DATA_DIR/secrets/smtp/from"',
'printf %s "$SMTP_USERNAME" > "$DATA_DIR/secrets/smtp/username"',
'printf %s "$SMTP_SECURITY" > "$DATA_DIR/secrets/smtp/security"',
'chmod 600 "$DATA_DIR/secrets/smtp/host" "$DATA_DIR/secrets/smtp/port" "$DATA_DIR/secrets/smtp/from" "$DATA_DIR/secrets/smtp/username" "$DATA_DIR/secrets/smtp/security"',
]
if (includePassword) {
// Password from stdin, stripped of stray CR/LF a paste may carry.
steps.push('tr -d \'\\n\\r\' > "$DATA_DIR/secrets/smtp/password"')
steps.push('chmod 600 "$DATA_DIR/secrets/smtp/password"')
}
return steps.join('; ')
}
export const inputSpec = InputSpec.of({
host: Value.text({
name: i18n('SMTP Host'),
description: i18n('SMTP server hostname, e.g. smtp.gmail.com or email-smtp.us-east-1.amazonaws.com.'),
warning: null,
required: true,
default: null,
masked: false,
placeholder: 'smtp.example.com',
}),
port: Value.number({
name: i18n('SMTP Port'),
description: i18n('Usually 587 for STARTTLS, 465 for TLS/SSL, or 25 for an unauthenticated relay.'),
warning: null,
required: true,
default: 587,
integer: true,
min: 1,
max: 65535,
placeholder: '587',
}),
security: Value.select({
name: i18n('Connection Security'),
description: i18n('STARTTLS (port 587) or TLS/SSL (port 465) for hosted providers; None only for a trusted LAN relay.'),
default: 'starttls',
values: {
starttls: i18n('STARTTLS'),
tls: i18n('TLS / SSL'),
none: i18n('None (plaintext)'),
},
}),
fromAddress: Value.text({
name: i18n('From Address'),
description: i18n('The address the digest is sent from. Defaults to the username if left blank.'),
warning: null,
required: false,
default: null,
masked: false,
placeholder: 'crm-digest@example.com',
}),
username: Value.text({
name: i18n('SMTP Username'),
description: i18n('Login for the SMTP account. Leave blank only for an unauthenticated relay.'),
warning: null,
required: false,
default: null,
masked: false,
placeholder: 'crm-digest@example.com',
}),
password: Value.text({
name: i18n('SMTP Password'),
description: i18n(
'Password or app-password for the SMTP account. Stored only on this server ' +
'at /data/secrets/smtp/password (owner-only, 0600). Leave blank to keep the ' +
'currently saved password unchanged.',
),
warning: null,
required: false,
default: null,
masked: true,
placeholder: '••••••••',
}),
})
export const configureDigestSmtp = sdk.Action.withInput(
// id
'configure-digest-smtp',
// metadata
async ({ effects }) => ({
name: i18n('Configure Digest SMTP'),
description: i18n(
'Set the SMTP account the CRM uses to email the daily activity digest. ' +
'This is a dedicated mailbox for this service — it does not require a ' +
'StartOS system-wide SMTP account. Credentials are stored only on this ' +
'server under /data/secrets/smtp/ (owner-only). Restart the service after ' +
'saving for the change to take effect.',
),
warning: i18n(
'After saving, restart Ten31 Database (Stop, then Start) so the new SMTP ' +
'settings are loaded. The running service does not pick them up until it restarts.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// form input specification
inputSpec,
// pre-fill: never read the secret back out — always present a blank form.
async ({ effects, prefill }) => null,
// execution
async ({ effects, input }) => {
const includePassword = !!(input.password && input.password.length > 0)
const subcontainer = await sdk.SubContainer.of(
effects,
{ imageId: IMAGE_ID },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: DATA_MOUNT_PATH,
readonly: false,
}),
'ten31-database-configure-digest-smtp',
)
try {
const options: { env: Record<string, string>; input?: string } = {
env: {
DATA_DIR: DATA_MOUNT_PATH,
SMTP_HOST: input.host,
SMTP_PORT: String(input.port),
SMTP_FROM: input.fromAddress ?? '',
SMTP_USERNAME: input.username ?? '',
SMTP_SECURITY: input.security,
},
}
// The password is piped in over stdin — never argv/env — so it cannot
// appear in a process listing.
if (includePassword) options.input = input.password ?? ''
await subcontainer.execFail(
['/bin/sh', '-c', writeScript(includePassword)],
options,
60 * 1000,
)
} finally {
await subcontainer.destroy()
}
return {
version: '1',
title: i18n('Digest SMTP settings saved'),
message: i18n(
`SMTP settings were written to ${SMTP_DIR} (owner-only). ` +
(includePassword ? '' : 'The existing password was kept unchanged. ') +
'IMPORTANT: restart Ten31 Database now (Stop, then Start) so the digest ' +
'mailer picks up the new settings, then use "Send Test Digest Email" to verify.',
),
result: null,
}
},
)