视频回放: https://www.bilibili.com/video/av14410658/25
使用场景 00:00
很多用户习惯使用 Email 来处理工作,所以工单系统也接入了邮件功能,不光可以用邮件接收工单提醒,还可以直接通过邮件来回复工单。
选择邮件服务提供商 01:40
如果要在应用中接入邮件功能,首先需要选择一个邮件服务提供商。对于服务商的选择不是本文的重点,我们就以 Mailgun9 来举例,其他的邮件服务使用起来也大同小异。
预先准备:
- 注册邮件服务商的账号。
- 添加需要的 domain,根据需要修改相关 DNS 的信息,或者使用默认提供的 domain。
- 从相关配置页面获取一些 key 等信息,以备程序中使用。比如 mailgun 就需要
apiKey
和 domain
两个信息。
- 查找是否有相关的 SDK,能极大的提高程序开发效率。比如 mailgun-js1。
提示:mailgun 的 apiKey
等信息不要写死在代码里,建议配置在云引擎的环境变量中,设置方法:LeanCloud 控制台 -> 云引擎 -> 设置 -> 自定义环境变量 中配置。
Ticket 中发送邮件 05:37
本文使用的 LeanTicket2 版本 6b962f2
。
以「新工单产生」事件为例:新的工单产生会触发 Ticket
的 afterSave hook1,会调用 api/notify.js
的 newTicket
1 方法:
// file: api/Ticket.js
// ...
AV.Cloud.afterSave('Ticket', (req) => {
// ...
return notify.newTicket(req.object, req.currentUser, assignee)
// ...
})
// ...
// file: api/notify.js
// ...
exports.newTicket = (ticket, author, assignee) => {
// ...
mail.newTicket(ticket, author, assignee),
// ...
}
// ...
最终调用到 api/mail.js
的 newTicket
方法:
// ...
exports.mailgun = require('mailgun-js')({apiKey: config.mailgunKey, domain: config.mailgunDomain})
// ...
exports.newTicket = (ticket, from, to) => {
// ...
return send({
from: `${from.get('username')} <ticket@leancloud.cn>`,
to: to.get('email'),
subject: `[LeanTicket] ${ticket.get('title')} (#${ticket.get('nid')})`,
'h:Reply-To': `ticket-${to.id}@leancloud.cn`,
text: ticket.get('content'),
url: common.getTicketUrl(ticket),
})
}
// ...
const send = (params) => {
// ...
return exports.mailgun.messages().send({
from: params.from,
to: params.to,
subject: params.subject,
'h:Reply-To': params['h:Reply-To'],
text: `${params.text},
--
您能收到邮件是因为该工单与您相关。
可以直接回复邮件,或者点击 ${params.url} 查看。`,
})
.catch((err) => {
// ...
})
}
基本上就是封装发送邮件所需要的参数,然后调用 mailgun SDK API 即可。replyTicket
和 changeAssignee
方法类似。
关于 h:Reply-To
部分在后面的「确定『回复人』」部分提到。
通过邮件回复工单 12:06
通过邮件回复工单,邮件先发送到 mailgun 服务器,通过 mailgun 配置的 route 就可以把邮件转发到 LeanTicket 中。所以我们需要先在 mailgun 中配置一个 route:
- Expression Type: Match Recipient (匹配收件人)
- Recipient:
ticket(.*)@leancloudcn
(收件人是 ticket
开头 @leancloud.cn
结尾)
- Actions: Store and notify:
https://leanticket.cn/webhooks/mailgun/catchall
(邮件发送到指定的 URL)
也需要在应用中配置相关的路由来接受邮件:
// file: api/index.js
router.use('/webhooks/mailgun', require('./mailgun'))
对于接受邮件的 webHook 路由,需要确认发起请求的是合法的(是 mailgun 服务器发出,而不是第三方伪造),需要一个鉴权的过程(代码):
router.post('/*', function (req, res, next) {
const body = req.body
if (!mailgun.validateWebhook(body.timestamp, body.token, body.signature)) {
console.error('Request came, but not from Mailgun')
res.send({ error: { message: 'Invalid signature. Are you even Mailgun?' } })
return
}
next()
})
鉴权通过后就可以从邮件中提取相关的信息,来生成工单的回复信息:
router.post('/catchall', function (req, res, next) {
Promise.all([
getFromUser(req.body),
getTicket(req.body),
]).spread((fromUser, ticket) => {
return new AV.Object('Reply').save({
ticket,
content: req.body['stripped-text'].replace(/\r\n/g, '\n')
}, {user: fromUser})
}).then(() => {
res.send('OK')
}).catch(next)
})
确定「回复人」18:42
邮件回复工单时,确定回复人可能是一个问题,假设场景:
- 有一个工单,通过邮件发送到客服张三的 zhangsan@lc.cn 邮箱。
- 张三习惯使用个人邮箱 zhangsan@gmail.com 抓取自己其他邮箱邮件统一浏览,所以工单邮件被抓取到 zhangsan@gmail.com 中。
- 张三回复邮件,此时邮件的「回复人」是 zhangsan@gmail.com 。
- 邮件经过 mailgun 到达 Ticket 应用,Ticket 根据邮件的回复者 zhangsan@gmail.com 到
_User
表查询不到任何信息(因为这不是张三在 Ticket 中注册的邮箱)。
为了解决上面的问题,Ticket 在发送邮件时设置了 特殊的头信息:
// file: api/mail.js
'h:Reply-To': `ticket-${to.id}@leancloud.cn`
邮件客户端在回复邮件时会将该头信息的值作为邮件的「收件人」,我们在其中拼接了 userId
信息,通过该信息就能定位到 邮件的回复人在 LeanTicket 中具体是哪个用户,而不用关心其 email 信息:
// file: api/mailgun.js
const getFromUser = (mail) => {
const match = mail.To.match(/^.*<ticket-(.*)@leancloud.cn>.*$/)
if (match) {
return new AV.Query('_User').get(match[1], {useMasterKey: true})
.then((user) => {
if (!user) {
throw new Error('user not found, objectId=' + match[1])
}
return user
})
}
throw new Error('user objectId mismatch:' + mail.To)
}
提示:因为此流程是 mailgun 触发,调用该方法的上下文没有 currentUser
信息,所以查询 _User
时使用 masterKey 权限。
另外通过邮件标题中的工单编号,很容易定位到具体的工单:
const getTicket = (mail) => {
const match = mail.Subject.match(/.*\s\(#(\d+)\)$/)
if (match) {
return new AV.Query('Ticket').equalTo('nid', parseInt(match[1])).first({useMasterKey: true})
}
return Promise.resolve()
}