From bdefc52331d96c5b573654e3eec9b51b6a1401c8 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 23 Feb 2026 03:30:16 +0000 Subject: [PATCH] SMTP settings --- .../Settings/ServerSettings/SMTPSection.tsx | 292 ++++++++++++++++++ .../Dashboard/Settings/SystemSettings.tsx | 10 +- .../api/server-settings/server-settings.ts | 2 + src/servers/api/server-settings/smtp.ts | 112 +++++++ src/workspaces/emailer/src/index.ts | 19 +- src/workspaces/emailer/src/transport.ts | 70 ++++- 6 files changed, 488 insertions(+), 17 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/SMTPSection.tsx create mode 100644 src/servers/api/server-settings/smtp.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/SMTPSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/SMTPSection.tsx new file mode 100644 index 00000000..3c12bb20 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/SMTPSection.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { useClient } from 'hooks/useClient'; +import { useAuth } from 'hooks/useAuth'; + +type Provider = 'resend' | 'smtp' | 'mailhog'; + +type SmtpConfig = { + provider: Provider; + apiKey?: string; + host?: string; + port?: number; + username?: string; + password?: string; + secure?: boolean; + fromName: string; + fromEmail: string; +}; + +const PROVIDER_DEFAULTS: Record> = { + mailhog: { host: 'localhost', port: 1025, secure: false, username: '', password: '' }, + resend: { apiKey: '' }, + smtp: { host: '', port: 587, secure: false, username: '', password: '' }, +}; + +export const SMTPSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const { data: config, isLoading } = useQuery({ + queryKey: ['SMTP_CONFIG'], + queryFn: () => client.get('/server-settings/smtp'), + }); + + const [provider, setProvider] = useState('mailhog'); + const [apiKey, setApiKey] = useState(''); + const [host, setHost] = useState('localhost'); + const [port, setPort] = useState('1025'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [secure, setSecure] = useState(false); + const [fromName, setFromName] = useState('officerdev'); + const [fromEmail, setFromEmail] = useState('no-reply@officer.dev'); + const [isSaving, setIsSaving] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [testEmail, setTestEmail] = useState(user?.email ?? ''); + const [isTesting, setIsTesting] = useState(false); + + useEffect(() => { + if (!config) return; + setProvider(config.provider); + setApiKey(config.apiKey ?? ''); + setHost(config.host ?? ''); + setPort(String(config.port ?? 587)); + setUsername(config.username ?? ''); + setPassword(config.password ?? ''); + setSecure(config.secure ?? false); + setFromName(config.fromName || 'officerdev'); + setFromEmail(config.fromEmail || 'no-reply@officer.dev'); + }, [config]); + + const handleProviderChange = (v: Provider) => { + setProvider(v); + const defaults = PROVIDER_DEFAULTS[v]; + if (defaults.host !== undefined) setHost(defaults.host); + if (defaults.port !== undefined) setPort(String(defaults.port)); + if (defaults.secure !== undefined) setSecure(defaults.secure); + if (defaults.username !== undefined) setUsername(defaults.username); + if (defaults.password !== undefined) setPassword(defaults.password); + if (defaults.apiKey !== undefined) setApiKey(defaults.apiKey); + }; + + const buildConfig = (): SmtpConfig => ({ + provider, + fromName, + fromEmail, + ...(provider === 'resend' + ? { apiKey } + : { host, port: parseInt(port) || 587, username, password, secure }), + }); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await client.put('/server-settings/smtp', buildConfig()); + queryClient.invalidateQueries({ queryKey: ['SMTP_CONFIG'] }); + toast.success('SMTP settings saved'); + } catch { + toast.error('Failed to save SMTP settings'); + } finally { + setIsSaving(false); + } + }; + + const handleTestConnection = async () => { + if (isTestingConnection) return; + setIsTestingConnection(true); + try { + await client.post('/server-settings/smtp/test-connection'); + toast.success('Connection successful'); + } catch (err: unknown) { + const raw = (err as { message?: string })?.message; + let msg = 'Connection failed'; + try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ } + toast.error(msg); + } finally { + setIsTestingConnection(false); + } + }; + + const handleTest = async () => { + if (isTesting || !testEmail) return; + setIsTesting(true); + try { + const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail }); + if (result.error) { + toast.error(result.error); + } else { + toast.success(`Test email sent to ${testEmail}`); + } + } catch (err: unknown) { + const raw = (err as { message?: string })?.message; + let msg = 'Failed to send test email'; + try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ } + toast.error(msg); + } finally { + setIsTesting(false); + } + }; + + if (isLoading) return

Loading...

