Compare commits

..
9 Commits
Author SHA1 Message Date
tamakyi 1407fa9fb6 发信账号来源开关:app.ini / 程序自带 SMTP
- params.ini 新增 [mail]:source=app(默认,用 conf/app.ini [mail])或
  own(用 account/password/smtp/port/skip_tls_verify 这套);--mail-source
  app|own 可临时覆盖(命令行 > params.ini)
- 切换后发信与退信核查(IMAP,含主机推导)都走这套账号;切换到 own 但
  配置缺项时直接 FATAL,不用半套配置
- password 支持 env:变量名:params.ini 会被 git 跟踪,明文密码时打印警告
- 交互模式:[mail] 配齐时多问一次来源;保存时 [mail] 小节原样回写
- 修复:save_params 缺少 batch_size/smtp_idle_reconnect 形参,但调用点已在
  传 → 交互模式选「保存到 params.ini」会 TypeError 崩溃(上一并行编辑漏改)
- 补回上一轮被漏掉的 README「合并信封批量发送」章节与 452 安全机制条目
2026-09-08 00:23:51 +08:00
tamakyi d4e5c611b0 退信核查记住扫描位置:UID 游标续扫,不再每次从头拉全量头部
- broadcast_bounces_seen.json 扩展为 {seen, cursor}(兼容旧列表格式):
  cursor 记录 INBOX 的 uidvalidity + last_uid
- 下次 --check-bounces 默认 UID n+1:* 续扫,只查新到的邮件;随邮箱
  邮件增多不再越扫越慢
- 回退全扫的情形:首次核查(无记录)、UIDVALIDITY 变化(换号/重建邮箱)、
  显式 --since、新增 --full-scan
- 扫描循环统一走 UID 检索/取信;UID n:* 在 n 超过邮箱最大 UID 时会返回
  最后一封,已防御过滤,游标不回退、不误报
- 发送中巡检不推进扫描位置(not_before 会过滤历史邮件,推进会把未处理
  的邮件标记成已扫导致漏报);位置只由 --check-bounces 推进
- 退信报告新增 scan_mode / scan_cursor 字段
2026-09-07 22:53:35 +08:00
tamakyi 6dc33604d1 合并信封批量发送 --batch-size:同内容多收件人一封多投,452 自动拆批
- 渲染后内容 sha256 分桶,仅完全相同的收件人合并信封;含 {{name}} 等
  个人化占位符时自动回退逐封,不会错合
- 一个信封 = 1×MAIL FROM + N×RCPT TO + 1×DATA:发信次数从「人数」降到
  「信封数」,显著降低触发「外发频率超过限制」类限流的概率
- To: 头只显示站点名+发件邮箱,真实收件人走 RCPT TO 信封互相不可见
- 452/too many recipients 特判:不算限流不中止,batch_size 砍半重试
- refused dict 逐人处理:5xx 记无效清单、限流码仍触发立即中止
- SmtpSession.send_to 统一重连重试;断点续发按人记录;巡检按收件人计数
- 顺带修复:交互模式保存 params.ini 时会抹掉手工加的 smtp_idle_reconnect
- 默认 0(逐封)不变,--batch-size N 或 params.ini [send] batch_size 显式开启
2026-09-07 21:25:48 +08:00
tamakyi 5823b5fbb3 SMTP 断线自动重连:修掉大 delay 下整批 please run connect() first
现象:--delay 调到 60s/封、发 305 人时,第一封报 Server not connected,
之后每一封都是 please run connect() first,全军覆没。

根因:SMTP 服务器会掐掉空闲连接(QQ/163/阿里云常见 5~15 分钟,有的更短)。
脚本只在开头 smtp_login 一次,之后从不重连——连接一旦被服务器关掉,
同一个死掉的连接对象会被反复使用,除第一封外全是 connect() 报错。
delay 越大必然越早触发;以前默认 1s/封时几乎等不到超时,所以没暴露过。

改动:
- 新增 SmtpSession 包装连接:
  * 空闲超过 smtp_idle_reconnect 秒(默认 30,可 0 关闭)就主动断开重连,
    不必每封都先吃一发失败
  * 仍遇到断线类异常(ServerDisconnected/ConnectError/OSError)立即重连
    重试,最多 3 次(间隔 2s/4s);耗尽后抛原始异常、该收件人如实计失败
  * 只重试连接类异常;SMTP 拒收(5xx/4xx 回话)不重试,避免重复投递
  * 「Server not connected」不会被误判为限流,不会误触发发送中止
