1. 版权
本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/116099081.
文中代码属于 public domain (无版权).
2. 免费邮箱方式
最开始很自然想到的是申请一个免费邮箱, 用来在程序中发送邮件. 但是实践证明不可行: 免费的速度慢、服务商设置了较严格的数量/频率限制. 另外一个很大的坑是很容易收不到/被判为垃圾邮件(网上建议将sender加到接收邮箱的白名单里, 或加到邮件to/cc列表里, 但不可靠/不现实).
参考:
https://answers.microsoft.com/zh-hans/outlook_com/forum/oemail-osend/outlook%E9%82%AE%E7%AE%B1%E5%8F%91%E9%80%81/fbe09be3-0304-48bd-af9e-a70d4ab1d168
outlook邮箱发送邮件限制怎么办???
https://zhinan.sogou.com/guide/detail/?id=316513649807
各大邮箱每天限制发送数量是多少?
网易邮箱:
企业邮箱:单个用户每天最多只能发送 1000 封邮件。单个邮件最多包含 500 个收件人邮箱地址。
163VIP邮箱:每天限制最多能发送800封邮件。
163 、 126 、 yeah 的邮箱:一封邮件最多发送给 40 个收件人 , 每天发送限额为 50 封。
3. 阿里云邮件推送方式
https://help.aliyun.com/product/29412.html
阿里云自己实现了一个SMTP服务器(支持http/smtp调用), 可用来将你的邮件投递到目标邮箱. 速度快、异步发送、有较高的免费额度(收费则5封/分). 当然向同一个地址发送过多邮件的话, 对方服务商仍可能判定你在发送垃圾邮件.
购买域名
使用阿里云邮件推送(以下简称AliDM), 需要你有自己的域名例如aaa.com, 这样对方收到的邮件显示来自xx@aaa.com (就是说虽然是通过AliDM smtp服务器发送, 但邮件不会显示是来自xx@ali.com).
namesilo.com
可购买便宜的域名(.xyz $0.99/年).
配置域名
https://help.aliyun.com/document_detail/29426.html
通过配置所购域名的TXT、MX记录, 将该域名与AliDM smtp关联起来(例如程序使用xx@aaa.com发邮件时, aaa.com的MX记录指向了AliDM smtp服务器).
验证:
nslookup -qt=TXT xxx.com
smtp协议
https://help.aliyun.com/document_detail/51622.html
客户端连上服务器;
HELO;
AUTH 认证;
MAIL 发件人;
RCPT 一个收件人;
DATA 邮件正文;
. 结束正文;
实际Golang的例子:
https://help.aliyun.com/document_detail/29457.html
使用gomail库发送
示例代码:
import (
"gopkg.in/gomail.v2"
)
// 邮件发送.
type Emailer struct {
from string
host string
port int
username string
password string
}
// 发送一封html邮件. 错误格式: "xxx错误: xx" 或"xxx".
func (e *Emailer) SendHtml(to, subject, htmlBody string) error {
m := gomail.NewMessage()
m.SetHeader("From", e.from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", htmlBody)
d := gomail.NewDialer(e.host, e.port, e.username, e.password)
if err := d.DialAndSend(m); err != nil {
return errors.New(e.transformErrorMsg(err.Error()))
}
return nil
}
错误处理
AliDM有错误码, 其次接收方邮箱服务商也有错误码.
示例代码:
// 按"https://help.aliyun.com/knowledge_detail/44499.html" 转换错误信息.
// 如识别, 返回old + "错误: xx"; 否则仍返回old.
//
// 注意: old里可能含有敏感信息如发送方的ip地址(e.g. 559 Invalid rcptto [@sm070102]
// at DATA State(Connection IP address:218.57.96.14) ...).
//
// 另, 经实验alidm 是异步发送, 接口一般都成功. Gomail会报地址格式的错误.
func (e *Emailer) transformErrorMsg(old string) string {
s := ""
if strings.Contains(old, "535 ") {
s = "错误: 认证失败(系统错误)"
} else if strings.Contains(old, "556 ") {
s = "错误: 收件地址数量超限(系统错误)"
} else if strings.Contains(old, "557 ") {
s = "错误: 邮件大小超限(系统错误)"
} else if strings.Contains(old, "559 ") {
s = "错误: 命中无效地址库(收件人地址无效)"
} else if strings.Contains(old, "423 ") || strings.Contains(old, "524 ") || strings.Contains(old, "526 ") {
s = "错误: MX 解析查询失败(收信域名 MX 解析查询失败)"
} else if strings.Contains(old, "427 ") {
s = "错误: 连接失败(目标主机不可达,通常是接收域名的邮件解析(MX)记录不存在)"
} else if strings.Contains(old, "552 ") {
s = "错误: 发信额度超限制(系统错误)"
} else if strings.Contains(old, "554 ") {
s = "错误: 反垃圾类错误(系统错误)"
} else if strings.Contains(old, "551 ") {
s = "错误: 发信账户状态异常(系统错误)"
}
return old + s
}
TODO AliDM收到请求-返回成功-之后异步发送时如果出错, 要么购买其结果通知服务(似乎是MQ/HTTP推送, 未试过), 要么只能人工查询AliDM的发送日志.
4. 安全
delay
假设s1是发邮件接口, 在之前设置s0接口, 要求s0->s1及s1->s1必须间隔一段时间.
这可以增加攻击的难度:
只有s1: 创建线程->s1->s1->…
加了s0: 创建线程->s0->wait->s1->wait->s1->…
实现方式就是在session里存放上次请求的时间. s1的示例代码:
/** 没有ses delay, 或与当前时间间隔不足(默认3秒)时抛错. 总是更新ses delay. */
function ses_forceDelay(ses) {
var now = new Date().getTime(), old = ses.get("delay");
ses.set("delay", ""+now);
if (old == null || now - parseInt(old, 10) < 3000) throw "错误: 操作太频繁. 请稍后再试";
}
在s0 设置时间. 示例代码:
/** 更新ses delay. */
function ses_setDelay(ses) {
ses.set("delay", ""+new Date().getTime());
}
limit
经典的频率限制. 例如: 在1分钟内请求 >= 10次时, 随机拒绝70% 的请求.
import (
"math/rand"
"sync"
"time"
)
func init() {
rand.Seed(time.Now().Unix())
}
// (可并发使用)
type Limiter struct {
du time.Duration
n int
ratio float32 // 拒绝率
mu *sync.Mutex
last time.Time // du内首次请求时间
cnt int // du内请求次数
}
// 例如NewLimiter("1m", 10, 0.7) - 在1分钟内请求 >= 10次时, 随机拒绝70% 的请求.
//
// d 格式不正确时, panic.
func NewLimiter(d string, n int, ratio float32) *Limiter {
if duration, err := time.ParseDuration(d); err != nil {
panic("invalid d")
} else {
return &Limiter{du: duration, n: n, ratio: ratio, mu: new(sync.Mutex)}
}
}
// 返回true = 拒绝.
func (l *Limiter) Req() bool {
l.mu.Lock()
defer l.mu.Unlock()
if now := time.Now(); now.Sub(l.last) < l.du { // du内
l.cnt++
} else {
l.last = now
l.cnt = 1
}
if l.cnt < l.n { // du内 < n次
return false
} else {
if l.ratio <= 0.0 {
return false
} else if l.ratio >= 1.0 {
return true
} else {
return rand.Float32() < l.ratio
}
}
}
// 重置.
func (l *Limiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.last = *new(time.Time)
l.cnt = 0
}
测试其一(2秒内>=10次时总是拒绝):
func TestLimitDenyall(t *testing.T) {
l := NewLimiter("2s", 10, 1.0)
for i := 1; i <= 9; i++ {
l.Req()
}
cnt := 0
for i := 1; i <= 5; i++ {
if l.Req() {
cnt++
}
}
if cnt != 5 {
t.Error("cnt != 5")
}
time.Sleep(2 * time.Second)
cnt = 0
for i := 1; i <= 17; i++ {
if l.Req() {
cnt++
}
}
if cnt != 8 {
t.Error("cnt != 8")
}
}
by-域名 limit (mysql)
例如限制同一个接收地址域名 < 3次/min:
表结构(email_send_ctl):
*域名|domain_name|TEXT|PK
*最近发送时间|last_send_time|DATETIME
*1分钟内发送次数|send_cnt_1min|INT
注: 如在同一分钟, 次数加1; 否则=>新时间&1次.
Sql示例:
insert into email_send_ctl (domain_name, last_send_time, send_cnt_1min)
values (
p_domainName,
,NOW()
,1
)
on duplicate key update
send_cnt_1min = CASE
when values(last_send_time) >= last_send_time + interval 1 minute then 1
when values(last_send_time) >= last_send_time then
case when send_cnt_1min < 2 then send_cnt_1min+1 else send_cnt_1min end
else 1 END
,last_send_time = CASE
when values(last_send_time) >= last_send_time + interval 1 minute then values(last_send_time)
when values(last_send_time) >= last_send_time then last_send_time
else values(last_send_time) END
注1: on duplicate里的values(last_send_time) 是insert里提供的值(即NOW()).
注2: 更新顺序send_cnt_1min -> last_send_time 不能改, 因为cnt新值依赖time旧值 - update表达式里的列值是当前执行中的值.
Insert on duplicate返回: 1-ins, 2-upd, 0-oldvalue.
内容转义
现在发送的邮件一般都是HTML格式的, 那么在构造邮件内容时需要防止js注入.
如果是使用Golang模板拼内容, 应该使用html/template 而非text/template.
另, 经测163,outlook 在web展示邮件时会自动去掉"<script>xxx</script>".