setup email

This commit is contained in:
phaichayon
2026-07-06 23:07:55 +07:00
parent d93f25e1ce
commit 18cc9bbfd0
30 changed files with 8907 additions and 156 deletions

View File

@@ -0,0 +1,47 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createEmailConfiguration,
deleteEmailConfiguration,
sendEmailConfigurationTestEmail,
testEmailConfigurationConnection,
updateEmailConfiguration,
} from './service';
import { emailConfigurationKeys } from './queries';
import type { EmailConfigurationPayload, SendTestEmailPayload } from './types';
function invalidateEmailConfigurations() {
getQueryClient().invalidateQueries({ queryKey: emailConfigurationKeys.all });
}
export const createEmailConfigurationMutation = mutationOptions({
mutationFn: (payload: EmailConfigurationPayload) => createEmailConfiguration(payload),
onSuccess: invalidateEmailConfigurations,
});
export const updateEmailConfigurationMutation = mutationOptions({
mutationFn: ({ id, payload }: { id: string; payload: EmailConfigurationPayload }) =>
updateEmailConfiguration(id, payload),
onSuccess: invalidateEmailConfigurations,
});
export const deleteEmailConfigurationMutation = mutationOptions({
mutationFn: (id: string) => deleteEmailConfiguration(id),
onSuccess: invalidateEmailConfigurations,
});
export const testEmailConfigurationConnectionMutation = mutationOptions({
mutationFn: (id: string) => testEmailConfigurationConnection(id),
onSuccess: invalidateEmailConfigurations,
});
export const sendEmailConfigurationTestEmailMutation = mutationOptions({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: SendTestEmailPayload;
}) => sendEmailConfigurationTestEmail(id, payload),
onSuccess: invalidateEmailConfigurations,
});

View File

@@ -0,0 +1,14 @@
import { queryOptions } from '@tanstack/react-query';
import { listEmailConfigurations } from './service';
export const emailConfigurationKeys = {
all: ['email-configurations'] as const,
lists: () => [...emailConfigurationKeys.all, 'list'] as const,
list: () => [...emailConfigurationKeys.lists()] as const,
};
export const emailConfigurationsQueryOptions = () =>
queryOptions({
queryKey: emailConfigurationKeys.list(),
queryFn: () => listEmailConfigurations(),
});

View File

