bu1 ad email config

This commit is contained in:
phaichayon
2026-07-07 14:37:17 +07:00
parent 3b5fede134
commit 62762fc70c
8 changed files with 2183 additions and 52 deletions

View File

@@ -10,6 +10,14 @@ 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Sheet,
@@ -19,7 +27,6 @@ import {
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';
@@ -120,7 +127,6 @@ function SecurityNote({ title, description }: { title: string; description: stri
function ConfigCard({
configuration,
sendRecipientEmail,
onEdit,
onDelete,
onTestConnection,
@@ -130,7 +136,6 @@ function ConfigCard({
isSending,
}: {
configuration: EmailConfigurationSummary;
sendRecipientEmail: string;
onEdit: (configuration: EmailConfigurationSummary) => void;
onDelete: (configuration: EmailConfigurationSummary) => void;
onTestConnection: (configuration: EmailConfigurationSummary) => void;
@@ -218,7 +223,6 @@ function ConfigCard({
type='button'
onClick={() => onSendTest(configuration)}
isLoading={isSending}
disabled={!sendRecipientEmail.trim()}
>
<Icons.send className='mr-2 h-4 w-4' />
Send test email
@@ -238,10 +242,93 @@ function ConfigCard({
);
}
function QuickVerificationDialog({
configuration,
recipientEmail,
onRecipientEmailChange,
onOpenChange,
onSubmit,
isSending,
}: {
configuration: EmailConfigurationSummary | null;
recipientEmail: string;
onRecipientEmailChange: (value: string) => void;
onOpenChange: (open: boolean) => void;
onSubmit: () => void;
isSending: boolean;
}) {
return (
<Dialog open={configuration !== null} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Quick verification</DialogTitle>
<DialogDescription>
{configuration
? `Send a test email through ${configuration.name} to verify the active SMTP route.`
: 'Send a test email to verify the active SMTP route.'}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{configuration ? (
<div className='rounded-xl border border-border/60 bg-muted/30 p-3 text-sm'>
<div className='font-medium text-foreground'>{configuration.name}</div>
<div className='mt-1 text-muted-foreground'>
{configuration.host}:{configuration.port} via{' '}
{configuration.secure ? 'TLS/SSL' : 'STARTTLS / plain'}
</div>
</div>
) : null}
<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={recipientEmail}
onChange={(event) => onRecipientEmailChange(event.target.value)}
placeholder='ops@example.com'
/>
</div>
<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 health state without exposing the stored 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 use the same organization-level SMTP source.</span>
</div>
</div>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)} disabled={isSending}>
Cancel
</Button>
<Button type='button' onClick={onSubmit} isLoading={isSending} disabled={!recipientEmail.trim()}>
<Icons.send className={cn('mr-2 h-4 w-4', isSending && 'hidden')} />
Send test email
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function EmailConfigurationPage() {
const [sheetOpen, setSheetOpen] = useState(false);
const [editingConfiguration, setEditingConfiguration] = useState<EmailConfigurationSummary | null>(null);
const [sendRecipientEmail, setSendRecipientEmail] = useState('');
const [quickVerificationConfiguration, setQuickVerificationConfiguration] =
useState<EmailConfigurationSummary | null>(null);
const [quickVerificationRecipientEmail, setQuickVerificationRecipientEmail] = useState('');
const emailConfigurationsQuery = useQuery(emailConfigurationsQueryOptions());
const createMutation = useMutation(createEmailConfigurationMutation);
@@ -336,12 +423,18 @@ export function EmailConfigurationPage() {
}
}
async function handleSendTest(configuration: EmailConfigurationSummary) {
async function handleSendTest() {
if (!quickVerificationConfiguration) {
return;
}
const normalizedRecipientEmail = quickVerificationRecipientEmail.trim().toLowerCase();
try {
const result = await sendTestEmailMutation.mutateAsync({
id: configuration.id,
id: quickVerificationConfiguration.id,
payload: {
recipientEmail: sendRecipientEmail.trim().toLowerCase(),
recipientEmail: normalizedRecipientEmail,
},
});
@@ -350,7 +443,8 @@ export function EmailConfigurationPage() {
return;
}
toast.success(`Test email queued to ${sendRecipientEmail.trim().toLowerCase()}.`);
toast.success(`Test email queued to ${normalizedRecipientEmail}.`);
setQuickVerificationConfiguration(null);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to send test email.');
}
@@ -402,29 +496,16 @@ export function EmailConfigurationPage() {
<Card className='border-border/60 bg-background/80 shadow-none'>
<CardHeader>
<CardTitle>Quick verification</CardTitle>
<CardTitle>Operator actions</CardTitle>
<CardDescription>
Use one test recipient while reviewing multiple SMTP profiles.
Create and maintain SMTP routes for setup, notifications, and approvals.
</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>
<span>Add a route, then use each row&apos;s quick verification modal to send a test email.</span>
</div>
<div className='flex items-start gap-3'>
<Icons.lock className='mt-0.5 h-4 w-4 text-primary' />
@@ -508,14 +589,15 @@ export function EmailConfigurationPage() {
<ConfigCard
key={configuration.id}
configuration={configuration}
sendRecipientEmail={sendRecipientEmail}
onEdit={(item) => {
setEditingConfiguration(item);
setSheetOpen(true);
}}
onDelete={handleDelete}
onTestConnection={handleTestConnection}
onSendTest={handleSendTest}
onSendTest={(item) => {
setQuickVerificationConfiguration(item);
}}
isDeleting={deleteMutation.isPending && deleteMutation.variables === configuration.id}
isTesting={
testConnectionMutation.isPending &&
@@ -672,6 +754,19 @@ export function EmailConfigurationPage() {
</SheetFooter>
</SheetContent>
</Sheet>
<QuickVerificationDialog
configuration={quickVerificationConfiguration}
recipientEmail={quickVerificationRecipientEmail}
onRecipientEmailChange={setQuickVerificationRecipientEmail}
onOpenChange={(open) => {
if (!open) {
setQuickVerificationConfiguration(null);
}
}}
onSubmit={handleSendTest}
isSending={sendTestEmailMutation.isPending}
/>
</div>
);
}

View File

@@ -1,42 +1,53 @@
import 'server-only';
import "server-only";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "node:crypto";
const ENCRYPTION_PREFIX = 'enc:v1';
const ENCRYPTION_ENV_KEY = 'EMAIL_CONFIG_ENCRYPTION_KEY';
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');
return Buffer.from(value, "hex");
}
try {
const base64Buffer = Buffer.from(value, 'base64');
if (base64Buffer.length > 0 && base64Buffer.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')) {
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');
return Buffer.from(value, "utf8");
}
function getEncryptionKeyMaterial(): Buffer {
const configured = process.env[ENCRYPTION_ENV_KEY]?.trim() ?? '';
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.`);
throw new Error(
`${ENCRYPTION_ENV_KEY} must provide at least ${MIN_KEY_BYTES} bytes of key material.`,
);
}
return createHash('sha256').update(keyMaterial).digest();
return createHash("sha256").update(keyMaterial).digest();
}
export function assertSecretsEncryptionReady(): void {
@@ -46,37 +57,49 @@ export function assertSecretsEncryptionReady(): void {
export function encryptSecret(plainText: string): string {
const normalized = plainText.trim();
if (!normalized) {
throw new Error('Secret value is required for encryption.');
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 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(':');
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.');
if (!cipherText.startsWith(`${ENCRYPTION_PREFIX}:`)) {
throw new Error("Encrypted secret format is invalid.");
}
const payload = cipherText.slice(ENCRYPTION_PREFIX.length + 1);
const [ivPart, tagPart, encryptedPart, ...extraParts] = payload.split(":");
if (!ivPart || !tagPart || !encryptedPart || extraParts.length > 0) {
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 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.update(Buffer.from(encryptedPart, "base64")),
decipher.final(),
]);
return plain.toString('utf8');
return plain.toString("utf8");
}