Email Campaign Management - Complete Workflow
Purpose: Reusable workflow for creating, tracking, and managing email marketing campaigns with conversion tracking, reminder emails, and trial activation.
Scope: Growth marketing, email campaigns, conversion tracking, trial activation, campaign analytics
Campaign Database Schema
campaigns table
CODEBLOCK0
campaign_clicks table
CREATE TABLE campaign_clicks (
id UUID PRIMARY KEY,
campaign_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
click_source VARCHAR(100), -- e.g., "cta_button", "events_click"
utm_source VARCHAR(100),
utm_medium VARCHAR(100),
utm_campaign VARCHAR(100),
converted BOOLEAN DEFAULT FALSE,
converted_at TIMESTAMP,
user_id UUID,
created_at TIMESTAMP DEFAULT NOW()
);
Campaign Statistics Query Patterns
Get Campaign Overview
CODEBLOCK2
Get Registered vs Non-Registered
SELECT
COUNT(CASE WHEN u.id IS NOT NULL THEN 1 END) as registered_users,
COUNT(CASE WHEN u.id IS NULL THEN 1 END) as non_registered_users,
COUNT(CASE WHEN s.tier != 'free' AND s.status = 'active' THEN 1 END) as already_paid_users
FROM campaign_clicks cc
LEFT JOIN users u ON u.email = cc.email
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE cc.campaign_id = 'campaign-id-here'
AND cc.converted = FALSE;
Resend API Integration
Rate Limiting (CRITICAL)
Resend Free Tier: 2 requests per second (NOT 100/minute!)
Correct Implementation:
CODEBLOCK4
WRONG Implementation (causes 429 errors):
CODEBLOCK5
Email Sending Function
import os
import requests
RESEND_API_KEY = os.getenv("RESEND_API_KEY")
RESEND_API_URL = "https://api.resend.com/emails"
FROM_EMAIL = "no-reply@ainative.studio"
def send_campaign_email(email: str, campaign_id: str, template_html: str):
"""Send campaign email via Resend."""
# Replace template variables
html = template_html.replace("{{email}}", email)
html = html.replace("{{campaign_id}}", campaign_id)
payload = {
"from": FROM_EMAIL,
"to": [email],
"subject": "Your campaign subject here",
"html": html,
"tags": [
{"name": "campaign", "value": campaign_id},
{"name": "type", "value": "reminder"}
]
}
response = requests.post(
RESEND_API_URL,
headers={
"Authorization": f"Bearer {RESEND_API_KEY}",
"Content-Type": "application/json"
},
json=payload,
timeout=10
)
if response.status_code in [200, 201]:
return True, response.json().get("id")
else:
return False, response.text
Trial Activation Workflow
Activate Trial (CRITICAL: Use UPPERCASE Enums)
def activate_trial(user, campaign_id: str, trial_days: int, plan_name: str):
"""Activate ENTERPRISE trial for user."""
trial_end = datetime.utcnow() + timedelta(days=trial_days)
# CRITICAL: PostgreSQL enums are UPPERCASE
# 'enterprise' → 'ENTERPRISE'
# 'active' NOT 'trial' (trial status doesn't exist)
if user['subscription_id']:
# Update existing subscription
cur.execute("""
UPDATE subscriptions
SET tier = 'ENTERPRISE',
status = 'active',
trial_ends_at = %s,
plan_name = %s,
plan_price = 0,
current_period_end = %s,
updated_at = NOW()
WHERE id = %s
""", (trial_end, plan_name, trial_end, user['subscription_id']))
else:
# Create new subscription
cur.execute("""
INSERT INTO subscriptions (
user_id, tier, status,
trial_ends_at, plan_name, plan_price,
billing_email, start_date,
current_period_start, current_period_end,
max_users, max_projects, monthly_token_limit,
max_ai_requests_per_day, max_models,
created_at, updated_at
) VALUES (
%s, 'ENTERPRISE', 'active',
%s, %s, 0,
%s, NOW(),
NOW(), %s,
10, 15, 1000000,
10000, 100,
NOW(), NOW()
)
""", (
str(user['user_id']),
trial_end,
plan_name,
user['email'],
trial_end
))
# Mark click as converted
cur.execute("""
UPDATE campaign_clicks
SET converted = TRUE,
converted_at = NOW(),
user_id = %s
WHERE id = %s
""", (str(user['user_id']), user['click_id']))
conn.commit()
Common Pitfalls & Solutions
PITFALL 1: Case-Sensitive Enums
Problem:
'enterprise' fails with "invalid input value for enum"
Solution: Always use UPPERCASE:
'ENTERPRISE',
'SCALE', INLINECODE5
PITFALL 2: Invalid Status Values
Problem: Using
status = 'trial' (doesn't exist)
Solution: Use
status = 'active' with
trial_ends_at field
PITFALL 3: Rate Limiting Too High
Problem: Setting rate limit to 100/minute causes 429 errors
Solution: Use 2 per second (0.5s delay between sends)
PITFALL 4: Missing Organization ID
Problem: Some users don't have
organization_id, causing NULL constraint errors
Solution: Check if
organization_id exists before INSERT, use separate query if NULL
PITFALL 5: Hardcoded Email in Templates
Problem: Test email in production template
Solution: Use
{{email}} template variable, replace at send time
PITFALL 6: Not Closing DB Connections
Problem: "too many clients already" error
Solution: Always use try/finally to close connections
Email Template Requirements
Template Structure
All campaign email templates MUST be stored in:
CODEBLOCK8
Example filenames:
- -
ny2026_gift.html - Initial campaign email - INLINECODE13 - Reminder for non-registered users
- INLINECODE14 - Welcome email
Required Template Variables
CODEBLOCK9
Styling Standards (Dark Theme)
/* Base colors */
body {
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #131726;
}
.email-container {
background-color: #22263c;
border-radius: 16px;
}
/* Header gradient */
.header {
background: linear-gradient(135deg, #4B6FED 0%, #5867EF 100%);
padding: 50px 40px;
}
/* CTA Button */
.cta-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
padding: 18px 48px;
border-radius: 12px;
}
Quick Reference Commands
Check Campaign Stats
CODEBLOCK11
Send Reminder Campaign
railway run -s "AINative- Core -Production" \
python3 scripts/send_{campaign_id}_reminder.py --yes
End-to-End Campaign Checklist
- - [ ] Create campaign record in database
- [ ] Add campaign to backend
VALID_CAMPAIGNS config - [ ] Create email templates (gift + reminder)
- [ ] Test email send to yourself
- [ ] Deploy backend changes to Railway
- [ ] Launch initial campaign
- [ ] Monitor click tracking (first 24h)
- [ ] Check conversion rate (day 3)
- [ ] Send reminder emails (day 7)
- [ ] Activate trials for registered users
- [ ] Generate final campaign report
邮件活动管理 - 完整工作流程
目的: 用于创建、跟踪和管理电子邮件营销活动的可复用工作流程,包含转化跟踪、提醒邮件和试用激活功能。
范围: 增长营销、邮件活动、转化跟踪、试用激活、活动分析
活动数据库架构
campaigns 表
sql
CREATE TABLE campaigns (
id UUID PRIMARY KEY,
campaign_id VARCHAR(255) UNIQUE NOT NULL, -- 例如:ny2026, summer2026
name VARCHAR(255) NOT NULL,
description TEXT,
trial_days INTEGER,
tier VARCHAR(50), -- ENTERPRISE, SCALE 等
plan_name TEXT,
offer
expiresat TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
campaign_clicks 表
sql
CREATE TABLE campaign_clicks (
id UUID PRIMARY KEY,
campaign_id VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
click
source VARCHAR(100), -- 例如:ctabutton, events_click
utm_source VARCHAR(100),
utm_medium VARCHAR(100),
utm_campaign VARCHAR(100),
converted BOOLEAN DEFAULT FALSE,
converted_at TIMESTAMP,
user_id UUID,
created_at TIMESTAMP DEFAULT NOW()
);
活动统计查询模式
获取活动概览
sql
SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT email) as unique_users,
COUNT(CASE WHEN converted = TRUE THEN 1 END) as converted_count,
COUNT(CASE WHEN converted = FALSE THEN 1 END) as pending_count,
ROUND(100.0
COUNT(CASE WHEN converted = TRUE THEN 1 END) / COUNT(), 2) as conversion_rate
FROM campaign_clicks
WHERE campaign_id = campaign-id-here;
获取已注册与未注册用户
sql
SELECT
COUNT(CASE WHEN u.id IS NOT NULL THEN 1 END) as registered_users,
COUNT(CASE WHEN u.id IS NULL THEN 1 END) as non
registeredusers,
COUNT(CASE WHEN s.tier != free AND s.status = active THEN 1 END) as already
paidusers
FROM campaign_clicks cc
LEFT JOIN users u ON u.email = cc.email
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE cc.campaign_id = campaign-id-here
AND cc.converted = FALSE;
Resend API 集成
速率限制(关键)
Resend 免费套餐: 每秒 2 个请求(不是每分钟 100 个!)
正确实现:
python
import time
RATELIMITDELAY = 0.5 # 每秒 2 封邮件
for email in emails:
send_email(email)
if i < len(emails):
time.sleep(RATELIMITDELAY) # 等待 0.5 秒
错误实现(导致 429 错误):
python
不要这样做 - 发送太快
RATE_LIMIT = 100 # 每分钟邮件数
for email in emails:
send_email(email)
# 每 100 封邮件才暂停一次 - 太晚了!
if i % RATE_LIMIT == 0:
time.sleep(60)
邮件发送函数
python
import os
import requests
RESENDAPIKEY = os.getenv(RESENDAPIKEY)
RESENDAPIURL = https://api.resend.com/emails
FROM_EMAIL = no-reply@ainative.studio
def sendcampaignemail(email: str, campaignid: str, templatehtml: str):
通过 Resend 发送活动邮件。
# 替换模板变量
html = template_html.replace({{email}}, email)
html = html.replace({{campaignid}}, campaignid)
payload = {
from: FROM_EMAIL,
to: [email],
subject: 您的活动主题在此,
html: html,
tags: [
{name: campaign, value: campaign_id},
{name: type, value: reminder}
]
}
response = requests.post(
RESENDAPIURL,
headers={
Authorization: fBearer {RESENDAPIKEY},
Content-Type: application/json
},
json=payload,
timeout=10
)
if response.status_code in [200, 201]:
return True, response.json().get(id)
else:
return False, response.text
试用激活工作流程
激活试用(关键:使用大写枚举)
python
def activate
trial(user, campaignid: str, trial
days: int, planname: str):
为用户激活 ENTERPRISE 试用。
trialend = datetime.utcnow() + timedelta(days=trialdays)
# 关键:PostgreSQL 枚举是大写的
# enterprise → ENTERPRISE
# active 而非 trial(trial 状态不存在)
if user[subscription_id]:
# 更新现有订阅
cur.execute(
UPDATE subscriptions
SET tier = ENTERPRISE,
status = active,
trialendsat = %s,
plan_name = %s,
plan_price = 0,
currentperiodend = %s,
updated_at = NOW()
WHERE id = %s
, (trialend, planname, trialend, user[subscriptionid]))
else:
# 创建新订阅
cur.execute(
INSERT INTO subscriptions (
user_id, tier, status,
trialendsat, planname, planprice,
billingemail, startdate,
currentperiodstart, currentperiodend,
maxusers, maxprojects, monthlytokenlimit,
maxairequestsperday, max_models,
createdat, updatedat
) VALUES (
%s, ENTERPRISE, active,
%s, %s, 0,
%s, NOW(),
NOW(), %s,
10, 15, 1000000,
10000, 100,
NOW(), NOW()
)
, (
str(user[user_id]),
trial_end,
plan_name,
user[email],
trial_end
))
# 标记点击为已转化
cur.execute(
UPDATE campaign_clicks
SET converted = TRUE,
converted_at = NOW(),
user_id = %s
WHERE id = %s
, (str(user[userid]), user[clickid]))
conn.commit()
常见陷阱与解决方案
陷阱 1:大小写敏感的枚举
问题: enterprise 失败,报错 invalid input value for enum
解决方案: 始终使用大写:ENTERPRISE、SCALE、FREE
陷阱 2:无效的状态值
问题: 使用 status = trial(不存在)
解决方案: 使用 status = active 配合 trial
endsat 字段
陷阱 3:速率限制设置过高
问题: 将速率限制设置为每分钟 100 次导致 429 错误
解决方案: 使用每秒 2 次(发送间隔 0.5 秒)
陷阱 4:缺少组织 ID
问题: 部分用户没有 organization_id,导致 NULL 约束错误
解决方案: 在 INSERT 前检查 organization_id 是否存在,若为 NULL 则使用单独查询
陷阱 5:模板中硬编码邮件地址
问题: 生产模板中包含测试邮件地址
解决方案: 使用 {{email}} 模板变量,发送时替换
陷阱 6:未关闭数据库连接
问题: too many clients already 错误
解决方案: 始终使用 try/finally 关闭连接
邮件模板要求
模板结构
所有活动邮件模板必须存储在:
src/backend/app/services/templates/{campaignid}{type}.html
文件名示例:
- - ny2026gift.html - 初始活动邮件
- ny2026reminder.html - 未注册用户提醒邮件
- summer2026_welcome.html - 欢迎邮件
必需的模板变量
html
领取您的试用
样式标准(深色主题)
css
/
基础颜色 /
body {
font-family: Poppins, -apple-system, BlinkMac