feat: add email diagnostics
This commit is contained in:
@@ -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 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시.
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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://'),
|
||||||
|
|||||||
@@ -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
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