- 新增 --smtp-idle-reconnect / params.ini [send] smtp_idle_reconnect
- 发送循环改用 SmtpSession,结束统一 close
2026-09-07 20:14:34 +08:00
tamakyi f83ea3e29a 退信/巡检开关移入 params.ini [bounce],命令行仅作临时覆盖
原来这些开关只能靠命令行传,每次群发都得敲一长串,也容易漏。现在统一在
params.ini 的 [bounce] 小节长期配置,优先级为 命令行 > params.ini > 内置默认。

改动:
- load_params 新增 [bounce] 解析:watch_bounces / watch_every / prune_unknown /
  no_invalid_list / invalid_file / bounce_report / imap_host / imap_port /
  since / since_days;新增 _parse_bool 支持 1/0、yes/no、true/false、on/off,
  留空或非法值忽略并回退默认(带警告)
- 相关 argparse 默认值改为 None,以便区分「用户显式传了」还是「没传」——
  没传才用 params.ini 的值,保证命令行优先级
- save_params 回写 [bounce](用当前生效值),并用 _preserve_extra_sections
  原样保留文件里其它手写小节,避免保存时把配置抹掉
- 交互模式参数摘要里显示巡检开关的当前值与来源
- 把 argparse 构造抽成 build_arg_parser(),便于测试
- params.ini 补上 [bounce] 小节的默认配置与注释;README 同步
2026-09-07 19:25:20 +08:00
tamakyi 1cae63b671 发送中巡检限流退信:发现即中止发送
背景:限流退信是异步投到发件箱的——SMTP 当场返回 250,几十秒后收件箱
才收到「您的账号外发频率超过邮件系统限制」。只等发完再核查,等发现时
被限流的那一批早就全废了,白耗额度。

改动:
- 把 IMAP 扫描抽成 _scan_imap_bounces(),供退信核查与发送中巡检共用
  (新增 not_before 参数:只看指定时刻之后到达的退信)
- 发送过程中每 --watch-every 封(默认 10)巡检一次收件箱,命中限流类
  退信立即中止;只看本次运行开始后的新退信,历史退信不误触发
- SMTP 当场返回限流(如 450 MI:CEL)也立即中止;硬退信则照旧记入
  无效地址清单并继续发下一封
- 中止后把限流地址从断点清单剔除,等限制恢复重跑即自动补发
- IMAP 连续两轮不可用则自动关闭本次巡检并提示,不影响发送
- 新增 --no-watch-bounces / --watch-every,报告新增 aborted 等字段
- 顺带:分类依据里的状态码改为只显示匹配到的码(原来是整串原文)
2026-09-07 19:11:14 +08:00
tamakyi 5aad4a97e4 fix path error 2026-09-07 18:38:52 +08:00
tamakyi 909e853500 gitignore: 忽略 .workbuddy 项目数据目录 2026-09-07 18:30:04 +08:00
tamakyi bc971deb37 退信核查按原因分类:只有限流/临时性退信才剔除补发
问题:退信核查此前凡是退信一律从断点清单剔除、下次重跑补发。
但退信分两类——「您的账号外发频率超过邮件系统限制」这类限流退信重发
可成功;「地址不存在」这类硬退信重发也发不出去,只会浪费每日发信额度、
拖垮发信信誉。

改动:
- 新增退信分类 rate/hard/unknown:优先用 DSN 状态码 5.x.x/4.x.x 判定,
  同一封退信里的多个收件人可分别归类;无 DSN 时回退正文关键词匹配
  (硬退信优先于限流,宁可不补发可疑地址)
- --prune-state 只剔除「可重试」退信;永久失败不剔除,即不再补发
- 新增无效地址清单 broadcast_invalid.json:硬退信地址记入后每次运行
  直接跳过(含 --reset-state),附 --reset-invalid / --no-invalid-list
- 新增 --prune-unknown / --invalid-file / --bounce-report 参数
- 新增退信明细报告 broadcast_bounce_report.json(含每个收件人判定依据)
- 修正 _part_text 取正文的两个解码坑:utf-8 正文默认 base64 传输编码未
  解码、str 形态 payload 走 raw-unicode-escape 致中文变 \uXXXX,两者都会
  让中文关键词匹配静默失效
