- 이메일 발송 정책 기술문서 (dev/standards/email-policy.md) - 개발팀 설명용 PPTX 12슬라이드 (presentations/sam-email-policy.pptx) - INDEX.md에 이메일 정책 문서 등록
1084 lines
44 KiB
JavaScript
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);
|