From 95ae65dc03f606276369c0475a792396faae80b1 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Mon, 18 Dec 2023 21:41:15 +0800 Subject: [PATCH] feat: add auto reply (#49) * feat: add auto reply * feat: add auto reply * feat: update readme * feat: add auto reply --- README.md | 1 + README_EN.md | 1 + db/schema.sql | 29 +++++++-- frontend/src/api/index.js | 9 ++- frontend/src/router/index.js | 5 ++ frontend/src/store/index.js | 11 ++++ frontend/src/views/Content.vue | 19 +++--- frontend/src/views/Header.vue | 32 +++++++++- frontend/src/views/Settings.vue | 102 ++++++++++++++++++++++++++++++++ worker/package.json | 2 + worker/pnpm-lock.yaml | 71 +++++++++++++++++++++- worker/src/email.js | 31 ++++++++++ worker/src/router.js | 65 ++++++++++++++++---- worker/src/worker.js | 4 +- 14 files changed, 349 insertions(+), 33 deletions(-) create mode 100644 frontend/src/views/Settings.vue diff --git a/README.md b/README.md index 9d010aa5..65448b86 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ - [x] 获取自定义名字的邮箱 - [x] 支持多语言 - [x] 增加访问授权,可作为私人站点 +- [x] 增加自动回复功能 - [ ] 免费版附件过大会造成 Exceeded CPU Limit 错误 --- diff --git a/README_EN.md b/README_EN.md index f5ab3fb9..71b767e9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -16,6 +16,7 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo - [x] Get Custom Name Email - [x] Support multiple languages - [x] Add access authorization, which can be used as a private site +- [x] Add auto reply feature - [ ] Exceeded CPU Limit error caused by the free version of the attachment ![demo](readme_assets/demo.png) diff --git a/db/schema.sql b/db/schema.sql index 710222bf..4ec6aba5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,25 @@ -DROP TABLE IF EXISTS mails; -DROP TABLE IF EXISTS address; -CREATE TABLE IF NOT EXISTS mails (id INTEGER PRIMARY KEY, source TEXT, address TEXT, subject TEXT, message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS address (id INTEGER PRIMARY KEY, name TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS mails ( + id INTEGER PRIMARY KEY, + source TEXT, + address TEXT, + subject TEXT, + message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS address ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS auto_reply_mails ( + id INTEGER PRIMARY KEY, + source_prefix TEXT, + name TEXT, + address TEXT UNIQUE, + subject TEXT, + message TEXT, + enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 3ab0538a..320c2722 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -2,7 +2,8 @@ import { useGlobalState } from '../store' import axios from 'axios' const API_BASE = import.meta.env.VITE_API_BASE || ""; -const { loading, auth, jwt, openSettings, showAuth, adminAuth, showAdminAuth } = useGlobalState(); +const { loading, auth, jwt, settings, openSettings } = useGlobalState(); +const { showAuth, adminAuth, showAdminAuth } = useGlobalState(); const instance = axios.create({ baseURL: API_BASE, @@ -65,9 +66,11 @@ const getSettings = async () => { if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') { return ""; } - loading.value = true; const res = await apiFetch("/api/settings");; - return res["address"]; + settings.value = { + address: res["address"], + auto_reply: res["auto_reply"] + }; } const adminShowPassword = async (id) => { diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 7516cee5..cc6f8a7c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' import Index from '../views/Index.vue' +import Settings from '../views/Settings.vue' import Admin from '../views/Admin.vue' const router = createRouter({ @@ -9,6 +10,10 @@ const router = createRouter({ path: '/', component: Index }, + { + path: '/settings', + component: Settings + }, { path: '/admin', component: Admin diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index edbf6c5c..110a9947 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -12,6 +12,16 @@ export const useGlobalState = createGlobalState( value: 'test.com' }] }) + const settings = ref({ + address: '', + auto_reply: { + subject: '', + message: '', + enabled: false, + source_prefix: '', + name: '', + } + }) const showAuth = ref(false); const showAdminAuth = ref(false); const auth = useStorage('auth', ''); @@ -21,6 +31,7 @@ export const useGlobalState = createGlobalState( const themeSwitch = useStorage('themeSwitch', false); return { loading, + settings, openSettings, showAuth, auth, diff --git a/frontend/src/views/Content.vue b/frontend/src/views/Content.vue index fe4958ff..f65a8b4c 100644 --- a/frontend/src/views/Content.vue +++ b/frontend/src/views/Content.vue @@ -12,8 +12,7 @@ import { api } from '../api' const { toClipboard } = useClipboard() const message = useMessage() -const address = ref("") -const { jwt, openSettings } = useGlobalState() +const { jwt, settings, openSettings } = useGlobalState() const autoRefresh = ref(false) const data = ref([]) const timer = ref(null) @@ -84,7 +83,7 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => { }) const refresh = async () => { - if (typeof address.value != 'string' || address.value.trim() === '') { + if (typeof settings.value.address != 'string' || settings.value.address.trim() === '') { return; } try { @@ -105,7 +104,7 @@ const refresh = async () => { const copy = async () => { try { - await toClipboard(address.value) + await toClipboard(settings.value.address) message.success('Copied'); } catch (e) { message.error(e.message || "error"); @@ -120,7 +119,7 @@ const newEmail = async () => { + `&domain=${emailDomain.value || ''}` ); jwt.value = res["jwt"]; - address.value = await api.getSettings(); + await api.getSettings(); await refresh(); showNewEmail.value = false; showPassword.value = true; @@ -132,7 +131,7 @@ const newEmail = async () => { onMounted(async () => { await api.getOpenSettings(message); emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : ""; - address.value = await api.getSettings(); + await api.getSettings(); await refresh(); }); @@ -140,9 +139,9 @@ onMounted(async () => {

{{ t('logoutConfirm') }}

diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 00000000..f646c16f --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,102 @@ + + + diff --git a/worker/package.json b/worker/package.json index c17306fe..5a93d7cd 100644 --- a/worker/package.json +++ b/worker/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { + "dev": "wrangler dev", "deploy": "wrangler deploy", "start": "wrangler dev" }, @@ -12,6 +13,7 @@ "dependencies": { "hono": "^3.11.7", "mailparser": "^3.6.5", + "mimetext": "^3.0.16", "postal-mime": "^1.1.0" }, "pnpm": { diff --git a/worker/pnpm-lock.yaml b/worker/pnpm-lock.yaml index 4c324b95..3dbe40cd 100644 --- a/worker/pnpm-lock.yaml +++ b/worker/pnpm-lock.yaml @@ -16,6 +16,9 @@ dependencies: mailparser: specifier: ^3.6.5 version: 3.6.5(patch_hash=ykuld7ytssxm3j6t6ehwmtonk4) + mimetext: + specifier: ^3.0.16 + version: 3.0.16 postal-mime: specifier: ^1.1.0 version: 1.1.0 @@ -27,6 +30,21 @@ devDependencies: packages: + /@babel/runtime-corejs3@7.23.6: + resolution: {integrity: sha512-Djs/ZTAnpyj0nyg7p1J6oiE/tZ9G2stqAFlLGZynrW+F3k2w2jGK2mLOBxzYIOcZYA89+c3d3wXKpYLcpwcU6w==} + engines: {node: '>=6.9.0'} + dependencies: + core-js-pure: 3.34.0 + regenerator-runtime: 0.14.1 + dev: false + + /@babel/runtime@7.23.6: + resolution: {integrity: sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@cloudflare/kv-asset-handler@0.2.0: resolution: {integrity: sha512-MVbXLbTcAotOPUj0pAMhVtJ+3/kFkwJqc5qNOleOZTv6QkZZABDMS21dSrSlVswEHwrpWC03e4fWytjqKvuE2A==} dependencies: @@ -306,6 +324,19 @@ packages: selderee: 0.11.0 dev: false + /@tsconfig/esm@1.0.5: + resolution: {integrity: sha512-JzoZ0h299JRLPfV5VBsMq1TuMy+OmU9bdV/7NcjfRojL0eIcA1k5ESrtjWrDwJRJnk9B0QmgR0rq04LERbdfWw==} + deprecated: this package has been deprecated + dev: false + + /@tsconfig/node18@2.0.1: + resolution: {integrity: sha512-UqdfvuJK0SArA2CxhKWwwAWfnVSXiYe63bVpMutc27vpngCntGUZQETO24pEJ46zU6XM+7SpqYoMgcO3bM11Ew==} + dev: false + + /@tsconfig/strictest@2.0.2: + resolution: {integrity: sha512-jt4jIsWKvUvuY6adJnQJlb/UR7DdjC8CjHI/OaSQruj2yX9/K6+KOvDt/vD6udqos/FUk5Op66CvYT7TBLYO5Q==} + dev: false + /@types/node-forge@1.3.10: resolution: {integrity: sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==} dependencies: @@ -316,7 +347,6 @@ packages: resolution: {integrity: sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==} dependencies: undici-types: 5.26.5 - dev: true /acorn-walk@8.3.1: resolution: {integrity: sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==} @@ -392,6 +422,11 @@ packages: engines: {node: '>= 0.6'} dev: true + /core-js-pure@3.34.0: + resolution: {integrity: sha512-pmhivkYXkymswFfbXsANmBAewXx86UBfmagP+w0wkK06kLsLlTK5oQmsURPivzMkIBQiYq2cjamcZExIwlFQIg==} + requiresBuild: true + dev: false + /data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} dev: true @@ -588,6 +623,10 @@ packages: engines: {node: '>=0.12.0'} dev: true + /js-base64@3.7.5: + resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} + dev: false + /leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} dev: false @@ -653,12 +692,37 @@ packages: libqp: 2.0.1 dev: false + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + /mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true dev: true + /mimetext@3.0.16: + resolution: {integrity: sha512-uqtsQ2eNEpqoLUBSGSF6Y9eSbbNNeiknv7M6k5nzcs7M0uWtSjqWINYe6ZY/CowxnVDtNFSX+k9K6IicPboNBg==} + dependencies: + '@babel/runtime': 7.23.6 + '@babel/runtime-corejs3': 7.23.6 + '@tsconfig/esm': 1.0.5 + '@tsconfig/node18': 2.0.1 + '@tsconfig/strictest': 2.0.2 + '@types/node': 20.10.4 + js-base64: 3.7.5 + mime-types: 2.1.35 + dev: false + /miniflare@3.20231030.4: resolution: {integrity: sha512-7MBz0ArLuDop1WJGZC6tFgN6c5MRyDOIlxbm3yp0TRBpvDS/KsTuWCQcCjsxN4QQ5zvL3JTkuIZbQzRRw/j6ow==} engines: {node: '>=16.13'} @@ -747,6 +811,10 @@ packages: picomatch: 2.3.1 dev: true + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: false + /resolve.exports@2.0.2: resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} engines: {node: '>=10'} @@ -842,7 +910,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: true /undici@5.28.2: resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} diff --git a/worker/src/email.js b/worker/src/email.js index 5c7b8a0a..db198e34 100644 --- a/worker/src/email.js +++ b/worker/src/email.js @@ -1,3 +1,6 @@ +import { createMimeMessage } from "mimetext"; +import { EmailMessage } from "cloudflare:email"; + const PostalMime = require("postal-mime"); const simpleParser = require('mailparser').simpleParser; global.setImmediate = (callback) => callback(); @@ -44,6 +47,34 @@ async function email(message, env, ctx) { message.setReject(`Failed save message to ${message.to}`); console.log(`Failed save message from ${message.from} to ${message.to}`); } + try { + const results = await env.DB.prepare( + `SELECT * FROM auto_reply_mails where address = ? and enabled = 1` + ).bind(message.to).first(); + if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) { + const msg = createMimeMessage(); + msg.setHeader("In-Reply-To", message.headers.get("Message-ID")); + msg.setSender({ + name: results.name || results.address, + addr: results.address + }); + msg.setRecipient(message.from); + msg.setSubject(results.subject || "Auto-reply"); + msg.addMessage({ + contentType: 'text/plain', + data: results.message || "This is an auto-reply message, please reconact later." + }); + + const replyMessage = new EmailMessage( + message.to, + message.from, + msg.asRaw() + ); + await message.reply(replyMessage); + } + } catch (error) { + console.log("reply email error", error); + } } else { message.setReject(`Unknown address ${message.to}`); console.log(`Unknown address ${message.to}`); diff --git a/worker/src/router.js b/worker/src/router.js index 5241adaa..4ee307dc 100644 --- a/worker/src/router.js +++ b/worker/src/router.js @@ -32,14 +32,59 @@ api.get('/api/mails', async (c) => { }) api.get('/api/settings', async (c) => { - return c.json(c.get("jwtPayload")); + const { address } = c.get("jwtPayload") + const results = await c.env.DB.prepare( + `SELECT * FROM auto_reply_mails where address = ?` + ).bind(address).first(); + if (!results) { + return c.json({ + auto_reply: {}, + address: address + }); + } + return c.json({ + auto_reply: { + subject: results.subject, + message: results.message, + enabled: results.enabled == 1, + source_prefix: results.source_prefix, + name: results.name, + }, + address: address + }); +}) + + +api.post('/api/settings', async (c) => { + const { address } = c.get("jwtPayload") + const { auto_reply } = await c.req.json(); + const { name, subject, source_prefix, message, enabled } = auto_reply; + if ((!subject || !message) && enabled) { + return c.text("Invalid subject or message", 400) + } + else if (subject.length > 255 || message.length > 255) { + return c.text("Subject or message too long", 400) + } + const { success } = await c.env.DB.prepare( + `INSERT OR REPLACE INTO + auto_reply_mails + (name, address, source_prefix, subject, message, enabled) + VALUES + (?, ?, ?, ?, ?, ?)` + ).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run(); + if (!success) { + return c.text("Failed to save settings", 500) + } + return c.json({ + success: success + }) }) api.get('/open_api/settings', async (c) => { // check header x-custom-auth let needAuth = false; if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) { - const auth = c.req.headers.get("x-custom-auth"); + const auth = c.req.raw.headers.get("x-custom-auth"); needAuth = !c.env.PASSWORDS.includes(auth); } return c.json({ @@ -63,7 +108,7 @@ api.get('/api/new_address', async (c) => { const emailAddress = c.env.PREFIX + name + "@" + domain try { const { success } = await c.env.DB.prepare( - `INSERT INTO address (name) VALUES (?)` + `INSERT INTO address(name) VALUES(?)` ).bind(name + "@" + domain).run(); if (!success) { return c.text("Failed to create address", 500) @@ -92,7 +137,7 @@ api.get('/admin/address', async (c) => { return c.text("Invalid offset", 400) } const { results } = await c.env.DB.prepare( - `SELECT * FROM address order by id desc limit ? offset ?` + `SELECT * FROM address order by id desc limit ? offset ? ` ).bind(limit, offset).all(); let count = 0; if (offset == 0) { @@ -113,7 +158,7 @@ api.get('/admin/address', async (c) => { api.delete('/admin/delete_address/:id', async (c) => { const { id } = c.req.param(); const { success } = await c.env.DB.prepare( - `DELETE FROM address WHERE id = ?` + `DELETE FROM address WHERE id = ? ` ).bind(id).run(); if (!success) { return c.text("Failed to delete address", 500) @@ -126,7 +171,7 @@ api.delete('/admin/delete_address/:id', async (c) => { api.get('/admin/show_password/:id', async (c) => { const { id } = c.req.param(); const name = await c.env.DB.prepare( - `SELECT name FROM address WHERE id = ?` + `SELECT name FROM address WHERE id = ? ` ).bind(id).first("name"); // compute address const emailAddress = c.env.PREFIX + name @@ -148,12 +193,12 @@ api.get('/admin/mails', async (c) => { return c.text("Invalid offset", 400) } const { results } = await c.env.DB.prepare( - `SELECT id, source, subject, message FROM mails where address = ? order by id desc limit ? offset ?` + `SELECT id, source, subject, message FROM mails where address = ? order by id desc limit ? offset ? ` ).bind(address, limit, offset).all(); let count = 0; if (offset == 0) { const { count: mailCount } = await c.env.DB.prepare( - `SELECT count(*) as count FROM mails where address = ?` + `SELECT count(*) as count FROM mails where address = ? ` ).bind(address).first(); count = mailCount; } @@ -173,7 +218,7 @@ api.get('/admin/mails_unknow', async (c) => { } const { results } = await c.env.DB.prepare(` SELECT id, source, subject, message FROM mails - where address NOT IN (select concat('${c.env.PREFIX}', name) from address) + where address NOT IN(select concat('${c.env.PREFIX}', name) from address) order by id desc limit ? offset ? ` ).bind(limit, offset).all(); let count = 0; @@ -181,7 +226,7 @@ api.get('/admin/mails_unknow', async (c) => { const { count: mailCount } = await c.env.DB.prepare(` SELECT count(*) as count FROM mails where address NOT IN - (select concat('${c.env.PREFIX}', name) from address)` + (select concat('${c.env.PREFIX}', name) from address)` ).first(); count = mailCount; } diff --git a/worker/src/worker.js b/worker/src/worker.js index 0a7580ff..689e0fa4 100644 --- a/worker/src/worker.js +++ b/worker/src/worker.js @@ -10,7 +10,7 @@ app.use('/*', cors()); app.use('/api/*', async (c, next) => { // check header x-custom-auth if (c.env.PASSWORDS && c.env.PASSWORDS.length > 0) { - const auth = c.req.headers.get("x-custom-auth"); + const auth = c.req.raw.headers.get("x-custom-auth"); if (!auth || !c.env.PASSWORDS.includes(auth)) { return c.text("Need Password", 401) } @@ -25,7 +25,7 @@ app.use('/api/*', async (c, next) => { app.use('/admin/*', async (c, next) => { // check header x-admin-auth if (c.env.ADMIN_PASSWORDS && c.env.ADMIN_PASSWORDS.length > 0) { - const adminAuth = c.req.headers.get("x-admin-auth"); + const adminAuth = c.req.raw.headers.get("x-admin-auth"); if (adminAuth && c.env.ADMIN_PASSWORDS.includes(adminAuth)) { await next(); return;