一、业务分析
电邮业务一般包括以下几部分功能:
- 订阅邮件
- 发送邮件
- 退订邮件
1、订阅邮件
前端需要提供一个可供订阅邮件的窗口,并结合接口实现以下功能:
- 可选择订阅类型。
- 需要校验:有没有选择类型、有没有输入邮箱,以及输入的邮箱格式是否正确。
- 若订阅成功,提示成功。
- 若订阅失败,提示失败。
- 关闭订阅弹窗。
订阅邮件的接口的实现:
- 拿到前端传来的订阅的:账号、类型和语言。
- 查寻该邮箱账号对应的 id。
- 遍历前端传来的用户本次要订阅的邮件类型。
2、发送邮件
发送邮件的模式有 2 种:
- 交互式:前端需要提供发送邮件的按钮,点击后即可发送邮件。
- 自动式——内容有更新就定点发送邮件(比较常见):后端指定邮件自动发送的时机。
自动式邮件发送机制的实现:
- 检查数据表24小时内是否有新数据产生,有则继续,没有则退出。
- 获取所有的邮箱账号,遍历这些账号,返回根据不同语言而遍历的结果。
- 在遍历某一个账号时,取该账号的id,根据邮箱 id 和 status:1(1表示正常,0表示已删除) 查询 该账号下所有正常的数据。
- 根据邮件类型对拿到的这些正常的数据进行分类,这样就能得到该邮箱账号下所有的邮件类型了。
- 然后根据语言和该账号已订阅的所有类型列表来获取并处理邮件的内容,有内容就发送邮件,没有内容不发送邮件。
- 然后就是定义好邮件的模板,套用模板生成邮件。
- 调用第三方街口(比如飞鸽)发送邮件,发送成功或失败均有提示或原因反馈给前端页面。
3、退订邮件
前端需要提供一个可供退订邮件的页面,并结合接口实现以下功能:
- 获取 token。
- 前端通过接口,将 token 作为参数传递给后端,后端通过密钥来解析校验 token 是否有效:
- 如果 token 失效,前端要抛出 token 已过期的提示,提示从右侧滑入,3 秒后滑出;
- 无论 token 是否有效,最终都要获取该账号已订阅的所有邮件类型,并将其渲染。
- 用户可一一选择退订,也可以一键退订全部。
- 退订成功会有个退订成功的提示,5 秒后自动关闭,跳转到首页。
- 退订失败会从右侧滑入一个提示消息,提示失败原因,3 秒后滑出。
查询并返回该账号已订阅的邮件类型:
- 解析前端传来的 token,拿到 token 里加密的邮箱账号的 id。
- 根据 id 查询其已订阅的邮件类型,将结果返回给前端。
【拓展】加密按对称性分为:对称密钥加密(也叫“私钥加密”) 和 非对称密钥加密(也叫“公钥加密”)。更多 JS 的加密解密请戳这里。
1.私钥加密:加密和解密使用相同密钥的加密算法。这就有一定的概率导致“加密密钥和解密密钥是相同的”。没有非对称密钥加密安全。
2.公钥加密:该加密算法使用两个不同的密钥:加密密钥和解密密钥。前者公开,又称公开密钥,简称公钥。后者保密,又称私有密钥,简称私钥。
公钥加密的另一用途是“身份验证”:用私钥加密的信息,可以用公钥拷贝对其解密,接收者由此可知这条信息确实来自于拥有私钥的某人。
二、数据表结构的制定
例如:
- 订阅账号信息表
CREATE TABLE `subscribe_mails` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`email` varchar(256) NOT NULL DEFAULT '' COMMENT 'email',
`lang` varchar(256) NOT NULL DEFAULT '' COMMENT '语言',
`create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=85 DEFAULT CHARSET=utf8 COMMENT='订阅邮件';
- 订阅类型信息表
CREATE TABLE `subscribe_mail_items` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`sm_id` int(11) NOT NULL DEFAULT '1' COMMENT '订阅邮件',
`type` int(11) NOT NULL DEFAULT '1' COMMENT '订阅类型(1:one,2:two,3:three)',
`status` int(11) NOT NULL DEFAULT '1' COMMENT '订阅状态(1:已订阅,0:取消订阅)',
`create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=85 DEFAULT CHARSET=utf8 COMMENT='订阅项目';
- 订阅历史信息表
CREATE TABLE `send_mail_historys` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
`content_id` varchar(256) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '内容id',
`to_user` varchar(256) CHARACTER SET utf8 NOT NULL DEFAULT '' COMMENT '收件人',
`create_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT '1970-01-01 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=85 DEFAULT CHARSET=utf8 COMMENT='邮件发送历史';
三、技术调研
前端:react
后端:node
- 自动发送邮件——node-cron
- token——jsonwebtoken
- 数据库操作——sequelize
用 node-cron 实现 系统定时自动发送邮件:
在后端项目里,编写一个 js 文件来使用 node-cro:
import Cron from 'node-cron';
// 每天上午8点,推送信息
export const task = Cron.schedule(`0 0 8 * * *`, async(ctx) => {
console.log("---------- Running Cron Job -----------");
try {
await subscribeMailContents.sendMail(ctx);
} catch (error) {
console.log(error)
}
});
同时,要在主程序(app.js或main.js)中引入并调用:
import { task } from './crontab.js';
task.start();
四、功能实现
1、邮件模板
邮件内容要在后端 node.js 里去实现。
微软一向地特立独行,使得 OutLook 成为了最难啃的骨头。因为 OutLook 支持的标签和属性少得可怜,所以只要兼容了 OutLook,其他邮箱客户端基本都不会有什么问题。
在实现邮件模板时,需要考虑 “邮件的兼容性”:
- 可以使用的标签有限:table 系列标签、以及 span、img、a 标签。
- 不能用浮动、定位、以及 CSS3 的所有属性,因为他们在 outlook 邮箱中无法识别。
- 在 node 里通过 JavaScript 整合邮件内容时,能用 table 系列标签来实现的,尽量不要使用 span。
- 使用 img 标签时要设置好其 title 和 alt 属性,图片的 src 属性值最好使用线上服务器里的图片而不是本地的。另外,Windows 上的 outlook 不支持 svg 格式的图片,所以要注意图片的格式。
- 如果你遇到有些邮箱不支持 colspan / rowspan 属性,你必须使用 table 嵌套来解决此问题(td 里是可以嵌套 table 标签的)。
- 如果你遇到有些邮箱不支持 margin 和 padding 样式,你可以尝试 hspace 和 vspace 属性来解决此问题。
- font-family 只支持系统字体,不支持自定义字体,所以使用默认字体就好,如果必须要设置字体,最好将其放在 a 或 span 标签里面。
- 邮件的样式支持两种书写格式:
- 全局的可以写在 head 里的 style 里,记得设置 type 属性为 text/css;
- 局部样式的定义只能使用内联样式,写在每个标签的 style 属性里。
在使用 table 时,为了避免“产生多余的空白像素”,我们需要给 table 加上边框 border、单元格内边距 cellpadding、单元格间距 cellspacing和边框合并属性 border-collapse 这些属性:
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;">
<!-- ... -->
</table>
能用属性就不要用样式:
-
table 标签常用属性:
- border:设置表格的边框宽度,像素值(默认为0)。
- cellspacing:设置单元格与单元格边框之间的空白间距宽度,像素值(默认为2像素)。
- cellpadding:设置单元格内容与边框线之间的空白间距宽度,像素值(默认为1像素)。
- width:设置表格的宽度,像素值,不带 px。
- height:设置表格的高度,像素值,不带 px。
- align:设置表格在网页中的水平对齐方式,left、center、right。
-
td/th 标签常用属性:
- width:设置单元格的宽度,像素值,不带 px。
- height:设置单元格的高度,像素值,不带 px。
- align:设置单元格中的内容的水平对齐方式,left、center、right。
- valign:设置单元格中的内容的垂直对齐方式,top、middle、bottom。
- rowspan:设置要跨行(纵向)合并的单元格数,要合并的数量。
- colspan:设置要跨列(横向)合并的单元格数,要合并的数量。
<img width="10" height="10" src="*.png" />
一个基本的邮件模板:
<html style="height:100%; margin: 0; padding: 0;">
<head>
<style type="text/css">
img {
max-width: 750px;
}
</style>
</head>
<body style="height:100%; margin: 0; padding: 0;">
<span style="display: block;width: 850px; height: 100%;margin: 0 auto; padding: 0;">
<table
border="0"
cellpadding="0"
cellspacing="0"
align="center"
style="border-collapse: collapse;width: 750px;"
>
<tr>
<td align="center" colspan="2" style="padding: 48px 0 44px;">
<!-- ... -->
</td>
</tr>
<!-- ... -->
</table>
</span>
</body>
【拓展】
网络容错处理——用递归实现当网络异常时自动触发有限次数重复请求:
// 这里用到了 request 插件
questFromX = async (url, num, resolveFun) => {
console.log('xxxxxxxxxxxxxx');
try {
return new Promise((resolve, reject) => {
request.get({
url,
}, (error, response, body) => {
const myResolve = resolveFun || resolve;
if(error) {
console.log('error', error);
num ++;
console.log('num', num);
if (num > 3) {
console.log("Sorry, the internet connection is unstable.");
reject("Sorry, the internet connection is unstable.");
return;
}
this.timer && clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.questFromX(url, num, myResolve);
}, 1000 * 60 * 1);
} else {
console.log('myResolve', myResolve);
this.timer && clearTimeout(this.timer);
myResolve && myResolve(body);
}
})
})
} catch (error) {
console.log('请求的资源报错:', error);
}
}