使用亚马逊的邮件服务(SES)发送邮件实战

本篇文章记录了本人使用 AWS 的 SES 发送邮件的心得,以下的操作都是基于 AWS 提供的SES 服务的文档,读者在使用 AWS 的邮件服务遇到困惑时,不妨阅读下本篇文章,希望会给你提供一些帮助。

Simple Email Service 简称 SES 是 AWS 的邮件服务,除了有基本的发送邮件的功能,还可以对邮件的事件进行监控,进而获取一些数据,用于以后的分析。 邮件事件分为以下几种:

  • 发送 – 对 Amazon SES 的调用已成功且 Amazon SES 将尝试发送电子邮件。
  • 拒绝 – Amazon SES 接受了电子邮件,并确定电子邮件中包含病毒,然后拒绝了电子邮件。Amazon
    SES 未尝试将电子邮件发送到收件人的邮件服务器。
  • 退回邮件 – 收件人的邮件服务器永久拒绝了电子邮件。此事件对应查无此人的邮件。只有当 Amazon
    SES 重试一段时间后仍无法发送邮件时才包括软退回邮件。
  • 投诉 – 已将电子邮件成功发送给收件人。收件人将电子邮件标记为垃圾邮件。
  • 送达 – Amazon SES 已将电子邮件成功送达至收件人的邮件服务器。
  • 打开 – 收件人收到了邮件并在其电子邮件客户端中打开了邮件。
  • 点击 – 收件人点击了电子邮件中包含的一个或多个链接。
  • 呈现失败 – 由于模板呈现问题,未发送电子邮件。此事件类型仅在您使用 SendTemplatedEmail 或
    SendBulkTemplatedEmail API 操作发送模板化电子邮件时发生。当模板数据丢失或模板参数与数据
    不匹配时,可能会发生此事件类型

如果是向用户发送营销邮件,则可以通过这些事件,监控到用户填写的邮箱是否正确、邮件是否成功的发送到用户的邮箱、用户有没有查看邮件、邮件里面附带的营销链接有没有被点过等,然后根据这些数据对邮件的内容进行调整,以达到最好效益。

以下是本人使用该功能的项目背景:

项目背景

我们的项目是针对于 B 端的用户,B 端用户可以实现给他的客户进行自动去信。

B 端用户要设置发件人、发件箱(以 B 端用户的名义发邮件)以及发送邮件的模板,B 端用户还有一个客户邮箱的列表,满足一定的条件之后,我们的系统会给他的客户按照他提供的模板发送邮件。

B 端用户还可以看到他发给客户邮件的送达率、打开率、邮件内的链接的点击率等统计类的信息。

接下来就是具体开发的流程了:

验证发件箱

如果要使用 AWS 发送邮件,第一步就是要验证发件箱,而且这个步骤是必须的。之所以需要验证,是因为为了防止垃圾邮件以及诈骗邮件,
试想:如果我以admin@qq.com的名义发送邮件给某人,提示他 QQ 的账号密码有问题,让他将原有的账号密码发我,这样其 QQ 号就被盗了,当然这只是最简单的例子。

验证邮件的发件箱有两种方式:

  1. 验证域名
  2. 验证邮箱

验证域名

比如,谷歌的邮箱:owen.zhao.sz@gmail.comgmail.com即是这个邮箱的域名,而owen.zhao.sz是邮箱的用户名。

验证域名是为了验证这个域名是为你所有的,需要在域名解析里面进行配置。由此可见,gmail.com或者qq.com这些域名你是没办法验证的,因为他们分别属于谷歌和腾讯。

当我购买了一个域名,如:owenlittlewhite.top,其使用权为我所有,那么这个域名就可以在 AWS 上进行验证。验证时,AWS 会提供几条域名解析的记录,然后登上自己使用的域名解析服务商的控制台,添加进去这几条记录就大功告成了!

