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; 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 send a test (admin API: POST ' + '/api/admin/digest/test-email) to verify delivery.', ), result: null, } }, )