- 补充退信主题关键词(delivery failed、未能送达、无法送达 等)
- README 补充退信分类表与无效地址清单说明
- 新增 .gitignore,移除误入库的 __pycache__/broadcast.cpython-313.pyc
2026-09-07 18:26:09 +08:00
5 changed files with 1455 additions and 185 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
# WorkBuddy 项目数据(会话记忆等)
.workbuddy/
# 运行时生成的状态/报告文件(含真实用户邮箱,不入库)
broadcast_state.json
broadcast_report.json
broadcast_invalid.json
broadcast_bounce_report.json
broadcast_bounces_seen.json
*.tmp
+155 -9
View File
@@ -17,6 +17,9 @@ README — TamaBox 站内信群发工具(mail-broadcast
single.subject.txt 单文件模式主题(一行)
broadcast_state.json (运行后生成)断点续发记录,已发邮箱重跑自动跳过
broadcast_report.json (运行后生成)每次运行的监控报告
broadcast_invalid.json (运行后生成)无效地址清单:硬退信地址,永久跳过
broadcast_bounce_report.json (运行后生成)退信核查明细(含分类与判定依据)
broadcast_bounces_seen.json (运行后生成)已核查退信的 Message-ID,避免重复报告
参数文件 params.ini
-------------------
@@ -38,6 +41,20 @@ README — TamaBox 站内信群发工具(mail-broadcast
to = ; 可选:默认测试收件邮箱
delay = 1.0
group_pause = 3.0
smtp_idle_reconnect = 30 ; SMTP 空闲超时秒数,超时就重连
batch_size = 0 ; 合并信封:内容相同的收件人每 N 人一封(0=逐封)
[bounce] ; 退信核查 / 发送中限流巡检(见下节)
watch_bounces = 1 ; 发送中巡检限流退信,发现即中止
watch_every = 10 ; 每发 N 封巡检一次
prune_unknown = 0 ; 未分类退信是否也按可重试补发
no_invalid_list= 0 ; 停用无效地址清单
invalid_file = ; 留空用默认 broadcast_invalid.json
bounce_report = ; 留空用默认
imap_host = ; 留空按 SMTP 域名推导
imap_port = 993
since = auto ; auto = 从断点清单最早记录开始扫
since_days = 3
conf/app.ini 路径的解析优先级:
命令行 -c > params.ini [path] config > 环境变量 TAMABOX_CONFIG_PATH > ./conf/app.ini
@@ -45,6 +62,11 @@ conf/app.ini 路径的解析优先级:
安全边界:params.ini 不提供 --send / --yes——群发只能在命令行显式指定
(或走交互模式在会话中确认),防止改配置文件时误发全量邮件。
优先级:**命令行参数 > params.ini > 内置默认**。所有 [bounce] 项都有同名命令行
参数可临时覆盖(如 `--watch-every 5``--no-watch-bounces`)。布尔值写
`1/0``yes/no``true/false``on/off` 均可,留空或写错会忽略并回退默认
(控制台会给出警告)。
交互模式(默认)
--------
直接运行 `python3 broadcast.py` 即进入交互模式(无需加任何参数);
@@ -135,22 +157,40 @@ conf/app.ini 路径的解析优先级:
# 单文件模式:所有人发 templates/single.html(不按语言)
python3 broadcast.py --single --send --to you@example.com
退信核查(验证真实送达)
------------------------
退信核查(验证真实送达 · 按原因分类处理
----------------------------------------
背景:SMTP 返回 250 只代表服务器收下了,不代表送达。阿里云企业邮箱等对
「发送频率超限」「收件人不存在」等情况往往是先收下、再异步把退信通知投到
发件账号的收件箱——脚本当时的「发送成功」并不真实。
# 发送完过几分钟,扫描近 3 天的退信通知,列出实际未送达的收件人(仅核查)
**退信不是一类,必须分类处理**v3 起):
| 类别 | 典型原因 | 脚本动作 |
| ---- | -------- | -------- |
| 可重试 `rate` | 「您的账号外发频率超过邮件系统限制」、系统繁忙、DSN 4.x.x | 从断点清单剔除 → 下次/本次补发 |
| 永久失败 `hard` | 地址不存在、用户不存在、DSN 5.x.x | **不补发**;记入无效地址清单,永久跳过 |
| 未分类 `unknown` | 判不出来 | 默认不补发(保守);`--prune-unknown` 可强制补发 |
以前是「凡是退信一律剔除补发」,结果**地址不存在的死信也会被反复重发**——
既浪费每日发信额度,又拖垮发信信誉。现在只有真正可重试的才会补发。
判定优先级:DSN 状态码(5.x.x / 4.x.x,最权威,且**同一封退信里的多个收件人
可分别归类**)→ 正文硬退信关键词 → 限流/临时性关键词 → 正文 SMTP 状态码 → 未分类。
硬退信优先于限流:宁可少补发一个可疑地址,也不给死信反复重发。
# 发送完过几分钟,扫描退信并分类报告(仅核查,不改动任何清单)
python3 broadcast.py --check-bounces
# 仅剔除:把这些用户从断点续发清单剔除后结束,本次不发送
#(下次任意一次重跑会自动补上这批人)
# 仅剔除:把「可重试」的退信从断点续发清单剔除后结束,本次不发送
#(下次任意一次重跑会自动补上这批人;地址不存在的不会补发
python3 broadcast.py --check-bounces --prune-state
# 剔除并补发:剔除后继续正常发送流程,本次就把退信用户补上
# 剔除并补发:剔除后继续正常发送流程,本次就把可重试的用户补上
python3 broadcast.py --check-bounces --prune-state --send
# 未分类的也按可重试一并剔除补发(确认不是死信后再用)
python3 broadcast.py --check-bounces --prune-state --prune-unknown
# 可选:--imap-host imap.xxx.com(默认由 SMTP 域名推导 smtp.→imap.
# --imap-port 993;扫描起点 --since auto(默认,见下)
扫描起点(避免把群发无关的退信算进来):
@@ -158,11 +198,112 @@ conf/app.ini 路径的解析优先级:
时间开始——即只核查本次群发发出的那些邮件的退信
- --since 2026-09-07 可显式指定起点日期
- 断点清单为空时回退为 --since-days N(默认 3 天)
发送中限流巡检(边发边看,及时止损)
----------------------------------
限流退信是**异步**投到发件箱的——SMTP 当场返回 250,几十秒后收件箱才收到
「您的账号外发频率超过邮件系统限制」。如果只等发完再核查,等发现时限流那一批
早就全废了。所以正式群发时会**边发边巡检收件箱**:
-`--watch-every` 封(默认 10)登录一次 IMAP 查新退信
- 只看**本次运行开始之后**到达的退信,历史退信不会误触发
- 一旦出现限流类退信 → 立即停止发送
- SMTP 当场就返回限流(如 `450 MI:CEL 发送频率超限`)→ 同样立即停止
- 善后:把限流退信的地址从断点清单剔除,等限制恢复后重跑自动补发
- 巡检到的「地址不存在」类退信照旧写入无效地址清单
这两个开关**优先写在 params.ini 的 [bounce] 小节**(长期生效),命令行只用来
临时覆盖:
# params.ini
[bounce]
watch_bounces = 1 ; 0 关闭巡检
watch_every = 10 ; 每 10 封查一次
# 命令行临时覆盖(优先级更高,只影响这一次)
python3 broadcast.py --send --yes --watch-every 5
python3 broadcast.py --send --yes --no-watch-bounces
--to 单封测试时本就不巡检)
中止时的输出示例:
[中止发送] 收件箱出现 1 条限流退信(如 a@b.com:关键词「您的账号外发频率超过邮件系统限制」)
[善后] 已将 1 个限流退信地址从断点清单剔除,等限制恢复后重跑同一条命令即可自动补发。
发送中止:已成功 30 封,失败 0 封,未发送 30 封。
注意:巡检依赖 IMAP 可用。若连续两轮连不上 IMAP,会自动关闭本次巡检并提示
(不影响发送),此时请发完手动跑一次 `--check-bounces`
无效地址清单 broadcast_invalid.json
-----------------------------------
硬退信(地址不存在等)的地址会写进这里,之后**每次运行都直接跳过**,
即使加了 --reset-state 也不会再发——重发也发不出去,只会浪费额度。
条目里保留了退信原因、主题与时间,便于核对到底是哪些地址失效了。
python3 broadcast.py --reset-invalid # 清空清单(确认地址已修正后用)
python3 broadcast.py --no-invalid-list ... # 本次忽略清单(既不跳过也不写入)
python3 broadcast.py --invalid-file /path/to/other.json # 换一个清单文件
说明:
- 用的是 conf/app.ini [mail] 的账号与密码(需邮箱已开启 IMAP,密码为邮箱登录密码)
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发
- 已核查过的退信(按 Message-ID)记录在 broadcast_bounces_seen.json
重复核查不会重复报告/重复剔除
- 交互模式的运行方式「4) 退信核查」里同样三选一:仅核查 / 仅剔除 / 剔除并补发
选择「仅剔除 / 剔除并补发」时会再问一次是否连未分类的一起补发
- 已核查过的退信(按 Message-ID)与**上次扫描位置**INBOX UID 游标 +
UIDVALIDITY)都记录在 broadcast_bounces_seen.json
默认从上次位置续扫(UID n+1:*),只拉新邮件的头部,重复核查不会
重复报告/重复剔除,也不会随邮箱邮件增多越扫越慢
- 以下情况自动回退为按日期窗口从头扫(扫完位置照常更新):
首次核查(无位置记录)、邮箱 UIDVALIDITY 变化(换号/重建邮箱)、
显式指定 --since、加 --full-scan
- 发送中巡检不推进扫描位置(它只看本次运行之后新到的退信,
范围内的历史邮件不标记「已扫」),位置只由 --check-bounces 推进
- 每次核查另写一份明细报告 broadcast_bounce_report.json(含每个收件人的
类别与判定依据,以及 scan_mode / scan_cursor
发信账号来源:app.ini / 程序自带 SMTP
------------------------------------
默认用 conf/app.ini [mail] 的 SMTP(站内信跟随站点自己的邮箱)。切到程序
自带的一套后,发信与退信核查(IMAP)都走这套账号:
[mail]
source = app ; app(默认)= conf/app.ini [mail]own = 下面这套
account = bot@other.com
password = env:MY_SMTP_PASSWORD ; 支持 env:变量名,推荐
smtp = smtp.other.com
port = 465
skip_tls_verify = 0
临时切换(优先级高于 params.ini):`--mail-source own` / `--mail-source app`
交互模式里若 [mail] 配齐了,会多问一次选哪个来源。
安全提醒:
- params.ini 会被 git 跟踪,**不要把密码明文写进去**;用 `env:变量名`
从环境变量读取,或把 params.ini 加入 .gitignore
- 明文写死时脚本会打印警告,但不会阻止运行
- 切到 own 但 [mail] 缺 smtp/account/password 时直接 FATAL,不会用半套配置
合并信封批量发送(可选)
------------------------
默认逐人一封(一个 SMTP 信封 = 1 个 RCPT TO)。开启 batch_size 后,
「渲染后内容完全相同」的收件人合并发送:一个信封 = 1×MAIL FROM +
N×RCPT TO + 1×DATA,发送次数从「人数」降到「信封数」(如 305 人、
每 50 人一封 → 约 7 次发信)。
开启方式(0=关闭):
broadcast.py --send --single --batch-size 25
或 params.ini [send] batch_size = 25
与限流的关系(要点):
- 服务商按「发信次数」计频率 → 合并后成倍降低触发概率(主要收益)
- 服务商按「单位时间收件人总数」计数 → 无缓解,仍靠 delay/pause 控制节奏
- 单封收件人数上限常见 50~100:超限的 RCPT 会被 452 拒收,脚本自动把
batch_size 砍半、被拒者重试,不会中止也不会误判为限流
- 信封收件人对其他收件人不可见;批量信封的 To: 头显示为「站点名+发件邮箱」
适用的前提是内容逐字相同:模板含 {{name}}/{{box_link}} 等个人化占位符时,
渲染结果逐人不同,会自动落回逐封,不会错合。断点续发按人记录(DATA 被
服务器接收即整批入账,被拒的除外);发送中限流巡检照常按收件人数计数。
安全机制
--------
@@ -170,7 +311,12 @@ conf/app.ini 路径的解析优先级:
- 发送前打印数据库统计与语言分布,人工核对
- 每封间隔 --delay 秒(默认 1.0);--pause-every/--pause-for 防限流
- 按语言分群发送,组间 --group-pause 秒(默认 3.0
- SMTP 断线自动重连:服务器会掐掉空闲连接,--delay 调大(如 60s/封)时
必现「Server not connected / please run connect() first」,整批失败。
连接空闲超过 `smtp_idle_reconnect` 秒(默认 30,0 关闭)就主动重连;
仍遇到断线则立即重试最多 3 次(间隔 2s/4s),失败才会记为该收件人失败
- SMTP 拒收(refused)计入失败并写入报告
- 批量信封遇 452「收件人数超限」自动砍半拆批重试;限流类拒收仍立即中止
- From/Subject 头自动做 RFC2047 编码(中文显示名不会被 QQ 邮箱 550 拒收)
- 自动定位 users 表所在 schema(避免 psql 命中别的同名空表)
- 语言列自动试跑探测(language → lang → NULL 兜底),老库没有该列也能发
Binary file not shown.
+1199 -142
View File
File diff suppressed because it is too large Load Diff
+54 -1
View File
@@ -3,7 +3,7 @@
[path]
; conf/app.ini 路径(相对路径按运行脚本时的当前目录解析)
config = ./auto/conf/app.ini
config = ../auto/conf/app.ini
[site]
site_url = https://box.shiroko.one
@@ -18,3 +18,56 @@ old_domain = box.tama.guru
; to = 可选:默认测试收件邮箱(正式群发用命令行 --send,不加 --to
delay = 1.0
group_pause = 3.0
; SMTP 连接空闲超过该秒数就主动重连再发。
; delay 调大时(比如 60s/封)服务器会掐掉空闲连接,出现整批
; 「please run connect() first」;保持默认 30 即可自动重连,设 0 关闭
smtp_idle_reconnect = 30
; 合并信封批量发送(0=逐封)。渲染内容完全相同的收件人每 N 人共用一个
; SMTP 信封:发送次数从「人数」降到「信封数」(如 305 人、每 50 人一封
; → 约 7 次发信),可显著降低触发「外发频率超过邮件系统限制」类限流的
; 概率;若服务商按收件人计数则无缓解。
; 注意:单封收件人有服务商上限(常见 50~100),超限被 452 拒收时脚本会
; 自动砍半拆批重试,不会中止;delay 在批量模式下的语义是「每批之间」间隔。
; 模板含 {{name}} 等个人化占位符时内容逐人不同,自动回退逐封,不会错合。
batch_size = 0
; 发信账号来源:app = 用 conf/app.ini [mail] 的 SMTP(默认,站内信跟随站点邮箱)
; own = 用下面这套程序自带 SMTP(命令行 --mail-source own 可临时覆盖)
; 切到 own 后,发信与退信核查(IMAP)都走这套账号
[mail]
source = app
account =
; password 支持 env:变量名(推荐),避免明文写进会被 git 跟踪的文件
password =
smtp =
port = 465
skip_tls_verify = 0
; 退信核查 / 发送中限流巡检开关
; 这些都能在 params.ini 里长期配置,命令行同名参数可临时覆盖
[bounce]
; 发送中巡检收件箱:出现「外发频率超过邮件系统限制」类限流退信立即中止发送
; 中止后会把限流地址从断点清单剔除,等限制恢复重跑即自动补发
watch_bounces = 1
; 每发 N 封巡检一次退信。调小更及时,但 IMAP 登录更频繁(不建议 < 5)
watch_every = 10
; 退信核查时,分类不明的退信是否也按「可重试」一并剔除补发(默认 0,保守不补发)
prune_unknown = 0
; 停用无效地址清单:既不跳过已知无效地址,也不写入新的硬退信
no_invalid_list = 0
; 留空则用脚本同目录的默认文件名
invalid_file =
bounce_report =
; IMAP(退信核查要用发件邮箱的 IMAP;留空则按 SMTP 域名推导 smtp.xxx → imap.xxx
imap_host =
imap_port = 993
; since = auto 表示从断点清单最早一条发送记录的时间开始扫描,只查本次群发的退信
since = auto
since_days = 3