当我验证成功owenlittlewhite.top这个域名之后,那么就可以使用它发送邮件了,而用户名是可以任意填的,比如说以admin@owenlittlewhite.topsupport@owenlittlewhite.top这些名义发送邮件都是 OK 的。

验证邮箱

验证域名看起来比较麻烦,那么也可以采用验证邮箱的方式。

比如,我就是想以owen.zhao.sz@gmail.com的名义发邮件,这个时候就要在 SES 服务上验证此邮箱,然后 AWS 会给此邮箱发送一封激活邮件,点击里面的链接就验证成功了,之后就可以用owen.zhao.sz@gmail.com的名义发邮件了,而且激活邮件的内容是可以自定义的。

在我的应用场景下,就是去采用验证邮箱的方式动态的验证 B 端用户的发件箱。

这种方式有以下的缺点:

  1. AWS 对验证邮箱以及域名做了个数的限制,最多有 10000 个验证的邮箱或域名
  2. 验证邮箱需要额外的给用户发送邮件去激活
  3. AWS 对验证的接口请求做了限制,最多一秒一次请求

这也是因为 AWS 对于发件箱必须要进行验证的缘故,如果不考虑垃圾邮件、诈骗邮件,而是希望用户的发件箱可以任意填写时,就只能采用其他的邮件服务了...诸如:sendgird

验证完邮箱后就可以发送邮件了!

发送邮件

一种是通过 HTTP 请求调用 API,一种是通过 SDK 的方式去请求 AWS 的接口。简单点还是通过 SDK 的方式去做吧!

我采用的是 Node.js 当然其他语言的 SDK 也都是一样,接口定义是一致的。

我使用的是sendEmail这个方法进行发送的,具体如下:

const AWS = require("aws-sdk");
AWS.config.update({
  region: "us-east-1"
});
AWS.config.logger = console;
let ses = new AWS.SES({
  apiVersion: "2010-12-01",
  region: "us-east-1",
  accessKeyId: "YOUR_ACCESS_KEY_ID",
  secretAccessKey: "YOUR_SECRET_ACCESS_KEY"
});
ses.sendEmail(
  {
    Source: "owen.zhao.sz@gmail.com", // 发件箱
    Destination: {
      ToAddresses: ["wuyanzu@tempmailbox.cn"] // 收件箱
    },
    Message: {
      // 正文内容
      Body: {
        Html: "<h1>say hello!</h1>"
      },
      // 主题
      Subject: {
        Charset: "UTF-8",
        Data: "你好"
      }
    }
  },
  (err, data) => {
    if (err) {
      console.error(err);
    } else {
      // 返回的数据,会带一个邮件ID是唯一的
      console.log(data);
    }
  }
);

由此就成功的发送了邮件了,然后去wuyanzu@tempmailbox.cn邮箱查看下邮件吧

但是接下来,作为发件人,我想知道我这封邮件到底发给客户了没有,客户有没有打开,客户有没有点击里面的链接,甚至说将我的邮件标记为垃圾邮件了,这个时候对邮件的事件也要做处理了。

邮件事件

邮件事件在文章的开始部分做了简单的介绍了,接下来说一下我在项目中具体如何使用的。

发邮件时可以指定配置集,配置集里进行事件类型的选择,然后还要选择一个目的地,也就是说事件要去往的地方,AWS 这点做的有点捆绑销售的意味了...只有三个可以选择的去往的地方,而这三个指向的是 AWS 另外的服务,分别是:CloudWatch、Kinesis Data Firehose、Amazon SNS。本人这里使用的是 SNS(Simple Notification Service),以下是具体的操作:

  1. 在 SES 服务上设置一个配置集,名为 test;
  2. 然后选择这个配置集,对于 Add Destination ,选择 SNS;
  3. 对于 Name,输入 1tracking_events;
  4. 对于 Event types, 选择 发送、拒绝、退回邮件、投诉、送达、打开、点击;
  5. 选择 Enabled
  6. 对于 Topic,选择建一个新的主题,(在 SNS 创建)主题名称为 topic_test
  7. 创建订阅,选择刚才创建的主题,协议选择 http,终端节点传入:http://yourapi.com/mail_events

