docs: [email] 멀티테넌시 이메일 정책 문서 및 발표자료 추가
- 이메일 발송 정책 기술문서 (dev/standards/email-policy.md) - 개발팀 설명용 PPTX 12슬라이드 (presentations/sam-email-policy.pptx) - INDEX.md에 이메일 정책 문서 등록
This commit is contained in:
2
INDEX.md
2
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) | 멀티테넌시 이메일 발송 정책 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
411
dev/standards/email-policy.md
Normal file
411
dev/standards/email-policy.md
Normal file
@@ -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": "<encrypted>",
|
||||
"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": "<encrypted>",
|
||||
"secret": "<encrypted>",
|
||||
"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
|
||||
1083
presentations/email-policy-convert.cjs
Normal file
1083
presentations/email-policy-convert.cjs
Normal file
File diff suppressed because it is too large
Load Diff
BIN
presentations/sam-email-policy.pptx
Normal file
BIN
presentations/sam-email-policy.pptx
Normal file
Binary file not shown.
Reference in New Issue
Block a user