Frappe 인스턴스별 이메일 서버 및 관리 앱 구축 기획서
문서: prego-email-server-per-tenant-plan.md
버전: Draft v1.0
작성일: 2026-04-14
상태: Planning
English {#english}
1. Executive Summary
This document outlines a plan to build tenant-isolated email sending infrastructure on Cloudflare, replacing or augmenting the current centralized Resend-based approach. The goal is to provide per-tenant email domain customization, improved deliverability isolation, and centralized monitoring while maintaining the existing PREGO platform architecture.
2. Current State Analysis
2.1 Existing Email Paths
| Path | Purpose | Implementation | Location |
|---|---|---|---|
| Trial/OTP | Trial signup verification, manual approval OTP | prego-control-plane → Resend API | src/email/ |
| Scheduled automation Output | Delivering Scheduled automation execution results | prego_ai → Resend API | workers/src/services/output-delivery.ts |
| Frappe Business | DocType-triggered business emails (invoices, notifications) | prego_saas → Zuplo POST /email → Queue Worker | prego-zuplo/config/26-email.oas.json |
2.2 Current Limitations
- Shared Reputation: All tenants share Resend sender reputation — one tenant’s spam affects others
- No Custom Domains: All emails sent from
@pregoi.comor@mail.pregoi.com - Limited Frappe Integration: Frappe Email Queue bypassed; custom hook required
- Centralized SPOF: Resend API outage affects all email delivery
- No Per-Tenant Analytics: Cannot isolate open rates, bounces, complaints by tenant
3. Proposed Architecture
3.1 Architecture Diagram
flowchart TB
subgraph tenants["Tenant Frappe Instances"]
T1["Tenant A<br/>Frappe + prego_saas"]
T2["Tenant B<br/>Frappe + prego_saas"]
T3["Tenant C<br/>Frappe + prego_saas"]
end
subgraph gateway["Edge Layer"]
Zuplo["Zuplo Gateway<br/>POST /email<br/>POST /email/batch"]
end
subgraph central["Central Email Platform"]
EW["Email Router Worker<br/>(Hono)"]
Queue["Cloudflare Queue<br/>(email-outbound)"]
Consumer["Email Consumer Worker"]
D1["D1: email_logs<br/>email_templates<br/>tenant_email_config"]
end
subgraph delivery["Delivery Layer"]
CF["Cloudflare Email Routing<br/>(per-tenant subdomain)"]
SES["AWS SES<br/>(fallback)"]
Resend["Resend<br/>(fallback)"]
end
subgraph analytics["Analytics"]
R2["R2: email_attachments<br/>delivery_receipts"]
Webhook["Webhook Receiver<br/>/email/webhook"]
end
T1 --> Zuplo
T2 --> Zuplo
T3 --> Zuplo
Zuplo --> EW
EW --> Queue
Queue --> Consumer
Consumer --> CF
Consumer --> SES
Consumer --> Resend
Consumer --> D1
CF --> Webhook
SES --> Webhook
Webhook --> D1
EW --> R2
3.2 Component Responsibilities
| Component | Responsibility | Binding/Storage |
|---|---|---|
| Email Router Worker | Validate request, apply templates, route to queue | D1 (config), R2 (attachments) |
| Email Queue | Buffer outbound messages, handle backpressure | Cloudflare Queue |
| Email Consumer Worker | Actual delivery via CF Email/SES/Resend | D1 (logs), KV (rate limits) |
| Webhook Receiver | Process delivery receipts, bounces, complaints | D1 (update logs) |
| Email Admin UI | Template management, tenant config, analytics dashboard | React (admin-web /email/*) |
4. Data Model
4.1 D1 Schema
-- Migration: 0030_email_platform.sql
-- Tenant email configurationCREATE TABLE tenant_email_config ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL UNIQUE, sender_domain TEXT, -- e.g., "mail.tenant-a.pregoi.com" sender_name TEXT DEFAULT 'Prego', reply_to TEXT, provider TEXT DEFAULT 'cloudflare', -- cloudflare | ses | resend provider_config TEXT, -- JSON: API keys, region, etc. dkim_verified INTEGER DEFAULT 0, spf_verified INTEGER DEFAULT 0, dmarc_verified INTEGER DEFAULT 0, daily_limit INTEGER DEFAULT 1000, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);
-- Email templates (tenant-scoped or global)CREATE TABLE email_templates ( id TEXT PRIMARY KEY, tenant_id TEXT, -- NULL = global template name TEXT NOT NULL, -- e.g., "invoice_created" subject TEXT NOT NULL, html_body TEXT NOT NULL, text_body TEXT, variables TEXT, -- JSON: expected variables locale TEXT DEFAULT 'en', version INTEGER DEFAULT 1, active INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(tenant_id, name, locale, version));
-- Email send logsCREATE TABLE email_logs ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, message_id TEXT UNIQUE, -- Provider message ID template_id TEXT, recipient TEXT NOT NULL, subject TEXT NOT NULL, status TEXT DEFAULT 'queued', -- queued | sent | delivered | bounced | complained | failed provider TEXT, provider_response TEXT, sent_at TEXT, delivered_at TEXT, opened_at TEXT, clicked_at TEXT, bounced_at TEXT, bounce_type TEXT, -- hard | soft bounce_reason TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, trace_id TEXT -- Optional funnel correlation);
-- Daily send counts (for rate limiting)CREATE TABLE email_daily_stats ( tenant_id TEXT NOT NULL, date TEXT NOT NULL, -- YYYY-MM-DD sent_count INTEGER DEFAULT 0, delivered_count INTEGER DEFAULT 0, bounced_count INTEGER DEFAULT 0, complained_count INTEGER DEFAULT 0, PRIMARY KEY (tenant_id, date));
-- IndexesCREATE INDEX idx_email_logs_tenant_status ON email_logs(tenant_id, status);CREATE INDEX idx_email_logs_created ON email_logs(created_at);CREATE INDEX idx_email_logs_message_id ON email_logs(message_id);4.2 Zod Schemas (@platform/contracts)
import { z } from 'zod';
export const emailRecipientSchema = z.object({ email: z.string().email(), name: z.string().optional(),});
export const emailSendRequestSchema = z.object({ to: z.union([emailRecipientSchema, z.array(emailRecipientSchema)]), cc: z.array(emailRecipientSchema).optional(), bcc: z.array(emailRecipientSchema).optional(), subject: z.string().min(1).max(998), template: z.string().optional(), // Template name variables: z.record(z.unknown()).optional(), // Template variables html: z.string().optional(), // Raw HTML (if no template) text: z.string().optional(), // Plain text fallback attachments: z.array(z.object({ filename: z.string(), content: z.string(), // Base64 or R2 key contentType: z.string().optional(), })).optional(), replyTo: emailRecipientSchema.optional(), headers: z.record(z.string()).optional(), traceId: z.string().max(64).optional(), // Funnel correlation});
export const emailBatchRequestSchema = z.object({ messages: z.array(emailSendRequestSchema).min(1).max(100),});
export const emailStatusSchema = z.enum([ 'queued', 'sent', 'delivered', 'bounced', 'complained', 'failed',]);
export const emailLogSchema = z.object({ id: z.string(), tenantId: z.string(), messageId: z.string().nullable(), recipient: z.string().email(), subject: z.string(), status: emailStatusSchema, provider: z.string().nullable(), sentAt: z.string().datetime().nullable(), deliveredAt: z.string().datetime().nullable(), bouncedAt: z.string().datetime().nullable(), bounceType: z.enum(['hard', 'soft']).nullable(), createdAt: z.string().datetime(),});
export const emailWebhookPayloadSchema = z.object({ type: z.enum(['delivered', 'bounced', 'complained', 'opened', 'clicked']), messageId: z.string(), recipient: z.string().email(), timestamp: z.string().datetime(), metadata: z.record(z.unknown()).optional(),});5. API Design
5.1 OpenAPI Routes (prego-zuplo)
# config/36-email-platform.oas.json (new file)
paths: /email: post: operationId: sendEmail summary: Send a single email description: | Send an email using tenant's configured provider. Rate limited to 50/min per tenant API key. tags: [Email] security: - apiKey: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailSendRequest' responses: '202': description: Email queued for delivery content: application/json: schema: $ref: '#/components/schemas/EmailQueuedResponse' '400': description: Invalid request '429': description: Rate limit exceeded
/email/batch: post: operationId: sendEmailBatch summary: Send multiple emails description: | Send up to 100 emails in a single request. Each message counts against rate limit. tags: [Email] security: - apiKey: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailBatchRequest' responses: '202': description: Emails queued for delivery
/email/{messageId}: get: operationId: getEmailStatus summary: Get email delivery status tags: [Email] security: - apiKey: [] parameters: - name: messageId in: path required: true schema: type: string responses: '200': description: Email status content: application/json: schema: $ref: '#/components/schemas/EmailLog'
/email/webhook: post: operationId: emailWebhook summary: Receive delivery webhooks from providers description: | Internal endpoint for Cloudflare Email Routing, SES SNS notifications, and Resend webhooks. tags: [Email] security: - internalSig: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EmailWebhookPayload' responses: '200': description: Webhook processed5.2 Internal Admin Routes (Worker-only, not Zuplo)
| Route | Method | Purpose |
|---|---|---|
/internal/email/templates | GET/POST | List/create templates |
/internal/email/templates/:id | GET/PUT/DELETE | Template CRUD |
/internal/email/config/:tenantId | GET/PATCH | Tenant email config |
/internal/email/stats | GET | Aggregate statistics |
/internal/email/logs | GET | Query email logs |
6. Implementation Phases
Phase 1: Central Queue Enhancement (2 sprints)
Scope:
- Enhance existing
POST /emailto use Cloudflare Queue - Add D1 logging (
email_logstable) - Basic template support (D1
email_templates) - Keep Resend as primary provider
Deliverables:
- D1 migration
0030_email_platform.sql - Email Router Worker (
workers/email-router/) - Email Consumer Worker (Queue consumer)
@platform/contractsemail schemas- Admin-web
/email/logspage
Success Criteria:
- All existing email paths migrated to queue
- Email logs queryable in admin console
- No increase in delivery failure rate
Phase 2: Multi-Provider Support (2 sprints)
Scope:
- Add AWS SES as secondary provider
- Cloudflare Email Routing for custom domains
- Tenant-level provider configuration
- Webhook receiver for delivery receipts
Deliverables:
- SES integration (
email-consumer/providers/ses.ts) - Cloudflare Email integration
- Webhook endpoint
/email/webhook - Admin-web
/email/configpage - DNS verification flow
Success Criteria:
- Tenant can configure custom sender domain
- Automatic failover Resend → SES
- Bounce/complaint tracking in D1
Phase 3: Per-Tenant Isolation (Optional, Enterprise)
Scope:
- Dedicated Email Workers per tenant
- Separate Cloudflare Email Routing zones
- Tenant-specific rate limits and quotas
- Advanced analytics (opens, clicks)
Deliverables:
- Terraform/Pulumi for tenant email zone
- Per-tenant Worker deployment script
- Tenant isolation verification tests
- Enterprise pricing tier integration
Success Criteria:
- Tenant A spam does not affect Tenant B deliverability
- Independent DKIM/SPF verification per tenant
- SLA-backed delivery metrics
7. Frappe Integration
7.1 prego_saas Hooks
doc_events = { "Communication": { "after_insert": "prego_saas.email.hooks.send_via_prego_email" }}
# Disable default email queueemail_brand_image = None # Use template from central platform7.2 Email Hook Implementation
import frappeimport requestsfrom frappe.utils import get_url
def send_via_prego_email(doc, method): """ Intercept Frappe Communication and route to Prego Email Platform. Replaces default Email Queue behavior. """ if doc.communication_type != "Communication": return
if doc.sent_or_received != "Sent": return
# Get tenant API key from site config api_key = frappe.conf.get("prego_gateway_api_key") gateway_url = frappe.conf.get("prego_gateway_url", "https://api.pregoi.com")
if not api_key: frappe.log_error("Prego Gateway API key not configured", "Email Hook") return
# Build email payload payload = { "to": {"email": doc.recipients, "name": doc.recipient_name}, "subject": doc.subject, "html": doc.content, "replyTo": {"email": doc.sender} if doc.sender else None, }
# Add attachments if present if doc.attachments: payload["attachments"] = [] for attachment in doc.attachments: file_doc = frappe.get_doc("File", {"file_url": attachment}) payload["attachments"].append({ "filename": file_doc.file_name, "content": file_doc.get_content().encode("base64"), "contentType": file_doc.content_type, })
# Send to Prego Email Platform try: response = requests.post( f"{gateway_url}/email", json=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, timeout=10, )
if response.status_code == 202: result = response.json() doc.db_set("message_id", result.get("messageId")) doc.db_set("delivery_status", "Queued") else: frappe.log_error(f"Email send failed: {response.text}", "Email Hook") doc.db_set("delivery_status", "Error")
except Exception as e: frappe.log_error(f"Email send exception: {str(e)}", "Email Hook") doc.db_set("delivery_status", "Error")8. DNS and Domain Setup
8.1 Wildcard Domain Strategy
# Email subdomain pattern: mail.{tenant_short_id}.pregoi.com
# DNS Records (Cloudflare Zone)*.mail.pregoi.com CNAME email.cloudflare.netmail.pregoi.com MX 10 route1.mx.cloudflare.netmail.pregoi.com MX 20 route2.mx.cloudflare.netmail.pregoi.com TXT "v=spf1 include:_spf.mx.cloudflare.net ~all"
# Per-tenant DKIM (added during provisioning)tenant-a._domainkey.mail.pregoi.com TXT "v=DKIM1; k=rsa; p=..."8.2 Provisioning Flow
sequenceDiagram
participant Admin as Admin UI
participant CP as Control Plane
participant DNS as Cloudflare DNS API
participant Email as Email Router
Admin->>CP: Enable email for tenant-a
CP->>DNS: Create DKIM TXT record
CP->>DNS: Verify SPF/DMARC
DNS-->>CP: Records created
CP->>Email: Update tenant_email_config
Email-->>Admin: Email enabled, domain verified
9. Monitoring and Alerting
9.1 Metrics to Track
| Metric | Source | Alert Threshold |
|---|---|---|
| Queue depth | Cloudflare Queue metrics | > 10,000 messages |
| Delivery rate | D1 email_logs aggregation | < 95% in 1 hour |
| Bounce rate | D1 per-tenant | > 5% in 24 hours |
| Complaint rate | D1 per-tenant | > 0.1% in 24 hours |
| Provider errors | Worker logs | > 10/minute |
9.2 Admin Dashboard Pages
| Page | Route | Features |
|---|---|---|
| Email Overview | /cp/email | Global stats, queue status |
| Tenant Config | /cp/email/tenants/:id | Per-tenant settings, domain verification |
| Templates | /cp/email/templates | CRUD, preview, versioning |
| Logs | /cp/email/logs | Search, filter, export |
| Analytics | /cp/email/analytics | Open/click rates, deliverability |
10. Security Considerations
10.1 API Security
- Rate Limiting: 50 emails/minute per tenant API key (configurable)
- Payload Validation: Strict Zod schema validation
- Attachment Limits: Max 10 attachments, 25MB total per email
- Template Injection: Sanitize variables before template rendering
- Internal Sig: Webhook endpoints verify
X-Internal-Sigheader
10.2 Data Protection
- PII in Logs: Email addresses stored, subject/body truncated after 30 days
- Encryption: R2 attachments encrypted at rest
- Access Control: Admin routes require
cp_internal_keyBearer token - Audit Log: All template changes logged with actor and timestamp
11. Cost Estimation
| Component | Free Tier | Estimated Monthly (10K emails/day) |
|---|---|---|
| Cloudflare Email | 100K/month | Free |
| Cloudflare Queue | 1M ops | ~$0.40 |
| D1 Storage | 5GB | ~$0.75/GB over |
| R2 Attachments | 10GB | ~$0.015/GB |
| AWS SES (fallback) | - | ~$1/1K emails |
| Total | - | ~$15-30/month |
12. Rollback Plan
If critical issues arise:
- Immediate: Disable Queue consumer, route directly to Resend
- Short-term: Revert
prego_saashooks to Frappe default Email Queue - Data: Email logs preserved in D1 for audit
13. Success Metrics
| Metric | Current | Target |
|---|---|---|
| Delivery Success Rate | ~97% | > 99% |
| Mean Delivery Time | Unknown | < 30 seconds |
| Bounce Rate | Unknown | < 2% |
| Per-Tenant Visibility | None | Full |
| Custom Domain Support | No | Yes |
한국어 {#korean}
1. 개요
이 문서는 현재 중앙 집중형 Resend 기반 이메일 시스템을 Cloudflare 기반의 테넌트 격리형 이메일 인프라로 확장하거나 대체하기 위한 상세 기획서입니다.
2. 현재 상태
2.1 기존 이메일 경로
| 경로 | 용도 | 구현 |
|---|---|---|
| Trial/OTP | 트라이얼 가입 인증, 수동 승인 OTP | prego-control-plane → Resend |
| Scheduled automation 출력 | Scheduled automation 실행 결과 전달 | prego_ai → Resend |
| Frappe 비즈니스 | DocType 트리거 비즈니스 이메일 | prego_saas → Zuplo → Queue Worker |
2.2 현재 한계
- 공유 평판: 모든 테넌트가 Resend 발신 평판 공유
- 커스텀 도메인 없음: 모든 이메일이
@pregoi.com에서 발송 - Frappe 통합 제한: Frappe Email Queue 우회 필요
- 중앙 SPOF: Resend API 장애 시 전체 이메일 중단
- 테넌트별 분석 없음: 오픈율, 반송률 등 개별 추적 불가
3. 제안 아키텍처
위 영어 섹션의 Mermaid 다이어그램 참조.
핵심 컴포넌트:
| 컴포넌트 | 책임 |
|---|---|
| Email Router Worker | 요청 검증, 템플릿 적용, 큐 라우팅 |
| Email Queue | 발송 메시지 버퍼링, 백프레셔 처리 |
| Email Consumer Worker | CF Email/SES/Resend 통한 실제 전송 |
| Webhook Receiver | 전송 확인, 반송, 불만 처리 |
| Email Admin UI | 템플릿 관리, 테넌트 설정, 분석 대시보드 |
4. 구현 단계
Phase 1: 중앙 큐 강화 (2 스프린트)
- 기존
POST /email을 Cloudflare Queue 사용으로 전환 - D1 로깅 추가 (
email_logs테이블) - 기본 템플릿 지원
- Resend를 주 프로바이더로 유지
Phase 2: 멀티 프로바이더 지원 (2 스프린트)
- AWS SES를 보조 프로바이더로 추가
- Cloudflare Email Routing으로 커스텀 도메인 지원
- 테넌트 레벨 프로바이더 설정
- 웹훅 수신기로 전송 확인 처리
Phase 3: 테넌트별 격리 (선택, 엔터프라이즈)
- 테넌트별 전용 Email Worker
- 별도의 Cloudflare Email Routing 존
- 테넌트별 레이트 리밋 및 쿼터
- 고급 분석 (오픈, 클릭)
5. Frappe 통합
prego_saas의 hooks.py에서 Communication DocType의 after_insert 이벤트를 가로채어 Prego Email Platform으로 라우팅합니다. 기본 Frappe Email Queue는 비활성화됩니다.
6. 보안 고려사항
- 레이트 리밋: 테넌트 API 키당 분당 50 이메일
- 페이로드 검증: 엄격한 Zod 스키마 검증
- 첨부 파일 제한: 최대 10개, 총 25MB
- 템플릿 인젝션: 렌더링 전 변수 새니타이즈
- 내부 서명: 웹훅 엔드포인트는
X-Internal-Sig헤더 검증
7. 비용 추정
| 컴포넌트 | 무료 티어 | 예상 월간 (일 1만 이메일) |
|---|---|---|
| Cloudflare Email | 월 10만 | 무료 |
| Cloudflare Queue | 100만 ops | ~$0.40 |
| D1 스토리지 | 5GB | ~$0.75/GB 초과분 |
| R2 첨부파일 | 10GB | ~$0.015/GB |
| AWS SES (폴백) | - | ~$1/1K 이메일 |
| 합계 | - | ~$15-30/월 |
8. 성공 지표
| 지표 | 현재 | 목표 |
|---|---|---|
| 전송 성공률 | ~97% | > 99% |
| 평균 전송 시간 | 미측정 | < 30초 |
| 반송률 | 미측정 | < 2% |
| 테넌트별 가시성 | 없음 | 완전 |
| 커스텀 도메인 | 미지원 | 지원 |