程序发邮件 - 从忐忑到坦然

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>".

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值