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

@@ -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,
},
};
}
}