From 6180161225bb4d7f143de61deb8be675f24bae15 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Thu, 16 Jul 2026 17:50:58 +0900 Subject: [PATCH] feat: add email diagnostics --- docs/ACS작업일지.md | 7 ++ frontend/src/api.ts | 3 + frontend/src/pages/AdminManagementPage.tsx | 52 ++++++++- frontend/src/types.ts | 15 +++ server/config/env.ts | 8 ++ server/routes/admin.ts | 6 + server/services/mail.ts | 123 +++++++++++++++++++++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 server/services/mail.ts diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index f40b6f4..36e3722 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -836,3 +836,10 @@ - 실제 문자 발송 없이 현재 SMS provider/API URL 기준으로 서버 컨테이너에서 `/sens/sms` 연결을 짧게 점검. - 점검 결과로 provider, URL, 연결 성공/실패, HTTP 상태, 소요시간, 오류 메시지를 표시. - 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 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4837759..ee35bb0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -9,6 +9,7 @@ import { ChangePasswordRequest, CurrentUser, DeliveryStatus, + EmailDiagnosticsResult, ExcelImportResult, InsideVisitor, LoginRequest, @@ -188,6 +189,8 @@ export const updateWatcher1Settings = (payload: Watcher1Settings) => request('/admin/settings/watcher1', jsonInit('PUT', payload)); export const runSmsDiagnostics = () => request('/admin/sms/diagnostics', { method: 'POST' }); +export const runEmailDiagnostics = () => + request('/admin/email/diagnostics', { method: 'POST' }); export const listAdminUsers = () => request('/admin/users'); export const updateAdminUserRoles = (id: number, roles: Role[]) => request(`/admin/users/${id}/roles`, jsonInit('PUT', { roles })); diff --git a/frontend/src/pages/AdminManagementPage.tsx b/frontend/src/pages/AdminManagementPage.tsx index 8c900d1..f3ed875 100644 --- a/frontend/src/pages/AdminManagementPage.tsx +++ b/frontend/src/pages/AdminManagementPage.tsx @@ -12,15 +12,16 @@ import { updateAdminPurposeCode, updateAdminUserTeam, updateAdminUserRoles, + runEmailDiagnostics, runSmsDiagnostics, uploadAdminTeams, uploadAdminUserRoles, updateWatcher1Settings, getWatcher1Settings, } 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 = { code: '', @@ -139,6 +140,7 @@ export const AdminManagementPage: React.FC = () => { const [userRoleUploadFile, setUserRoleUploadFile] = useState(null); const [userRoleUploadResult, setUserRoleUploadResult] = useState(null); const [smsDiagnostics, setSmsDiagnostics] = useState(null); + const [emailDiagnostics, setEmailDiagnostics] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); 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) => { setEditingPurposeId(p.id); setPurposeForm({ @@ -422,6 +439,7 @@ export const AdminManagementPage: React.FC = () => { + {tab === 'watcher' && ( @@ -623,6 +641,36 @@ export const AdminManagementPage: React.FC = () => { )} )} + + {tab === 'email' && ( +
+
+

Email SMTP 연결 점검

+ +
+ {emailDiagnostics ? ( + + + + + + + + + + + + + + +
Host{emailDiagnostics.host || '-'}
Port{emailDiagnostics.port}
From{emailDiagnostics.from || '-'}
SMTP Auth{emailDiagnostics.smtpAuth ? 'Y' : 'N'}
STARTTLS{emailDiagnostics.startTls ? 'Y' : 'N'}
계정 설정{emailDiagnostics.usernameConfigured ? 'Y' : 'N'}
비밀번호 설정{emailDiagnostics.passwordConfigured ? 'Y' : 'N'}
연결{emailDiagnostics.reachable ? '성공' : '실패'}
소요시간{emailDiagnostics.elapsedMs} ms
점검시각{new Date(emailDiagnostics.checkedAt).toLocaleString()}
오류{emailDiagnostics.error ?? '-'}
+ ) : ( +

점검 실행 버튼을 누르면 서버 컨테이너에서 현재 Email SMTP host/port로 연결을 확인합니다.

+ )} +
+ )} ); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e41ef73..5d67691 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -59,6 +59,21 @@ export interface SmsDiagnosticsResult { 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 { id: number; code: string; diff --git a/server/config/env.ts b/server/config/env.ts index 0e5ad36..cb64518 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -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://'), diff --git a/server/routes/admin.ts b/server/routes/admin.ts index c9e4d67..c264d80 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -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)); diff --git a/server/services/mail.ts b/server/services/mail.ts new file mode 100644 index 0000000..fe61212 --- /dev/null +++ b/server/services/mail.ts @@ -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 { + 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 { + 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 { + 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), + }); + } +}