这些操作不需要去调用 SDK 的方法去动态的执行,只需要在 AWS 的控制台配置好就可以。按照上述操作完之后,发邮件时带上配置集为 test 的参数,
那么这封邮件的事件最终就会 POST 请求发送到 http://yourapi.com/mail_events这个 API 中,以下是带上配置集发邮件:

// ...
ses.sendEmail(
  {
    Source: "owen.zhao.sz@gmail.com", // 发件箱
    Destination: {
      ToAddresses: ["wuyanzu@tempmailbox.cn"] // 收件箱
    },
    Message: {
      // 正文内容
      Body: {
        Html: "<h1>say hello!</h1>"
      },
      // 主题
      Subject: {
        Charset: "UTF-8",
        Data: "你好"
      }
    },
    ConfigurationSetName: 'test' // 配置集名字
  },
  (err, data) => {
    if (err) {
      console.error(err);
    } else {
      // 返回的数据,会带一个邮件ID是唯一的
      console.log(data);
    }
  }
);

这个 API 就是最终用来接收数据的,是要你自己进行编写的,然后对数据进行处理。

至于这个 API 怎么编写,需要查看 SNS 的文档,这个 API 主要要做的事情:

  1. 订阅确认。当将此 API 配置到订阅中的时候,需要认证这个 API 是属于你的,所以这个 API 要有确认的功能。
  2. 验证来源。因为此 API 需要暴露至公网来让外部访问,所以需要请求接口的一方是属于 AWS,如果不是,就将数据舍弃,AWS 提供了数据校验的方法,基本思想是通过非对称加密实现的。
  3. 接收数据。数据的格式在 SES 服务的文档中有说明。

在我的项目中是将事件数据接收下来,然后写入到队列中去,其他程序在从队列中取出来数据做处理。

下面的代码是我根据其文档中的说明,编写的 API 的 handler 层(Node.js 实现):

const superagent = require('superagent');
const pem = require('pem');
const crypto = require('crypto');
/**
 * 邮件事件接收
 * 代码参考aws文档https://docs.aws.amazon.com/zh_cn/sns/latest/dg/SendMessageToHttp.example.java.html
 * @param {Request} req
 * @param {Response} res
 */
function eventReceiveHandle (req, res) {
    let headers = req.headers;
    let message;
    try {
        message = JSON.parse(req.body);
    } catch (error) {
        message = {};
    }
    // 验证消息签名
    isMessageSignatureValid(message)
        .then((isFromAws) => {
            if (!isFromAws) {
                return res.sendStatus(400);
            }
            let msgType = headers['x-amz-sns-message-type'];
            if (!msgType) {
                return res.sendStatus(200);
            }
            if (msgType === 'SubscriptionConfirmation') {
                let subscribeUrl = message.SubscribeURL;
                superagent.get(subscribeUrl).end((err, resp) => {
                    if (err) {
                        console.error(new Date(), `Error at subscription: ${err.name}`);
                        res.sendStatus(500);
                    } else {
                        console.log(new Date(), `success to subscribe`);
                        res.sendStatus(200);
                    }
                });
            } else if (msgType === 'Notification') {
                writeMsgToQueue(message.Message)
                    .then((data) => {
                        res.sendStatus(200);
                    })
                    .catch((err) => {
                        console.error(new Date(), 'Error at writeToQueue', err);
                        res.sendStatus(500);
                    });
            } else if (msgType === 'UnsubscribeConfirmation') {
                // Handle UnsubscribeConfirmation message.
                // For example, take action if unsubscribing should not have occurred.
                // You can read the SubscribeURL from this message and
                // re-subscribe the endpoint.
                console.log('>>Unsubscribe confirmation: ' + message.Message);
                res.sendStatus(200);
            } else {
                // Handle unknown message type.
                console.log('>>Unknown message type.');
                res.sendStatus(200);
            }
        })
        .catch((e) => {
            console.error(new Date(), e);
            res.sendStatus(500);
        });
}
// 验证消息签名
function isMessageSignatureValid (message) {
    return new Promise((resolve, reject) => {
        let url = message.SigningCertURL;
        superagent
            .get(url)
            .buffer(true)
            .end((err, data) => {
                if (err) {
                    reject(err);
                } else {
                    let pemStr = data.text;
                    pem.getPublicKey(pemStr, (err, publicKey) => {
                        if (err) {
                            reject(err);
                        } else {
                            try {
                                const verify = crypto.createVerify('SHA1');
                                verify.update(getMessageBytesToSign(message));
                                let isVerify = verify.verify(
                                    Buffer.from(publicKey.publicKey),
                                    Buffer.from(message.Signature, 'base64')
                                );
                                resolve(isVerify);
                            } catch (error) {
                                reject(error);
                            }
                        }
                    });
                }
            });
    });
}

