Files
sam-docs/presentations/email-policy-convert.cjs
김보곤 5c7685f6aa docs: [email] 멀티테넌시 이메일 정책 문서 및 발표자료 추가
- 이메일 발송 정책 기술문서 (dev/standards/email-policy.md)
- 개발팀 설명용 PPTX 12슬라이드 (presentations/sam-email-policy.pptx)
- INDEX.md에 이메일 정책 문서 등록
2026-03-11 21:47:36 +09:00

1084 lines
44 KiB
JavaScript

const path = require('path');
module.paths.unshift(path.join(require('os').homedir(), '.claude/skills/pptx-skill/scripts/node_modules'));
const PptxGenJS = require('pptxgenjs');
async function main() {
const pres = new PptxGenJS();
pres.defineLayout({ name: 'CUSTOM_16x9', width: 10, height: 5.625 });
pres.layout = 'CUSTOM_16x9';
// === 컬러 팔레트 ===
const C = {
navy: '0f172a',
navyLight: '1e293b',
navyMid: '334155',
blue: '3b82f6',
blueLight: '60a5fa',
blueDark: '1d4ed8',
blueBg: '1e3a5f',
white: 'FFFFFF',
gray100: 'f1f5f9',
gray200: 'e2e8f0',
gray300: 'cbd5e1',
gray400: '94a3b8',
gray500: '64748b',
gray600: '475569',
gray700: '334155',
red: 'ef4444',
redBg: 'fef2f2',
redLight: 'fca5a5',
green: '22c55e',
greenBg: 'f0fdf4',
greenLight: '86efac',
amber: 'f59e0b',
amberBg: 'fffbeb',
purple: 'a855f7',
purpleBg: 'faf5ff',
cyan: '06b6d4',
cyanBg: 'ecfeff',
};
const biLogoPath = '/home/aweso/sam/docs/assets/bi/sam_bi_white.png';
// === 공통 푸터 함수 ===
function addFooter(slide, pageNum, totalPages) {
// 하단 라인
slide.addShape(pres.ShapeType.rect, {
x: 0.5, y: 5.15, w: 9, h: 0.01, fill: { color: C.navyMid }
});
slide.addText('SAM 이메일 정책 | (주)코드브릿지엑스', {
x: 0.5, y: 5.2, w: 7, h: 0.3,
fontSize: 7, color: C.gray500, fontFace: 'Arial'
});
slide.addText(`${pageNum} / ${totalPages}`, {
x: 7.5, y: 5.2, w: 2, h: 0.3,
fontSize: 7, color: C.gray500, align: 'right', fontFace: 'Arial'
});
}
// === 공통 페이지 헤더 ===
function addPageHeader(slide, title, subtitle) {
slide.background = { fill: C.white };
// 상단 네이비 바
slide.addShape(pres.ShapeType.rect, {
x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.blue }
});
// 제목 액센트 바
slide.addShape(pres.ShapeType.rect, {
x: 0.5, y: 0.4, w: 0.06, h: 0.4, fill: { color: C.blue }
});
slide.addText(title, {
x: 0.72, y: 0.35, w: 6, h: 0.5,
fontSize: 20, bold: true, color: C.navy, fontFace: 'Arial'
});
if (subtitle) {
slide.addText(subtitle, {
x: 0.72, y: 0.8, w: 8, h: 0.3,
fontSize: 10, color: C.gray500, fontFace: 'Arial'
});
}
}
// === 넘버 서클 ===
function addNumberCircle(slide, num, x, y, color) {
slide.addShape(pres.ShapeType.ellipse, {
x, y, w: 0.35, h: 0.35, fill: { color: color || C.blue }
});
slide.addText(String(num), {
x, y, w: 0.35, h: 0.35,
fontSize: 12, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
}
const TOTAL = 12;
// ==============================
// 슬라이드 1: 표지
// ==============================
const s1 = pres.addSlide();
s1.background = { fill: C.navy };
// 상단 그라데이션 효과 (가상)
s1.addShape(pres.ShapeType.rect, {
x: 0, y: 0, w: 10, h: 2, fill: { color: C.navyLight }
});
s1.addShape(pres.ShapeType.rect, {
x: 0, y: 0, w: 10, h: 0.08, fill: { color: C.blue }
});
// BI 로고
s1.addImage({
path: biLogoPath,
x: 0.7, y: 0.4, w: 1.6, h: 0.7
});
// 메인 제목
s1.addText('멀티테넌시 이메일 정책', {
x: 0.7, y: 2.2, w: 8.5, h: 0.8,
fontSize: 36, bold: true, color: C.white, fontFace: 'Arial'
});
// 서브 제목
s1.addText('SAM Email System Architecture & Policy', {
x: 0.7, y: 2.95, w: 8.5, h: 0.5,
fontSize: 16, color: C.blueLight, fontFace: 'Arial'
});
// 구분선
s1.addShape(pres.ShapeType.rect, {
x: 0.7, y: 3.6, w: 2.5, h: 0.03, fill: { color: C.blue }
});
// 메타 정보
s1.addText([
{ text: '2026-03-11', options: { fontSize: 11, color: C.gray400 } },
{ text: ' | ', options: { fontSize: 11, color: C.navyMid } },
{ text: '개발팀 내부 공유', options: { fontSize: 11, color: C.gray400 } },
], { x: 0.7, y: 3.85, w: 6, h: 0.4, fontFace: 'Arial' });
// 하단
s1.addText('(주)코드브릿지엑스', {
x: 0.7, y: 5.0, w: 5, h: 0.3,
fontSize: 9, color: C.gray500, fontFace: 'Arial'
});
// ==============================
// 슬라이드 2: 현재 문제점 (AS-IS)
// ==============================
const s2 = pres.addSlide();
addPageHeader(s2, '현재 문제점 (AS-IS)', '기존 이메일 시스템의 한계');
const problems = [
{ icon: '1', title: '단일 SMTP 설정', desc: '.env에 고정된 Gmail SMTP\nGmail 일일 500건 제한에 전체 영향', color: C.red, bg: C.redBg },
{ icon: '2', title: '단일 발신 주소', desc: 'develop@codebridge-x.com 하나로\n모든 테넌트 메일 발송', color: C.red, bg: C.redBg },
{ icon: '3', title: '발송 기록 없음', desc: '메일 발송 이력 추적 불가\n장애 시 원인 파악 어려움', color: C.amber, bg: C.amberBg },
{ icon: '4', title: 'Mailable 중복', desc: 'EsignRequestMail이\nAPI + MNG 양쪽에 존재', color: C.amber, bg: C.amberBg },
{ icon: '5', title: '템플릿 하드코딩', desc: '테넌트별 로고/서명/컬러\n커스터마이징 불가', color: C.amber, bg: C.amberBg },
{ icon: '6', title: 'Mail::to() 직접 호출', desc: 'TenantMailService 없이\n각 컨트롤러에서 직접 발송', color: C.purple, bg: C.purpleBg },
];
problems.forEach((p, i) => {
const col = i % 3;
const row = Math.floor(i / 3);
const x = 0.5 + col * 3.1;
const y = 1.3 + row * 1.95;
// 카드 배경
s2.addShape(pres.ShapeType.roundRect, {
x, y, w: 2.9, h: 1.75, rectRadius: 0.1,
fill: { color: p.bg }, line: { color: p.color, width: 0.5, dashType: 'solid' }
});
// 넘버 원
addNumberCircle(s2, p.icon, x + 0.15, y + 0.15, p.color);
// 제목
s2.addText(p.title, {
x: x + 0.6, y: y + 0.15, w: 2.1, h: 0.35,
fontSize: 11, bold: true, color: p.color, fontFace: 'Arial'
});
// 설명
s2.addText(p.desc, {
x: x + 0.2, y: y + 0.6, w: 2.5, h: 0.9,
fontSize: 9, color: C.gray600, fontFace: 'Arial', lineSpacingMultiple: 1.3
});
});
addFooter(s2, 2, TOTAL);
// ==============================
// 슬라이드 3: 목표 아키텍처 (TO-BE)
// ==============================
const s3 = pres.addSlide();
addPageHeader(s3, '목표 아키텍처 (TO-BE)', '3-Layer 구조로 테넌트 격리, 브랜딩, 추적을 구현');
// Layer 1
s3.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.4, w: 9, h: 1.0, rectRadius: 0.12,
fill: { color: 'eff6ff' }, line: { color: C.blue, width: 1.5 }
});
s3.addText('Layer 1', {
x: 0.7, y: 1.45, w: 1.2, h: 0.3,
fontSize: 8, bold: true, color: C.white, fontFace: 'Arial'
});
s3.addShape(pres.ShapeType.roundRect, {
x: 0.7, y: 1.45, w: 0.7, h: 0.25, rectRadius: 0.04,
fill: { color: C.blue }
});
s3.addText('Layer 1', {
x: 0.7, y: 1.45, w: 0.7, h: 0.25,
fontSize: 8, bold: true, color: C.white, align: 'center', fontFace: 'Arial'
});
s3.addText('테넌트 메일 설정 (tenant_mail_configs)', {
x: 1.55, y: 1.45, w: 5, h: 0.3,
fontSize: 13, bold: true, color: C.blueDark, fontFace: 'Arial'
});
s3.addText('SMTP 설정 | 발신자 주소/이름 | 브랜딩 정보 (로고, 컬러, 서명) | Provider 선택', {
x: 0.9, y: 1.85, w: 8.2, h: 0.35,
fontSize: 9, color: C.gray600, fontFace: 'Arial'
});
// 화살표 1
s3.addText('▼', {
x: 4.5, y: 2.4, w: 1, h: 0.3,
fontSize: 16, color: C.blue, align: 'center', fontFace: 'Arial'
});
// Layer 2
s3.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 2.7, w: 9, h: 1.0, rectRadius: 0.12,
fill: { color: C.greenBg }, line: { color: C.green, width: 1.5 }
});
s3.addShape(pres.ShapeType.roundRect, {
x: 0.7, y: 2.75, w: 0.7, h: 0.25, rectRadius: 0.04,
fill: { color: C.green }
});
s3.addText('Layer 2', {
x: 0.7, y: 2.75, w: 0.7, h: 0.25,
fontSize: 8, bold: true, color: C.white, align: 'center', fontFace: 'Arial'
});
s3.addText('메일 발송 서비스 (TenantMailService)', {
x: 1.55, y: 2.75, w: 5, h: 0.3,
fontSize: 13, bold: true, color: '166534', fontFace: 'Arial'
});
s3.addText('테넌트 설정 자동 적용 | 큐/즉시 발송 | Fallback 전략 | 쿼터 확인', {
x: 0.9, y: 3.15, w: 8.2, h: 0.35,
fontSize: 9, color: C.gray600, fontFace: 'Arial'
});
// 화살표 2
s3.addText('▼', {
x: 4.5, y: 3.7, w: 1, h: 0.3,
fontSize: 16, color: C.green, align: 'center', fontFace: 'Arial'
});
// Layer 3
s3.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 4.0, w: 9, h: 1.0, rectRadius: 0.12,
fill: { color: C.purpleBg }, line: { color: C.purple, width: 1.5 }
});
s3.addShape(pres.ShapeType.roundRect, {
x: 0.7, y: 4.05, w: 0.7, h: 0.25, rectRadius: 0.04,
fill: { color: C.purple }
});
s3.addText('Layer 3', {
x: 0.7, y: 4.05, w: 0.7, h: 0.25,
fontSize: 8, bold: true, color: C.white, align: 'center', fontFace: 'Arial'
});
s3.addText('발송 기록/추적 (mail_logs)', {
x: 1.55, y: 4.05, w: 5, h: 0.3,
fontSize: 13, bold: true, color: '7c3aed', fontFace: 'Arial'
});
s3.addText('발송 이력 | 상태 추적 (queued/sent/failed/bounced) | 일일 쿼터 관리 | 감사 로그', {
x: 0.9, y: 4.45, w: 8.2, h: 0.35,
fontSize: 9, color: C.gray600, fontFace: 'Arial'
});
addFooter(s3, 3, TOTAL);
// ==============================
// 슬라이드 4: 테넌트별 메일 설정
// ==============================
const s4 = pres.addSlide();
addPageHeader(s4, '테넌트별 메일 설정', 'tenant_mail_configs 테이블 구조');
// 왼쪽: 테이블 구조
s4.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.3, w: 4.5, h: 3.7, rectRadius: 0.1,
fill: { color: C.navy }
});
s4.addText('tenant_mail_configs', {
x: 0.7, y: 1.4, w: 4, h: 0.35,
fontSize: 12, bold: true, color: C.blueLight, fontFace: 'Courier New'
});
// 구분선
s4.addShape(pres.ShapeType.rect, {
x: 0.7, y: 1.8, w: 4.1, h: 0.01, fill: { color: C.navyMid }
});
const fields = [
['tenant_id', 'FK', '테넌트 ID'],
['provider', 'ENUM', 'platform / smtp / ses / mailgun'],
['from_address', 'VARCHAR', '발신 이메일 주소'],
['from_name', 'VARCHAR', '발신자 표시명'],
['reply_to', 'VARCHAR', '회신 주소 (선택)'],
['is_verified', 'BOOL', '도메인 검증 여부'],
['daily_limit', 'INT', '일일 발송 한도 (기본 500)'],
['options', 'JSON', 'SMTP 설정, 브랜딩 정보'],
];
fields.forEach((f, i) => {
const fy = 1.9 + i * 0.38;
s4.addText(f[0], {
x: 0.7, y: fy, w: 1.6, h: 0.3,
fontSize: 8, color: C.blueLight, fontFace: 'Courier New'
});
s4.addText(f[1], {
x: 2.35, y: fy, w: 0.75, h: 0.3,
fontSize: 7, color: C.gray400, fontFace: 'Arial'
});
s4.addText(f[2], {
x: 3.15, y: fy, w: 1.8, h: 0.3,
fontSize: 7, color: C.gray300, fontFace: 'Arial'
});
});
// 오른쪽: Provider 카드
const providers = [
{ name: 'Platform', desc: 'SAM 기본 SMTP\n(설정 불필요)', color: C.blue, bg: 'eff6ff' },
{ name: 'Custom SMTP', desc: '테넌트 자체\nSMTP 서버', color: C.green, bg: C.greenBg },
{ name: 'Amazon SES', desc: 'AWS 대량 발송\n서비스', color: C.amber, bg: C.amberBg },
{ name: 'Mailgun', desc: '이메일 API\n서비스', color: C.purple, bg: C.purpleBg },
];
s4.addText('Provider 선택', {
x: 5.3, y: 1.3, w: 4, h: 0.35,
fontSize: 12, bold: true, color: C.navy, fontFace: 'Arial'
});
providers.forEach((p, i) => {
const py = 1.8 + i * 0.85;
s4.addShape(pres.ShapeType.roundRect, {
x: 5.3, y: py, w: 4.2, h: 0.7, rectRadius: 0.08,
fill: { color: p.bg }, line: { color: p.color, width: 0.5 }
});
s4.addShape(pres.ShapeType.ellipse, {
x: 5.5, y: py + 0.15, w: 0.4, h: 0.4, fill: { color: p.color }
});
s4.addText(p.name.charAt(0), {
x: 5.5, y: py + 0.15, w: 0.4, h: 0.4,
fontSize: 12, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s4.addText(p.name, {
x: 6.1, y: py + 0.05, w: 2, h: 0.3,
fontSize: 10, bold: true, color: p.color, fontFace: 'Arial'
});
s4.addText(p.desc, {
x: 6.1, y: py + 0.3, w: 3.2, h: 0.35,
fontSize: 8, color: C.gray600, fontFace: 'Arial'
});
});
addFooter(s4, 4, TOTAL);
// ==============================
// 슬라이드 5: 발송 흐름
// ==============================
const s5 = pres.addSlide();
addPageHeader(s5, '발송 흐름', 'TenantMailService를 경유한 중앙 집중 발송');
// 흐름 다이어그램 - 세로 플로우
const flowSteps = [
{ label: 'Controller / Service', sub: '메일 발송 요청', color: C.gray600, bg: C.gray100 },
{ label: 'TenantMailService::send()', sub: '중앙 발송 서비스', color: C.blue, bg: 'eff6ff' },
{ label: '테넌트 설정 조회', sub: 'tenant_mail_configs', color: C.blueDark, bg: 'dbeafe' },
{ label: '쿼터 확인', sub: '일일 500건 한도 체크', color: C.amber, bg: C.amberBg },
{ label: 'Mailer 동적 구성', sub: 'SMTP/SES/Mailgun 설정 적용', color: C.green, bg: C.greenBg },
{ label: '발송 (sync / queue)', sub: 'OTP=즉시, 나머지=큐', color: C.purple, bg: C.purpleBg },
{ label: 'mail_logs 기록', sub: 'status: queued → sent', color: C.cyan, bg: C.cyanBg },
];
flowSteps.forEach((step, i) => {
const sy = 1.25 + i * 0.55;
// 연결 화살표
if (i > 0) {
s5.addText('▼', {
x: 2.1, y: sy - 0.2, w: 0.5, h: 0.2,
fontSize: 8, color: C.gray400, align: 'center', fontFace: 'Arial'
});
}
// 넘버
addNumberCircle(s5, i + 1, 0.5, sy + 0.04, step.color);
// 박스
s5.addShape(pres.ShapeType.roundRect, {
x: 1.0, y: sy, w: 3.7, h: 0.42, rectRadius: 0.06,
fill: { color: step.bg }, line: { color: step.color, width: 0.5 }
});
s5.addText(step.label, {
x: 1.15, y: sy, w: 2.2, h: 0.42,
fontSize: 9, bold: true, color: step.color, valign: 'middle', fontFace: 'Arial'
});
s5.addText(step.sub, {
x: 3.1, y: sy, w: 1.5, h: 0.42,
fontSize: 7, color: C.gray500, valign: 'middle', fontFace: 'Arial'
});
});
// 오른쪽: Fallback 전략
s5.addShape(pres.ShapeType.roundRect, {
x: 5.3, y: 1.25, w: 4.2, h: 3.6, rectRadius: 0.12,
fill: { color: C.navy }
});
s5.addText('Fallback 전략', {
x: 5.5, y: 1.35, w: 3.5, h: 0.35,
fontSize: 13, bold: true, color: C.white, fontFace: 'Arial'
});
const fallbackItems = [
{ step: '1차', desc: '테넌트 자체 SMTP로 발송', status: '시도', color: C.blue },
{ step: '성공', desc: 'mail_logs (status: sent)', status: '', color: C.green },
{ step: '실패', desc: '플랫폼 기본 SMTP로 재시도', status: 'Fallback', color: C.amber },
{ step: '성공', desc: 'mail_logs (fallback_used: true)', status: '', color: C.green },
{ step: '실패', desc: 'mail_logs (status: failed)', status: '3회 재시도', color: C.red },
];
fallbackItems.forEach((item, i) => {
const fy = 1.85 + i * 0.6;
// 연결선
if (i > 0) {
s5.addShape(pres.ShapeType.rect, {
x: 6.0, y: fy - 0.15, w: 0.01, h: 0.15, fill: { color: C.navyMid }
});
}
s5.addShape(pres.ShapeType.roundRect, {
x: 5.6, y: fy, w: 0.6, h: 0.35, rectRadius: 0.04,
fill: { color: item.color }
});
s5.addText(item.step, {
x: 5.6, y: fy, w: 0.6, h: 0.35,
fontSize: 7, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s5.addText(item.desc, {
x: 6.35, y: fy, w: 2.5, h: 0.35,
fontSize: 8, color: C.gray300, valign: 'middle', fontFace: 'Arial'
});
if (item.status) {
s5.addText(item.status, {
x: 8.7, y: fy, w: 0.7, h: 0.35,
fontSize: 6, color: item.color, valign: 'middle', fontFace: 'Arial'
});
}
});
addFooter(s5, 5, TOTAL);
// ==============================
// 슬라이드 6: 발송 기록 (mail_logs)
// ==============================
const s6 = pres.addSlide();
addPageHeader(s6, '발송 기록 (mail_logs)', '모든 발송을 기록하여 추적/감사/통계 지원');
// 왼쪽: 테이블 구조
s6.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.3, w: 4.5, h: 3.7, rectRadius: 0.1,
fill: { color: C.navy }
});
s6.addText('mail_logs', {
x: 0.7, y: 1.4, w: 3, h: 0.35,
fontSize: 12, bold: true, color: C.blueLight, fontFace: 'Courier New'
});
s6.addShape(pres.ShapeType.rect, {
x: 0.7, y: 1.8, w: 4.1, h: 0.01, fill: { color: C.navyMid }
});
const logFields = [
['tenant_id', '테넌트 ID'],
['mailable_type', 'Mailable 클래스명'],
['to_address', '수신자 이메일'],
['from_address', '실제 발신 주소'],
['subject', '메일 제목'],
['status', 'queued / sent / failed / bounced'],
['sent_at', '발송 시각'],
['options', '에러메시지, 재시도횟수, 관련모델'],
];
logFields.forEach((f, i) => {
const fy = 1.9 + i * 0.38;
s6.addText(f[0], {
x: 0.7, y: fy, w: 1.6, h: 0.3,
fontSize: 8, color: C.blueLight, fontFace: 'Courier New'
});
s6.addText(f[1], {
x: 2.4, y: fy, w: 2.4, h: 0.3,
fontSize: 8, color: C.gray300, fontFace: 'Arial'
});
});
// 오른쪽: 상태 흐름 + 보안
s6.addText('상태 흐름', {
x: 5.3, y: 1.3, w: 3, h: 0.35,
fontSize: 12, bold: true, color: C.navy, fontFace: 'Arial'
});
const statuses = [
{ name: 'queued', desc: '큐에 등록됨', color: C.blue, bg: 'eff6ff' },
{ name: 'sent', desc: '발송 완료', color: C.green, bg: C.greenBg },
{ name: 'failed', desc: '발송 실패', color: C.red, bg: C.redBg },
{ name: 'bounced', desc: '수신 거부/반송', color: C.amber, bg: C.amberBg },
];
statuses.forEach((st, i) => {
const sy = 1.8 + i * 0.55;
s6.addShape(pres.ShapeType.roundRect, {
x: 5.3, y: sy, w: 4.2, h: 0.42, rectRadius: 0.06,
fill: { color: st.bg }, line: { color: st.color, width: 0.5 }
});
s6.addShape(pres.ShapeType.ellipse, {
x: 5.45, y: sy + 0.08, w: 0.26, h: 0.26, fill: { color: st.color }
});
s6.addText(st.name, {
x: 5.85, y: sy, w: 1.2, h: 0.42,
fontSize: 9, bold: true, color: st.color, valign: 'middle', fontFace: 'Courier New'
});
s6.addText(st.desc, {
x: 7.2, y: sy, w: 2, h: 0.42,
fontSize: 9, color: C.gray600, valign: 'middle', fontFace: 'Arial'
});
});
// 보안 주의사항
s6.addShape(pres.ShapeType.roundRect, {
x: 5.3, y: 4.15, w: 4.2, h: 0.8, rectRadius: 0.08,
fill: { color: C.redBg }, line: { color: C.red, width: 0.5 }
});
s6.addText('개인정보 보호', {
x: 5.5, y: 4.2, w: 3, h: 0.25,
fontSize: 9, bold: true, color: C.red, fontFace: 'Arial'
});
s6.addText('mail_logs에 메일 본문(body) 저장 금지\n메타데이터(제목, 수신자, 상태)만 기록', {
x: 5.5, y: 4.45, w: 3.8, h: 0.45,
fontSize: 8, color: C.gray600, fontFace: 'Arial', lineSpacingMultiple: 1.3
});
addFooter(s6, 6, TOTAL);
// ==============================
// 슬라이드 7: 메일 타입 정리
// ==============================
const s7 = pres.addSlide();
addPageHeader(s7, '메일 타입 정리', '5개 Mailable + sync/queue 구분');
const mailTypes = [
{ name: 'EsignRequestMail', cat: '전자계약', trigger: '서명 요청', mode: 'queue', color: C.blue, modeColor: C.green },
{ name: 'EsignOtpMail', cat: '전자계약', trigger: 'OTP 인증', mode: 'sync', color: C.blue, modeColor: C.red },
{ name: 'EsignCompletedMail', cat: '전자계약', trigger: '서명 완료', mode: 'queue', color: C.blue, modeColor: C.green },
{ name: 'UserPasswordMail', cat: '인증', trigger: '계정/비번 초기화', mode: 'sync', color: C.purple, modeColor: C.red },
{ name: 'PayslipMail', cat: '급여', trigger: '급여명세서 발송', mode: 'queue', color: C.amber, modeColor: C.green },
];
// 헤더 행
s7.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.3, w: 9, h: 0.45, rectRadius: 0.06,
fill: { color: C.navy }
});
const headers = [
{ text: 'Mailable', x: 0.7, w: 2.5 },
{ text: '카테고리', x: 3.3, w: 1.2 },
{ text: '트리거', x: 4.6, w: 1.8 },
{ text: '발송 모드', x: 6.5, w: 1.0 },
{ text: '사유', x: 7.6, w: 1.8 },
];
headers.forEach(h => {
s7.addText(h.text, {
x: h.x, y: 1.3, w: h.w, h: 0.45,
fontSize: 9, bold: true, color: C.white,
valign: 'middle', fontFace: 'Arial'
});
});
mailTypes.forEach((mt, i) => {
const ry = 1.85 + i * 0.55;
// 행 배경 (짝수 행 약간 다르게)
s7.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: ry, w: 9, h: 0.45, rectRadius: 0.06,
fill: { color: i % 2 === 0 ? C.gray100 : C.white },
line: { color: C.gray200, width: 0.3 }
});
s7.addText(mt.name, {
x: 0.7, y: ry, w: 2.5, h: 0.45,
fontSize: 9, bold: true, color: C.navy, valign: 'middle', fontFace: 'Courier New'
});
// 카테고리 배지
s7.addShape(pres.ShapeType.roundRect, {
x: 3.3, y: ry + 0.08, w: 0.9, h: 0.28, rectRadius: 0.04,
fill: { color: mt.color }
});
s7.addText(mt.cat, {
x: 3.3, y: ry + 0.08, w: 0.9, h: 0.28,
fontSize: 7, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s7.addText(mt.trigger, {
x: 4.6, y: ry, w: 1.8, h: 0.45,
fontSize: 9, color: C.gray600, valign: 'middle', fontFace: 'Arial'
});
// 모드 배지
s7.addShape(pres.ShapeType.roundRect, {
x: 6.55, y: ry + 0.08, w: 0.7, h: 0.28, rectRadius: 0.04,
fill: { color: mt.modeColor }
});
s7.addText(mt.mode, {
x: 6.55, y: ry + 0.08, w: 0.7, h: 0.28,
fontSize: 7, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s7.addText(mt.mode === 'sync' ? '시간 민감 (즉시)' : '비동기 발송 가능', {
x: 7.6, y: ry, w: 1.8, h: 0.45,
fontSize: 8, color: C.gray500, valign: 'middle', fontFace: 'Arial'
});
});
// 하단 설명
s7.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 4.5, w: 4.3, h: 0.55, rectRadius: 0.06,
fill: { color: C.redBg }, line: { color: C.red, width: 0.5 }
});
s7.addText([
{ text: 'sync', options: { bold: true, color: C.red, fontSize: 9 } },
{ text: ' = Mail::send() 즉시 발송 (OTP, 비밀번호)', options: { color: C.gray600, fontSize: 8 } }
], { x: 0.7, y: 4.5, w: 4, h: 0.55, valign: 'middle', fontFace: 'Arial' });
s7.addShape(pres.ShapeType.roundRect, {
x: 5.1, y: 4.5, w: 4.4, h: 0.55, rectRadius: 0.06,
fill: { color: C.greenBg }, line: { color: C.green, width: 0.5 }
});
s7.addText([
{ text: 'queue', options: { bold: true, color: C.green, fontSize: 9 } },
{ text: ' = Mail::queue() Supervisor 큐 워커 처리', options: { color: C.gray600, fontSize: 8 } }
], { x: 5.3, y: 4.5, w: 4, h: 0.55, valign: 'middle', fontFace: 'Arial' });
addFooter(s7, 7, TOTAL);
// ==============================
// 슬라이드 8: 템플릿 브랜딩
// ==============================
const s8 = pres.addSlide();
addPageHeader(s8, '템플릿 브랜딩', '테넌트별 로고, 컬러, 서명 커스터마이징');
// 이메일 레이아웃 시각화
// 외곽 (이메일 프레임)
s8.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.3, w: 4.5, h: 3.8, rectRadius: 0.12,
fill: { color: C.white }, line: { color: C.gray300, width: 1 }
});
// 헤더 영역
s8.addShape(pres.ShapeType.rect, {
x: 0.5, y: 1.3, w: 4.5, h: 0.8,
fill: { color: 'eff6ff' }
});
s8.addShape(pres.ShapeType.roundRect, {
x: 0.8, y: 1.45, w: 0.8, h: 0.5, rectRadius: 0.05,
fill: { color: C.blue }
});
s8.addText('LOGO', {
x: 0.8, y: 1.45, w: 0.8, h: 0.5,
fontSize: 8, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s8.addText('{{ 테넌트명 }}', {
x: 1.8, y: 1.45, w: 2.5, h: 0.5,
fontSize: 11, bold: true, color: C.navy, valign: 'middle', fontFace: 'Arial'
});
// 본문 영역
s8.addText('@yield(\'content\')', {
x: 0.8, y: 2.3, w: 3.9, h: 0.35,
fontSize: 9, color: C.blue, fontFace: 'Courier New'
});
s8.addShape(pres.ShapeType.rect, {
x: 0.8, y: 2.7, w: 3.9, h: 0.15, fill: { color: C.gray200 }
});
s8.addShape(pres.ShapeType.rect, {
x: 0.8, y: 2.95, w: 3.0, h: 0.12, fill: { color: C.gray200 }
});
s8.addShape(pres.ShapeType.rect, {
x: 0.8, y: 3.15, w: 3.5, h: 0.12, fill: { color: C.gray200 }
});
s8.addText('(각 Mailable에서 제공하는 본문)', {
x: 0.8, y: 3.4, w: 3.9, h: 0.3,
fontSize: 7, italic: true, color: C.gray400, fontFace: 'Arial'
});
// 푸터 영역
s8.addShape(pres.ShapeType.rect, {
x: 0.5, y: 4.2, w: 4.5, h: 0.01, fill: { color: C.gray300 }
});
s8.addText('{{ 회사명 }} | {{ 주소 }} | {{ 연락처 }}', {
x: 0.8, y: 4.3, w: 3.9, h: 0.25,
fontSize: 7, color: C.gray500, align: 'center', fontFace: 'Arial'
});
s8.addText('"SAM 시스템에서 발송된 메일입니다"', {
x: 0.8, y: 4.55, w: 3.9, h: 0.25,
fontSize: 7, italic: true, color: C.gray400, align: 'center', fontFace: 'Arial'
});
// 라벨
// 화살표 + 라벨
s8.addText('헤더 ─', { x: 4.1, y: 1.55, w: 1, h: 0.3, fontSize: 8, color: C.blue, align: 'right', fontFace: 'Arial' });
s8.addText('본문 ─', { x: 4.1, y: 2.9, w: 1, h: 0.3, fontSize: 8, color: C.blue, align: 'right', fontFace: 'Arial' });
s8.addText('푸터 ─', { x: 4.1, y: 4.35, w: 1, h: 0.3, fontSize: 8, color: C.blue, align: 'right', fontFace: 'Arial' });
// 오른쪽: 브랜딩 요소 카드
s8.addText('커스터마이징 요소', {
x: 5.3, y: 1.3, w: 4, h: 0.35,
fontSize: 12, bold: true, color: C.navy, fontFace: 'Arial'
});
const brandItems = [
{ icon: 'IMG', label: '로고 이미지', key: 'options.branding.logo_url', def: 'SAM BI 로고' },
{ icon: 'Co.', label: '회사명', key: 'options.branding.company_name', def: '(주)코드브릿지엑스' },
{ icon: 'Adr', label: '주소', key: 'options.branding.company_address', def: '(없음)' },
{ icon: 'Tel', label: '연락처', key: 'options.branding.company_phone', def: '(없음)' },
{ icon: 'CLR', label: '테마 컬러', key: 'options.branding.primary_color', def: '#1a56db' },
{ icon: 'Txt', label: '푸터 문구', key: 'options.branding.footer_text', def: 'SAM 시스템에서...' },
];
brandItems.forEach((bi, i) => {
const by = 1.8 + i * 0.52;
s8.addShape(pres.ShapeType.roundRect, {
x: 5.3, y: by, w: 4.2, h: 0.42, rectRadius: 0.06,
fill: { color: C.gray100 }
});
s8.addShape(pres.ShapeType.roundRect, {
x: 5.4, y: by + 0.06, w: 0.45, h: 0.3, rectRadius: 0.04,
fill: { color: C.blue }
});
s8.addText(bi.icon, {
x: 5.4, y: by + 0.06, w: 0.45, h: 0.3,
fontSize: 7, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
s8.addText(bi.label, {
x: 5.95, y: by, w: 1.2, h: 0.42,
fontSize: 9, bold: true, color: C.navy, valign: 'middle', fontFace: 'Arial'
});
s8.addText(bi.def, {
x: 7.3, y: by, w: 2, h: 0.42,
fontSize: 7, color: C.gray500, valign: 'middle', fontFace: 'Arial'
});
});
addFooter(s8, 8, TOTAL);
// ==============================
// 슬라이드 9: 코드 전환 예시
// ==============================
const s9 = pres.addSlide();
addPageHeader(s9, '코드 전환 예시', 'Before (직접 발송) → After (서비스 경유)');
// Before 코드
s9.addText('BEFORE (현재)', {
x: 0.5, y: 1.3, w: 4.3, h: 0.35,
fontSize: 11, bold: true, color: C.red, fontFace: 'Arial'
});
s9.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 1.7, w: 4.3, h: 1.6, rectRadius: 0.1,
fill: { color: C.navy }
});
s9.addText([
{ text: '// 컨트롤러에서 직접 발송\n', options: { color: C.gray500, fontSize: 8 } },
{ text: 'Mail', options: { color: C.blueLight, fontSize: 9, bold: true } },
{ text: '::', options: { color: C.gray400, fontSize: 9 } },
{ text: 'to', options: { color: C.green, fontSize: 9 } },
{ text: '($signer->email)\n', options: { color: C.gray300, fontSize: 9 } },
{ text: ' ->', options: { color: C.gray400, fontSize: 9 } },
{ text: 'send', options: { color: C.green, fontSize: 9 } },
{ text: '(\n ', options: { color: C.gray400, fontSize: 9 } },
{ text: 'new ', options: { color: C.purple, fontSize: 9 } },
{ text: 'EsignRequestMail', options: { color: C.amber, fontSize: 9 } },
{ text: '(...)\n );', options: { color: C.gray300, fontSize: 9 } },
], { x: 0.7, y: 1.8, w: 3.9, h: 1.4, fontFace: 'Courier New', lineSpacingMultiple: 1.4 });
// 문제점
s9.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 3.45, w: 4.3, h: 0.7, rectRadius: 0.06,
fill: { color: C.redBg }, line: { color: C.red, width: 0.5 }
});
s9.addText([
{ text: 'X ', options: { bold: true, color: C.red, fontSize: 8 } },
{ text: '항상 .env SMTP 사용\n', options: { color: C.gray600, fontSize: 8 } },
{ text: 'X ', options: { bold: true, color: C.red, fontSize: 8 } },
{ text: '발송 기록 없음 / 쿼터 확인 없음', options: { color: C.gray600, fontSize: 8 } },
], { x: 0.7, y: 3.5, w: 3.9, h: 0.6, fontFace: 'Arial', lineSpacingMultiple: 1.3 });
// 화살표
s9.addText('>>>', {
x: 4.5, y: 2.2, w: 1, h: 0.5,
fontSize: 20, bold: true, color: C.blue, align: 'center', fontFace: 'Arial'
});
// After 코드
s9.addText('AFTER (전환 후)', {
x: 5.2, y: 1.3, w: 4.3, h: 0.35,
fontSize: 11, bold: true, color: C.green, fontFace: 'Arial'
});
s9.addShape(pres.ShapeType.roundRect, {
x: 5.2, y: 1.7, w: 4.3, h: 1.6, rectRadius: 0.1,
fill: { color: C.navy }
});
s9.addText([
{ text: '// TenantMailService 경유\n', options: { color: C.gray500, fontSize: 8 } },
{ text: 'app', options: { color: C.blueLight, fontSize: 9 } },
{ text: '(TenantMailService', options: { color: C.amber, fontSize: 9 } },
{ text: '::class)\n', options: { color: C.gray300, fontSize: 9 } },
{ text: ' ->', options: { color: C.gray400, fontSize: 9 } },
{ text: 'send', options: { color: C.green, fontSize: 9, bold: true } },
{ text: '(\n', options: { color: C.gray400, fontSize: 9 } },
{ text: ' mailable: ', options: { color: C.purple, fontSize: 8 } },
{ text: 'new EsignRequestMail(...),\n', options: { color: C.gray300, fontSize: 8 } },
{ text: ' to: ', options: { color: C.purple, fontSize: 8 } },
{ text: '$signer->email\n );', options: { color: C.gray300, fontSize: 8 } },
], { x: 5.4, y: 1.8, w: 3.9, h: 1.4, fontFace: 'Courier New', lineSpacingMultiple: 1.4 });
// 장점
s9.addShape(pres.ShapeType.roundRect, {
x: 5.2, y: 3.45, w: 4.3, h: 0.7, rectRadius: 0.06,
fill: { color: C.greenBg }, line: { color: C.green, width: 0.5 }
});
s9.addText([
{ text: 'O ', options: { bold: true, color: C.green, fontSize: 8 } },
{ text: '테넌트 SMTP 자동 적용 + 브랜딩 주입\n', options: { color: C.gray600, fontSize: 8 } },
{ text: 'O ', options: { bold: true, color: C.green, fontSize: 8 } },
{ text: 'mail_logs 자동 기록 + 쿼터 확인', options: { color: C.gray600, fontSize: 8 } },
], { x: 5.4, y: 3.5, w: 3.9, h: 0.6, fontFace: 'Arial', lineSpacingMultiple: 1.3 });
// 하단 주의사항
s9.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 4.35, w: 9, h: 0.65, rectRadius: 0.08,
fill: { color: C.amberBg }, line: { color: C.amber, width: 0.5 }
});
s9.addText([
{ text: 'tenantId 자동 감지: ', options: { bold: true, color: C.amber, fontSize: 9 } },
{ text: 'TenantScope와 동일하게 세션/헤더에서 tenant_id를 자동 추출합니다. 명시적 전달도 가능합니다.', options: { color: C.gray600, fontSize: 9 } },
], { x: 0.7, y: 4.4, w: 8.6, h: 0.55, valign: 'middle', fontFace: 'Arial' });
addFooter(s9, 9, TOTAL);
// ==============================
// 슬라이드 10: 보안 규칙
// ==============================
const s10 = pres.addSlide();
addPageHeader(s10, '보안 규칙', 'SMTP 자격증명, 개인정보, 테넌트 격리');
// 필수 준수
s10.addText('필수 준수 사항', {
x: 0.5, y: 1.25, w: 4.3, h: 0.35,
fontSize: 12, bold: true, color: C.green, fontFace: 'Arial'
});
const mustDo = [
'SMTP 비밀번호는 encrypt()로 암호화 저장',
'mail_logs에 메일 본문 저장 금지 (메타데이터만)',
'급여명세서 등 민감 메일은 related_model/related_id만 기록',
'tenant_mail_configs 조회 시 TenantScope 자동 적용',
'API 응답에 SMTP 비밀번호 노출 금지 ($hidden 처리)',
];
mustDo.forEach((item, i) => {
const iy = 1.7 + i * 0.48;
s10.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: iy, w: 4.3, h: 0.38, rectRadius: 0.06,
fill: { color: C.greenBg }
});
s10.addText([
{ text: 'O ', options: { bold: true, color: C.green, fontSize: 10 } },
{ text: item, options: { color: C.gray700, fontSize: 8 } },
], { x: 0.7, y: iy, w: 3.9, h: 0.38, valign: 'middle', fontFace: 'Arial' });
});
// 금지 사항
s10.addText('금지 사항', {
x: 5.2, y: 1.25, w: 4.3, h: 0.35,
fontSize: 12, bold: true, color: C.red, fontFace: 'Arial'
});
const mustNot = [
'Mail::to() 직접 호출 (TenantMailService 사용)',
'.env SMTP에 운영 크리덴셜 하드코딩',
'타 테넌트 mail_logs 조회 (TenantScope로 방지)',
'이메일 본문에 비밀번호 평문 포함 (임시비번 제외)',
];
mustNot.forEach((item, i) => {
const iy = 1.7 + i * 0.48;
s10.addShape(pres.ShapeType.roundRect, {
x: 5.2, y: iy, w: 4.3, h: 0.38, rectRadius: 0.06,
fill: { color: C.redBg }
});
s10.addText([
{ text: 'X ', options: { bold: true, color: C.red, fontSize: 10 } },
{ text: item, options: { color: C.gray700, fontSize: 8 } },
], { x: 5.4, y: iy, w: 3.9, h: 0.38, valign: 'middle', fontFace: 'Arial' });
});
// 하단: 암호화 흐름
s10.addShape(pres.ShapeType.roundRect, {
x: 0.5, y: 4.1, w: 9, h: 0.9, rectRadius: 0.1,
fill: { color: C.navy }
});
s10.addText('SMTP 자격증명 암호화 흐름', {
x: 0.7, y: 4.15, w: 5, h: 0.3,
fontSize: 10, bold: true, color: C.blueLight, fontFace: 'Arial'
});
s10.addText([
{ text: '저장: ', options: { color: C.gray400, fontSize: 9 } },
{ text: 'encrypt($password)', options: { color: C.green, fontSize: 9, bold: true } },
{ text: ' → DB에 암호화된 문자열 저장 ', options: { color: C.gray400, fontSize: 9 } },
{ text: '조회: ', options: { color: C.gray400, fontSize: 9 } },
{ text: 'decrypt($encrypted)', options: { color: C.amber, fontSize: 9, bold: true } },
{ text: ' → SMTP 설정 시 복호화 사용', options: { color: C.gray400, fontSize: 9 } },
], { x: 0.7, y: 4.5, w: 8.6, h: 0.4, fontFace: 'Courier New' });
addFooter(s10, 10, TOTAL);
// ==============================
// 슬라이드 11: 구현 로드맵
// ==============================
const s11 = pres.addSlide();
addPageHeader(s11, '구현 로드맵', '4단계 점진적 구현 계획');
const phases = [
{
num: '1', title: '기반 구축', priority: '필수',
color: C.blue, bg: 'eff6ff', prColor: C.red,
items: [
'tenant_mail_configs 마이그레이션',
'mail_logs 마이그레이션',
'TenantMailService 생성',
'TenantMailConfig / MailLog 모델',
]
},
{
num: '2', title: '기존 전환', priority: '필수',
color: C.green, bg: C.greenBg, prColor: C.red,
items: [
'5개 Mailable → TenantMailService 경유',
'API EsignRequestMail 중복 제거',
'sync/queue 모드 적용',
'Fallback 전략 구현',
]
},
{
num: '3', title: '브랜딩', priority: '중요',
color: C.amber, bg: C.amberBg, prColor: C.amber,
items: [
'공통 레이아웃 Blade 생성',
'테넌트별 로고/컬러/서명 적용',
'MNG 메일 설정 관리 화면',
'도메인 검증 가이드',
]
},
{
num: '4', title: '고급 기능', priority: '권장',
color: C.purple, bg: C.purpleBg, prColor: C.green,
items: [
'실패 재시도 (queue retry)',
'바운스 처리',
'발송 통계 대시보드',
'Amazon SES / Mailgun 연동',
]
},
];
phases.forEach((phase, i) => {
const px = 0.5 + i * 2.3;
// 카드 배경
s11.addShape(pres.ShapeType.roundRect, {
x: px, y: 1.3, w: 2.1, h: 3.6, rectRadius: 0.1,
fill: { color: phase.bg }, line: { color: phase.color, width: 1 }
});
// Phase 넘버
s11.addShape(pres.ShapeType.ellipse, {
x: px + 0.15, y: 1.4, w: 0.45, h: 0.45,
fill: { color: phase.color }
});
s11.addText(phase.num, {
x: px + 0.15, y: 1.4, w: 0.45, h: 0.45,
fontSize: 16, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
// 제목
s11.addText(phase.title, {
x: px + 0.7, y: 1.42, w: 1.2, h: 0.4,
fontSize: 13, bold: true, color: phase.color, fontFace: 'Arial'
});
// 우선순위 배지
s11.addShape(pres.ShapeType.roundRect, {
x: px + 0.15, y: 1.95, w: 0.65, h: 0.22, rectRadius: 0.04,
fill: { color: phase.prColor }
});
s11.addText(phase.priority, {
x: px + 0.15, y: 1.95, w: 0.65, h: 0.22,
fontSize: 7, bold: true, color: C.white,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
// 항목들
phase.items.forEach((item, j) => {
const iy = 2.35 + j * 0.58;
s11.addShape(pres.ShapeType.roundRect, {
x: px + 0.1, y: iy, w: 1.9, h: 0.48, rectRadius: 0.06,
fill: { color: C.white }
});
s11.addText(item, {
x: px + 0.2, y: iy, w: 1.7, h: 0.48,
fontSize: 7.5, color: C.gray700, valign: 'middle', fontFace: 'Arial'
});
});
// 화살표 (마지막 제외)
if (i < 3) {
s11.addText('>', {
x: px + 2.1, y: 2.8, w: 0.2, h: 0.4,
fontSize: 16, bold: true, color: C.gray400,
align: 'center', valign: 'middle', fontFace: 'Arial'
});
}
});
addFooter(s11, 11, TOTAL);
// ==============================
// 슬라이드 12: Q&A
// ==============================
const s12 = pres.addSlide();
s12.background = { fill: C.navy };
s12.addShape(pres.ShapeType.rect, {
x: 0, y: 0, w: 10, h: 0.08, fill: { color: C.blue }
});
// BI 로고
s12.addImage({
path: biLogoPath,
x: 4.0, y: 1.2, w: 2.0, h: 0.85
});
s12.addText('Q & A', {
x: 1, y: 2.3, w: 8, h: 0.8,
fontSize: 42, bold: true, color: C.white,
align: 'center', fontFace: 'Arial'
});
s12.addText('질문 및 논의 사항을 공유해 주세요', {
x: 1, y: 3.2, w: 8, h: 0.5,
fontSize: 14, color: C.gray400,
align: 'center', fontFace: 'Arial'
});
// 구분선
s12.addShape(pres.ShapeType.rect, {
x: 3.5, y: 3.9, w: 3, h: 0.03, fill: { color: C.navyMid }
});
// 참조 문서
s12.addText([
{ text: '기술 문서: ', options: { color: C.gray500, fontSize: 9 } },
{ text: 'docs/dev/standards/email-policy.md', options: { color: C.blueLight, fontSize: 9 } },
], { x: 1, y: 4.2, w: 8, h: 0.3, align: 'center', fontFace: 'Arial' });
s12.addText('(주)코드브릿지엑스', {
x: 1, y: 4.8, w: 8, h: 0.3,
fontSize: 9, color: C.gray500,
align: 'center', fontFace: 'Arial'
});
// === 저장 ===
const outputPath = '/home/aweso/sam/docs/presentations/sam-email-policy.pptx';
await pres.writeFile({ fileName: outputPath });
console.log('PPTX created:', outputPath);
}
main().catch(console.error);