email.js
4.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
* 发信端口:验证码(auth 的 EmailPort.sendCode)与加入申请通过通知。
* 每次投递写入 email_delivery_logs。本文件不读 `process.env`,配置经 env 注入。
*/
import { createEmail } from '@drama/email'
import { recordEmailDelivery } from './email-delivery-log.js'
const EMAIL_LOCALES = Object.freeze(['en', 'zh', 'ja', 'ko', 'es', 'pt'])
/**
* @param {{ env: import('../env.js').VokoEnv, db: import('knex').Knex, logger: { error: Function } }} deps
*/
export function createEmailPorts({ env, db, logger }) {
const { host, port, secure, user, pass, from } = env.email
const email = createEmail({
provider: 'nodemailer',
credentials: { host, port, secure, auth: { user, pass }, defaultFrom: from },
})
/** @type {import('@drama/auth').EmailPort} */
const authEmail = {
async sendCode({ to, email: address, code, purpose, locale }) {
const toEmail = to || address
const result = await email.sendTemplate('verify-code', {
to: toEmail,
from,
code,
ttlMin: 10,
locale: emailLocale(locale),
})
await recordEmailDelivery(db, {
provider: result.provider || 'nodemailer',
messageId: result.messageId || null,
purpose: purpose || 'verify',
toEmail,
status: 'sent',
template: 'verify-code',
metadata: env.nodeEnv === 'production' ? null : { code },
})
},
}
const accessNotifier = {
/**
* 申请通过通知。暂复用 waitlist-invite 模板(文案为"邀请已就绪,完成注册"),链接指向登录页。
* @param {object} _ctx
* @param {{ to_email: string }} input
*/
async sendApproved(_ctx, { to_email }) {
try {
const result = await email.sendTemplate('waitlist-invite', {
to: to_email,
from,
locale: 'en',
inviteUrl: `${env.publicUrl}/login`,
})
await recordEmailDelivery(db, {
provider: result.provider || 'nodemailer',
messageId: result.message_id || null,
purpose: 'access_approved',
toEmail: to_email,
status: 'sent',
template: 'waitlist-invite',
})
} catch (err) {
// 外部 IO 的最小包裹:审核结果已提交,通知失败只记录,不回滚审核。
const errorMessage = err instanceof Error ? err.message : String(err)
logger.error('申请通过通知投递失败', { to_email, error: errorMessage })
await recordEmailDelivery(db, {
provider: 'nodemailer',
purpose: 'access_approved',
toEmail: to_email,
status: 'failed',
template: 'waitlist-invite',
errorMessage,
})
}
},
}
/**
* 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。
* @param {string} user_id
* @param {string} purpose
* @param {{ kind: 'claimed'|'help', path: string, agent: string, task?: string, question?: string }} notice
*/
async function sendOwnerNotice(user_id, purpose, { path, ...notice }) {
const owner = await db('users').where({ id: user_id }).first('email', 'locale')
if (owner === undefined) throw new Error(`users row missing for owner notice, id: ${user_id}`)
const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale: String(owner.locale).startsWith('zh') ? 'zh' : 'en', url: `${env.publicUrl}${path}`, ...notice })
await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' })
}
const ownerNotifier = {
/**
* 认领确认。
* @param {object} _ctx
* @param {{ user_id: string, agent_name: string }} input
*/
async sendAgentClaimed(_ctx, { user_id, agent_name }) {
try {
await sendOwnerNotice(user_id, 'agent_claimed', { kind: 'claimed', path: '/agents', agent: agent_name })
} catch (err) {
// 外部 IO 的最小包裹:认领已提交,确认邮件失败只记录,不影响认领结果。
logger.error('认领确认邮件投递失败', { user_id, error: err instanceof Error ? err.message : String(err) })
}
},
/**
* 求助通知。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。
* @param {object} _ctx
* @param {{ user_id: string, agent_name: string, task_title: string, question: string }} input
*/
async sendHelp(_ctx, { user_id, agent_name, task_title, question }) {
await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question })
},
}
return { authEmail, accessNotifier, ownerNotifier }
}
/**
* @param {unknown} value
* @returns {string}
*/
function emailLocale(value) {
return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en'
}