feat: add email diagnostics

This commit is contained in:
unknown
2026-07-16 17:50:58 +09:00
parent 9c4dac9a70
commit 6180161225
7 changed files with 212 additions and 2 deletions

View File

@@ -836,3 +836,10 @@
- 실제 문자 발송 없이 현재 SMS provider/API URL 기준으로 서버 컨테이너에서 `/sens/sms` 연결을 짧게 점검. - 실제 문자 발송 없이 현재 SMS provider/API URL 기준으로 서버 컨테이너에서 `/sens/sms` 연결을 짧게 점검.
- 점검 결과로 provider, URL, 연결 성공/실패, HTTP 상태, 소요시간, 오류 메시지를 표시. - 점검 결과로 provider, URL, 연결 성공/실패, HTTP 상태, 소요시간, 오류 메시지를 표시.
- timeout이면 방화벽/라우팅/허용 IP 문제, HTTP 응답이면 API 서버 도달 성공으로 판단 가능. - timeout이면 방화벽/라우팅/허용 IP 문제, HTTP 응답이면 API 서버 도달 성공으로 판단 가능.
- 2026-07-16 시스템관리 Email SMTP 연결 점검 기능 추가:
- Dooray API 가이드는 API 인증/서비스 API 안내 성격이고, 방문자 QR 이메일 발송은 별도 메일 발송 API보다 SMTP 경로를 우선 검증하는 방식이 적합하다고 판단.
- ADMIN 전용 `POST /api/admin/email/diagnostics` 추가.
- `ACS_MAIL_HOST`, `ACS_MAIL_PORT`, `ACS_MAIL_FROM`, `ACS_MAIL_USERNAME`, `ACS_MAIL_PASSWORD`, `ACS_MAIL_SMTP_AUTH`, `ACS_MAIL_STARTTLS`, `ACS_MAIL_TIMEOUT_MS` 환경변수를 서버에서 읽도록 추가.
- 실제 메일 발송 없이 서버 컨테이너에서 SMTP host/port TCP 연결만 점검.
- 시스템관리 화면에 `Email 점검` 탭을 추가하고 host, port, 발신자, 인증/STARTTLS 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시.

View File

@@ -9,6 +9,7 @@ import {
ChangePasswordRequest, ChangePasswordRequest,
CurrentUser, CurrentUser,
DeliveryStatus, DeliveryStatus,
EmailDiagnosticsResult,
ExcelImportResult, ExcelImportResult,
InsideVisitor, InsideVisitor,
LoginRequest, LoginRequest,
@@ -188,6 +189,8 @@ export const updateWatcher1Settings = (payload: Watcher1Settings) =>
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload)); request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
export const runSmsDiagnostics = () => export const runSmsDiagnostics = () =>
request<SmsDiagnosticsResult>('/admin/sms/diagnostics', { method: 'POST' }); request<SmsDiagnosticsResult>('/admin/sms/diagnostics', { method: 'POST' });
export const runEmailDiagnostics = () =>
request<EmailDiagnosticsResult>('/admin/email/diagnostics', { method: 'POST' });
export const listAdminUsers = () => request<AdminUser[]>('/admin/users'); export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
export const updateAdminUserRoles = (id: number, roles: Role[]) => export const updateAdminUserRoles = (id: number, roles: Role[]) =>
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles })); request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));

View File

