@zlr_236/email-marketing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @zlr_236/email-marketing
2
+
3
+ Professional Email Marketing extension for OpenClaw.
4
+
5
+ ## Features
6
+ - **Bulk Sender**: Send personalized emails from Excel with HTML templates.
7
+ - **Anti-Spam**: Built-in random fingerprinting and human-like delay.
8
+ - **Auto-Reply**: Automated FAQ-based email responses with language detection.
9
+
10
+ ## Installation
11
+ ```bash
12
+ openclaw plugins install @zlr_236/email-marketing
13
+ ```
14
+
15
+ ## Configuration
16
+ Requires environment variables:
17
+ - `EMAIL_SMTP_HOST`, `EMAIL_SMTP_PORT`, `EMAIL_SMTP_USER`, `EMAIL_SMTP_PASS`
18
+ - `EMAIL_IMAP_HOST`, `EMAIL_IMAP_PORT`
19
+ - `EMAIL_EXCEL_PATH`, `EMAIL_HTML_PATH`, `EMAIL_TITLE_PATH`
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@zlr_236/email-marketing",
3
+ "version": "1.0.0",
4
+ "description": "Professional Email Marketing & Auto-Reply extension for OpenClaw. Features: Bulk Sending, HTML Rendering, Gmail Anti-Spam, and FAQ-based Auto-Reply.",
5
+ "main": "index.js",
6
+ "keywords": ["openclaw", "email-marketing", "automation", "bulk-sender"],
7
+ "author": "zhenglr",
8
+ "license": "MIT",
9
+ "dependencies": {
10
+ "pandas": "*",
11
+ "openpyxl": "*"
12
+ }
13
+ }
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: email-marketing
3
+ description: 执行邮件营销任务,包括从桌面 Excel 读取名单、TXT 读取标题、HTML 读取正文并群发,以及统计回信和退信率。适用于需要通过网易邮箱进行自动化达人营销或客户开发的场景。
4
+ ---
5
+
6
+ # 邮件营销 Skill (Email Marketing)
7
+
8
+ 本 Skill 专门为哥哥定制,用于稳健地执行网易邮箱营销任务。
9
+
10
+ ## 核心功能
11
+
12
+ 1. **个性化群发**:自动读取桌面 `邮箱.xlsx`,根据每行数据动态替换 HTML 中的 `【变量名】` 占位符,实现一对一精准营销。
13
+ 2. **测试发信**:在正式群发前,向指定邮箱发送测试邮件。
14
+ 3. **效果统计**:实时检查今日的发信、回信、退信并分析原因。
15
+
16
+ ## 资源依赖
17
+
18
+ * **名单**:`/Users/admin/Desktop/邮箱.xlsx` (自动识别首行标题。支持读取多列数据,如 `kol name`, `gender` 等,用于内容替换)
19
+ * **标题**:`/Users/admin/Desktop/邮件标题.txt`
20
+ * **内容**:`/Users/admin/Desktop/邮件内容.html`
21
+
22
+ ## 操作指南
23
+
24
+ ### 1. 测试发送
25
+ 执行测试以检查标题和 HTML 渲染是否正确。测试邮件将发送至 `EMAIL_TEST_TARGET` 环境变量配置的邮箱。
26
+ `python3 final_sender.py`
27
+
28
+ ### 2. 执行全量群发
29
+ 确认测试无误后,执行正式发送。
30
+ `python3 final_sender.py run`
31
+
32
+ ### 3. 查看今日统计
33
+ 检查当天的发送、回信和退信情况。输出必须保持极度简洁。
34
+ `python3 check_replies.py`
35
+ **输出格式要求**:
36
+ - 发送:[数字]
37
+ - 失败:[数字]
38
+ - 回信:[数字]
39
+ - 退信:[数字]
40
+
41
+ **退信分析**:
42
+ 如果退信数量 > 0,必须读取退信邮件内容,并根据报错代码(如 550, 554 等)简洁总结失败原因(如:账号不存在、被判定为垃圾邮件、频率受限等)。
43
+
44
+ ## 注意事项
45
+ * **稳重原则**:发信脚本已配置批次发送策略:
46
+ * **分批发送**:每 135 个收件人为一个批次,每个批次独立建立连接。
47
+ * **快速投递**:取消单封邮件间隔,批次内连续发送以提高效率。
48
+ * **配置更新**:如需更换邮箱或授权码,请修改脚本开头的配置。
@@ -0,0 +1,157 @@
1
+ import imaplib
2
+ import email
3
+ from email.header import decode_header
4
+ import smtplib
5
+ from email.mime.text import MIMEText
6
+ from email.mime.multipart import MIMEMultipart
7
+ import os
8
+ import ssl
9
+ import json
10
+ import time
11
+
12
+ # --- 配置信息 (从环境变量读取) ---
13
+ IMAP_HOST = os.getenv("EMAIL_IMAP_HOST", "smtp.corp.netease.com")
14
+ IMAP_PORT = int(os.getenv("EMAIL_IMAP_PORT", 993))
15
+ SMTP_HOST = os.getenv("EMAIL_SMTP_HOST", "smtp.corp.netease.com")
16
+ SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", 465))
17
+ EMAIL_USER = os.getenv("EMAIL_SMTP_USER", "")
18
+ EMAIL_PASS = os.getenv("EMAIL_SMTP_PASS", "")
19
+
20
+ FAQ_PATH = "/home/node/.openclaw/media/inbound/c3b043ac-df37-4fb0-9e21-89c4282030ef"
21
+ PENDING_REPLIES_FILE = "pending_replies.json"
22
+
23
+ def get_unread_emails():
24
+ """连接 IMAP 获取未读邮件"""
25
+ try:
26
+ mail = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
27
+ mail.login(EMAIL_USER, EMAIL_PASS)
28
+ # 选择发件箱以检查是否已回复
29
+ mail.select('"[Gmail]/Sent Mail"' if "gmail" in IMAP_HOST else "Sent Messages") # 简化逻辑,实际可能需适配不同服务商
30
+ # 这里为了简化,我们改为:在收件箱中只处理未读邮件,或者通过数据库记录已处理的 Message-ID
31
+
32
+ mail.select("inbox")
33
+ # 搜索所有未读且未回复标志的邮件
34
+ status, response = mail.search(None, '(UNSEEN)')
35
+ unread_msg_nums = response[0].split()
36
+
37
+ emails = []
38
+ for num in unread_msg_nums:
39
+ status, msg_data = mail.fetch(num, '(RFC822)')
40
+ raw_email = msg_data[0][1]
41
+ msg = email.message_from_bytes(raw_email)
42
+
43
+ # 解析发件人
44
+ from_ = decode_header(msg.get("From"))[0][0]
45
+ if isinstance(from_, bytes): from_ = from_.decode()
46
+
47
+ # 解析主题
48
+ subject = decode_header(msg.get("Subject"))[0][0]
49
+ if isinstance(subject, bytes): subject = subject.decode()
50
+
51
+ # 解析正文
52
+ body = ""
53
+ if msg.is_multipart():
54
+ for part in msg.walk():
55
+ content_type = part.get_content_type()
56
+ if content_type == "text/plain" and not body:
57
+ body = part.get_payload(decode=True).decode(errors='ignore')
58
+ elif content_type == "text/html":
59
+ html_body = part.get_payload(decode=True).decode(errors='ignore')
60
+ # 如果没有纯文本,或者纯文本太短,则使用 HTML(后续可以加清洗)
61
+ if not body or len(body) < 10:
62
+ body = "[HTML Content] " + html_body
63
+ else:
64
+ body = msg.get_payload(decode=True).decode(errors='ignore')
65
+
66
+ emails.append({
67
+ "id": num.decode(),
68
+ "from": from_,
69
+ "subject": subject,
70
+ "body": body.strip(),
71
+ "msg_id": msg.get("Message-ID")
72
+ })
73
+
74
+ mail.logout()
75
+ return emails
76
+ except Exception as e:
77
+ print(f"IMAP 读取失败: {e}")
78
+ return []
79
+
80
+ def save_pending_reply(email_data, draft_reply):
81
+ """保存待确认的回信"""
82
+ try:
83
+ data = []
84
+ if os.path.exists(PENDING_REPLIES_FILE):
85
+ with open(PENDING_REPLIES_FILE, 'r') as f:
86
+ data = json.load(f)
87
+
88
+ data.append({
89
+ "email_id": email_data["id"],
90
+ "to": email_data["from"],
91
+ "original_subject": email_data["subject"],
92
+ "original_body": email_data["body"],
93
+ "draft_reply": draft_reply,
94
+ "status": "pending",
95
+ "timestamp": time.time()
96
+ })
97
+
98
+ with open(PENDING_REPLIES_FILE, 'w') as f:
99
+ json.dump(data, f, indent=4)
100
+ except Exception as e:
101
+ print(f"保存待回信失败: {e}")
102
+
103
+ def send_reply(to_email, subject, body, original_msg_id=None):
104
+ """发送回信"""
105
+ try:
106
+ # 跳过证书验证
107
+ context = ssl.create_default_context()
108
+ context.check_hostname = False
109
+ context.verify_mode = ssl.CERT_NONE
110
+
111
+ msg = MIMEMultipart()
112
+ msg['From'] = f"Youdao Ads <{EMAIL_USER}>"
113
+ msg['To'] = to_email
114
+ # 如果是回复,标题加上 Re:
115
+ if not subject.lower().startswith("re:"):
116
+ msg['Subject'] = "Re: " + subject
117
+ else:
118
+ msg['Subject'] = subject
119
+
120
+ if original_msg_id:
121
+ msg['In-Reply-To'] = original_msg_id
122
+ msg['References'] = original_msg_id
123
+
124
+ msg.attach(MIMEText(body, 'plain', 'utf-8'))
125
+
126
+ with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server:
127
+ server.login(EMAIL_USER, EMAIL_PASS)
128
+ server.send_message(msg)
129
+ return True
130
+ except Exception as e:
131
+ print(f"发送回信失败: {e}")
132
+ return False
133
+
134
+ if __name__ == "__main__":
135
+ import sys
136
+ # 如果通过命令行参数调用发送
137
+ if len(sys.argv) > 4 and sys.argv[1] == "send":
138
+ to = sys.argv[2]
139
+ subj = sys.argv[3]
140
+ content = sys.argv[4]
141
+ if send_reply(to, subj, content):
142
+ print("SUCCESS")
143
+ else:
144
+ print("FAILED")
145
+ sys.exit(0)
146
+
147
+ # 默认扫描逻辑
148
+ unread = get_unread_emails()
149
+ if unread:
150
+ print(f"发现 {len(unread)} 封新回信。")
151
+ for e in unread:
152
+ print(f"--- 邮件来自: {e['from']} ---")
153
+ print(f"标题: {e['subject']}")
154
+ print(f"内容摘要: {e['body'][:100]}...")
155
+ # 后续逻辑将交由 Agent 使用 FAQ 进行拟稿并通知用户
156
+ else:
157
+ print("没有发现新回信。")
@@ -0,0 +1,134 @@
1
+ import imaplib
2
+ import email
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+
7
+ # --- 配置信息 (优先从环境变量读取) ---
8
+ IMAP_HOST = os.getenv("EMAIL_IMAP_HOST","")
9
+ IMAP_PORT = int(os.getenv("EMAIL_IMAP_PORT",465)) if os.getenv("EMAIL_IMAP_PORT") else 465
10
+ IMAP_USER = os.getenv("EMAIL_SMTP_USER", "")
11
+ IMAP_PASS = os.getenv("EMAIL_SMTP_PASS", "")
12
+ REPLY_LOG = "reply_stats.json"
13
+
14
+ def analyze_bounce_reason(body):
15
+ body_lower = body.lower()
16
+ if "user not found" in body_lower or "invalid user" in body_lower or "不存在" in body:
17
+ return "账号不存在 (Invalid User)"
18
+ elif "spam" in body_lower or "rejected" in body_lower or "垃圾邮件" in body:
19
+ return "触发垃圾邮件风控 (Spam/Rejected)"
20
+ elif "full" in body_lower or "quota" in body_lower or "满" in body:
21
+ return "对方邮箱已满 (Mailbox Full)"
22
+ return "其他原因 (详见摘要)"
23
+
24
+ def check_replies():
25
+ # 修正已发人数统计:从日志文件中尝试汇总今日数据
26
+ sent_total = 0
27
+ if os.path.exists("email_status.json"):
28
+ try:
29
+ with open("email_status.json", "r") as f:
30
+ data = json.load(f)
31
+ # 如果是单次发信逻辑,这里显示最后一次成功数
32
+ sent_total = data.get("success", 0)
33
+ except: pass
34
+
35
+ replied_list = []
36
+ bounce_list = []
37
+
38
+ # 严格过滤名单:系统号/订阅号不计入回信
39
+ SYSTEM_KEYWORDS = ["noreply", "notification", "service", "support", "no-reply", "report"]
40
+
41
+ today_imap = datetime.now().strftime("%d-%b-%Y")
42
+
43
+ try:
44
+ mail = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
45
+ mail.login(IMAP_USER, IMAP_PASS)
46
+ mail.select("INBOX")
47
+
48
+ status, response = mail.search(None, f'(SINCE "{today_imap}")')
49
+
50
+ if status == 'OK' and response[0]:
51
+ ids = response[0].split()
52
+ for r_id in ids:
53
+ status, data = mail.fetch(r_id, '(RFC822)')
54
+ if status != 'OK': continue
55
+
56
+ msg = email.message_from_bytes(data[0][1])
57
+ from_addr = msg.get('From', '').lower()
58
+ subject = msg.get('Subject', '')
59
+
60
+ # 解码标题
61
+ try:
62
+ subject_parts = email.header.decode_header(subject)
63
+ decoded_subject = ""
64
+ for part, encoding in subject_parts:
65
+ if isinstance(part, bytes):
66
+ decoded_subject += part.decode(encoding or 'utf-8', errors='ignore')
67
+ else:
68
+ decoded_subject += part
69
+ subject = decoded_subject
70
+ except: pass
71
+
72
+ # 提取正文摘要用于退信分析
73
+ body = ""
74
+ if msg.is_multipart():
75
+ for part in msg.walk():
76
+ if part.get_content_type() == "text/plain":
77
+ payload = part.get_payload(decode=True)
78
+ if payload: body = payload.decode('utf-8', errors='ignore')
79
+ break
80
+ else:
81
+ payload = msg.get_payload(decode=True)
82
+ if payload: body = payload.decode('utf-8', errors='ignore')
83
+
84
+ # 1. 检查退信
85
+ if "系统退信" in subject or "systems bounce" in subject.lower() or "mailer-daemon" in from_addr:
86
+ reason = analyze_bounce_reason(body)
87
+ bounce_list.append({"from": from_addr, "reason": reason, "summary": body[:50]})
88
+
89
+ # 2. 检查真实回信 (排除自己和系统号)
90
+ else:
91
+ is_system = any(kw in from_addr for kw in SYSTEM_KEYWORDS)
92
+ is_self = IMAP_USER.lower() in from_addr
93
+
94
+ if not is_system and not is_self:
95
+ replied_list.append({"from": from_addr, "subject": subject})
96
+
97
+ mail.logout()
98
+
99
+ stats = {
100
+ "sent_total": sent_total,
101
+ "replied_total": len(replied_list),
102
+ "bounce_total": len(bounce_list),
103
+ "replied_details": replied_list,
104
+ "bounce_details": bounce_list,
105
+ "check_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
106
+ }
107
+
108
+ with open(REPLY_LOG, 'w', encoding='utf-8') as f:
109
+ json.dump(stats, f, ensure_ascii=False, indent=4)
110
+
111
+ return stats
112
+
113
+ except Exception as e:
114
+ return None
115
+
116
+ if __name__ == "__main__":
117
+ results = check_replies()
118
+ if results:
119
+ print("\n--- 邮件营销综合效果报告 ---")
120
+ print(f"统计日期: {datetime.now().strftime('%Y-%m-%d')}")
121
+ print(f"最近一次群发人数: {results['sent_total']}")
122
+ print(f"真实回信人数: {results['replied_total']}")
123
+ print(f"今日退信数量: {results['bounce_total']}")
124
+
125
+ if results['replied_total'] > 0:
126
+ print("\n[回信详情]:")
127
+ for r in results['replied_details']:
128
+ print(f"- 来自: {r['from']}")
129
+ print(f" 标题: {r['subject']}")
130
+
131
+ if results['bounce_total'] > 0:
132
+ print("\n[退信分析]:")
133
+ for b in results['bounce_details']:
134
+ print(f"- 原因: {b['reason']}")
@@ -0,0 +1,185 @@
1
+ import pandas as pd
2
+ import smtplib
3
+ import ssl
4
+ from email.mime.text import MIMEText
5
+ from email.mime.multipart import MIMEMultipart
6
+ import time
7
+ import json
8
+ import os
9
+ import random
10
+ import string
11
+ import re
12
+
13
+ # --- 配置信息 (从环境变量读取) ---
14
+ SMTP_HOST = os.getenv("EMAIL_SMTP_HOST", "")
15
+ SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", 465)) if os.getenv("EMAIL_SMTP_PORT") else 465
16
+ SMTP_USER = os.getenv("EMAIL_SMTP_USER", "")
17
+ SMTP_PASS = os.getenv("EMAIL_SMTP_PASS", "")
18
+ TEST_EMAIL = os.getenv("EMAIL_TEST_TARGET", "")
19
+
20
+ EXCEL_PATH = os.path.expanduser(os.getenv("EMAIL_EXCEL_PATH", "~/Desktop/邮箱.xlsx"))
21
+ HTML_PATH = os.path.expanduser(os.getenv("EMAIL_HTML_PATH", "~/Desktop/邮件内容.html"))
22
+ TITLE_TXT_PATH = os.path.expanduser(os.getenv("EMAIL_TITLE_PATH", "~/Desktop/邮件标题.txt"))
23
+ LOG_FILE = "email_status.json"
24
+
25
+ BATCH_SIZE = 135 # 每个批次的收件人数量
26
+
27
+ def get_title_from_txt(path):
28
+ try:
29
+ if os.path.exists(path):
30
+ with open(path, 'r', encoding='utf-8') as f:
31
+ return f.read().strip()
32
+ return "你好"
33
+ except Exception as e:
34
+ print(f"读取 TXT 标题失败: {e}")
35
+ return "你好"
36
+
37
+ def generate_random_tag():
38
+ """生成随机隐藏标识符,干扰反垃圾扫描"""
39
+ chars = string.ascii_letters + string.digits
40
+ tag = ''.join(random.choice(chars) for _ in range(8))
41
+ return f'<div style="display:none !important; color:transparent; visibility:hidden; opacity:0; font-size:0px;">ID:{tag}</div>'
42
+
43
+ def get_email_data():
44
+ """读取 Excel 并识别标题行和数据列"""
45
+ try:
46
+ # 先读取前几行看是否有标题
47
+ peek = pd.read_excel(EXCEL_PATH, nrows=5)
48
+ # 简单判断首行是否包含邮箱格式特征
49
+ has_header = not any("@" in str(col) for col in peek.columns)
50
+
51
+ if has_header:
52
+ df = pd.read_excel(EXCEL_PATH)
53
+ else:
54
+ df = pd.read_excel(EXCEL_PATH, header=None)
55
+ # 给第一列起个默认名
56
+ df.columns = ["email"] + [f"col_{i}" for i in range(1, len(df.columns))]
57
+
58
+ # 寻找包含邮箱的那一列
59
+ email_col = None
60
+ for col in df.columns:
61
+ if df[col].astype(str).str.contains("@").any():
62
+ email_col = col
63
+ break
64
+
65
+ if email_col is None:
66
+ raise ValueError("Excel 中未找到有效的邮箱地址列")
67
+
68
+ return df, email_col
69
+ except Exception as e:
70
+ print(f"读取 Excel 数据失败: {e}")
71
+ return None, None
72
+
73
+ def replace_placeholders(text, row_data):
74
+ """将文本中的 【变量名】 替换为行数据中的对应值"""
75
+ if not isinstance(text, str): return text
76
+
77
+ # 查找所有 【...】 格式的占位符
78
+ placeholders = re.findall(r"【(.*?)】", text)
79
+ for p in placeholders:
80
+ # 在列名中寻找匹配(不区分大小写,去掉空格)
81
+ match_col = None
82
+ clean_p = p.strip().lower()
83
+ for col in row_data.index:
84
+ if str(col).strip().lower() == clean_p:
85
+ match_col = col
86
+ break
87
+
88
+ if match_col is not None:
89
+ val = str(row_data[match_col])
90
+ if val.lower() == "nan": val = ""
91
+ text = text.replace(f"【{p}】", val)
92
+ return text
93
+
94
+ def send_bulk_emails(test_mode=True, test_email=None):
95
+ # 加载数据
96
+ df, email_col = get_email_data()
97
+ if df is None: return
98
+
99
+ # 获取基础标题
100
+ subject_template = get_title_from_txt(TITLE_TXT_PATH)
101
+
102
+ # 读取 HTML 模板
103
+ try:
104
+ with open(HTML_PATH, 'r', encoding='utf-8') as f:
105
+ html_template = f.read()
106
+ except Exception as e:
107
+ print(f"读取 HTML 失败: {e}")
108
+ return
109
+
110
+ context = ssl.create_default_context()
111
+ context.check_hostname = False
112
+ context.verify_mode = ssl.CERT_NONE
113
+ results = {"total": 1 if test_mode else len(df), "success": 0, "failed": [], "start_time": time.strftime("%Y-%m-%d %H:%M:%S")}
114
+
115
+ # 准备任务列表
116
+ if test_mode:
117
+ # 测试模式下,取第一行数据(如果有)来模拟替换效果,但发送给 test_email
118
+ row = df.iloc[0].copy()
119
+ row[email_col] = test_email
120
+ task_list = [row]
121
+ else:
122
+ task_list = [row for _, row in df.iterrows()]
123
+
124
+ # 分批发送
125
+ for i in range(0, len(task_list), BATCH_SIZE):
126
+ batch = task_list[i:i + BATCH_SIZE]
127
+ print(f"正在发送批次 {i//BATCH_SIZE + 1},包含 {len(batch)} 个收件人...")
128
+
129
+ try:
130
+ with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server:
131
+ server.login(SMTP_USER, SMTP_PASS)
132
+
133
+ for row in batch:
134
+ addr = str(row[email_col])
135
+ try:
136
+ # 执行变量替换
137
+ final_subject = replace_placeholders(subject_template, row)
138
+ final_html = replace_placeholders(html_template, row)
139
+
140
+ # 插入干扰码
141
+ final_html += generate_random_tag()
142
+
143
+ msg = MIMEMultipart()
144
+ msg['From'] = f"Youdao Ads <{SMTP_USER}>"
145
+ msg['To'] = addr
146
+ msg['Subject'] = final_subject
147
+ msg.attach(MIMEText(final_html, 'html', 'utf-8'))
148
+
149
+ server.send_message(msg)
150
+ results["success"] += 1
151
+ print(f"成功发送至: {addr}")
152
+
153
+ # 降低发信频率,增加随机性
154
+ if not test_mode:
155
+ # 基础间隔 + 随机波动
156
+ time.sleep(random.uniform(3.0, 8.0))
157
+ # 每发送 10 封进行一次长休息
158
+ if results["success"] % 10 == 0:
159
+ time.sleep(random.uniform(10, 20))
160
+
161
+ except Exception as e:
162
+ print(f"发送至 {addr} 失败: {e}")
163
+ results["failed"].append({"email": addr, "reason": str(e)})
164
+
165
+ if not test_mode: time.sleep(2)
166
+
167
+ except Exception as e:
168
+ print(f"连接失败: {e}")
169
+
170
+ results["end_time"] = time.strftime("%Y-%m-%d %H:%M:%S")
171
+ with open(LOG_FILE, 'w', encoding='utf-8') as f:
172
+ json.dump(results, f, ensure_ascii=False, indent=4)
173
+ return results
174
+
175
+ if __name__ == "__main__":
176
+ import sys
177
+ if len(sys.argv) > 1 and sys.argv[1] == "run":
178
+ send_bulk_emails(test_mode=False)
179
+ else:
180
+ test_addr = TEST_EMAIL
181
+ if not test_addr:
182
+ print("错误: 未配置 EMAIL_TEST_TARGET 环境变量")
183
+ sys.exit(1)
184
+ print(f"准备发送测试邮件至: {test_addr}")
185
+ send_bulk_emails(test_mode=True, test_email=test_addr)