Skip to content

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

PathPurposeImplementationLocation
Trial/OTPTrial signup verification, manual approval OTPprego-control-plane → Resend APIsrc/email/
Scheduled automation OutputDelivering Scheduled automation execution resultsprego_ai → Resend APIworkers/src/services/output-delivery.ts
Frappe BusinessDocType-triggered business emails (invoices, notifications)prego_saas → Zuplo POST /email → Queue Workerprego-zuplo/config/26-email.oas.json

2.2 Current Limitations

  1. Shared Reputation: All tenants share Resend sender reputation — one tenant’s spam affects others
  2. No Custom Domains: All emails sent from @pregoi.com or @mail.pregoi.com
  3. Limited Frappe Integration: Frappe Email Queue bypassed; custom hook required
  4. Centralized SPOF: Resend API outage affects all email delivery
  5. 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

ComponentResponsibilityBinding/Storage
Email Router WorkerValidate request, apply templates, route to queueD1 (config), R2 (attachments)
Email QueueBuffer outbound messages, handle backpressureCloudflare Queue
Email Consumer WorkerActual delivery via CF Email/SES/ResendD1 (logs), KV (rate limits)
Webhook ReceiverProcess delivery receipts, bounces, complaintsD1 (update logs)
Email Admin UITemplate management, tenant config, analytics dashboardReact (admin-web /email/*)

4. Data Model

4.1 D1 Schema

-- Migration: 0030_email_platform.sql
-- Tenant email configuration
CREATE 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 logs
CREATE 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)
);
-- Indexes
CREATE 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)

packages/contracts/src/email.ts
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 processed

5.2 Internal Admin Routes (Worker-only, not Zuplo)

RouteMethodPurpose
/internal/email/templatesGET/POSTList/create templates
/internal/email/templates/:idGET/PUT/DELETETemplate CRUD
/internal/email/config/:tenantIdGET/PATCHTenant email config
/internal/email/statsGETAggregate statistics
/internal/email/logsGETQuery email logs

6. Implementation Phases

Phase 1: Central Queue Enhancement (2 sprints)

Scope:

  • Enhance existing POST /email to use Cloudflare Queue
  • Add D1 logging (email_logs table)
  • Basic template support (D1 email_templates)
  • Keep Resend as primary provider

Deliverables:

  1. D1 migration 0030_email_platform.sql
  2. Email Router Worker (workers/email-router/)
  3. Email Consumer Worker (Queue consumer)
  4. @platform/contracts email schemas
  5. Admin-web /email/logs page

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:

  1. SES integration (email-consumer/providers/ses.ts)
  2. Cloudflare Email integration
  3. Webhook endpoint /email/webhook
  4. Admin-web /email/config page
  5. 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:

  1. Terraform/Pulumi for tenant email zone
  2. Per-tenant Worker deployment script
  3. Tenant isolation verification tests
  4. 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

prego_saas/hooks.py
doc_events = {
"Communication": {
"after_insert": "prego_saas.email.hooks.send_via_prego_email"
}
}
# Disable default email queue
email_brand_image = None # Use template from central platform

7.2 Email Hook Implementation

prego_saas/email/hooks.py
import frappe
import requests
from 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

pregoi.com
# Email subdomain pattern: mail.{tenant_short_id}.pregoi.com
# DNS Records (Cloudflare Zone)
*.mail.pregoi.com CNAME email.cloudflare.net
mail.pregoi.com MX 10 route1.mx.cloudflare.net
mail.pregoi.com MX 20 route2.mx.cloudflare.net
mail.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

MetricSourceAlert Threshold
Queue depthCloudflare Queue metrics> 10,000 messages
Delivery rateD1 email_logs aggregation< 95% in 1 hour
Bounce rateD1 per-tenant> 5% in 24 hours
Complaint rateD1 per-tenant> 0.1% in 24 hours
Provider errorsWorker logs> 10/minute

9.2 Admin Dashboard Pages

PageRouteFeatures
Email Overview/cp/emailGlobal stats, queue status
Tenant Config/cp/email/tenants/:idPer-tenant settings, domain verification
Templates/cp/email/templatesCRUD, preview, versioning
Logs/cp/email/logsSearch, filter, export
Analytics/cp/email/analyticsOpen/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-Sig header

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_key Bearer token
  • Audit Log: All template changes logged with actor and timestamp

11. Cost Estimation

ComponentFree TierEstimated Monthly (10K emails/day)
Cloudflare Email100K/monthFree
Cloudflare Queue1M ops~$0.40
D1 Storage5GB~$0.75/GB over
R2 Attachments10GB~$0.015/GB
AWS SES (fallback)-~$1/1K emails
Total-~$15-30/month

12. Rollback Plan

If critical issues arise:

  1. Immediate: Disable Queue consumer, route directly to Resend
  2. Short-term: Revert prego_saas hooks to Frappe default Email Queue
  3. Data: Email logs preserved in D1 for audit

13. Success Metrics

MetricCurrentTarget
Delivery Success Rate~97%> 99%
Mean Delivery TimeUnknown< 30 seconds
Bounce RateUnknown< 2%
Per-Tenant VisibilityNoneFull
Custom Domain SupportNoYes

한국어 {#korean}

1. 개요

이 문서는 현재 중앙 집중형 Resend 기반 이메일 시스템을 Cloudflare 기반의 테넌트 격리형 이메일 인프라로 확장하거나 대체하기 위한 상세 기획서입니다.

2. 현재 상태

2.1 기존 이메일 경로

경로용도구현
Trial/OTP트라이얼 가입 인증, 수동 승인 OTPprego-control-plane → Resend
Scheduled automation 출력Scheduled automation 실행 결과 전달prego_ai → Resend
Frappe 비즈니스DocType 트리거 비즈니스 이메일prego_saas → Zuplo → Queue Worker

2.2 현재 한계

  1. 공유 평판: 모든 테넌트가 Resend 발신 평판 공유
  2. 커스텀 도메인 없음: 모든 이메일이 @pregoi.com에서 발송
  3. Frappe 통합 제한: Frappe Email Queue 우회 필요
  4. 중앙 SPOF: Resend API 장애 시 전체 이메일 중단
  5. 테넌트별 분석 없음: 오픈율, 반송률 등 개별 추적 불가

3. 제안 아키텍처

위 영어 섹션의 Mermaid 다이어그램 참조.

핵심 컴포넌트:

컴포넌트책임
Email Router Worker요청 검증, 템플릿 적용, 큐 라우팅
Email Queue발송 메시지 버퍼링, 백프레셔 처리
Email Consumer WorkerCF 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_saashooks.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 Queue100만 ops~$0.40
D1 스토리지5GB~$0.75/GB 초과분
R2 첨부파일10GB~$0.015/GB
AWS SES (폴백)-~$1/1K 이메일
합계-~$15-30/월

8. 성공 지표

지표현재목표
전송 성공률~97%> 99%
평균 전송 시간미측정< 30초
반송률미측정< 2%
테넌트별 가시성없음완전
커스텀 도메인미지원지원

Help