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:
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { buildSearchIndex } from './buildSearchIndex'
|
||||
import { configureDigestSmtp } from './configureDigestSmtp'
|
||||
import { refreshSearchIndex } from './refreshSearchIndex'
|
||||
import { resolveDuplicates } from './resolveDuplicates'
|
||||
import { setAnthropicApiKey } from './setAnthropicApiKey'
|
||||
@@ -9,3 +10,4 @@ export const actions = sdk.Actions.of()
|
||||
.addAction(refreshSearchIndex)
|
||||
.addAction(resolveDuplicates)
|
||||
.addAction(setAnthropicApiKey)
|
||||
.addAction(configureDigestSmtp)
|
||||
|
||||
@@ -39,8 +39,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:71 (voice by-purpose larger sample + Tier-B: create Gmail draft w/ in-thread reply)
|
||||
// * 0.1.0:72 (stage v2.0 reserve-asset thesis spine as Workshop candidates)
|
||||
// * 0.1.0:73 (replace old settlement spine with v2.0 reserve-asset spine across Architect + outreach prompts, seed constants, and docs; promote v2.0 to the working approved spine + soft-retire old settlement nodes, reversibly, node-level only)
|
||||
// * Current: 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
|
||||
export const PACKAGE_VERSION = '0.1.0:74'
|
||||
// * 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
|
||||
// * Current: 0.1.0:75 (Phase-A digest SMTP: per-package "Configure Digest SMTP" action writes /data/secrets/smtp/*; entrypoint exports SMTP_*; backend smtp_send.py + admin "send test email" endpoint)
|
||||
export const PACKAGE_VERSION = '0.1.0:75'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -35,8 +35,9 @@ import { v_0_1_0_71 } from './v0.1.0.71'
|
||||
import { v_0_1_0_72 } from './v0.1.0.72'
|
||||
import { v_0_1_0_73 } from './v0.1.0.73'
|
||||
import { v_0_1_0_74 } from './v0.1.0.74'
|
||||
import { v_0_1_0_75 } from './v0.1.0.75'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_74,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73],
|
||||
current: v_0_1_0_75,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Phase-A of the daily activity digest: outbound SMTP send capability. Code-only,
|
||||
// no schema change (migrations are no-ops):
|
||||
// * New "Configure Digest SMTP" StartOS action (actions/configureDigestSmtp.ts):
|
||||
// writes a per-package, custom SMTP account to /data/secrets/smtp/{host,port,
|
||||
// from,username,password,security} — independent of any StartOS system-wide
|
||||
// SMTP account. Password is piped over stdin (never argv/env), like the
|
||||
// Anthropic-key action.
|
||||
// * docker_entrypoint.sh reads those files at boot and exports SMTP_* into the
|
||||
// server process (env still wins for an operator override).
|
||||
// * backend/smtp_send.py: stdlib smtplib wrapper reading SMTP_* (one code path
|
||||
// for dev .env and the box). New admin endpoint POST /api/admin/digest/test-email
|
||||
// sends a test message to the requesting `to` or to all active admins, to prove
|
||||
// the pipe before the digest itself (Phase B) is built.
|
||||
export const v_0_1_0_75 = VersionInfo.of({
|
||||
version: '0.1.0:75',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Add outbound email: a "Configure Digest SMTP" action sets a dedicated mailbox for this',
|
||||
'service (no server-wide SMTP account required), and a new admin "send test email" action',
|
||||
'verifies it works — groundwork for the daily activity digest.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user