From b1ccbfef41085dcf2fc6f3666f92c18eaa40f59f Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Tue, 17 Mar 2026 22:34:26 +0000 Subject: [PATCH] Implement email support with per-user permission model (closes #2) New modules: - server/mailer.mjs: nodemailer transport wrapper - server/mail_perms.mjs: runtime permission store, persisted to disk New actions: - send-email: checks (caller, to, topic) permission before sending - set-mail-permission: grant/revoke permissions, gated by canApprove - get-mail-permissions: list current permissions Handler signature extended to handler(params, ctx) where ctx carries caller, users, mail_perm_store and mailer_send. Existing handlers ignore ctx so the change is backwards-compatible. SMTP config lives in secrets.json under optional 'smtp' key. Mail permissions path via --mail-perms or CONDUIT_MAIL_PERMS. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 35 ++++++++++++++++++++++++--- package.json | 5 ++-- secrets.example.json | 7 ++++++ server/actions.mjs | 55 ++++++++++++++++++++++++++++++++++++++++++- server/index.mjs | 29 +++++++++++++++++++---- server/mail_perms.mjs | 49 ++++++++++++++++++++++++++++++++++++++ server/mailer.mjs | 18 ++++++++++++++ server/secrets.mjs | 4 ++-- 8 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 server/mail_perms.mjs create mode 100644 server/mailer.mjs diff --git a/package-lock.json b/package-lock.json index 2b734da..b3b7529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,22 @@ { "name": "claude-code-conduit", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-conduit", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { - "express": "^5.2.1" + "blessed": "^0.1.81", + "express": "^5.2.1", + "nodemailer": "^8.0.2" + }, + "bin": { + "ccc-client": "bin/ccc-client.mjs", + "ccc-keygen": "bin/ccc-keygen.mjs", + "ccc-queue": "bin/ccc-queue.mjs", + "ccc-server": "bin/ccc-server.mjs" } }, "node_modules/accepts": { @@ -24,6 +32,18 @@ "node": ">= 0.6" } }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -517,6 +537,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 2e684cf..eaf3805 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,12 @@ "bin": { "ccc-server": "bin/ccc-server.mjs", "ccc-client": "bin/ccc-client.mjs", - "ccc-queue": "bin/ccc-queue.mjs", + "ccc-queue": "bin/ccc-queue.mjs", "ccc-keygen": "bin/ccc-keygen.mjs" }, "dependencies": { "blessed": "^0.1.81", - "express": "^5.2.1" + "express": "^5.2.1", + "nodemailer": "^8.0.2" } } diff --git a/secrets.example.json b/secrets.example.json index f93c8b7..6c50082 100644 --- a/secrets.example.json +++ b/secrets.example.json @@ -1,4 +1,11 @@ { + "smtp": { + "host": "smtp.example.com", + "port": 587, + "secure": false, + "auth": { "user": "relay@example.com", "pass": "" }, + "from": "agent@example.com" + }, "users": { "": { "secret": "", "canApprove": [] }, "": { "secret": "", "canApprove": [""] } diff --git a/server/actions.mjs b/server/actions.mjs index bed6656..5c18f58 100644 --- a/server/actions.mjs +++ b/server/actions.mjs @@ -2,7 +2,8 @@ // policy: "auto-accept" | "auto-deny" | "queue" -import { resolve_path, exec } from "./helpers.mjs"; +import { resolve_path, exec } from './helpers.mjs'; +import { check_can_approve } from './auth.mjs'; export const actions = { "list-actions": { @@ -67,4 +68,56 @@ export const actions = { return { opened: resolved }; }, }, + + 'send-email': { + description: 'Send an email to a permitted recipient', + params: [ + { name: 'to', required: true, type: 'string' }, + { name: 'subject', required: true, type: 'string' }, + { name: 'body', required: true, type: 'string' }, + { name: 'topic', required: true, type: 'string' }, + ], + policy: 'auto-accept', + handler: async ({ to, subject, body, topic }, { caller, mail_perm_store, mailer_send }) => { + if (!mail_perm_store.check(caller, to, topic)) { + throw new Error(`Mail permission denied: ${caller} → ${to} [${topic}]`); + } + await mailer_send(to, subject, body); + return { sent: true, to, topic }; + }, + }, + + 'set-mail-permission': { + description: 'Grant or revoke permission for a user to send email to a recipient/topic', + params: [ + { name: 'target_user', required: true, type: 'string' }, + { name: 'to', required: true, type: 'string' }, + { name: 'topic', required: true, type: 'string' }, + { name: 'allow', required: true, type: 'boolean' }, + ], + policy: 'auto-accept', + handler: ({ target_user, to, topic, allow }, { caller, users, mail_perm_store }) => { + if (!check_can_approve(users, caller, target_user)) { + throw new Error(`Not authorized to set mail permissions for '${target_user}'`); + } + if (allow) { + mail_perm_store.add(target_user, to, topic); + } else { + mail_perm_store.remove(target_user, to, topic); + } + return { target_user, to, topic, allow, permissions: mail_perm_store.list() }; + }, + }, + + 'get-mail-permissions': { + description: 'List current mail permissions, optionally filtered by user', + params: [ + { name: 'target_user', required: false, type: 'string' }, + ], + policy: 'auto-accept', + handler: ({ target_user }, { mail_perm_store }) => { + const all = mail_perm_store.list(); + return { permissions: target_user ? all.filter(e => e.user === target_user) : all }; + }, + }, }; diff --git a/server/index.mjs b/server/index.mjs index 57de9c4..187f27a 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -3,6 +3,8 @@ import { actions } from './actions.mjs'; import { enqueue, get_entry, list_pending, resolve } from './queue.mjs'; import { load_secrets } from './secrets.mjs'; import { create_auth_middleware, check_can_approve } from './auth.mjs'; +import { create_mailer } from './mailer.mjs'; +import { load_mail_perms } from './mail_perms.mjs'; function get_arg(argv, flag) { const i = argv.indexOf(flag); @@ -17,7 +19,9 @@ const PORT = process.env.CONDUIT_PORT || 3015; const BIND = get_arg(process.argv, '--bind') || process.env.CONDUIT_BIND || '127.0.0.1'; const VERBOSE = process.argv.includes('--verbose'); -const secrets_path = get_arg(process.argv, '--secrets'); +const secrets_path = get_arg(process.argv, '--secrets'); +const mail_perms_path = get_arg(process.argv, '--mail-perms') || process.env.CONDUIT_MAIL_PERMS || null; + let secrets; try { secrets = load_secrets(secrets_path); @@ -25,7 +29,21 @@ try { console.error(`Fatal: ${err.message}`); process.exit(1); } -const { users } = secrets; +const { users, smtp } = secrets; + +let mail_perm_store; +try { + mail_perm_store = load_mail_perms(mail_perms_path); +} catch (err) { + console.error(`Fatal: ${err.message}`); + process.exit(1); +} + +if (!mail_perms_path) { + console.warn('Warning: --mail-perms not set; mail permissions will not persist across restarts'); +} + +const mailer_send = create_mailer(smtp); const app = express(); app.use(express.json({ @@ -75,9 +93,11 @@ app.post('/action', async (req, res) => { return res.status(403).json({ status: 'denied', reason: 'Policy: auto-deny' }); } + const ctx = { caller: req.conduit_user, users, mail_perm_store, mailer_send }; + if (def.policy === 'auto-accept') { try { - const result = await def.handler(params); + const result = await def.handler(params, ctx); return res.json({ status: 'accepted', result }); } catch (err) { return res.status(500).json({ status: 'error', error: err.message }); @@ -111,9 +131,10 @@ app.post('/queue/:id/approve', async (req, res) => { entry.resolved_by = req.conduit_user; resolve(req.params.id, 'approved'); + const ctx = { caller: entry.submitted_by, users, mail_perm_store, mailer_send }; const def = actions[entry.action]; try { - const result = await def.handler(entry.params); + const result = await def.handler(entry.params, ctx); res.json({ status: 'approved', result }); } catch (err) { res.status(500).json({ status: 'error', error: err.message }); diff --git a/server/mail_perms.mjs b/server/mail_perms.mjs new file mode 100644 index 0000000..bdad27f --- /dev/null +++ b/server/mail_perms.mjs @@ -0,0 +1,49 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; + +export function load_mail_perms(file_path) { + let allowed = []; + + if (file_path && existsSync(file_path)) { + try { + const parsed = JSON.parse(readFileSync(file_path, 'utf8')); + if (!Array.isArray(parsed.allowed)) { + throw new Error("'allowed' must be an array"); + } + allowed = parsed.allowed; + } catch (err) { + throw new Error(`Cannot load mail permissions from ${file_path}: ${err.message}`); + } + } + + function write() { + if (!file_path) { + return; + } + writeFileSync(file_path, JSON.stringify({ allowed }, null, '\t') + '\n', 'utf8'); + } + + function check(user, to, topic) { + return allowed.some(e => e.user === user && e.to === to && e.topic === topic); + } + + function add(user, to, topic) { + if (!check(user, to, topic)) { + allowed.push({ user, to, topic }); + write(); + } + } + + function remove(user, to, topic) { + const before = allowed.length; + allowed = allowed.filter(e => !(e.user === user && e.to === to && e.topic === topic)); + if (allowed.length !== before) { + write(); + } + } + + function list() { + return [...allowed]; + } + + return { check, add, remove, list }; +} diff --git a/server/mailer.mjs b/server/mailer.mjs new file mode 100644 index 0000000..6d63648 --- /dev/null +++ b/server/mailer.mjs @@ -0,0 +1,18 @@ +import nodemailer from 'nodemailer'; + +// Returns an async send(to, subject, body) function. +// If smtp_config is absent, returns a stub that always throws. +export function create_mailer(smtp_config) { + if (!smtp_config) { + return async () => { + throw new Error('SMTP not configured'); + }; + } + + const { from, ...transport_config } = smtp_config; + const transporter = nodemailer.createTransport(transport_config); + + return async function send(to, subject, body) { + await transporter.sendMail({ from, to, subject, text: body }); + }; +} diff --git a/server/secrets.mjs b/server/secrets.mjs index a4cc937..0adc199 100644 --- a/server/secrets.mjs +++ b/server/secrets.mjs @@ -16,8 +16,8 @@ export function load_secrets(file_path) { } catch (err) { throw new Error(`Secrets file is not valid JSON: ${err.message}`); } - if (!parsed.users || typeof parsed.users !== "object") { + if (!parsed.users || typeof parsed.users !== 'object') { throw new Error("Secrets file must have a top-level 'users' object"); } - return parsed; + return parsed; // callers destructure { users, smtp } }