diff --git a/INDEX.md b/INDEX.md index cd6a342..cdb46a5 100644 --- a/INDEX.md +++ b/INDEX.md @@ -16,6 +16,7 @@ | Git 커밋 | `dev/standards/git-conventions.md` | 커밋 메시지, 브랜치 전략 | | 품질 검증 | `dev/standards/quality-checklist.md` | 코드 품질 체크리스트 | | Swagger | `dev/guides/swagger-guide.md` | API 문서 작성법 | +| 이메일 | `dev/standards/email-policy.md` | 멀티테넌시 이메일 정책 | | 품목관리 | `rules/item-policy.md` | 품목 정책 | | 단가관리 | `rules/pricing-policy.md` | 원가/판매가, 리비전 | | 견적관리 | `features/quotes/README.md` | 견적 시스템, BOM 계산 | @@ -108,6 +109,7 @@ DB 도메인별: | [pagination-policy.md](dev/standards/pagination-policy.md) | 페이지네이션 표준 | | [options-column-policy.md](dev/standards/options-column-policy.md) | JSON options 컬럼 정책 | | [pdf-font-policy.md](dev/standards/pdf-font-policy.md) | PDF 생성 시 폰트 정책 (DomPDF) | +| [email-policy.md](dev/standards/email-policy.md) | 멀티테넌시 이메일 발송 정책 | --- diff --git a/dev/standards/email-policy.md b/dev/standards/email-policy.md new file mode 100644 index 0000000..30a2737 --- /dev/null +++ b/dev/standards/email-policy.md @@ -0,0 +1,411 @@ +# 이메일 발송 정책 (멀티테넌시) + +> **작성일**: 2026-03-11 +> **상태**: 설계 확정 + +--- + +## 1. 개요 + +### 1.1 목적 + +SAM 멀티테넌시 환경에서 이메일 발송의 테넌트 격리, 브랜딩, 발송 추적, 쿼터 관리를 위한 표준 정책을 정의한다. + +### 1.2 핵심 원칙 + +| 원칙 | 설명 | +|------|------| +| 🔴 테넌트 격리 | 테넌트 A의 메일 설정/발송 기록이 테넌트 B에 노출되지 않는다 | +| 🔴 중앙 서비스 경유 | 모든 메일 발송은 `TenantMailService`를 경유한다 | +| 🟡 테넌트 브랜딩 | 발신자명, 로고, 서명을 테넌트별로 커스터마이징한다 | +| 🟡 발송 기록 | 모든 발송은 `mail_logs` 테이블에 기록한다 | +| 🟢 쿼터 관리 | 테넌트별 일일 발송 한도를 관리한다 | + +### 1.3 현재 상태 (AS-IS) + +``` +문제점: +├── 단일 SMTP (.env 고정) → Gmail 일일 500건 제한에 전체 영향 +├── 단일 발신 주소 (develop@codebridge-x.com) → 테넌트 구분 불가 +├── EsignRequestMail 중복 (API + MNG 양쪽에 존재) +├── 발송 기록 없음 → 추적/감사 불가 +├── Mail::to() 직접 호출 → 테넌트 설정 적용 불가 +└── 템플릿 하드코딩 → 테넌트별 브랜딩 불가 +``` + +--- + +## 2. 아키텍처 + +### 2.1 3-Layer 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Layer 1: 테넌트 메일 설정 (tenant_mail_configs) │ +│ SMTP 설정, 발신자 주소, 브랜딩 정보 │ +├─────────────────────────────────────────────────────────┤ +│ Layer 2: 메일 발송 서비스 (TenantMailService) │ +│ 테넌트 설정 자동 적용, 큐 발송, Fallback │ +├─────────────────────────────────────────────────────────┤ +│ Layer 3: 발송 기록 (mail_logs) │ +│ 발송 이력, 상태 추적, 일일 쿼터 관리 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 발송 흐름 + +``` +Controller / Service + │ + ▼ +TenantMailService::send($mailable, $to, $tenantId?) + │ + ├── 1. 테넌트 설정 조회 (tenant_mail_configs) + │ └── 미설정 시 플랫폼 기본 SMTP 사용 + │ + ├── 2. 쿼터 확인 (일일 발송 한도) + │ └── 초과 시 예외 발생 + 관리자 알림 + │ + ├── 3. Mailer 동적 구성 + │ ├── SMTP host/port/user/pass 설정 + │ ├── from address/name 설정 + │ └── 브랜딩 데이터 주입 + │ + ├── 4. 발송 모드 결정 + │ ├── sync: OTP, 비밀번호 (시간 민감) + │ └── queue: 나머지 전부 + │ + ├── 5. mail_logs 기록 (status: queued/sent) + │ + └── 6. Fallback (자체 SMTP 실패 시) + └── 플랫폼 기본 SMTP로 재시도 +``` + +--- + +## 3. 테이블 설계 + +### 3.1 `tenant_mail_configs` (테넌트 메일 설정) + +> **마이그레이션 위치**: `/home/aweso/sam/api/database/migrations/` + +```sql +CREATE TABLE tenant_mail_configs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + provider ENUM('platform', 'smtp', 'ses', 'mailgun') DEFAULT 'platform', + from_address VARCHAR(255) NOT NULL COMMENT '발신 이메일', + from_name VARCHAR(255) NOT NULL COMMENT '발신자명', + reply_to VARCHAR(255) NULL COMMENT '회신 주소', + is_verified BOOLEAN DEFAULT FALSE COMMENT '도메인 검증 여부', + daily_limit INT UNSIGNED DEFAULT 500 COMMENT '일일 발송 한도', + is_active BOOLEAN DEFAULT TRUE, + options JSON NULL COMMENT 'SMTP 설정, 브랜딩 정보', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + + UNIQUE KEY uq_tenant_mail_configs (tenant_id), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) COMMENT '테넌트 메일 설정'; +``` + +**`options` JSON 구조**: + +```json +{ + "smtp": { + "host": "smtp.example.com", + "port": 587, + "username": "user@example.com", + "password": "", + "encryption": "tls" + }, + "branding": { + "logo_url": "/storage/tenants/1/logo.png", + "primary_color": "#1a56db", + "company_name": "테넌트 회사명", + "company_address": "서울시 강남구...", + "company_phone": "02-1234-5678", + "footer_text": "본 메일은 SAM 시스템에서 발송되었습니다." + }, + "ses": { + "key": "", + "secret": "", + "region": "ap-northeast-2" + } +} +``` + +> **보안**: `options.smtp.password`, `options.ses.key`, `options.ses.secret`은 `encrypt()` / `decrypt()`로 저장/조회한다. + +### 3.2 `mail_logs` (발송 기록) + +```sql +CREATE TABLE mail_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + mailable_type VARCHAR(100) NOT NULL COMMENT 'Mailable 클래스명', + to_address VARCHAR(255) NOT NULL COMMENT '수신자', + from_address VARCHAR(255) NOT NULL COMMENT '발신자', + subject VARCHAR(500) NOT NULL COMMENT '제목', + status ENUM('queued', 'sent', 'failed', 'bounced') DEFAULT 'queued', + sent_at TIMESTAMP NULL COMMENT '발송 시각', + options JSON NULL COMMENT '에러 메시지, 재시도 횟수, 관련 모델', + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + INDEX idx_mail_logs_tenant_status (tenant_id, status), + INDEX idx_mail_logs_tenant_date (tenant_id, created_at), + INDEX idx_mail_logs_mailable (tenant_id, mailable_type), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) COMMENT '메일 발송 기록'; +``` + +**`options` JSON 구조**: + +```json +{ + "error_message": "Connection refused", + "retry_count": 2, + "related_model": "App\\Models\\ESign\\EsignContract", + "related_id": 123, + "provider_used": "smtp", + "fallback_used": true +} +``` + +> **개인정보 보호**: `mail_logs`에 메일 본문(body)은 저장하지 않는다. 메타데이터만 기록한다. + +--- + +## 4. 서비스 설계 + +### 4.1 `TenantMailService` + +> **위치**: `/home/aweso/sam/api/app/Services/Mail/TenantMailService.php` +> MNG에서도 동일 패턴의 서비스를 생성한다. + +```php +class TenantMailService +{ + /** + * 테넌트 설정을 적용하여 메일 발송 + * + * @param Mailable $mailable 발송할 Mailable 인스턴스 + * @param string|array $to 수신자 + * @param int|null $tenantId 테넌트 ID (null이면 현재 테넌트) + * @param bool $sync 즉시 발송 여부 (기본: false = queue) + */ + public function send( + Mailable $mailable, + string|array $to, + ?int $tenantId = null, + bool $sync = false + ): MailLog; +} +``` + +### 4.2 발송 모드 기준 + +| 모드 | Mailable | 사유 | +|------|----------|------| +| **sync** (즉시) | `EsignOtpMail` | OTP 시간 제한 | +| **sync** (즉시) | `UserPasswordMail` | 즉시 로그인 필요 | +| **queue** (큐) | `EsignRequestMail` | 비동기 가능 | +| **queue** (큐) | `EsignCompletedMail` | 비동기 가능 | +| **queue** (큐) | `PayslipMail` | 대량 발송 가능 | + +### 4.3 Fallback 전략 + +``` +테넌트 자체 SMTP 발송 시도 + │ + ├── 성공 → mail_logs (status: sent) + │ + └── 실패 → 플랫폼 기본 SMTP로 재시도 + │ + ├── 성공 → mail_logs (status: sent, fallback_used: true) + │ + └── 실패 → mail_logs (status: failed) + └── 3회까지 자동 재시도 (queue retry) +``` + +--- + +## 5. 메일 타입 정리 + +### 5.1 현재 Mailable 목록 + +| Mailable | 위치 | 트리거 | 비고 | +|----------|------|--------|------| +| `EsignRequestMail` | API + MNG (중복) | 서명 요청 | 🔴 중복 제거 필요 | +| `EsignOtpMail` | MNG | OTP 인증 | | +| `EsignCompletedMail` | MNG | 서명 완료 | | +| `UserPasswordMail` | MNG | 계정 생성/비번 초기화 | | +| `PayslipMail` | MNG | 급여명세서 | | + +### 5.2 Mailable 위치 정리 방향 + +``` +❌ 현재: API와 MNG에 중복 존재 +✅ 목표: 비즈니스 로직은 API, MNG는 관리자 트리거만 + +API (app/Mail/) +├── EsignRequestMail.php ← API에서 통합 관리 +├── EsignOtpMail.php +├── EsignCompletedMail.php +├── UserPasswordMail.php +└── PayslipMail.php + +MNG → API의 Mailable을 HTTP API로 호출 + 또는 공유 패키지로 분리 +``` + +> **현실적 방향**: 현재 MNG에서 직접 DB 접근하므로, MNG의 Mailable을 유지하되 `TenantMailService` 경유로 통일한다. API의 중복 `EsignRequestMail`은 제거한다. + +--- + +## 6. 템플릿 브랜딩 + +### 6.1 공통 레이아웃 + +``` +┌─ emails.layouts.tenant ──────────────────────────┐ +│ │ +│ ┌─ 헤더 ──────────────────────────────────────┐ │ +│ │ [테넌트 로고] 테넌트명 │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 본문 ──────────────────────────────────────┐ │ +│ │ @yield('content') │ │ +│ │ (각 Mailable에서 제공) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 푸터 ──────────────────────────────────────┐ │ +│ │ {{ 회사명 }} | {{ 주소 }} | {{ 연락처 }} │ │ +│ │ "SAM 시스템에서 발송된 메일입니다" │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────┘ +``` + +### 6.2 브랜딩 요소 + +| 요소 | 저장 위치 | 기본값 | +|------|----------|--------| +| 로고 이미지 | `options.branding.logo_url` | SAM BI 로고 | +| 회사명 | `options.branding.company_name` | (주)코드브릿지엑스 | +| 주소 | `options.branding.company_address` | — | +| 연락처 | `options.branding.company_phone` | — | +| 테마 컬러 | `options.branding.primary_color` | `#1a56db` | +| 푸터 문구 | `options.branding.footer_text` | "SAM 시스템에서 발송된 메일입니다" | + +--- + +## 7. 쿼터 관리 + +### 7.1 쿼터 정책 + +| 항목 | 기본값 | 설명 | +|------|--------|------| +| 일일 발송 한도 | 500건 | `tenant_mail_configs.daily_limit` | +| 경고 임계치 | 80% (400건) | 도달 시 관리자 알림 | +| 초과 시 동작 | 발송 차단 + 예외 | `MailQuotaExceededException` | + +### 7.2 쿼터 확인 + +```php +// mail_logs에서 오늘 발송 건수 조회 +$todayCount = MailLog::where('tenant_id', $tenantId) + ->whereDate('created_at', today()) + ->whereIn('status', ['queued', 'sent']) + ->count(); + +if ($todayCount >= $config->daily_limit) { + throw new MailQuotaExceededException($tenantId); +} +``` + +--- + +## 8. 보안 규칙 + +### 8.1 필수 준수 사항 + +``` +✅ SMTP 비밀번호는 encrypt()로 암호화 저장 +✅ mail_logs에 메일 본문 저장 금지 (메타데이터만) +✅ 급여명세서 등 민감 메일은 related_model/related_id만 기록 +✅ tenant_mail_configs 조회 시 TenantScope 자동 적용 +✅ API 응답에 SMTP 비밀번호 노출 금지 (hidden 처리) +``` + +### 8.2 금지 사항 + +``` +❌ Mail::to() 직접 호출 금지 → TenantMailService 사용 +❌ .env SMTP 설정에 운영 크리덴셜 하드코딩 금지 +❌ 타 테넌트 mail_logs 조회 금지 (TenantScope로 방지) +❌ 이메일 본문에 비밀번호 평문 포함 금지 (임시 비밀번호 제외) +``` + +--- + +## 9. 구현 단계 + +| Phase | 범위 | 주요 작업 | 우선순위 | +|-------|------|----------|---------| +| **Phase 1** | 기반 구축 | `tenant_mail_configs` + `mail_logs` 마이그레이션, `TenantMailService` 생성, 모델 생성 | 🔴 필수 | +| **Phase 2** | 기존 전환 | 현재 5개 Mailable을 `TenantMailService` 경유로 변경, API `EsignRequestMail` 중복 제거 | 🔴 필수 | +| **Phase 3** | 브랜딩 | 공통 레이아웃 생성, 테넌트별 로고/컬러/서명 적용, MNG 관리 화면 | 🟡 중요 | +| **Phase 4** | 고급 기능 | 실패 재시도, 바운스 처리, 발송 통계 대시보드, SES/Mailgun 연동 | 🟢 권장 | + +--- + +## 10. SMTP 제공자 비교 + +| 제공자 | 일일 한도 | 비용 | 적합 시점 | +|--------|---------|------|----------| +| Gmail SMTP | 500건 | 무료 | 현재 (소규모) | +| Amazon SES | 무제한 | $0.10/1,000건 | 테넌트 10개+ | +| Mailgun | 5,000건/월 무료 | $0.80/1,000건 | 중규모 | +| 자체 SMTP | 무제한 | 서버 비용 | 테넌트 자체 운영 | + +> **권장**: Phase 1~2는 Gmail SMTP 유지, Phase 4에서 Amazon SES 전환 검토 + +--- + +## 11. 기존 코드 전환 가이드 + +### 11.1 Before (현재) + +```php +// MNG 컨트롤러에서 직접 발송 +Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer)); +``` + +### 11.2 After (전환 후) + +```php +// TenantMailService 경유 +app(TenantMailService::class)->send( + mailable: new EsignRequestMail($contract, $signer), + to: $signer->email, + // tenantId는 자동 감지 (현재 세션 기반) +); +``` + +--- + +## 관련 문서 + +- [테넌트 DB 구조](../../system/database/tenants.md) +- [전자서명 기능](../../features/esign/README.md) +- [급여관리 기능](../../features/finance/payroll.md) +- [API 개발 규칙](api-rules.md) +- [options JSON 컬럼 정책](options-column-policy.md) + +--- + +**최종 업데이트**: 2026-03-11 diff --git a/presentations/email-policy-convert.cjs b/presentations/email-policy-convert.cjs new file mode 100644 index 0000000..46890f9 --- /dev/null +++ b/presentations/email-policy-convert.cjs @@ -0,0 +1,1083 @@ +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); diff --git a/presentations/sam-email-policy.pptx b/presentations/sam-email-policy.pptx new file mode 100644 index 0000000..78e8ac5 Binary files /dev/null and b/presentations/sam-email-policy.pptx differ