function getMessageBytesToSign (message) {
    let buffer;
    if (message.Type === 'Notification') {
        buffer = Buffer.from(buildNotificationStringToSign(message));
    } else if (message.Type === 'SubscriptionConfirmation' || message.Type === 'UnsubscribeConfirmation') {
        buffer = Buffer.from(buildSubscriptionStringToSign(message));
    }
    return buffer;
}
function buildNotificationStringToSign (message) {
    let stringToSign = null;
    // Build the string to sign from the values in the message.
    // Name and values separated by newline characters
    // The name value pairs are sorted by name
    // in byte sort order.
    stringToSign = 'Message\n';
    stringToSign += message.Message + '\n';
    stringToSign += 'MessageId\n';
    stringToSign += message.MessageId + '\n';
    if (message.Subject) {
        stringToSign += 'Subject\n';
        stringToSign += message.Subject + '\n';
    }
    stringToSign += 'Timestamp\n';
    stringToSign += message.Timestamp + '\n';
    stringToSign += 'TopicArn\n';
    stringToSign += message.TopicArn + '\n';
    stringToSign += 'Type\n';
    stringToSign += message.Type + '\n';
    return stringToSign;
}

// Build the string to sign for SubscriptionConfirmation
// and UnsubscribeConfirmation messages.
function buildSubscriptionStringToSign (msg) {
    let stringToSign = null;
    // Build the string to sign from the values in the message.
    // Name and values separated by newline characters
    // The name value pairs are sorted by name
    // in byte sort order.
    stringToSign = 'Message\n';
    stringToSign += msg.Message + '\n';
    stringToSign += 'MessageId\n';
    stringToSign += msg.MessageId + '\n';
    stringToSign += 'SubscribeURL\n';
    stringToSign += msg.SubscribeURL + '\n';
    stringToSign += 'Timestamp\n';
    stringToSign += msg.Timestamp + '\n';
    stringToSign += 'Token\n';
    stringToSign += msg.Token + '\n';
    stringToSign += 'TopicArn\n';
    stringToSign += msg.TopicArn + '\n';
    stringToSign += 'Type\n';
    stringToSign += msg.Type + '\n';
    return stringToSign;
}

/**
 * 写入队列相关的方法
 */
function writeMsgToQueue (msg) {
}

事件接收下来后,通过里面的 mail 对象的邮件的 ID,就可以找到发送的对应的邮件,从而更新自己存储的数据。

总结

以上就是本人使用 AWS 的 SES 在项目中的应用,SES 服务还支持标签的操作,也支持简单的统计。但是其局限性也在于发件箱是不能随意填写的,而且还需要使用配套的其他服务。

希望以上文章对你有一些帮助!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容