feat: add email diagnostics
This commit is contained in:
@@ -22,6 +22,14 @@ export const env = {
|
||||
smsApiKey: process.env.ACS_SMS_API_KEY,
|
||||
smsSender: process.env.ACS_SMS_SENDER,
|
||||
smsTimeoutMs: Number(process.env.ACS_SMS_TIMEOUT_MS ?? 10000),
|
||||
mailHost: process.env.ACS_MAIL_HOST,
|
||||
mailPort: Number(process.env.ACS_MAIL_PORT ?? 25),
|
||||
mailFrom: process.env.ACS_MAIL_FROM,
|
||||
mailUsername: process.env.ACS_MAIL_USERNAME,
|
||||
mailPassword: process.env.ACS_MAIL_PASSWORD,
|
||||
mailSmtpAuth: (process.env.ACS_MAIL_SMTP_AUTH ?? '').toLowerCase() === 'true',
|
||||
mailStartTls: (process.env.ACS_MAIL_STARTTLS ?? '').toLowerCase() === 'true',
|
||||
mailTimeoutMs: Number(process.env.ACS_MAIL_TIMEOUT_MS ?? 10000),
|
||||
cookieSecure:
|
||||
(process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' ||
|
||||
(process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
|
||||
import { ok } from '../http/apiResponse.js';
|
||||
import { ApiError } from '../http/errors.js';
|
||||
import { query } from '../db/pool.js';
|
||||
import { diagnoseEmailConnectivity } from '../services/mail.js';
|
||||
import { diagnoseSmsConnectivity } from '../services/sms.js';
|
||||
import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||
|
||||
@@ -475,6 +476,11 @@ export function createAdminRouter(deps: AdminRouterDeps = { query }): Router {
|
||||
ok(res, await diagnoseSmsConnectivity());
|
||||
}));
|
||||
|
||||
router.post('/email/diagnostics', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
ok(res, await diagnoseEmailConnectivity());
|
||||
}));
|
||||
|
||||
router.get('/teams', asyncRoute(async (req, res) => {
|
||||
await requireAdmin(dbQuery, req);
|
||||
ok(res, await listTeams(dbQuery));
|
||||
|
||||
123
server/services/mail.ts
Normal file
123
server/services/mail.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import net from 'node:net';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export interface EmailDiagnosticsResult {
|
||||
host: string;
|
||||
port: number;
|
||||
from: string;
|
||||
smtpAuth: boolean;
|
||||
startTls: boolean;
|
||||
usernameConfigured: boolean;
|
||||
passwordConfigured: boolean;
|
||||
ok: boolean;
|
||||
reachable: boolean;
|
||||
elapsedMs: number;
|
||||
checkedAt: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function mailHost(): string {
|
||||
return String(process.env.ACS_MAIL_HOST ?? env.mailHost ?? '').trim();
|
||||
}
|
||||
|
||||
function mailPort(): number {
|
||||
const port = Number(process.env.ACS_MAIL_PORT ?? env.mailPort);
|
||||
return Number.isFinite(port) && port > 0 ? port : 25;
|
||||
}
|
||||
|
||||
function mailFrom(): string {
|
||||
return String(process.env.ACS_MAIL_FROM ?? env.mailFrom ?? '').trim();
|
||||
}
|
||||
|
||||
function mailUsername(): string {
|
||||
return String(process.env.ACS_MAIL_USERNAME ?? env.mailUsername ?? '').trim();
|
||||
}
|
||||
|
||||
function mailPassword(): string {
|
||||
return String(process.env.ACS_MAIL_PASSWORD ?? env.mailPassword ?? '').trim();
|
||||
}
|
||||
|
||||
function mailSmtpAuth(): boolean {
|
||||
return (process.env.ACS_MAIL_SMTP_AUTH ?? String(env.mailSmtpAuth)).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function mailStartTls(): boolean {
|
||||
return (process.env.ACS_MAIL_STARTTLS ?? String(env.mailStartTls)).toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function mailTimeoutMs(): number {
|
||||
const timeout = Number(process.env.ACS_MAIL_TIMEOUT_MS ?? env.mailTimeoutMs);
|
||||
return Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;
|
||||
}
|
||||
|
||||
function result(partial: Partial<EmailDiagnosticsResult>): EmailDiagnosticsResult {
|
||||
return {
|
||||
host: mailHost(),
|
||||
port: mailPort(),
|
||||
from: mailFrom(),
|
||||
smtpAuth: mailSmtpAuth(),
|
||||
startTls: mailStartTls(),
|
||||
usernameConfigured: Boolean(mailUsername()),
|
||||
passwordConfigured: Boolean(mailPassword()),
|
||||
ok: false,
|
||||
reachable: false,
|
||||
elapsedMs: 0,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function connectSmtp(host: string, port: number, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
let settled = false;
|
||||
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', () => finish());
|
||||
socket.once('timeout', () => finish(new Error(`SMTP connection timed out after ${timeoutMs} ms`)));
|
||||
socket.once('error', (error) => finish(error));
|
||||
});
|
||||
}
|
||||
|
||||
export async function diagnoseEmailConnectivity(): Promise<EmailDiagnosticsResult> {
|
||||
const host = mailHost();
|
||||
const port = mailPort();
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (!host) {
|
||||
return result({
|
||||
ok: false,
|
||||
reachable: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: 'ACS_MAIL_HOST가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await connectSmtp(host, port, mailTimeoutMs());
|
||||
return result({
|
||||
ok: true,
|
||||
reachable: true,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return result({
|
||||
ok: false,
|
||||
reachable: false,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: message.slice(0, 500),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user