@@ -12,15 +12,16 @@ import {
updateAdminPurposeCode, updateAdminPurposeCode,
updateAdminUserTeam, updateAdminUserTeam,
updateAdminUserRoles, updateAdminUserRoles,
runEmailDiagnostics,
runSmsDiagnostics, runSmsDiagnostics,
uploadAdminTeams, uploadAdminTeams,
uploadAdminUserRoles, uploadAdminUserRoles,
updateWatcher1Settings, updateWatcher1Settings,
getWatcher1Settings, getWatcher1Settings,
} from '../api'; } from '../api';
import { AdminExcelImportResult, AdminUser, PurposeCode, Role, SmsDiagnosticsResult, Team, Watcher1Settings } from '../types'; import { AdminExcelImportResult, AdminUser, EmailDiagnosticsResult, PurposeCode, Role, SmsDiagnosticsResult, Team, Watcher1Settings } from '../types';
type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms'; type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms' | 'email';
const EMPTY_PURPOSE = { const EMPTY_PURPOSE = {
code: '', code: '',
@@ -139,6 +140,7 @@ export const AdminManagementPage: React.FC = () => {
const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null); const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null);
const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null); const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null);
const [smsDiagnostics, setSmsDiagnostics] = useState<SmsDiagnosticsResult | null>(null); const [smsDiagnostics, setSmsDiagnostics] = useState<SmsDiagnosticsResult | null>(null);
const [emailDiagnostics, setEmailDiagnostics] = useState<EmailDiagnosticsResult | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -186,6 +188,21 @@ export const AdminManagementPage: React.FC = () => {
} }
}; };
const checkEmail = async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
const result = await runEmailDiagnostics();
setEmailDiagnostics(result);
setNotice(result.reachable ? 'Email SMTP 연결 점검이 완료되었습니다.' : 'Email SMTP 연결 점검에 실패했습니다.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Email SMTP 연결 점검 실패');
} finally {
setBusy(false);
}
};
const editPurpose = (p: PurposeCode) => { const editPurpose = (p: PurposeCode) => {
setEditingPurposeId(p.id); setEditingPurposeId(p.id);
setPurposeForm({ setPurposeForm({
@@ -422,6 +439,7 @@ export const AdminManagementPage: React.FC = () => {
<button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}> </button> <button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}> </button>
<button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}></button> <button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}></button>
<button className={tab === 'sms' ? 'tab-active' : ''} onClick={() => setTab('sms')}>SMS </button> <button className={tab === 'sms' ? 'tab-active' : ''} onClick={() => setTab('sms')}>SMS </button>
<button className={tab === 'email' ? 'tab-active' : ''} onClick={() => setTab('email')}>Email </button>
</div> </div>
{tab === 'watcher' && ( {tab === 'watcher' && (
@@ -623,6 +641,36 @@ export const AdminManagementPage: React.FC = () => {
)} )}
</div> </div>
)} )}
{tab === 'email' && (
<div className="card">
<div className="admin-upload-head">
<h3>Email SMTP </h3>
<button className="btn-primary" type="button" disabled={busy} onClick={() => void checkEmail()}>
</button>
</div>
{emailDiagnostics ? (
<table className="table diagnostic-table">
<tbody>
<tr><th>Host</th><td>{emailDiagnostics.host || '-'}</td></tr>
<tr><th>Port</th><td>{emailDiagnostics.port}</td></tr>
<tr><th>From</th><td>{emailDiagnostics.from || '-'}</td></tr>
<tr><th>SMTP Auth</th><td>{emailDiagnostics.smtpAuth ? 'Y' : 'N'}</td></tr>
<tr><th>STARTTLS</th><td>{emailDiagnostics.startTls ? 'Y' : 'N'}</td></tr>
<tr><th> </th><td>{emailDiagnostics.usernameConfigured ? 'Y' : 'N'}</td></tr>
<tr><th> </th><td>{emailDiagnostics.passwordConfigured ? 'Y' : 'N'}</td></tr>
<tr><th></th><td className={emailDiagnostics.reachable ? '' : 'text-danger'}>{emailDiagnostics.reachable ? '성공' : '실패'}</td></tr>
<tr><th></th><td>{emailDiagnostics.elapsedMs} ms</td></tr>
<tr><th></th><td>{new Date(emailDiagnostics.checkedAt).toLocaleString()}</td></tr>
<tr><th></th><td className={emailDiagnostics.error ? 'text-danger' : ''}>{emailDiagnostics.error ?? '-'}</td></tr>
</tbody>
</table>
) : (
<p className="muted"> Email SMTP host/port로 .</p>
)}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -59,6 +59,21 @@ export interface SmsDiagnosticsResult {
error: string | null; error: string | null;
} }
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;
}
export interface Team { export interface Team {
id: number; id: number;
code: string; code: string;

View File

@@ -22,6 +22,14 @@ export const env = {
smsApiKey: process.env.ACS_SMS_API_KEY, smsApiKey: process.env.ACS_SMS_API_KEY,
smsSender: process.env.ACS_SMS_SENDER, smsSender: process.env.ACS_SMS_SENDER,
smsTimeoutMs: Number(process.env.ACS_SMS_TIMEOUT_MS ?? 10000), 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: cookieSecure:
(process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' || (process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' ||
(process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'), (process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'),

View File

@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import { ok } from '../http/apiResponse.js'; import { ok } from '../http/apiResponse.js';
import { ApiError } from '../http/errors.js'; import { ApiError } from '../http/errors.js';
import { query } from '../db/pool.js'; import { query } from '../db/pool.js';
import { diagnoseEmailConnectivity } from '../services/mail.js';
import { diagnoseSmsConnectivity } from '../services/sms.js'; import { diagnoseSmsConnectivity } from '../services/sms.js';
import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.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()); 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) => { router.get('/teams', asyncRoute(async (req, res) => {
await requireAdmin(dbQuery, req); await requireAdmin(dbQuery, req);
ok(res, await listTeams(dbQuery)); ok(res, await listTeams(dbQuery));

123
server/services/mail.ts Normal file
View 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),
});
}
}