diff --git a/next/api/package-lock.json b/next/api/package-lock.json index 8c2e3559c..d963f195b 100644 --- a/next/api/package-lock.json +++ b/next/api/package-lock.json @@ -26,6 +26,7 @@ "elastic-builder": "^2.24.0", "eventemitter3": "^4.0.7", "form-data": "^4.0.0", + "handlebars": "^4.7.8", "highlight.js": "^11.4.0", "html-to-text": "^9.0.5", "imapflow": "^1.0.158", @@ -4421,6 +4422,34 @@ "dev": true, "peer": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -6231,8 +6260,7 @@ "node_modules/minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "node_modules/minipass": { "version": "7.0.4", @@ -6304,6 +6332,11 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_modules/node_memcached": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/node_memcached/-/node_memcached-1.1.3.tgz", @@ -8178,6 +8211,18 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -8516,6 +8561,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", @@ -12055,6 +12105,25 @@ "dev": true, "peer": true }, + "handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -13483,8 +13552,7 @@ "minimist": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" }, "minipass": { "version": "7.0.4", @@ -13537,6 +13605,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_memcached": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/node_memcached/-/node_memcached-1.1.3.tgz", @@ -14939,6 +15012,12 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, + "uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -15198,6 +15277,11 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", diff --git a/next/api/package.json b/next/api/package.json index 691d092c4..902ca2d65 100644 --- a/next/api/package.json +++ b/next/api/package.json @@ -32,6 +32,7 @@ "elastic-builder": "^2.24.0", "eventemitter3": "^4.0.7", "form-data": "^4.0.0", + "handlebars": "^4.7.8", "highlight.js": "^11.4.0", "html-to-text": "^9.0.5", "imapflow": "^1.0.158", diff --git a/next/api/src/controller/email-notification.ts b/next/api/src/controller/email-notification.ts new file mode 100644 index 000000000..13538f7cc --- /dev/null +++ b/next/api/src/controller/email-notification.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +import { Body, Controller, Delete, Get, Put, UseMiddlewares } from '@/common/http'; +import { ZodValidationPipe } from '@/common/pipe'; +import { adminOnly, auth } from '@/middleware'; +import { emailNotificationService } from '@/service/email-notification'; +import { EmailNotificationResponse } from '@/response/email-notification'; + +const SmtpSchema = z.object({ + host: z.string(), + port: z.number().int().positive(), + secure: z.boolean(), + username: z.string(), + password: z.string().optional(), +}); + +const MessageSchema = z.object({ + text: z.string().optional(), + html: z.string().optional(), +}); + +const EventSchema = z.object({ + type: z.enum(['ticketRepliedByCustomerService']), + from: z.string().optional(), + to: z.string(), + subject: z.string(), + message: MessageSchema, +}); + +const SetEmailNotificationSchema = z.object({ + send: z.object({ + smtp: SmtpSchema, + }), + events: z.array(EventSchema).default([]), +}); + +@Controller('email-notification') +@UseMiddlewares(auth, adminOnly) +export class EmailNotificationController { + @Put() + async set( + @Body(new ZodValidationPipe(SetEmailNotificationSchema)) + data: z.infer + ) { + await emailNotificationService.set(data); + } + + @Get() + async get() { + const value = await emailNotificationService.get(false); + if (value) { + return new EmailNotificationResponse(value); + } + return null; + } + + @Delete() + async remove() { + await emailNotificationService.remove(); + } +} diff --git a/next/api/src/interfaces/email-notification.ts b/next/api/src/interfaces/email-notification.ts new file mode 100644 index 000000000..ee19b6f75 --- /dev/null +++ b/next/api/src/interfaces/email-notification.ts @@ -0,0 +1,42 @@ +export type EventType = 'ticketRepliedByCustomerService'; + +export interface SmtpConfig { + host: string; + port: number; + secure: boolean; + username: string; + password?: string; +} + +export interface MessageConfig { + text?: string; + html?: string; +} + +export interface EventConfig { + type: EventType; + from?: string; + to: string; + subject: string; + message: MessageConfig; +} + +export interface EmailNotification { + send: { + smtp: { + host: string; + port: number; + secure: boolean; + username: string; + password: string; + }; + }; + events: EventConfig[]; +} + +export interface SetEmailNotificationData { + send: { + smtp: SmtpConfig; + }; + events: EventConfig[]; +} diff --git a/next/api/src/model/Config.ts b/next/api/src/model/Config.ts index 5b6c6eb73..ac6aedce9 100644 --- a/next/api/src/model/Config.ts +++ b/next/api/src/model/Config.ts @@ -45,4 +45,12 @@ export class Config extends Model { configCache.delete(key); return newConfig; } + + static async remove(key: string) { + const config = await this.findOneByKey(key); + if (config) { + await config.delete({ useMasterKey: true }); + } + configCache.delete(key); + } } diff --git a/next/api/src/response/email-notification.ts b/next/api/src/response/email-notification.ts new file mode 100644 index 000000000..506204e91 --- /dev/null +++ b/next/api/src/response/email-notification.ts @@ -0,0 +1,19 @@ +import { EmailNotification } from '@/interfaces/email-notification'; + +export class EmailNotificationResponse { + constructor(private data: EmailNotification) {} + + toJSON() { + return { + ...this.data, + send: { + ...this.data.send, + smtp: { + ...this.data.send.smtp, + // Strip password + password: undefined, + }, + }, + }; + } +} diff --git a/next/api/src/service/email-notification.ts b/next/api/src/service/email-notification.ts new file mode 100644 index 000000000..c27186c6f --- /dev/null +++ b/next/api/src/service/email-notification.ts @@ -0,0 +1,124 @@ +import nodemailer from 'nodemailer'; +import Handlebars from 'handlebars'; + +import notification from '@/notification'; +import { HttpError } from '@/common/http'; +import { Config } from '@/config'; +import { + EmailNotification, + SetEmailNotificationData, + SmtpConfig, +} from '@/interfaces/email-notification'; +import { Reply } from '@/model/Reply'; +import { Ticket } from '@/model/Ticket'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function isEmail(value: string) { + return EMAIL_PATTERN.test(value); +} + +export class EmailNotificationService { + private createClient(smtp: SmtpConfig) { + return nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: smtp.secure, + auth: { + user: smtp.username, + pass: smtp.password, + }, + }); + } + + async set(data: SetEmailNotificationData) { + const config = await Config.get('emailNotification', { cache: false }); + const password = data.send.smtp.password ?? config?.send.smtp.password; + if (!password) { + throw new HttpError(400, 'Missing SMTP password'); + } + const newConfig: EmailNotification = { + send: { + smtp: { + ...data.send.smtp, + password, + }, + }, + events: data.events, + }; + const client = this.createClient(newConfig.send.smtp); + try { + if (!(await client.verify())) { + throw new HttpError(400, 'Invalid SMTP config'); + } + await Config.set('emailNotification', newConfig); + } finally { + client.close(); + } + } + + get(cache = true) { + return Config.get('emailNotification', { cache }); + } + + remove() { + return Config.remove('emailNotification'); + } + + async onCustomerServiceReply(ticket: Ticket, reply: Reply) { + const config = await this.get(); + if (!config) { + return; + } + + const events = config.events.filter((e) => e.type === 'ticketRepliedByCustomerService'); + if (events.length === 0) { + return; + } + + // Prepare ticket author for template + await ticket.load('author'); + + const tmplCtx = { ticket, reply }; + const render = (tmplString: string) => { + const template = Handlebars.compile(tmplString); + return template(tmplCtx); + }; + + const client = this.createClient(config.send.smtp); + try { + for (const event of events) { + const to = render(event.to); + if (!isEmail(to)) { + continue; + } + + if (ticket.channel === 'email' && to === ticket.author?.email) { + // 邮件渠道创建的工单,避免重复回复用户 + continue; + } + + await client.sendMail({ + from: event.from ?? config.send.smtp.username, + to, + subject: render(event.subject), + text: event.message.text && render(event.message.text), + html: event.message.html && render(event.message.html), + }); + } + } finally { + client.close(); + } + } +} + +export const emailNotificationService = new EmailNotificationService(); + +notification.on('replyTicket', (ctx) => { + if (ctx.reply.internal) { + return; + } + if (ctx.reply.isCustomerService) { + emailNotificationService.onCustomerServiceReply(ctx.ticket, ctx.reply); + } +}); diff --git a/next/web/src/App/Admin/Settings/EmailNotification/index.tsx b/next/web/src/App/Admin/Settings/EmailNotification/index.tsx new file mode 100644 index 000000000..dfbe1603b --- /dev/null +++ b/next/web/src/App/Admin/Settings/EmailNotification/index.tsx @@ -0,0 +1,289 @@ +import { useEffect } from 'react'; +import { useMutation, useQuery } from 'react-query'; +import { + Controller, + FormProvider, + useFieldArray, + useForm, + useFormContext, + useWatch, +} from 'react-hook-form'; +import { Button, Checkbox, Divider, Form, Input, InputNumber, Modal, Select, message } from 'antd'; +import { MdOutlineClose } from 'react-icons/md'; + +import { + EmailNotificationEvent, + SetEmailNotificationData, + getEmailNotification, + removeEmailNotification, + setEmailNotification, +} from '@/api/email-notification'; +import { LoadingCover } from '@/components/common'; + +const DEFAULT_VALUES = { + send: { + smtp: { + port: 465, + secure: true, + }, + }, + events: [], +}; + +const EVENTS: EmailNotificationEvent[] = ['ticketRepliedByCustomerService']; + +const EVENT_NAME: Record = { + ticketRepliedByCustomerService: '客服回复工单', +}; + +interface EventConfigProps { + index: number; +} + +function EventConfig({ index }: EventConfigProps) { + const { setValue } = useFormContext(); + + const message = useWatch({ name: `events.${index}.message` }); + + const htmlMode = !!message?.html; + + const handleChangeHtmlMode = (htmlMode: boolean) => { + const content = message.html ?? message.text; + if (htmlMode) { + setValue(`events.${index}.message`, { html: content }); + } else { + setValue(`events.${index}.message`, { text: content }); + } + }; + + return ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + +
+ handleChangeHtmlMode(e.target.checked)}> + HTML 模式 + +
+
+ )} + /> + + ); +} + +export function EmailNotification() { + const form = useForm({ + defaultValues: DEFAULT_VALUES, + }); + + const { fields: events, append: addEvent, remove: removeEvent } = useFieldArray({ + control: form.control, + name: 'events', + }); + + const handleAddEvent = () => { + addEvent({ + type: 'ticketRepliedByCustomerService', + to: '', + subject: '', + message: { + text: '', + }, + }); + }; + + const { data, isLoading } = useQuery({ + queryKey: ['EmailNotification'], + queryFn: getEmailNotification, + }); + + const { mutate, isLoading: isSaving } = useMutation({ + mutationFn: setEmailNotification, + onSuccess: () => { + message.success('已保存'); + }, + }); + + const { mutateAsync: remove } = useMutation({ + mutationFn: removeEmailNotification, + onSuccess: () => { + message.success('已移除'); + }, + }); + + const handleSubmit = (data: SetEmailNotificationData) => { + if (!data.send.smtp.password) { + data.send.smtp.password = undefined; + } + mutate(data); + }; + + const handleRemove = () => { + Modal.confirm({ + title: '移除邮件通知', + content: '已设置的数据将会丢失,该操作不课恢复。', + okButtonProps: { danger: true }, + onOk: remove, + }); + }; + + useEffect(() => { + if (data) form.reset(data); + }, [data]); + + return ( +
+

邮件通知

+ + {isLoading && } + + +
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + 启用 + + + )} + /> +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> +
+ + 发送事件 + +
+ {events.map((event, index) => ( +
+ + +
+ ))} + + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/next/web/src/App/Admin/Settings/index.tsx b/next/web/src/App/Admin/Settings/index.tsx index fdfc026e0..f36aff294 100644 --- a/next/web/src/App/Admin/Settings/index.tsx +++ b/next/web/src/App/Admin/Settings/index.tsx @@ -39,6 +39,7 @@ import { useCurrentUserIsAdmin } from '@/leancloud'; import { EvaluationConfig } from './Evaluation'; import { MergeUser } from './Users/MergeUser'; import { ExportTicketTask } from './ExportTicket/Tasks'; +import { EmailNotification } from './EmailNotification'; const SettingRoutes = () => ( @@ -150,6 +151,7 @@ const AdminSettingRoutes = () => ( } /> } /> + } /> } /> @@ -239,6 +241,10 @@ const routeGroups: MenuItem[] = [ name: '支持邮箱', path: 'support-emails', }, + { + name: '邮件通知', + path: 'email-notification', + }, { name: '导出记录', path: 'export-ticket/tasks', diff --git a/next/web/src/api/email-notification.ts b/next/web/src/api/email-notification.ts new file mode 100644 index 000000000..5dbece6c4 --- /dev/null +++ b/next/web/src/api/email-notification.ts @@ -0,0 +1,53 @@ +import { http } from '@/leancloud'; + +export type EmailNotificationEvent = 'ticketRepliedByCustomerService'; + +export interface EmailNotification { + send: { + smtp: { + host: string; + port: number; + secure: boolean; + username: string; + password: string; + }; + }; + events: EmailNotificationEventConfig[]; +} + +export interface EmailNotificationEventConfig { + type: EmailNotificationEvent; + from?: string; + to: string; + subject: string; + message: { + text?: string; + html?: string; + }; +} + +export interface SetEmailNotificationData { + send: { + smtp: { + host: string; + port: number; + secure: boolean; + username: string; + password?: string; + }; + }; + events: EmailNotificationEventConfig[]; +} + +export async function setEmailNotification(data: SetEmailNotificationData) { + await http.put('/api/2/email-notification', data); +} + +export async function getEmailNotification() { + const res = await http.get('/api/2/email-notification'); + return res.data; +} + +export async function removeEmailNotification() { + await http.delete('/api/2/email-notification'); +}