setup email
This commit is contained in:
47
src/features/foundation/email/api/mutations.ts
Normal file
47
src/features/foundation/email/api/mutations.ts
Normal 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,
|
||||
});
|
||||
14
src/features/foundation/email/api/queries.ts
Normal file
14
src/features/foundation/email/api/queries.ts
Normal 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(),
|
||||
});
|
||||
60
src/features/foundation/email/api/service.ts
Normal file
60
src/features/foundation/email/api/service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
58
src/features/foundation/email/api/types.ts
Normal file
58
src/features/foundation/email/api/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
420
src/features/foundation/email/server/email-config.service.ts
Normal file
420
src/features/foundation/email/server/email-config.service.ts
Normal 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);
|
||||
}
|
||||
52
src/features/foundation/email/server/email-provider.ts
Normal file
52
src/features/foundation/email/server/email-provider.ts
Normal 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>;
|
||||
}
|
||||
85
src/features/foundation/email/server/email-test.service.ts
Normal file
85
src/features/foundation/email/server/email-test.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
src/features/foundation/email/server/smtp-provider.ts
Normal file
89
src/features/foundation/email/server/smtp-provider.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
82
src/features/foundation/secrets/server/crypto.ts
Normal file
82
src/features/foundation/secrets/server/crypto.ts
Normal 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');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -422,7 +422,7 @@ export async function getSetupVerificationReport(
|
||||
)
|
||||
);
|
||||
|
||||
checks.push(await checkEmailConfig());
|
||||
checks.push(await checkEmailConfig(organizationId));
|
||||
|
||||
checks.push(
|
||||
buildSetupCheck(
|
||||
|
||||
Reference in New Issue
Block a user