; + + const showSmtpFields = provider === 'smtp' || provider === 'mailhog'; + + return ( +
+ + + {provider === 'resend' && ( + + )} + + {showSmtpFields && ( + <> +
+ + +
+ {provider === 'smtp' && ( + <> +
+ + +
+ + + )} + + )} + +
+ + +
+ +
+ + +
+ +
+ Send Test Email +
+ setTestEmail(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') handleTest(); + }} + /> + +
+
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index c0e7dfb6..0c4813b3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react'; import { toast } from 'sonner'; -import { Terminal, Eye, Bot, Settings, X, Plus } from 'lucide-react'; +import { Terminal, Eye, Bot, Settings, X, Plus, Mail } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -19,6 +19,7 @@ import { usePiModels, useVisiblePiModels, modelKey, getProviderDisplayName, type import type { UserSettings } from 'state/useSettings'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; +import { SMTPSection } from './ServerSettings/SMTPSection'; const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', @@ -51,6 +52,13 @@ const groups: SettingsSectionGroup[] = [ { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, ], }, + { + label: 'Email', + icon: Mail, + sections: [ + { key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: }, + ], + }, ]; const { Sidebar, Content } = createSettingsPanelComponents({ diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 921329c5..c177291b 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -9,6 +9,7 @@ import { opencodeRouter } from './opencode'; import { piMonoRouter } from './pi-mono'; import { applicationsRouter } from './applications'; import { resourcesRouter } from './resources'; +import { smtpRouter } from './smtp'; const configDir = `${homedir()}/.config/officer.dev`; export const settingsPath = `${configDir}/server-settings.json`; @@ -26,6 +27,7 @@ serverSettingsRouter.route('/opencode', opencodeRouter); serverSettingsRouter.route('/pi-mono', piMonoRouter); serverSettingsRouter.route('/applications', applicationsRouter); serverSettingsRouter.route('/resources', resourcesRouter); +serverSettingsRouter.route('/smtp', smtpRouter); serverSettingsRouter.get('/settings', async (ctx) => { const settings = await Bun.file(settingsPath).json(); diff --git a/src/servers/api/server-settings/smtp.ts b/src/servers/api/server-settings/smtp.ts new file mode 100644 index 00000000..c5425787 --- /dev/null +++ b/src/servers/api/server-settings/smtp.ts @@ -0,0 +1,112 @@ +import { createTransport } from 'nodemailer'; +import { createRouter } from '../../create-router'; +import { settingsPath } from './server-settings'; +import { getTransport } from 'emailer'; + +type SmtpConfig = { + provider: 'resend' | 'smtp' | 'mailhog'; + apiKey?: string; + host?: string; + port?: number; + username?: string; + password?: string; + secure?: boolean; + fromName: string; + fromEmail: string; +}; + +function maskSecret(value: string | undefined): string | undefined { + if (!value || value.length < 8) return value ? '****' : undefined; + return value.slice(0, 4) + '****' + value.slice(-4); +} + +function serializeConfig(smtp: SmtpConfig) { + return { + provider: smtp.provider, + apiKey: maskSecret(smtp.apiKey), + host: smtp.host, + port: smtp.port, + username: smtp.username, + password: maskSecret(smtp.password), + secure: smtp.secure, + fromName: smtp.fromName, + fromEmail: smtp.fromEmail, + }; +} + +function buildTransportUrl(config: SmtpConfig): string { + if (config.provider === 'resend') { + return `smtps://resend:${config.apiKey}@smtp.resend.com:465`; + } + if (config.provider === 'mailhog') { + return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`; + } + const auth = config.username ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` : ''; + const protocol = config.secure ? 'smtps' : 'smtp'; + return `${protocol}://${auth}${config.host}:${config.port ?? 587}`; +} + +export const smtpRouter = createRouter(); + +smtpRouter.get('/', async (ctx) => { + const settings = await Bun.file(settingsPath).json().catch(() => ({})); + const smtp: SmtpConfig | undefined = settings.smtp; + if (smtp) return ctx.json(serializeConfig(smtp)); + return ctx.json(null); +}); + +smtpRouter.put('/', async (ctx) => { + const body = await ctx.req.json(); + const settings = await Bun.file(settingsPath).json().catch(() => ({})); + + const existing: SmtpConfig | undefined = settings.smtp; + if (existing) { + if (body.apiKey && body.apiKey.includes('****')) body.apiKey = existing.apiKey; + if (body.password && body.password.includes('****')) body.password = existing.password; + } + + settings.smtp = body; + await Bun.write(settingsPath, JSON.stringify(settings, null, 2)); + return ctx.json({ success: true }); +}); + +smtpRouter.post('/test-connection', async (ctx) => { + try { + const { transport } = await getTransport(); + await transport.verify(); + return ctx.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return ctx.json({ error: message }, 500); + } +}); + +smtpRouter.post('/test', async (ctx) => { + const body = await ctx.req.json(); + if (!body.to) return ctx.json({ error: 'Recipient address required' }, 400); + + // Resolve masked secrets from saved config + const settings = await Bun.file(settingsPath).json().catch(() => ({})); + const saved: SmtpConfig | undefined = settings.smtp; + if (saved) { + if (body.apiKey?.includes('****')) body.apiKey = saved.apiKey; + if (body.password?.includes('****')) body.password = saved.password; + } + + try { + const url = buildTransportUrl(body); + const transport = createTransport(url); + const from = `${body.fromName} <${body.fromEmail}>`; + + await transport.sendMail({ + from, + to: body.to, + subject: 'Officer Test Email', + html: '

Officer Test Email

If you received this, your email configuration is working correctly.

', + }); + return ctx.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return ctx.json({ error: message }, 500); + } +}); diff --git a/src/workspaces/emailer/src/index.ts b/src/workspaces/emailer/src/index.ts index fcf9ee8f..5c60c681 100644 --- a/src/workspaces/emailer/src/index.ts +++ b/src/workspaces/emailer/src/index.ts @@ -1,5 +1,5 @@ import { render } from '@react-email/render'; -import transport from './transport'; +import { getTransport } from './transport'; import { templates } from './templates'; type SendMailArgs = { @@ -10,22 +10,15 @@ type SendMailArgs = { from?: string; }; -export async function sendMail({ template, subject, to, data, from = 'Officer ' }: SendMailArgs) { - const options = { - from, - to, - subject, - }; +export async function sendMail({ template, subject, to, data, from }: SendMailArgs) { + const { transport, from: configuredFrom } = await getTransport(); + const sender = from ?? configuredFrom ?? 'officerdev '; const theTemplate = templates[template]; const templated = theTemplate(data); - // try { const emailHtml = await render(templated); - transport.sendMail({ ...options, html: emailHtml }); - // } catch (ex) { - // console.log("ex", ex); - // } + await transport.sendMail({ from: sender, to, subject, html: emailHtml }); } -export { templates }; +export { getTransport, templates }; diff --git a/src/workspaces/emailer/src/transport.ts b/src/workspaces/emailer/src/transport.ts index 51538773..8220108b 100644 --- a/src/workspaces/emailer/src/transport.ts +++ b/src/workspaces/emailer/src/transport.ts @@ -1,6 +1,70 @@ -import { createTransport } from 'nodemailer'; +import { createTransport, type Transporter } from 'nodemailer'; +import { homedir } from 'node:os'; + const { MAIL_TRANSPORT } = process.env; -const transport = createTransport(MAIL_TRANSPORT); +type SmtpConfig = { + provider: 'resend' | 'smtp' | 'mailhog'; + apiKey?: string; + host?: string; + port?: number; + username?: string; + password?: string; + secure?: boolean; + fromName: string; + fromEmail: string; +}; -export default transport; +const settingsPath = `${homedir()}/.config/officer.dev/server-settings.json`; + +let cachedTransport: Transporter | null = null; +let cachedConfigHash: string | null = null; + +function buildTransportUrl(config: SmtpConfig): string { + if (config.provider === 'resend') { + return `smtps://resend:${config.apiKey}@smtp.resend.com:465`; + } + if (config.provider === 'mailhog') { + return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`; + } + const auth = config.username ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` : ''; + const protocol = config.secure ? 'smtps' : 'smtp'; + return `${protocol}://${auth}${config.host}:${config.port ?? 587}`; +} + +type TransportResult = { + transport: Transporter; + from: string | null; +}; + +export async function getTransport(): Promise { + try { + const file = Bun.file(settingsPath); + if (await file.exists()) { + const settings = await file.json(); + const smtp: SmtpConfig | undefined = settings.smtp; + if (smtp) { + const hash = JSON.stringify(smtp); + if (cachedTransport && cachedConfigHash === hash) { + return { transport: cachedTransport, from: `${smtp.fromName} <${smtp.fromEmail}>` }; + } + const url = buildTransportUrl(smtp); + cachedTransport = createTransport(url); + cachedConfigHash = hash; + return { transport: cachedTransport, from: `${smtp.fromName} <${smtp.fromEmail}>` }; + } + } + } catch { + // Fall through to env var + } + + if (MAIL_TRANSPORT) { + if (!cachedTransport || cachedConfigHash !== 'env') { + cachedTransport = createTransport(MAIL_TRANSPORT); + cachedConfigHash = 'env'; + } + return { transport: cachedTransport, from: null }; + } + + throw new Error('No email transport configured'); +}