@@ -0,0 +1,60 @@
import { apiClient } from '@/lib/api-client';
import type {
EmailConfigurationPayload,
EmailConfigurationSummary,
EmailConnectionTestResult,
SendTestEmailPayload,
SendTestEmailResult,
} from './types';
const EMAIL_CONFIG_ENDPOINT = '/notifications/email-configurations';
export async function listEmailConfigurations(): Promise<EmailConfigurationSummary[]> {
return apiClient<EmailConfigurationSummary[]>(EMAIL_CONFIG_ENDPOINT);
}
export async function createEmailConfiguration(
payload: EmailConfigurationPayload
): Promise<EmailConfigurationSummary> {
return apiClient<EmailConfigurationSummary>(EMAIL_CONFIG_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function updateEmailConfiguration(
id: string,
payload: EmailConfigurationPayload
): Promise<EmailConfigurationSummary> {
return apiClient<EmailConfigurationSummary>(`${EMAIL_CONFIG_ENDPOINT}/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}
export async function deleteEmailConfiguration(id: string): Promise<{ success: true }> {
return apiClient<{ success: true }>(`${EMAIL_CONFIG_ENDPOINT}/${id}`, {
method: 'DELETE',
});
}
export async function testEmailConfigurationConnection(
id: string
): Promise<EmailConnectionTestResult> {
return apiClient<EmailConnectionTestResult>(`${EMAIL_CONFIG_ENDPOINT}/${id}/test`, {
method: 'POST',
});
}
export async function sendEmailConfigurationTestEmail(
id: string,
payload: SendTestEmailPayload
): Promise<SendTestEmailResult> {
return apiClient<SendTestEmailResult>(`${EMAIL_CONFIG_ENDPOINT}/${id}/send-test`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,58 @@
export type EmailConfigurationProviderType = 'smtp';
export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed';
export interface EmailConfigurationSummary {
id: string;
organizationId: string;
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
isDefault: boolean;
isActive: boolean;
hasPassword: boolean;
lastTestStatus: EmailConfigurationTestStatus;
lastTestedAt: string | null;
lastTestError: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface EmailConfigurationPayload {
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username?: string | null;
password?: string | null;
clearPassword?: boolean;
fromEmail: string;
fromName?: string | null;
replyToEmail?: string | null;
isDefault?: boolean;
isActive?: boolean;
}
export interface EmailConnectionTestResult {
ok: boolean;
detail?: string;
}
export interface SendTestEmailPayload {
recipientEmail: string;
}
export interface SendTestEmailResult {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,677 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { formatDateTime } from '@/lib/date-format';
import { cn } from '@/lib/utils';
import {
createEmailConfigurationMutation,
deleteEmailConfigurationMutation,
sendEmailConfigurationTestEmailMutation,
testEmailConfigurationConnectionMutation,
updateEmailConfigurationMutation,
} from '../api/mutations';
import { emailConfigurationsQueryOptions } from '../api/queries';
import type { EmailConfigurationPayload, EmailConfigurationSummary } from '../api/types';
const EMPTY_EMAIL_CONFIGURATIONS: EmailConfigurationSummary[] = [];
const emailConfigurationFormSchema = z.object({
providerType: z.literal('smtp'),
name: z.string().trim().min(1, 'Configuration name is required.'),
host: z.string().trim().min(1, 'SMTP host is required.'),
port: z
.string()
.trim()
.min(1, 'Port is required.')
.refine((value) => {
const port = Number(value);
return Number.isInteger(port) && port > 0 && port <= 65535;
}, 'Port must be between 1 and 65535.'),
secure: z.boolean(),
username: z.string().trim().max(255).optional(),
password: z.string().trim().max(1000).optional(),
clearPassword: z.boolean(),
fromEmail: z.string().trim().email('From email must be valid.'),
fromName: z.string().trim().max(255).optional(),
replyToEmail: z
.string()
.trim()
.optional()
.refine((value) => !value || z.string().email().safeParse(value).success, 'Reply-to email must be valid.'),
isDefault: z.boolean(),
isActive: z.boolean(),
});
type EmailConfigurationFormValues = z.infer<typeof emailConfigurationFormSchema>;
function buildDefaultValues(
configuration?: EmailConfigurationSummary
): EmailConfigurationFormValues {
return {
providerType: 'smtp',
name: configuration?.name ?? '',
host: configuration?.host ?? '',
port: String(configuration?.port ?? 587),
secure: configuration?.secure ?? false,
username: configuration?.username ?? '',
password: '',
clearPassword: false,
fromEmail: configuration?.fromEmail ?? '',
fromName: configuration?.fromName ?? '',
replyToEmail: configuration?.replyToEmail ?? '',
isDefault: configuration?.isDefault ?? false,
isActive: configuration?.isActive ?? true,
};
}
function statusBadgeVariant(status: EmailConfigurationSummary['lastTestStatus']) {
if (status === 'failed') return 'destructive';
if (status === 'passed') return 'default';
return 'secondary';
}
function SummaryMetric({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<div className='rounded-2xl border border-border/70 bg-background/85 p-4 shadow-sm'>
<div className='text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground'>
{label}
</div>
<div className='mt-2 text-2xl font-semibold text-foreground'>{value}</div>
<div className='mt-1 text-sm text-muted-foreground'>{hint}</div>
</div>
);
}
function SecurityNote({ title, description }: { title: string; description: string }) {
return (
<div className='rounded-2xl border border-border/60 bg-background/80 p-4'>
<div className='flex items-start gap-3'>
<div className='rounded-full bg-primary/10 p-2 text-primary'>
<Icons.lock className='h-4 w-4' />
</div>
<div className='space-y-1'>
<div className='font-medium'>{title}</div>
<div className='text-sm text-muted-foreground'>{description}</div>
</div>
</div>
</div>
);
}
function ConfigCard({
configuration,
sendRecipientEmail,
onEdit,
onDelete,
onTestConnection,
onSendTest,
isDeleting,
isTesting,
isSending,
}: {
configuration: EmailConfigurationSummary;
sendRecipientEmail: string;
onEdit: (configuration: EmailConfigurationSummary) => void;
onDelete: (configuration: EmailConfigurationSummary) => void;
onTestConnection: (configuration: EmailConfigurationSummary) => void;
onSendTest: (configuration: EmailConfigurationSummary) => void;
isDeleting: boolean;
isTesting: boolean;
isSending: boolean;
}) {
return (
<Card className='overflow-hidden border-border/70 bg-gradient-to-br from-background via-background to-muted/30 shadow-sm'>
<CardHeader className='gap-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<CardTitle className='text-xl'>{configuration.name}</CardTitle>
{configuration.isDefault ? <Badge>Default</Badge> : null}
<Badge variant={configuration.isActive ? 'outline' : 'secondary'}>
{configuration.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<CardDescription>
{configuration.host}:{configuration.port} · {configuration.secure ? 'TLS/SSL' : 'STARTTLS / plain'}
</CardDescription>
</div>
<Badge variant={statusBadgeVariant(configuration.lastTestStatus)}>
{configuration.lastTestStatus === 'not_tested'
? 'Not tested'
: configuration.lastTestStatus === 'passed'
? 'SMTP ready'
: 'Connection failed'}
</Badge>
</div>
</CardHeader>
<CardContent className='space-y-5'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>From</div>
<div className='mt-1 text-sm font-medium'>{configuration.fromEmail}</div>
<div className='text-sm text-muted-foreground'>{configuration.fromName || 'No display name'}</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Auth</div>
<div className='mt-1 text-sm font-medium'>{configuration.username || 'Anonymous / relay'}</div>
<div className='text-sm text-muted-foreground'>
Password {configuration.hasPassword ? 'configured' : 'not configured'}
</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Reply-to</div>
<div className='mt-1 text-sm font-medium'>
{configuration.replyToEmail || 'Same as from address'}
</div>
</div>
<div className='rounded-xl border border-border/60 bg-background/75 p-3'>
<div className='text-xs uppercase tracking-[0.14em] text-muted-foreground'>Last verification</div>
<div className='mt-1 text-sm font-medium'>
{configuration.lastTestedAt ? formatDateTime(configuration.lastTestedAt) : 'Never tested'}
</div>
</div>
</div>
{configuration.lastTestError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Last SMTP error</AlertTitle>
<AlertDescription>{configuration.lastTestError}</AlertDescription>
</Alert>
) : null}
<div className='flex flex-wrap gap-2'>
<Button type='button' variant='outline' onClick={() => onEdit(configuration)}>
<Icons.settings className='mr-2 h-4 w-4' />
Edit
</Button>
<Button
type='button'
variant='outline'
onClick={() => onTestConnection(configuration)}
isLoading={isTesting}
>
<Icons.check className='mr-2 h-4 w-4' />
Test connection
</Button>
<Button
type='button'
onClick={() => onSendTest(configuration)}
isLoading={isSending}
disabled={!sendRecipientEmail.trim()}
>
<Icons.send className='mr-2 h-4 w-4' />
Send test email
</Button>
<Button
type='button'
variant='outline'
onClick={() => onDelete(configuration)}
isLoading={isDeleting}
>
<Icons.close className='mr-2 h-4 w-4' />
Delete
</Button>
</div>
</CardContent>
</Card>
);
}
export function EmailConfigurationPage() {
const [sheetOpen, setSheetOpen] = useState(false);
const [editingConfiguration, setEditingConfiguration] = useState<EmailConfigurationSummary | null>(null);
const [sendRecipientEmail, setSendRecipientEmail] = useState('');
const emailConfigurationsQuery = useQuery(emailConfigurationsQueryOptions());
const createMutation = useMutation(createEmailConfigurationMutation);
const updateMutation = useMutation(updateEmailConfigurationMutation);
const deleteMutation = useMutation(deleteEmailConfigurationMutation);
const testConnectionMutation = useMutation(testEmailConfigurationConnectionMutation);
const sendTestEmailMutation = useMutation(sendEmailConfigurationTestEmailMutation);
const configurations = emailConfigurationsQuery.data ?? EMPTY_EMAIL_CONFIGURATIONS;
const summary = useMemo(() => {
const activeCount = configurations.filter((item) => item.isActive).length;
const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length;
const defaultConfig = configurations.find((item) => item.isDefault) ?? null;
return {
total: configurations.length,
activeCount,
passingCount,
defaultName: defaultConfig?.name ?? 'Not selected',
};
}, [configurations]);
const defaultValues = useMemo(
() => buildDefaultValues(editingConfiguration ?? undefined),
[editingConfiguration]
);
const { FormTextField, FormSwitchField } = useFormFields<EmailConfigurationFormValues>();
const form = useAppForm({
defaultValues,
validators: {
onSubmit: emailConfigurationFormSchema,
},
onSubmit: async ({ value }) => {
const payload: EmailConfigurationPayload = {
providerType: 'smtp',
name: value.name.trim(),
host: value.host.trim(),
port: Number(value.port),
secure: value.secure,
username: value.username?.trim() || null,
password: value.password?.trim() || undefined,
clearPassword: value.clearPassword,
fromEmail: value.fromEmail.trim().toLowerCase(),
fromName: value.fromName?.trim() || null,
replyToEmail: value.replyToEmail?.trim() || null,
isDefault: value.isDefault,
isActive: value.isActive,
};
if (editingConfiguration) {
await updateMutation.mutateAsync({
id: editingConfiguration.id,
payload,
});
toast.success('Email configuration updated.');
} else {
await createMutation.mutateAsync(payload);
toast.success('Email configuration created.');
}
setSheetOpen(false);
setEditingConfiguration(null);
},
});
useEffect(() => {
if (sheetOpen) {
form.reset(defaultValues);
}
}, [defaultValues, form, sheetOpen]);
async function handleDelete(configuration: EmailConfigurationSummary) {
try {
await deleteMutation.mutateAsync(configuration.id);
toast.success(`Removed ${configuration.name}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to delete email configuration.');
}
}
async function handleTestConnection(configuration: EmailConfigurationSummary) {
try {
const result = await testConnectionMutation.mutateAsync(configuration.id);
if (!result.ok) {
toast.error(result.detail ?? 'SMTP connection failed.');
return;
}
toast.success(`SMTP connection succeeded for ${configuration.name}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to test SMTP connection.');
}
}
async function handleSendTest(configuration: EmailConfigurationSummary) {
try {
const result = await sendTestEmailMutation.mutateAsync({
id: configuration.id,
payload: {
recipientEmail: sendRecipientEmail.trim().toLowerCase(),
},
});
if (result.rejectedRecipients.length > 0) {
toast.error(`SMTP rejected: ${result.rejectedRecipients.join(', ')}`);
return;
}
toast.success(`Test email queued to ${sendRecipientEmail.trim().toLowerCase()}.`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to send test email.');
}
}
const saving = createMutation.isPending || updateMutation.isPending;
return (
<div className='space-y-6'>
<section className='overflow-hidden rounded-[28px] border border-border/60 bg-[linear-gradient(135deg,rgba(240,246,255,0.9),rgba(255,255,255,0.96),rgba(243,247,252,0.92))] shadow-sm'>
<div className='grid gap-6 px-6 py-6 lg:grid-cols-[1.4fr_0.9fr] lg:px-8'>
<div className='space-y-5'>
<div className='inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs font-medium tracking-[0.16em] text-primary uppercase'>
<Icons.send className='h-3.5 w-3.5' />
Email delivery center
</div>
<div className='space-y-3'>
<h2 className='max-w-3xl text-3xl font-semibold tracking-tight text-foreground'>
Manage SMTP delivery in one calm, auditable place.
</h2>
<p className='max-w-2xl text-sm leading-6 text-muted-foreground'>
Configure secure outbound email for approvals, password recovery, invitations, and setup warnings.
Secrets stay encrypted at rest and the UI only exposes safe operational state.
</p>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryMetric
label='Configurations'
value={String(summary.total)}
hint='Stored SMTP profiles'
/>
<SummaryMetric
label='Active'
value={String(summary.activeCount)}
hint='Eligible for delivery'
/>
<SummaryMetric
label='Passing'
value={String(summary.passingCount)}
hint='Last SMTP test passed'
/>
<SummaryMetric
label='Default route'
value={summary.defaultName}
hint='Used by notification runtime'
/>
</div>
</div>
<Card className='border-border/60 bg-background/80 shadow-none'>
<CardHeader>
<CardTitle>Quick verification</CardTitle>
<CardDescription>
Use one test recipient while reviewing multiple SMTP profiles.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label className='text-sm font-medium' htmlFor='email-test-recipient'>
Test recipient
</label>
<Input
id='email-test-recipient'
type='email'
value={sendRecipientEmail}
onChange={(event) => setSendRecipientEmail(event.target.value)}
placeholder='ops@example.com'
/>
</div>
<Separator />
<div className='space-y-3 text-sm text-muted-foreground'>
<div className='flex items-start gap-3'>
<Icons.check className='mt-0.5 h-4 w-4 text-primary' />
<span>Connection test updates the configuration health state without exposing the password.</span>
</div>
<div className='flex items-start gap-3'>
<Icons.lock className='mt-0.5 h-4 w-4 text-primary' />
<span>Stored passwords can be replaced or cleared, but are never returned from the API.</span>
</div>
<div className='flex items-start gap-3'>
<Icons.settings className='mt-0.5 h-4 w-4 text-primary' />
<span>Setup wizard and notification runtime read the same organization-level SMTP source.</span>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
onClick={() => {
setEditingConfiguration(null);
setSheetOpen(true);
}}
>
<Icons.check className='mr-2 h-4 w-4' />
Add configuration
</Button>
<Button asChild type='button' variant='outline'>
<Link href='/dashboard/administration/setup'>Open setup wizard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
<div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'>
<div className='space-y-4'>
{emailConfigurationsQuery.isLoading ? (
<Card>
<CardContent className='flex items-center gap-3 p-6 text-sm text-muted-foreground'>
<Icons.spinner className='h-4 w-4 animate-spin' />
Loading email configurations...
</CardContent>
</Card>
) : null}
{emailConfigurationsQuery.isError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Unable to load SMTP profiles</AlertTitle>
<AlertDescription>
{emailConfigurationsQuery.error instanceof Error
? emailConfigurationsQuery.error.message
: 'Unknown email configuration error.'}
</AlertDescription>
</Alert>
) : null}
{!emailConfigurationsQuery.isLoading && configurations.length === 0 ? (
<Card className='border-dashed'>
<CardContent className='flex flex-col items-center gap-3 px-6 py-12 text-center'>
<div className='rounded-full bg-primary/10 p-3 text-primary'>
<Icons.send className='h-6 w-6' />
</div>
<div className='space-y-2'>
<div className='text-lg font-semibold'>No SMTP configuration yet</div>
<div className='max-w-xl text-sm text-muted-foreground'>
Add a profile to enable outbound email for system notifications, approvals, and future workflow messages.
</div>
</div>
<Button
type='button'
onClick={() => {
setEditingConfiguration(null);
setSheetOpen(true);
}}
>
<Icons.check className='mr-2 h-4 w-4' />
Create first configuration
</Button>
</CardContent>
</Card>
) : null}
{configurations.map((configuration) => (
<ConfigCard
key={configuration.id}
configuration={configuration}
sendRecipientEmail={sendRecipientEmail}
onEdit={(item) => {
setEditingConfiguration(item);
setSheetOpen(true);
}}
onDelete={handleDelete}
onTestConnection={handleTestConnection}
onSendTest={handleSendTest}
isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id}
isTesting={
testConnectionMutation.isPending &&
testConnectionMutation.variables === configuration.id
}
isSending={
sendTestEmailMutation.isPending &&
sendTestEmailMutation.variables?.id === configuration.id
}
/>
))}
</div>
<aside className='space-y-4'>
<Card className='border-border/70 bg-muted/20'>
<CardHeader>
<CardTitle>Operator notes</CardTitle>
<CardDescription>
A calm checklist for setup and post-go-live operations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<SecurityNote
title='Encrypt before save'
description='Saving a password requires EMAIL_CONFIG_ENCRYPTION_KEY on the server.'
/>
<SecurityNote
title='Choose one default route'
description='The notification runtime will prefer the organization default SMTP profile.'
/>
<SecurityNote
title='Verify after every credential change'
description='Run connection test and test email again whenever host, username, or password changes.'
/>
</CardContent>
</Card>
</aside>
</div>
<Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpen(open);
if (!open) {
setEditingConfiguration(null);
}
}}
>
<SheetContent side='right' className='w-full overflow-y-auto sm:max-w-2xl'>
<SheetHeader>
<SheetTitle>{editingConfiguration ? 'Edit SMTP configuration' : 'Add SMTP configuration'}</SheetTitle>
<SheetDescription>
Keep the profile readable for operators. Passwords can be updated, but never viewed again.
</SheetDescription>
</SheetHeader>
<div className='mt-6'>
<form.AppForm>
<form.Form className='space-y-4' id='email-configuration-form'>
<div className='grid gap-4 md:grid-cols-2'>
<FormTextField
name='name'
label='Configuration name'
placeholder='Primary corporate SMTP'
required
/>
<FormTextField
name='host'
label='SMTP host'
placeholder='smtp.example.com'
required
/>
<FormTextField
name='port'
label='Port'
placeholder='587'
required
/>
<FormTextField
name='username'
label='Username'
placeholder='mailer@example.com'
/>
<FormTextField
name='password'
label={editingConfiguration?.hasPassword ? 'Replace password' : 'Password'}
type='password'
placeholder={editingConfiguration?.hasPassword ? 'Leave blank to keep existing password' : 'SMTP password'}
/>
<FormTextField
name='fromEmail'
label='From email'
type='email'
placeholder='noreply@example.com'
required
/>
<FormTextField
name='fromName'
label='From name'
placeholder='ALLA OS'
/>
<FormTextField
name='replyToEmail'
label='Reply-to email'
type='email'
placeholder='support@example.com'
/>
</div>
<div className='grid gap-3 rounded-2xl border border-border/70 bg-muted/25 p-4'>
<FormSwitchField
name='secure'
label='Use secure SMTP transport'
description='Enable this when the provider expects implicit TLS, usually on port 465.'
/>
<FormSwitchField
name='isDefault'
label='Make this the default delivery route'
description='The default profile is used by email notifications when no override is selected.'
/>
<FormSwitchField
name='isActive'
label='Allow this profile for runtime delivery'
description='Inactive profiles stay visible for audit and editing, but are skipped for sending.'
/>
{editingConfiguration?.hasPassword ? (
<FormSwitchField
name='clearPassword'
label='Clear stored password on save'
description='Use this only when the SMTP server no longer requires the stored credential.'
/>
) : null}
</div>
</form.Form>
</form.AppForm>
</div>
<SheetFooter className='mt-6'>
<Button
type='button'
variant='outline'
onClick={() => {
setSheetOpen(false);
setEditingConfiguration(null);
}}
disabled={saving}
>
Cancel
</Button>
<Button type='submit' form='email-configuration-form' isLoading={saving}>
<Icons.check className={cn('mr-2 h-4 w-4', saving && 'hidden')} />
{editingConfiguration ? 'Save changes' : 'Create configuration'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { emailConfigurationsQueryOptions } from '../api/queries';
export function SetupEmailCard() {
const query = useQuery(emailConfigurationsQueryOptions());
const summary = useMemo(() => {
const configurations = query.data ?? [];
const defaultConfiguration = configurations.find((item) => item.isDefault) ?? null;
const passingCount = configurations.filter((item) => item.lastTestStatus === 'passed').length;
return {
total: configurations.length,
defaultName: defaultConfiguration?.name ?? 'Not configured',
passingCount,
};
}, [query.data]);
return (
<Card>
<CardHeader>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<CardTitle>Email Delivery</CardTitle>
<CardDescription>
Optional setup step for SMTP readiness. This does not block setup unless your chosen profile requires it.
</CardDescription>
</div>
<Badge variant='outline'>Optional</Badge>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{query.isLoading ? (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Icons.spinner className='h-4 w-4 animate-spin' />
Loading email configuration status...
</div>
) : null}
{query.isError ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Unable to load email status</AlertTitle>
<AlertDescription>
{query.error instanceof Error ? query.error.message : 'Unknown email configuration error.'}
</AlertDescription>
</Alert>
) : null}
{!query.isLoading && !query.isError ? (
<>
<div className='grid gap-2 sm:grid-cols-3'>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Profiles</div>
<div className='text-lg font-semibold'>{summary.total}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Ready</div>
<div className='text-lg font-semibold'>{summary.passingCount}</div>
</div>
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>Default</div>
<div className='truncate text-sm font-semibold'>{summary.defaultName}</div>
</div>
</div>
<div className='rounded-md border border-dashed p-4 text-sm text-muted-foreground'>
Configure SMTP now if you want setup verification to include live email readiness against the active organization.
</div>
</>
) : null}
<Button asChild type='button' variant='outline'>
<Link href='/dashboard/administration/email'>Open email configuration</Link>
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,420 @@
import 'server-only';
import { and, desc, eq, ne } from 'drizzle-orm';
import { emailConfigurations } from '@/db/schema';
import { auditAction, auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import { decryptSecret, encryptSecret } from '@/features/foundation/secrets/server/crypto';
import { db } from '@/lib/db';
import type { EmailProviderConfig } from './email-provider';
export type EmailConfigurationProviderType = 'smtp';
export type EmailConfigurationTestStatus = 'not_tested' | 'passed' | 'failed';
export type EmailConfigurationRecord = {
id: string;
organizationId: string;
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
isDefault: boolean;
isActive: boolean;
hasPassword: boolean;
lastTestStatus: EmailConfigurationTestStatus;
lastTestedAt: string | null;
lastTestError: string | null;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
};
export type SaveEmailConfigurationInput = {
providerType: EmailConfigurationProviderType;
name: string;
host: string;
port: number;
secure: boolean;
username?: string | null;
password?: string | null;
clearPassword?: boolean;
fromEmail: string;
fromName?: string | null;
replyToEmail?: string | null;
isDefault?: boolean;
isActive?: boolean;
};
type EmailConfigurationRow = typeof emailConfigurations.$inferSelect;
function toIso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
function mapRecord(row: EmailConfigurationRow): EmailConfigurationRecord {
return {
id: row.id,
organizationId: row.organizationId,
providerType: row.providerType as EmailConfigurationProviderType,
name: row.name,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
isDefault: row.isDefault,
isActive: row.isActive,
hasPassword: Boolean(row.passwordEncrypted),
lastTestStatus: row.lastTestStatus as EmailConfigurationTestStatus,
lastTestedAt: toIso(row.lastTestedAt),
lastTestError: row.lastTestError ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function normalizeOptionalText(value?: string | null): string | null {
const nextValue = value?.trim() ?? '';
return nextValue.length > 0 ? nextValue : null;
}
function truncateErrorMessage(value: string): string {
return value.slice(0, 500);
}
async function unsetOtherDefaults(organizationId: string, currentId?: string): Promise<void> {
await db
.update(emailConfigurations)
.set({
isDefault: false,
updatedAt: new Date(),
})
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
currentId ? ne(emailConfigurations.id, currentId) : undefined
)
);
}
export async function listEmailConfigurations(organizationId: string): Promise<EmailConfigurationRecord[]> {
const rows = await db
.select()
.from(emailConfigurations)
.where(eq(emailConfigurations.organizationId, organizationId))
.orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.isActive), emailConfigurations.name);
return rows.map(mapRecord);
}
export async function getEmailConfiguration(
organizationId: string,
id: string
): Promise<EmailConfigurationRecord | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id)))
.limit(1);
return row ? mapRecord(row) : null;
}
export async function getEmailConfigurationForUse(
organizationId: string,
providerType: EmailConfigurationProviderType = 'smtp'
): Promise<(EmailProviderConfig & { id: string; name: string }) | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
eq(emailConfigurations.providerType, providerType),
eq(emailConfigurations.isActive, true)
)
)
.orderBy(desc(emailConfigurations.isDefault), desc(emailConfigurations.updatedAt))
.limit(1);
if (!row) {
return null;
}
return {
id: row.id,
name: row.name,
providerType: row.providerType as EmailConfigurationProviderType,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
};
}
export async function getEmailConfigurationByIdForUse(
organizationId: string,
id: string
): Promise<(EmailProviderConfig & { id: string; name: string }) | null> {
const [row] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, organizationId), eq(emailConfigurations.id, id)))
.limit(1);
if (!row) {
return null;
}
return {
id: row.id,
name: row.name,
providerType: row.providerType as EmailConfigurationProviderType,
host: row.host,
port: row.port,
secure: row.secure,
username: row.username ?? null,
password: row.passwordEncrypted ? decryptSecret(row.passwordEncrypted) : null,
fromEmail: row.fromEmail,
fromName: row.fromName ?? null,
replyToEmail: row.replyToEmail ?? null,
};
}
export async function createEmailConfiguration(input: {
organizationId: string;
userId: string;
values: SaveEmailConfigurationInput;
}): Promise<EmailConfigurationRecord> {
const now = new Date();
const username = normalizeOptionalText(input.values.username);
const shouldEncryptPassword = typeof input.values.password === 'string' && input.values.password.trim().length > 0;
if (input.values.isDefault) {
await unsetOtherDefaults(input.organizationId);
}
const [created] = await db
.insert(emailConfigurations)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
providerType: input.values.providerType,
name: input.values.name.trim(),
host: input.values.host.trim(),
port: input.values.port,
secure: input.values.secure,
username,
passwordEncrypted: shouldEncryptPassword ? encryptSecret(input.values.password!.trim()) : null,
fromEmail: input.values.fromEmail.trim().toLowerCase(),
fromName: normalizeOptionalText(input.values.fromName),
replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null,
isDefault: Boolean(input.values.isDefault),
isActive: input.values.isActive ?? true,
lastTestStatus: 'not_tested',
createdBy: input.userId,
updatedBy: input.userId,
createdAt: now,
updatedAt: now,
})
.returning();
await auditCreate({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: created.id,
afterData: {
providerType: created.providerType,
name: created.name,
host: created.host,
port: created.port,
secure: created.secure,
username: created.username,
fromEmail: created.fromEmail,
isDefault: created.isDefault,
isActive: created.isActive,
hasPassword: Boolean(created.passwordEncrypted),
},
});
return mapRecord(created);
}
export async function updateEmailConfiguration(input: {
organizationId: string;
userId: string;
id: string;
values: SaveEmailConfigurationInput;
}): Promise<EmailConfigurationRecord> {
const [existing] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.limit(1);
if (!existing) {
throw new Error('Email configuration was not found.');
}
if (input.values.isDefault) {
await unsetOtherDefaults(input.organizationId, input.id);
}
const passwordEncrypted =
input.values.clearPassword === true
? null
: typeof input.values.password === 'string' && input.values.password.trim().length > 0
? encryptSecret(input.values.password.trim())
: existing.passwordEncrypted;
const [updated] = await db
.update(emailConfigurations)
.set({
providerType: input.values.providerType,
name: input.values.name.trim(),
host: input.values.host.trim(),
port: input.values.port,
secure: input.values.secure,
username: normalizeOptionalText(input.values.username),
passwordEncrypted,
fromEmail: input.values.fromEmail.trim().toLowerCase(),
fromName: normalizeOptionalText(input.values.fromName),
replyToEmail: normalizeOptionalText(input.values.replyToEmail)?.toLowerCase() ?? null,
isDefault: Boolean(input.values.isDefault),
isActive: input.values.isActive ?? existing.isActive,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.returning();
await auditUpdate({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: updated.id,
beforeData: {
...mapRecord(existing),
hasPassword: Boolean(existing.passwordEncrypted),
},
afterData: mapRecord(updated),
});
return mapRecord(updated);
}
export async function deleteEmailConfiguration(input: {
organizationId: string;
userId: string;
id: string;
}): Promise<void> {
const [existing] = await db
.select()
.from(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.limit(1);
if (!existing) {
throw new Error('Email configuration was not found.');
}
await db
.delete(emailConfigurations)
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)));
if (existing.isDefault) {
const [fallback] = await db
.select()
.from(emailConfigurations)
.where(eq(emailConfigurations.organizationId, input.organizationId))
.orderBy(desc(emailConfigurations.isActive), desc(emailConfigurations.updatedAt))
.limit(1);
if (fallback) {
await db
.update(emailConfigurations)
.set({
isDefault: true,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(eq(emailConfigurations.id, fallback.id));
}
}
await auditDelete({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: input.id,
beforeData: mapRecord(existing),
});
}
export async function markEmailConfigurationTestResult(input: {
organizationId: string;
userId: string;
id: string;
status: Exclude<EmailConfigurationTestStatus, 'not_tested'>;
errorMessage?: string | null;
}): Promise<EmailConfigurationRecord> {
const [updated] = await db
.update(emailConfigurations)
.set({
lastTestStatus: input.status,
lastTestedAt: new Date(),
lastTestError: input.errorMessage ? truncateErrorMessage(input.errorMessage) : null,
updatedBy: input.userId,
updatedAt: new Date(),
})
.where(and(eq(emailConfigurations.organizationId, input.organizationId), eq(emailConfigurations.id, input.id)))
.returning();
if (!updated) {
throw new Error('Email configuration was not found.');
}
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'email_configuration',
entityId: input.id,
action: input.status === 'passed' ? 'email_configuration_test_passed' : 'email_configuration_test_failed',
afterData: {
lastTestStatus: updated.lastTestStatus,
lastTestedAt: toIso(updated.lastTestedAt),
lastTestError: updated.lastTestError,
},
});
return mapRecord(updated);
}
export async function hasActiveEmailConfiguration(organizationId: string): Promise<boolean> {
const [row] = await db
.select({ id: emailConfigurations.id })
.from(emailConfigurations)
.where(
and(
eq(emailConfigurations.organizationId, organizationId),
eq(emailConfigurations.isActive, true)
)
)
.limit(1);
return Boolean(row);
}

View File

@@ -0,0 +1,52 @@
import 'server-only';
export type EmailProviderConfig = {
providerType: 'smtp';
host: string;
port: number;
secure: boolean;
username: string | null;
password: string | null;
fromEmail: string;
fromName: string | null;
replyToEmail: string | null;
};
export type EmailTestResult = {
ok: boolean;
detail?: string;
};
export type SendEmailRecipient = {
email: string;
name?: string | null;
};
export type SendEmailAttachment = {
fileName: string;
contentType: string;
buffer: Buffer;
};
export type SendEmailInput = {
subject: string;
text: string;
html?: string | null;
to: SendEmailRecipient[];
cc?: SendEmailRecipient[];
bcc?: SendEmailRecipient[];
replyTo?: string | null;
attachments?: SendEmailAttachment[];
};
export type SendEmailResult = {
providerMessageId: string | null;
acceptedRecipients: string[];
rejectedRecipients: string[];
metadata?: Record<string, unknown>;
};
export interface EmailProvider {
testConnection(config: EmailProviderConfig): Promise<EmailTestResult>;
sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise<SendEmailResult>;
}

View File

@@ -0,0 +1,85 @@
import 'server-only';
import type { SendEmailResult } from './email-provider';
import { SmtpEmailProvider } from './smtp-provider';
import {
getEmailConfigurationByIdForUse,
markEmailConfigurationTestResult,
} from './email-config.service';
export async function testEmailConfigurationConnection(input: {
organizationId: string;
userId: string;
configurationId: string;
}) {
const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId);
if (!config) {
throw new Error('Email configuration was not found.');
}
const provider = new SmtpEmailProvider();
const result = await provider.testConnection(config);
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: result.ok ? 'passed' : 'failed',
errorMessage: result.detail ?? null,
});
return result;
}
export async function sendTestEmail(input: {
organizationId: string;
userId: string;
configurationId: string;
recipientEmail: string;
}): Promise<SendEmailResult> {
const config = await getEmailConfigurationByIdForUse(input.organizationId, input.configurationId);
if (!config) {
throw new Error('Email configuration was not found.');
}
const provider = new SmtpEmailProvider();
try {
const result = await provider.sendEmail(config, {
subject: `ALLA OS test email from ${config.name}`,
text: [
'This is a test email from ALLA OS.',
'',
`Configuration: ${config.name}`,
`SMTP host: ${config.host}:${config.port}`,
`Sent at: ${new Date().toISOString()}`,
].join('\n'),
html: [
'<p>This is a test email from <strong>ALLA OS</strong>.</p>',
`<p>Configuration: <strong>${config.name}</strong></p>`,
`<p>SMTP host: <code>${config.host}:${config.port}</code></p>`,
`<p>Sent at: <code>${new Date().toISOString()}</code></p>`,
].join(''),
to: [{ email: input.recipientEmail.trim().toLowerCase() }],
});
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: 'passed',
});
return result;
} catch (error) {
await markEmailConfigurationTestResult({
organizationId: input.organizationId,
userId: input.userId,
id: config.id,
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown email send failure',
});
throw error;
}
}

View File

@@ -0,0 +1,89 @@
import 'server-only';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import type { Transporter } from 'nodemailer';
import type {
EmailProvider,
EmailProviderConfig,
EmailTestResult,
SendEmailInput,
SendEmailRecipient,
SendEmailResult,
} from './email-provider';
function formatRecipient(recipient: SendEmailRecipient): string {
return recipient.name?.trim()
? `"${recipient.name.trim()}" <${recipient.email}>`
: recipient.email;
}
function getFromValue(config: EmailProviderConfig): string {
return config.fromName?.trim()
? `"${config.fromName.trim()}" <${config.fromEmail}>`
: config.fromEmail;
}
async function createTransporter(config: EmailProviderConfig): Promise<Transporter> {
const nodemailer = await import('nodemailer');
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username
? {
user: config.username,
pass: config.password ?? undefined,
}
: undefined,
} satisfies SMTPTransport.Options);
}
function normalizeRecipients(values: Array<string | { address: string }> | undefined): string[] {
return Array.isArray(values) ? values.map((value) => String(value)) : [];
}
export class SmtpEmailProvider implements EmailProvider {
async testConnection(config: EmailProviderConfig): Promise<EmailTestResult> {
const transporter = await createTransporter(config);
try {
await transporter.verify();
return { ok: true };
} catch (error) {
return {
ok: false,
detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure',
};
}
}
async sendEmail(config: EmailProviderConfig, input: SendEmailInput): Promise<SendEmailResult> {
const transporter = await createTransporter(config);
const info = await transporter.sendMail({
from: getFromValue(config),
to: input.to.map(formatRecipient),
cc: input.cc?.map(formatRecipient),
bcc: input.bcc?.map(formatRecipient),
replyTo: input.replyTo ?? config.replyToEmail ?? undefined,
subject: input.subject,
text: input.text,
html: input.html ?? undefined,
attachments: input.attachments?.map((attachment) => ({
filename: attachment.fileName,
content: attachment.buffer,
contentType: attachment.contentType,
})),
});
return {
providerMessageId: info.messageId ?? null,
acceptedRecipients: normalizeRecipients(info.accepted),
rejectedRecipients: normalizeRecipients(info.rejected),
metadata: {
envelope: info.envelope,
response: info.response ?? null,
},
};
}
}

View File

@@ -1,7 +1,7 @@
import 'server-only';
import type { Transporter } from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service';
import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider';
import type {
NotificationChannel,
NotificationProvider,
@@ -9,126 +9,59 @@ import type {
NotificationProviderSendResult,
} from '../types';
type SmtpConfig = {
host: string;
port: number;
secure: boolean;
user?: string;
pass?: string;
from: string;
};
function parseBoolean(value: string | undefined, fallback: boolean) {
if (!value) {
return fallback;
}
return value === 'true' || value === '1';
}
function getSmtpConfig(): SmtpConfig {
const host = process.env.SMTP_HOST?.trim() ?? '';
const port = Number(process.env.SMTP_PORT ?? '587');
const secure = parseBoolean(process.env.SMTP_SECURE, port === 465);
const user = process.env.SMTP_USER?.trim();
const pass = process.env.SMTP_PASS?.trim();
const from = process.env.SMTP_FROM?.trim() ?? '';
return { host, port, secure, user, pass, from };
}
async function createTransporter() {
const nodemailer = await import('nodemailer');
const config = getSmtpConfig();
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
} satisfies SMTPTransport.Options);
}
function formatRecipients(
recipients: NotificationProviderSendInput['to'],
): string[] {
return recipients.map((recipient) =>
recipient.name?.trim()
? `"${recipient.name.trim()}" <${recipient.email}>`
: recipient.email,
);
}
export class EmailNotificationProvider implements NotificationProvider {
readonly channel: NotificationChannel = 'email';
readonly key = 'smtp';
constructor(private readonly organizationId: string) {}
private async resolveConfig() {
const config = await getEmailConfigurationForUse(this.organizationId, 'smtp');
if (!config) {
throw new Error('No active SMTP email configuration was found for this organization.');
}
return config;
}
async validate() {
const config = getSmtpConfig();
if (!config.host) {
throw new Error('SMTP host is not configured.');
}
if (!Number.isFinite(config.port) || config.port <= 0) {
throw new Error('SMTP port is invalid.');
}
if (!config.from) {
throw new Error('SMTP from address is not configured.');
}
await this.resolveConfig();
}
async healthCheck() {
try {
await this.validate();
const transporter = await createTransporter();
await transporter.verify();
const config = await this.resolveConfig();
const provider = new SmtpEmailProvider();
const result = await provider.testConnection(config);
return { ok: true as const };
return result.ok
? { ok: true }
: { ok: false, detail: result.detail ?? 'SMTP health check failed.' };
} catch (error) {
return {
ok: false as const,
ok: false,
detail: error instanceof Error ? error.message : 'Unknown SMTP health check failure',
};
}
}
async send(
input: NotificationProviderSendInput,
): Promise<NotificationProviderSendResult> {
await this.validate();
const transporter = (await createTransporter()) as Transporter;
const config = getSmtpConfig();
async send(input: NotificationProviderSendInput): Promise<NotificationProviderSendResult> {
const config = await this.resolveConfig();
const provider = new SmtpEmailProvider();
const info = await transporter.sendMail({
from: config.from,
to: formatRecipients(input.to),
cc: input.cc.length ? formatRecipients(input.cc) : undefined,
bcc: input.bcc.length ? formatRecipients(input.bcc) : undefined,
replyTo: input.replyTo ?? undefined,
return provider.sendEmail(config, {
subject: input.subject,
text: input.bodyText,
html: input.bodyHtml ?? undefined,
html: input.bodyHtml,
to: input.to,
cc: input.cc,
bcc: input.bcc,
replyTo: input.replyTo,
attachments: input.attachments.map((attachment, index) => ({
filename: attachment.fileName,
content: input.attachmentBuffers[index],
fileName: attachment.fileName,
contentType: attachment.contentType,
buffer: input.attachmentBuffers[index] ?? Buffer.alloc(0),
})),
});
return {
providerMessageId: info.messageId ?? null,
acceptedRecipients: Array.isArray(info.accepted)
? info.accepted.map((value: string | { address: string }) => String(value))
: [],
rejectedRecipients: Array.isArray(info.rejected)
? info.rejected.map((value: string | { address: string }) => String(value))
: [],
metadata: {
envelope: info.envelope,
response: info.response ?? null,
},
};
}
}

View File

@@ -5,11 +5,11 @@ import type { NotificationChannel, NotificationProvider } from '../types';
export function getNotificationProvider(
channel: NotificationChannel,
providerKey?: string,
organizationId: string,
providerKey?: string
): NotificationProvider {
if (channel === 'email') {
const provider = new EmailNotificationProvider();
const provider = new EmailNotificationProvider(organizationId);
if (providerKey && provider.key !== providerKey) {
throw new Error(`Unsupported email provider: ${providerKey}`);
}

View File

@@ -430,7 +430,11 @@ async function runDispatch(input: {
dispatch: NotificationDispatchRecord;
attachments: ResolvedNotificationAttachment[];
}) {
const provider = getNotificationProvider(input.dispatch.channel, input.dispatch.providerKey);
const provider = getNotificationProvider(
input.dispatch.channel,
input.organizationId,
input.dispatch.providerKey
);
await db
.update(appNotificationDispatches)

View File

@@ -0,0 +1,82 @@
import 'server-only';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
const ENCRYPTION_PREFIX = 'enc:v1';
const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY';
const MIN_KEY_BYTES = 32;
function decodeConfiguredKey(rawValue: string): Buffer {
const value = rawValue.trim();
if (/^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0) {
return Buffer.from(value, 'hex');
}
try {
const base64Buffer = Buffer.from(value, 'base64');
if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) {
return base64Buffer;
}
} catch {
// Fall back to UTF-8 below.
}
return Buffer.from(value, 'utf8');
}
function getEncryptionKeyMaterial(): Buffer {
const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? '';
if (!configured) {
throw new Error(`${ENCRYPTION_ENV_KEY} is not configured.`);
}
const keyMaterial = decodeConfiguredKey(configured);
if (keyMaterial.byteLength < MIN_KEY_BYTES) {
throw new Error(`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`);
}
return createHash('sha256').update(keyMaterial).digest();
}
export function assertSecretsEncryptionReady(): void {
getEncryptionKeyMaterial();
}
export function encryptSecret(plainText: string): string {
const normalized = plainText.trim();
if (!normalized) {
throw new Error('Secret value is required for encryption.');
}
const key = getEncryptionKeyMaterial();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(normalized, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
ENCRYPTION_PREFIX,
iv.toString('base64'),
tag.toString('base64'),
encrypted.toString('base64'),
].join(':');
}
export function decryptSecret(cipherText: string): string {
const [prefix, ivPart, tagPart, encryptedPart] = cipherText.split(':');
if (prefix !== ENCRYPTION_PREFIX || !ivPart || !tagPart || !encryptedPart) {
throw new Error('Encrypted secret format is invalid.');
}
const key = getEncryptionKeyMaterial();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64'));
decipher.setAuthTag(Buffer.from(tagPart, 'base64'));
const plain = Buffer.concat([
decipher.update(Buffer.from(encryptedPart, 'base64')),
decipher.final(),
]);
return plain.toString('utf8');
}

View File

@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { SetupEmailCard } from '@/features/foundation/email/components/setup-email-card';
import { cn } from '@/lib/utils';
import {
useConfiguredBootstrapCsvCommit,
@@ -616,16 +617,17 @@ export function SetupWizard({ mode = 'admin', tokenRequired = false }: SetupWiza
onSelect={setCurrentStepKey}
/>
<div className='space-y-6'>
<SourcePanel source={importSource} />
<SelectedFileDetail
<div className='space-y-6'>
<SourcePanel source={importSource} />
<SelectedFileDetail
step={currentStep}
sourceFile={selectedSourceFile}
template={selectedTemplate}
previewFile={selectedPreviewFile}
/>
<SetupEmailCard />
<Card>
<Card>
<CardHeader>
<CardTitle>Final Verification</CardTitle>
<CardDescription>Setup can complete after system-required import steps pass.</CardDescription>

View File

@@ -15,7 +15,8 @@ import {
organizations,
users
} from '@/db/schema';
import { EmailNotificationProvider } from '@/features/foundation/notifications/server/email-provider';
import { getEmailConfigurationForUse } from '@/features/foundation/email/server/email-config.service';
import { SmtpEmailProvider } from '@/features/foundation/email/server/smtp-provider';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { getSetupPluginRegistry } from '@/features/setup/plugins/plugin-registry';
import { db } from '@/lib/db';
@@ -366,28 +367,44 @@ export async function checkPdfMappingCoverage(
});
}
export async function checkEmailConfig(): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'EMAIL_CONFIG',
area: 'Email',
name: 'Email configuration',
requiredFor: ['optional notifications'],
purpose: 'Confirm optional email readiness.',
suggestedFix: 'Fix SMTP provider settings.'
};
return runSafeCheck(meta, async () => {
const hasSmtpConfig = Boolean(process.env.SMTP_HOST?.trim() || process.env.SMTP_FROM?.trim());
if (!hasSmtpConfig) {
return buildSetupCheck(
meta,
'WARNING',
'SMTP email is not configured; in-app notifications can still work.'
);
}
const provider = new EmailNotificationProvider();
await provider.validate();
return buildSetupCheck(meta, 'PASS', 'SMTP email configuration is valid.');
});
export async function checkEmailConfig(organizationId?: string | null): Promise<SetupCheckResult> {
const meta: CheckMeta = {
id: 'EMAIL_CONFIG',
area: 'Email',
name: 'Email configuration',
requiredFor: ['optional notifications'],
purpose: 'Confirm optional email readiness.',
suggestedFix: 'Fix SMTP provider settings.'
};
return runSafeCheck(meta, async () => {
if (!organizationId) {
return buildSetupCheck(
meta,
'WARNING',
'No organization context available for email verification yet.'
);
}
const config = await getEmailConfigurationForUse(organizationId, 'smtp');
if (!config) {
return buildSetupCheck(
meta,
'WARNING',
'Email delivery is not configured for this organization; in-app notifications can still work.'
);
}
const provider = new SmtpEmailProvider();
const result = await provider.testConnection(config);
if (!result.ok) {
return buildSetupCheck(
meta,
'FAIL',
result.detail
? `Email configuration exists but SMTP connection failed: ${result.detail}`
: 'Email configuration exists but SMTP connection failed.'
);
}
return buildSetupCheck(meta, 'PASS', 'Organization email configuration is valid.');
});
}
export async function getSetupReadinessReport(input: {
@@ -419,7 +436,7 @@ export async function getSetupReadinessReport(input: {
checkUploadDirectory(),
checkPdfTemplateAsset(input.organizationId),
checkPdfMappingCoverage(input.organizationId),
checkEmailConfig()
checkEmailConfig(input.organizationId)
]);
return buildSetupReport([...checks, ...pluginChecks], 'Setup readiness checks completed.');

View File

@@ -422,7 +422,7 @@ export async function getSetupVerificationReport(
)
);
checks.push(await checkEmailConfig());
checks.push(await checkEmailConfig(organizationId));
checks.push(
buildSetupCheck(