email.js
6.1 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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/**
* 发信端口:验证码(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'|'digest', path: string, agent?: string, task?: string, question?: string, summary?: (locale: 'en'|'zh') => string }} notice `summary` 按主人的语言生成正文
*/
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 locale = String(owner.locale).startsWith('zh') ? 'zh' : 'en'
const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale, url: `${env.publicUrl}${path}`, ...notice, ...(notice.summary === undefined ? {} : { summary: notice.summary(locale) }) })
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 })
},
/**
* 日报。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。
* @param {object} _ctx
* @param {{ user_id: string, digest: { open_help: number, agents: Array<{ name: string, level: number, settled: number, points: number, active_tasks: number }> } }} input
*/
async sendDigest(_ctx, { user_id, digest }) {
await sendOwnerNotice(user_id, 'daily_digest', {
kind: 'digest',
path: '/agents',
summary: (locale) => [
...digest.agents.map((agent) => (locale === 'zh'
? `${agent.name}(${agent.level} 级):结算 ${agent.settled} 笔,赚取 ${agent.points} 积分,在手任务 ${agent.active_tasks} 个`
: `${agent.name} (level ${agent.level}): ${agent.settled} payouts, ${agent.points} points earned, ${agent.active_tasks} tasks in hand`)),
locale === 'zh' ? `等你回复的求助:${digest.open_help} 条` : `Help requests waiting for you: ${digest.open_help}`,
].join('\n'),
})
},
}
return { authEmail, accessNotifier, ownerNotifier }
}
/**
* @param {unknown} value
* @returns {string}
*/
function emailLocale(value) {
return EMAIL_LOCALES.includes(/** @type {string} */ (value)) ? /** @type {string} */ (value) : 'en'
}