使用亚马逊的邮件服务(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 发送邮件,第一步就是要验证发件箱,而且这个步骤是必须的。之所以需要验证,是因为为了防止垃圾邮件以及诈骗邮件,
试想:如果我以[email protected]的名义发送邮件给某人,提示他 QQ 的账号密码有问题,让他将原有的账号密码发我,这样其 QQ 号就被盗了,当然这只是最简单的例子。

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

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

验证域名

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

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

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

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

验证邮箱

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

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

在我的应用场景下,就是去采用验证邮箱的方式动态的验证 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: "[email protected]", // 发件箱
    Destination: {
      ToAddresses: ["[email protected]"] // 收件箱
    },
    Message: {
      // 正文内容
      Body: {
        Html: "

say hello!

" }, // 主题 Subject: { Charset: "UTF-8", Data: "你好" } } }, (err, data) => { if (err) { console.error(err); } else { // 返回的数据,会带一个邮件ID是唯一的 console.log(data); } } );

由此就成功的发送了邮件了,然后去[email protected]邮箱查看下邮件吧

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

邮件事件

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

发邮件时可以指定配置集,配置集里进行事件类型的选择,然后还要选择一个目的地,也就是说事件要去往的地方,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: "[email protected]", // 发件箱
    Destination: {
      ToAddresses: ["[email protected]"] // 收件箱
    },
    Message: {
      // 正文内容
      Body: {
        Html: "

say hello!

" }, // 主题 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 服务还支持标签的操作,也支持简单的统计。但是其局限性也在于发件箱是不能随意填写的,而且还需要使用配套的其他服务。

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

你可能感兴趣的:(使用亚马逊的邮件服务(SES)发送邮件实战)