├── mail-worker ├── src │ ├── api │ │ ├── test-api.js │ │ ├── init-api.js │ │ ├── analysis-api.js │ │ ├── resend-api.js │ │ ├── telegram-api.js │ │ ├── r2-api.js │ │ ├── oauth-api.js │ │ ├── all-email-api.js │ │ ├── public-api.js │ │ ├── login-api.js │ │ ├── star-api.js │ │ ├── my-api.js │ │ ├── account-api.js │ │ ├── setting-api.js │ │ ├── reg-key-api.js │ │ ├── role-api.js │ │ ├── email-api.js │ │ └── user-api.js │ ├── entity │ │ ├── orm.js │ │ ├── role-perm.js │ │ ├── perm.js │ │ ├── star.js │ │ ├── verify-record.js │ │ ├── reg-key.js │ │ ├── oauth.js │ │ ├── account.js │ │ ├── att.js │ │ ├── role.js │ │ ├── user.js │ │ ├── email.js │ │ └── setting.js │ ├── const │ │ ├── kv-const.js │ │ ├── constant.js │ │ └── entity-const.js │ ├── error │ │ └── biz-error.js │ ├── model │ │ └── result.js │ ├── utils │ │ ├── verify-utils.js │ │ ├── date-uitil.js │ │ ├── domain-uitls.js │ │ ├── email-utils.js │ │ ├── req-utils.js │ │ ├── crypto-utils.js │ │ ├── file-utils.js │ │ └── jwt-utils.js │ ├── security │ │ └── user-context.js │ ├── i18n │ │ ├── i18n.js │ │ └── zh.js │ ├── hono │ │ ├── webs.js │ │ └── hono.js │ ├── template │ │ ├── email-text.js │ │ └── email-msg.js │ ├── service │ │ ├── kv-obj-service.js │ │ ├── turnstile-service.js │ │ ├── perm-service.js │ │ ├── resend-service.js │ │ ├── r2-service.js │ │ ├── star-service.js │ │ ├── s3-service.js │ │ ├── analysis-service.js │ │ ├── verify-record-service.js │ │ ├── telegram-service.js │ │ ├── reg-key-service.js │ │ └── oauth-service.js │ └── index.js ├── .prettierrc ├── .editorconfig ├── vitest.config.js ├── wrangler-dev.toml ├── package.json ├── test │ └── index.spec.js ├── wrangler.toml ├── wrangler-test.toml └── wrangler-action.toml ├── mail-vue ├── public │ ├── content.css │ ├── mail.png │ ├── mail-pwa.png │ ├── image │ │ └── linuxdo.webp │ ├── tinymce │ │ ├── langs │ │ │ └── README.md │ │ ├── license.md │ │ ├── skins │ │ │ ├── ui │ │ │ │ ├── oxide │ │ │ │ │ ├── skin.shadowdom.min.css │ │ │ │ │ └── skin.shadowdom.js │ │ │ │ ├── oxide-dark │ │ │ │ │ ├── skin.shadowdom.min.css │ │ │ │ │ └── skin.shadowdom.js │ │ │ │ ├── tinymce-5 │ │ │ │ │ ├── skin.shadowdom.min.css │ │ │ │ │ └── skin.shadowdom.js │ │ │ │ └── tinymce-5-dark │ │ │ │ │ ├── skin.shadowdom.min.css │ │ │ │ │ └── skin.shadowdom.js │ │ │ └── content │ │ │ │ ├── default │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ │ │ ├── tinymce-5 │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ │ │ ├── writer │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ │ │ ├── dark │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ │ │ ├── tinymce-5-dark │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ │ │ └── document │ │ │ │ ├── content.min.css │ │ │ │ └── content.js │ │ ├── notices.txt │ │ ├── plugins │ │ │ ├── code │ │ │ │ └── plugin.min.js │ │ │ ├── visualblocks │ │ │ │ └── plugin.min.js │ │ │ ├── nonbreaking │ │ │ │ └── plugin.min.js │ │ │ ├── save │ │ │ │ └── plugin.min.js │ │ │ ├── pagebreak │ │ │ │ └── plugin.min.js │ │ │ ├── autoresize │ │ │ │ └── plugin.min.js │ │ │ ├── anchor │ │ │ │ └── plugin.min.js │ │ │ ├── insertdatetime │ │ │ │ └── plugin.min.js │ │ │ ├── help │ │ │ │ └── js │ │ │ │ │ └── i18n │ │ │ │ │ └── keynav │ │ │ │ │ ├── zh_CN.js │ │ │ │ │ ├── zh_TW.js │ │ │ │ │ ├── ja.js │ │ │ │ │ └── ko_KR.js │ │ │ ├── autolink │ │ │ │ └── plugin.min.js │ │ │ ├── autosave │ │ │ │ └── plugin.min.js │ │ │ └── importcss │ │ │ │ └── plugin.min.js │ │ └── css │ │ │ └── index.css │ └── _headers ├── src │ ├── enums │ │ └── email-enum.js │ ├── utils │ │ ├── time-utils.js │ │ ├── verify-utils.js │ │ ├── text.js │ │ ├── convert.js │ │ ├── file-utils.js │ │ ├── icon-utils.js │ │ └── day.js │ ├── request │ │ ├── analysis.js │ │ ├── ouath.js │ │ ├── login.js │ │ ├── my.js │ │ ├── star.js │ │ ├── all-email.js │ │ ├── account.js │ │ ├── setting.js │ │ ├── reg-key.js │ │ ├── role.js │ │ ├── email.js │ │ └── user.js │ ├── store │ │ ├── send.js │ │ ├── draft.js │ │ ├── account.js │ │ ├── writer.js │ │ ├── role.js │ │ ├── setting.js │ │ ├── user.js │ │ ├── email.js │ │ └── ui.js │ ├── i18n │ │ └── index.js │ ├── components │ │ ├── send-percent │ │ │ └── index.vue │ │ ├── hamburger │ │ │ └── index.vue │ │ ├── loading │ │ │ └── index.vue │ │ └── shadow-html │ │ │ └── index.vue │ ├── db │ │ └── db.js │ ├── App.vue │ ├── views │ │ ├── 404 │ │ │ └── index.vue │ │ ├── test │ │ │ └── index.vue │ │ ├── star │ │ │ └── index.vue │ │ ├── send │ │ │ └── index.vue │ │ └── draft │ │ │ └── index.vue │ ├── main.js │ ├── echarts │ │ └── index.js │ ├── init │ │ └── init.js │ ├── layout │ │ └── index.vue │ └── perm │ │ └── perm.js ├── .env.eo ├── .env.remote ├── .env.dev ├── .env.release ├── jsconfig.json ├── package.json ├── vite.config.js └── index.html ├── doc ├── demo │ ├── demo1.png │ ├── demo2.png │ ├── demo3.png │ ├── demo4.png │ └── logo.png ├── images │ └── support.png └── github-action.md ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── need-help.md │ └── feature_request.md ├── .gitignore ├── LICENSE └── README.md /mail-worker/src/api/test-api.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /mail-vue/public/content.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #000; 3 | } -------------------------------------------------------------------------------- /doc/demo/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/demo/demo1.png -------------------------------------------------------------------------------- /doc/demo/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/demo/demo2.png -------------------------------------------------------------------------------- /doc/demo/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/demo/demo3.png -------------------------------------------------------------------------------- /doc/demo/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/demo/demo4.png -------------------------------------------------------------------------------- /doc/demo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/demo/logo.png -------------------------------------------------------------------------------- /doc/images/support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/doc/images/support.png -------------------------------------------------------------------------------- /mail-vue/public/mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/mail-vue/public/mail.png -------------------------------------------------------------------------------- /mail-vue/public/mail-pwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/mail-vue/public/mail-pwa.png -------------------------------------------------------------------------------- /mail-vue/src/enums/email-enum.js: -------------------------------------------------------------------------------- 1 | export const EmailUnreadEnum = { 2 | UNREAD: 0, 3 | READ: 1 4 | } -------------------------------------------------------------------------------- /mail-vue/.env.eo: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'eo' 2 | VITE_APP_TITLE = 'eo环境' 3 | VITE_BASE_URL = '' 4 | VITE_PWA_NAME = 'Cloud Mail' 5 | -------------------------------------------------------------------------------- /mail-vue/public/image/linuxdo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0ise/cloud-mail/main/mail-vue/public/image/linuxdo.webp -------------------------------------------------------------------------------- /mail-vue/.env.remote: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'remote' 2 | VITE_APP_TITLE = '远程环境' 3 | VITE_BASE_URL = '' 4 | VITE_PWA_NAME = 'Cloud Mail' 5 | -------------------------------------------------------------------------------- /mail-worker/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /mail-vue/src/utils/time-utils.js: -------------------------------------------------------------------------------- 1 | export function sleep(ms) { 2 | return new Promise(resolve => setTimeout(resolve, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /mail-vue/.env.dev: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'dev' 2 | VITE_APP_TITLE = '开发环境' 3 | VITE_BASE_URL = 'http://127.0.0.1:8787/api' 4 | VITE_PWA_NAME = 'Cloud Mail' 5 | -------------------------------------------------------------------------------- /mail-vue/.env.release: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'release' 2 | VITE_APP_TITLE = '发布环境' 3 | VITE_BASE_URL = '/api' 4 | VITE_PWA_NAME = 'Cloud Mail' 5 | VITE_OUT_DIR = ../mail-worker/dist 6 | -------------------------------------------------------------------------------- /mail-vue/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /mail-worker/src/entity/orm.js: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1'; 2 | 3 | export default function orm(c) { 4 | return drizzle(c.env.db,{logger: c.env.orm_log}) 5 | } 6 | -------------------------------------------------------------------------------- /mail-vue/src/request/analysis.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js' 2 | 3 | export function analysisEcharts(timeZone) { 4 | return http.get('/analysis/echarts',{params: {timeZone}}); 5 | } -------------------------------------------------------------------------------- /mail-vue/src/store/send.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useSendStore = defineStore('send', { 4 | state: () => ({ 5 | deleteId: 0 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /mail-worker/src/api/init-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import initService from '../init/init'; 3 | 4 | app.get('/init/:secret', (c) => { 5 | return initService.init(c); 6 | }) 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/need-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Need Help 3 | about: Create a issue to find help 4 | title: "[Help] " 5 | labels: help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/langs/README.md: -------------------------------------------------------------------------------- 1 | This is where language files should be placed. 2 | 3 | Please DO NOT translate these directly, use this service instead: https://crowdin.com/project/tinymce 4 | -------------------------------------------------------------------------------- /mail-vue/src/store/draft.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const userDraftStore = defineStore('draft', { 4 | state: () => ({ 5 | refreshList: 0, 6 | setDraft: {}, 7 | }) 8 | }) -------------------------------------------------------------------------------- /mail-worker/src/const/kv-const.js: -------------------------------------------------------------------------------- 1 | const KvConst = { 2 | AUTH_INFO: 'auth-uid:', 3 | SETTING: 'setting:', 4 | SEND_DAY_COUNT: 'send_day_count:', 5 | PUBLIC_KEY: "public_key:" 6 | } 7 | 8 | export default KvConst; 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature request] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /mail-vue/public/_headers: -------------------------------------------------------------------------------- 1 | /assets/* 2 | Cache-Control: public, max-age=31556952, immutable 3 | /tinymce/* 4 | Cache-Control: public, max-age=604800, immutable 5 | /image/* 6 | Cache-Control: public, max-age=604800, immutable 7 | -------------------------------------------------------------------------------- /mail-worker/src/error/biz-error.js: -------------------------------------------------------------------------------- 1 | class BizError extends Error { 2 | constructor(message, code) { 3 | super(message); 4 | this.code = code ? code : 501; 5 | this.name = 'BizError'; 6 | } 7 | } 8 | 9 | export default BizError; 10 | -------------------------------------------------------------------------------- /mail-worker/src/model/result.js: -------------------------------------------------------------------------------- 1 | const result = { 2 | ok(data) { 3 | return { code: 200, message: 'success', data: data ? data : null }; 4 | }, 5 | fail(message, code = 500) { 6 | return { code, message }; 7 | } 8 | }; 9 | export default result; 10 | -------------------------------------------------------------------------------- /mail-worker/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /mail-vue/src/store/account.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useAccountStore = defineStore('account', { 4 | state: () => ({ 5 | currentAccountId: 0, 6 | currentAccount: {}, 7 | changeUserAccountName: '' 8 | }) 9 | }) -------------------------------------------------------------------------------- /mail-vue/src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | import en from './en.js' 3 | import zh from './zh.js' 4 | const i18n = createI18n({ 5 | legacy: false, 6 | messages: { 7 | zh, 8 | en 9 | }, 10 | }); 11 | 12 | export default i18n; -------------------------------------------------------------------------------- /mail-vue/src/request/ouath.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function oauthLinuxDoLogin(code) { 4 | return http.post('/oauth/linuxDo/login',{code}) 5 | } 6 | 7 | export function oauthBindUser(form) { 8 | return http.put('/oauth/bindUser', form) 9 | } 10 | -------------------------------------------------------------------------------- /mail-vue/src/store/writer.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useWriterStore = defineStore('writer', { 4 | state: () => ({ 5 | sendRecipientRecord: [] 6 | }), 7 | persist: { 8 | pick: ['sendRecipientRecord'], 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /mail-vue/src/store/role.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useRoleStore = defineStore('role', { 4 | state: () => ({ 5 | refresh: 0, 6 | }), 7 | actions: { 8 | refreshSelect() { 9 | this.refresh ++ 10 | } 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /mail-vue/src/utils/verify-utils.js: -------------------------------------------------------------------------------- 1 | export function isEmail(email) { 2 | const reg = /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/; 3 | return reg.test(email); 4 | } 5 | 6 | export function isDomain(str) { 7 | return /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(str); 8 | } -------------------------------------------------------------------------------- /mail-worker/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: './wrangler.jsonc' }, 8 | }, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /mail-worker/src/entity/role-perm.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | export const rolePerm = sqliteTable('role_perm', { 3 | id: integer('id').primaryKey({ autoIncrement: true }), 4 | roleId: integer('role_id'), 5 | permId: integer('perm_id') 6 | }); 7 | export default rolePerm 8 | -------------------------------------------------------------------------------- /mail-worker/src/utils/verify-utils.js: -------------------------------------------------------------------------------- 1 | const verifyUtils = { 2 | isEmail(str) { 3 | return /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(str); 4 | }, 5 | isDomain(str) { 6 | return /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(str); 7 | } 8 | } 9 | 10 | export default verifyUtils 11 | -------------------------------------------------------------------------------- /mail-worker/src/api/analysis-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import analysisService from '../service/analysis-service'; 3 | import result from '../model/result'; 4 | 5 | app.get('/analysis/echarts', async (c) => { 6 | const data = await analysisService.echarts(c, c.req.query()); 7 | return c.json(result.ok(data)); 8 | }) 9 | -------------------------------------------------------------------------------- /mail-worker/src/api/resend-api.js: -------------------------------------------------------------------------------- 1 | import resendService from '../service/resend-service'; 2 | import app from '../hono/hono'; 3 | app.post('/webhooks',async (c) => { 4 | try { 5 | await resendService.webhooks(c, await c.req.json()); 6 | return c.text('success', 200) 7 | } catch (e) { 8 | return c.text(e.message, 500) 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/license.md: -------------------------------------------------------------------------------- 1 | # Software License Agreement 2 | 3 | **TinyMCE** – [](https://github.com/tinymce/tinymce) 4 | Copyright (c) 2024, Ephox Corporation DBA Tiny Technologies, Inc. 5 | 6 | Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). 7 | -------------------------------------------------------------------------------- /mail-vue/src/request/login.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function login(email, password) { 4 | return http.post('/login', {email: email, password: password}) 5 | } 6 | 7 | export function logout() { 8 | return http.delete('/logout') 9 | } 10 | 11 | export function register(form) { 12 | return http.post('/register', form) 13 | } -------------------------------------------------------------------------------- /mail-vue/src/request/my.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function loginUserInfo() { 4 | return http.get('/my/loginUserInfo') 5 | } 6 | 7 | export function resetPassword(password) { 8 | return http.put('/my/resetPassword', {password}) 9 | } 10 | 11 | export function userDelete() { 12 | return http.delete('/my/delete') 13 | } 14 | 15 | -------------------------------------------------------------------------------- /mail-vue/src/store/setting.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useSettingStore = defineStore('setting', { 4 | state: () => ({ 5 | domainList: [], 6 | settings: { 7 | r2Domain: '', 8 | loginOpacity: 1.00, 9 | }, 10 | lang: '', 11 | }), 12 | actions: { 13 | 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /mail-vue/src/utils/text.js: -------------------------------------------------------------------------------- 1 | export function getTextWidth(text, font = '14px sans-serif') { 2 | // 强制设置 Canvas 分辨率 3 | const canvas = document.createElement('canvas'); 4 | canvas.width = 2000; // 足够大的画布 5 | canvas.style.width = '1000px'; // 避免 CSS 缩放影响 6 | const ctx = canvas.getContext('2d'); 7 | ctx.font = font; 8 | return ctx.measureText(text).width; 9 | } -------------------------------------------------------------------------------- /mail-worker/src/api/telegram-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import telegramService from '../service/telegram-service'; 3 | 4 | app.get('/telegram/getEmail/:token', async (c) => { 5 | const content = await telegramService.getEmailContent(c, c.req.param()); 6 | c.header('Cache-Control', 'public, max-age=604800, immutable'); 7 | return c.html(content) 8 | }); 9 | 10 | -------------------------------------------------------------------------------- /mail-vue/src/request/star.js: -------------------------------------------------------------------------------- 1 | import http from "@/axios/index.js"; 2 | 3 | export function starAdd(emailId) { 4 | return http.post('/star/add', {emailId}) 5 | } 6 | 7 | export function starCancel(emailId) { 8 | return http.delete('/star/cancel', {params: {emailId}}) 9 | } 10 | 11 | export function starList(emailId,size) { 12 | return http.get('/star/list', {params: {emailId,size}}) 13 | } -------------------------------------------------------------------------------- /mail-worker/src/utils/date-uitil.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import utc from 'dayjs/plugin/utc' 3 | import timezone from 'dayjs/plugin/timezone' 4 | dayjs.extend(utc) 5 | dayjs.extend(timezone) 6 | 7 | export function formatDetailDate(time) { 8 | return dayjs(time).format('YYYY-MM-DD HH:mm:ss') 9 | } 10 | 11 | export function toUtc(time) { 12 | return dayjs.utc(time || dayjs()) 13 | } 14 | -------------------------------------------------------------------------------- /mail-worker/src/utils/domain-uitls.js: -------------------------------------------------------------------------------- 1 | const domainUtils = { 2 | toOssDomain(domain) { 3 | 4 | if (!domain) { 5 | return null 6 | } 7 | 8 | if (!domain.startsWith('http')) { 9 | return 'https://' + domain 10 | } 11 | 12 | if (domain.endsWith("/")) { 13 | domain = domain.slice(0, -1); 14 | } 15 | 16 | return domain 17 | } 18 | } 19 | 20 | export default domainUtils 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | lerna-debug.log* 8 | 9 | node_modules 10 | dist-ssr 11 | *.local 12 | dist 13 | 14 | .vscode/* 15 | !.vscode/extensions.json 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | mail-vue/node_modules 25 | mail-vue/dist 26 | .wrangler 27 | .venv 28 | -------------------------------------------------------------------------------- /mail-worker/src/const/constant.js: -------------------------------------------------------------------------------- 1 | const constant = { 2 | TOKEN_HEADER: 'Authorization', 3 | JWT_UID: 'user_id:', 4 | JWT_TOKEN: 'token:', 5 | TOKEN_EXPIRE: 60 * 60 * 24 * 30, 6 | ATTACHMENT_PREFIX: 'attachments/', 7 | BACKGROUND_PREFIX: 'static/background/', 8 | ADMIN_ROLE: { 9 | name: 'admin', 10 | sendCount: 0, 11 | sendType: 'count', 12 | accountCount: 0 13 | } 14 | } 15 | 16 | export default constant 17 | -------------------------------------------------------------------------------- /mail-worker/src/entity/perm.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | export const perm = sqliteTable('perm', { 3 | permId: integer('perm_id').primaryKey({ autoIncrement: true }), 4 | name: text('name').notNull(), 5 | permKey: text('perm_key'), 6 | pid: integer('pid').notNull().default(0), 7 | type: integer('type').notNull().default(2), 8 | sort: integer('sort') 9 | }); 10 | export default perm 11 | -------------------------------------------------------------------------------- /mail-vue/src/request/all-email.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function allEmailList(params) { 4 | return http.get('/allEmail/list', {params: {...params}}) 5 | } 6 | 7 | export function allEmailDelete(emailIds) { 8 | return http.delete('/allEmail/delete?emailIds=' + emailIds) 9 | } 10 | 11 | export function allEmailBatchDelete(params) { 12 | return http.delete('/allEmail/batchDelete', {params: params} ) 13 | } 14 | -------------------------------------------------------------------------------- /mail-worker/src/entity/star.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | 4 | export const star = sqliteTable('star', { 5 | starId: integer('star_id').primaryKey({ autoIncrement: true }), 6 | userId: integer('user_id').notNull(), 7 | emailId: integer('email_id').notNull(), 8 | createTime: text('create_time') 9 | .notNull() 10 | .default(sql`CURRENT_TIMESTAMP`), 11 | }); 12 | -------------------------------------------------------------------------------- /mail-worker/src/security/user-context.js: -------------------------------------------------------------------------------- 1 | import JwtUtils from '../utils/jwt-utils'; 2 | import constant from '../const/constant'; 3 | 4 | const userContext = { 5 | getUserId(c) { 6 | return c.get('user').userId; 7 | }, 8 | 9 | getUser(c) { 10 | return c.get('user'); 11 | }, 12 | 13 | async getToken(c) { 14 | const jwt = c.req.header(constant.TOKEN_HEADER); 15 | const { token } = JwtUtils.verifyToken(c,jwt); 16 | return token; 17 | }, 18 | }; 19 | export default userContext; 20 | -------------------------------------------------------------------------------- /mail-worker/src/api/r2-api.js: -------------------------------------------------------------------------------- 1 | import r2Service from '../service/r2-service'; 2 | import app from '../hono/hono'; 3 | 4 | app.get('/oss/*', async (c) => { 5 | const key = c.req.path.split('/oss/')[1]; 6 | const obj = await r2Service.getObj(c, key); 7 | return new Response(obj.body, { 8 | headers: { 9 | 'Content-Type': obj.httpMetadata?.contentType || 'application/octet-stream', 10 | 'Content-Disposition': obj.httpMetadata?.contentDisposition || null 11 | } 12 | }); 13 | }); 14 | 15 | 16 | -------------------------------------------------------------------------------- /mail-worker/src/api/oauth-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import result from "../model/result"; 3 | import oauthService from "../service/oauth-service"; 4 | 5 | app.post('/oauth/linuxDo/login', async (c) => { 6 | const loginInfo = await oauthService.linuxDoLogin(c, await c.req.json()); 7 | return c.json(result.ok(loginInfo)) 8 | }); 9 | 10 | app.put('/oauth/bindUser', async (c) => { 11 | const loginInfo = await oauthService.bindUser(c, await c.req.json()); 12 | return c.json(result.ok(loginInfo)) 13 | }) 14 | -------------------------------------------------------------------------------- /mail-worker/src/entity/verify-record.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const email = sqliteTable('verify_record', { 4 | vrId: integer('vr_id').primaryKey({ autoIncrement: true }), 5 | ip: integer('ip').notNull().default(''), 6 | count: integer('count').notNull().default(1), 7 | type: integer('type').notNull().default(0), 8 | updateTime: text('update_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 9 | }); 10 | export default email 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css: -------------------------------------------------------------------------------- 1 | body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} 2 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css: -------------------------------------------------------------------------------- 1 | body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} 2 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/tinymce-5/skin.shadowdom.min.css: -------------------------------------------------------------------------------- 1 | body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} 2 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/tinymce-5-dark/skin.shadowdom.min.css: -------------------------------------------------------------------------------- 1 | body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} 2 | -------------------------------------------------------------------------------- /mail-vue/src/components/send-percent/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 17 | 18 | -------------------------------------------------------------------------------- /mail-vue/src/request/account.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js' 2 | 3 | export function accountList(accountId, size) { 4 | return http.get('/account/list', {params: {accountId, size}}); 5 | } 6 | 7 | export function accountAdd(email,token) { 8 | return http.post('/account/add', {email,token}) 9 | } 10 | 11 | export function accountSetName(accountId,name) { 12 | return http.put('/account/setName', {name,accountId}) 13 | } 14 | 15 | export function accountDelete(accountId) { 16 | return http.delete('/account/delete', {params: {accountId}}) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/notices.txt: -------------------------------------------------------------------------------- 1 | Below is a list of third party libraries that this software uses: 2 | ---------------------------------------------------------------- 3 | 4 | dompurify - Patched by Tiny 5 | owner: Mario Heiderich 6 | repo: https://github.com/cure53/DOMPurify 7 | version: 3.2.4 8 | license: MPL-2.0 OR Apache-2.0 9 | 10 | prismjs 11 | owner: Lea Verou 12 | repo: https://github.com/PrismJS/prism 13 | version: 1.25.0 14 | license: MIT 15 | 16 | 17 | prism-themes 18 | owner: Lea Verou 19 | repo: https://github.com/PrismJS/prism-themes 20 | version: 1.9.0 21 | license: MIT 22 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/oxide/skin.shadowdom.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('ui/oxide/skin.shadowdom.css', `body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}`) -------------------------------------------------------------------------------- /mail-vue/src/db/db.js: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | import {useUserStore} from "@/store/user.js" 3 | import { watch, shallowRef } from "vue"; 4 | 5 | const userStore = useUserStore(); 6 | 7 | 8 | let db = shallowRef({}) 9 | 10 | function createDB() { 11 | db.value = new Dexie(userStore.user.email); 12 | db.value.version(1).stores({ 13 | draft: '++draftId,createTime' 14 | }) 15 | 16 | db.value.version(1).stores({ 17 | att: 'draftId' 18 | }) 19 | } 20 | 21 | createDB() 22 | 23 | watch(() => userStore.user.email,() => createDB()) 24 | 25 | export default db; -------------------------------------------------------------------------------- /mail-vue/src/request/setting.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function settingSet(setting) { 4 | return http.put('/setting/set',setting) 5 | } 6 | 7 | export function settingQuery() { 8 | return http.get('/setting/query') 9 | } 10 | 11 | export function websiteConfig() { 12 | return http.get('/setting/websiteConfig') 13 | } 14 | 15 | export function setBackground(background) { 16 | return http.put('/setting/setBackground',{background}) 17 | } 18 | 19 | export function deleteBackground() { 20 | return http.delete('/setting/deleteBackground') 21 | } 22 | -------------------------------------------------------------------------------- /mail-vue/src/store/user.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import {loginUserInfo} from "@/request/my.js"; 3 | 4 | export const useUserStore = defineStore('user', { 5 | state: () => ({ 6 | user: {}, 7 | refreshList: 0, 8 | }), 9 | actions: { 10 | refreshUserList() { 11 | loginUserInfo().then(user => { 12 | this.refreshList ++ 13 | }) 14 | }, 15 | refreshUserInfo() { 16 | loginUserInfo().then(user => { 17 | this.user = user 18 | }) 19 | } 20 | } 21 | }) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('ui/oxide-dark/skin.shadowdom.css', `body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/tinymce-5/skin.shadowdom.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('ui/tinymce-5/skin.shadowdom.css', `body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/ui/tinymce-5-dark/skin.shadowdom.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('ui/tinymce-5-dark/skin.shadowdom.css', `body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}`) -------------------------------------------------------------------------------- /mail-vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 17 | -------------------------------------------------------------------------------- /mail-vue/src/store/email.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useEmailStore = defineStore('email', { 4 | state: () => ({ 5 | deleteIds: 0, 6 | starScroll: null, 7 | emailScroll: null, 8 | cancelStarEmailId: 0, 9 | addStarEmailId: 0, 10 | contentData: { 11 | email: null, 12 | delType: null, 13 | showStar: true, 14 | showReply: true, 15 | showUnread: false 16 | }, 17 | sendScroll: null, 18 | }), 19 | persist: { 20 | pick: ['contentData'], 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /mail-worker/src/entity/reg-key.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const regKey = sqliteTable('reg_key', { 4 | regKeyId: integer('rege_key_id').primaryKey({ autoIncrement: true }), 5 | code: text('code').notNull().default(''), 6 | count: integer('count').notNull().default(0), 7 | roleId: integer('role_id').notNull().default(0), 8 | userId: integer('user_id').notNull().default(0), 9 | expireTime: text('expire_time'), 10 | createTime: text('create_time').notNull().default(sql`CURRENT_TIMESTAMP`) 11 | }); 12 | export default regKey 13 | -------------------------------------------------------------------------------- /mail-vue/src/request/reg-key.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function regKeyList(params) { 4 | return http.get('/regKey/list', {params:{...params}}) 5 | } 6 | 7 | export function regKeyAdd(form) { 8 | return http.post('/regKey/add',form) 9 | } 10 | 11 | export function regKeyDelete(regKeyIds) { 12 | return http.delete('/regKey/delete?regKeyIds='+ regKeyIds) 13 | } 14 | 15 | export function regKeyClearNotUse() { 16 | return http.delete('/regKey/clearNotUse') 17 | } 18 | 19 | export function regKeyHistory(regKeyId) { 20 | return http.get('/regKey/history', {params:{regKeyId}}) 21 | } 22 | -------------------------------------------------------------------------------- /mail-vue/src/views/404/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /mail-worker/src/entity/oauth.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | 4 | export const oauth = sqliteTable('oauth', { 5 | oauthId: integer('oauth_id').primaryKey({ autoIncrement: true }), 6 | oauthUserId: text('oauth_user_id'), 7 | username: text('username'), 8 | name: text('name'), 9 | avatar: text('avatar'), 10 | active: integer('active'), 11 | trustLevel: integer('trust_level'), 12 | silenced: integer('silenced'), 13 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 14 | userId: integer('user_id').default(0).notNull() 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /mail-worker/src/i18n/i18n.js: -------------------------------------------------------------------------------- 1 | import i18next from 'i18next'; 2 | import zh from './zh.js' 3 | import en from './en.js' 4 | import app from '../hono/hono'; 5 | 6 | app.use('*', async (c, next) => { 7 | const lang = c.req.header('accept-language')?.split('-')[0] 8 | i18next.init({ 9 | lng: lang, 10 | }); 11 | return await next() 12 | }) 13 | 14 | const resources = { 15 | en: { 16 | translation: en 17 | }, 18 | zh: { 19 | translation: zh, 20 | }, 21 | }; 22 | 23 | i18next.init({ 24 | fallbackLng: 'zh', 25 | resources, 26 | }); 27 | 28 | export const t = (key, values) => i18next.t(key, values) 29 | 30 | export default i18next; 31 | -------------------------------------------------------------------------------- /mail-worker/src/api/all-email-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import emailService from '../service/email-service'; 3 | import result from '../model/result'; 4 | 5 | app.get('/allEmail/list',async (c) => { 6 | const data = await emailService.allList(c, c.req.query()); 7 | return c.json(result.ok(data)); 8 | }) 9 | 10 | app.delete('/allEmail/delete',async (c) => { 11 | const list = await emailService.physicsDelete(c, c.req.query()); 12 | return c.json(result.ok(list)); 13 | }) 14 | 15 | app.delete('/allEmail/batchDelete',async (c) => { 16 | await emailService.batchDelete(c, c.req.query()); 17 | return c.json(result.ok()); 18 | }) 19 | -------------------------------------------------------------------------------- /mail-worker/src/entity/account.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const account = sqliteTable('account', { 4 | accountId: integer('account_id').primaryKey({ autoIncrement: true }), 5 | email: text('email').notNull(), 6 | name: text('name').notNull().default(''), 7 | status: integer('status').default(0).notNull(), 8 | latestEmailTime: text('latest_email_time'), 9 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`), 10 | userId: integer('user_id').notNull(), 11 | isDel: integer('is_del').default(0).notNull(), 12 | }); 13 | export default account 14 | -------------------------------------------------------------------------------- /mail-worker/src/api/public-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import result from '../model/result'; 3 | import publicService from '../service/public-service'; 4 | 5 | app.post('/public/genToken', async (c) => { 6 | const data = await publicService.genToken(c, await c.req.json()); 7 | return c.json(result.ok(data)); 8 | }); 9 | 10 | app.post('/public/emailList', async (c) => { 11 | const list = await publicService.emailList(c, await c.req.json()); 12 | return c.json(result.ok(list)); 13 | }); 14 | 15 | app.post('/public/addUser', async (c) => { 16 | await publicService.addUser(c, await c.req.json()); 17 | return c.json(result.ok()); 18 | }); 19 | -------------------------------------------------------------------------------- /mail-vue/src/main.js: -------------------------------------------------------------------------------- 1 | import {createApp} from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import './style.css'; 5 | import { init } from '@/init/init.js'; 6 | import { createPinia } from 'pinia'; 7 | import piniaPersistedState from 'pinia-plugin-persistedstate'; 8 | import 'element-plus/theme-chalk/dark/css-vars.css'; 9 | import 'nprogress/nprogress.css'; 10 | import perm from "@/perm/perm.js"; 11 | const pinia = createPinia().use(piniaPersistedState) 12 | import i18n from "@/i18n/index.js"; 13 | const app = createApp(App).use(pinia) 14 | await init() 15 | app.use(router).use(i18n).directive('perm',perm) 16 | app.config.devtools = true; 17 | 18 | app.mount('#app'); 19 | -------------------------------------------------------------------------------- /mail-worker/src/api/login-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import loginService from '../service/login-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.post('/login', async (c) => { 7 | const token = await loginService.login(c, await c.req.json()); 8 | return c.json(result.ok({ token: token })); 9 | }); 10 | 11 | app.post('/register', async (c) => { 12 | const jwt = await loginService.register(c, await c.req.json()); 13 | return c.json(result.ok(jwt)); 14 | }); 15 | 16 | app.delete('/logout', async (c) => { 17 | await loginService.logout(c, userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /mail-worker/src/hono/webs.js: -------------------------------------------------------------------------------- 1 | import app from './hono'; 2 | import '../security/security' 3 | 4 | import '../api/email-api'; 5 | import '../api/user-api'; 6 | import '../api/login-api'; 7 | import '../api/setting-api'; 8 | import '../api/account-api'; 9 | import '../api/star-api'; 10 | import '../api/test-api'; 11 | import '../api/r2-api'; 12 | import '../api/resend-api'; 13 | import '../api/user-api'; 14 | import '../api/my-api'; 15 | import '../api/role-api' 16 | import '../api/all-email-api' 17 | import '../api/init-api' 18 | import '../api/analysis-api' 19 | import '../api/reg-key-api' 20 | import '../api/public-api' 21 | import '../api/telegram-api' 22 | import '../api/oauth-api' 23 | export default app; 24 | -------------------------------------------------------------------------------- /mail-worker/wrangler-dev.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail-dev" 2 | main = "src/index.js" 3 | compatibility_date = "2025-06-04" 4 | keep_vars = true 5 | 6 | [observability] 7 | enabled = true 8 | 9 | [dev] 10 | ip = "0.0.0.0" 11 | 12 | [[d1_databases]] 13 | binding = "db" 14 | database_name = "email" 15 | database_id = "a4c1a63a-6ef5-4e6d-8e8c-b6d9e8feb810" 16 | 17 | [[kv_namespaces]] 18 | binding = "kv" 19 | id = "2io01d4b299e481b9de060ece9e7785c" 20 | 21 | #[[r2_buckets]] 22 | #binding = "r2" 23 | #bucket_name = "email" 24 | 25 | 26 | [vars] 27 | orm_log = false 28 | domain = ["example.com", "example2.com", "example3.com", "example4.com"] 29 | admin = "admin@example.com" 30 | jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd" 31 | -------------------------------------------------------------------------------- /mail-worker/src/api/star-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import starService from '../service/star-service'; 3 | import userContext from '../security/user-context'; 4 | import result from '../model/result'; 5 | 6 | app.post('/star/add', async (c) => { 7 | await starService.add(c, await c.req.json(), userContext.getUserId(c)); 8 | return c.json(result.ok()); 9 | }); 10 | 11 | app.get('/star/list', async (c) => { 12 | const data = await starService.list(c, c.req.query(), userContext.getUserId(c)); 13 | return c.json(result.ok(data)); 14 | }); 15 | 16 | app.delete('/star/cancel', async (c) => { 17 | await starService.cancel(c, await c.req.query(), userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }); 20 | -------------------------------------------------------------------------------- /mail-worker/src/api/my-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import userService from '../service/user-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.get('/my/loginUserInfo', async (c) => { 7 | const user = await userService.loginUserInfo(c, userContext.getUserId(c)); 8 | return c.json(result.ok(user)); 9 | }); 10 | 11 | app.put('/my/resetPassword', async (c) => { 12 | await userService.resetPassword(c, await c.req.json(), userContext.getUserId(c)); 13 | return c.json(result.ok()); 14 | }); 15 | 16 | app.delete('/my/delete', async (c) => { 17 | await userService.delete(c, userContext.getUserId(c)); 18 | return c.json(result.ok()); 19 | }); 20 | 21 | 22 | -------------------------------------------------------------------------------- /mail-vue/src/echarts/index.js: -------------------------------------------------------------------------------- 1 | 2 | import * as echarts from 'echarts/core'; 3 | 4 | import { BarChart,PieChart,LineChart,GaugeChart} from 'echarts/charts'; 5 | // 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component 6 | import { 7 | TooltipComponent, 8 | GridComponent, 9 | } from 'echarts/components'; 10 | // 标签自动布局、全局过渡动画等特性 11 | // 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步 12 | import { CanvasRenderer } from 'echarts/renderers'; 13 | import { LegendComponent } from 'echarts/components'; 14 | // 注册必须的组件 15 | echarts.use([ 16 | GaugeChart, 17 | LegendComponent, 18 | PieChart, 19 | TooltipComponent, 20 | GridComponent, 21 | BarChart, 22 | LineChart, 23 | CanvasRenderer 24 | ]); 25 | 26 | export default echarts -------------------------------------------------------------------------------- /mail-vue/src/request/role.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function roleAdd(params) { 4 | return http.post('/role/add',params) 5 | } 6 | 7 | export function rolePermTree() { 8 | return http.get('/role/permTree') 9 | } 10 | 11 | export function roleRoleList() { 12 | return http.get('/role/list') 13 | } 14 | 15 | export function roleSet(params) { 16 | return http.put('/role/set',params) 17 | } 18 | 19 | export function roleDelete(roleId) { 20 | return http.delete('/role/delete',{params:{roleId}}) 21 | } 22 | 23 | export function roleSetDef(roleId) { 24 | return http.put('/role/setDefault',{roleId}) 25 | } 26 | 27 | 28 | export function roleSelectUse() { 29 | return http.get('/role/selectUse') 30 | } 31 | -------------------------------------------------------------------------------- /mail-vue/src/views/test/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /mail-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev --config wrangler-dev.toml", 7 | "test": "wrangler deploy --config wrangler-test.toml", 8 | "deploy": "wrangler deploy", 9 | "start": "wrangler dev" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/vitest-pool-workers": "^0.7.5", 13 | "vitest": "~3.0.7", 14 | "wrangler": "^4.7.0" 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-s3": "^3.882.0", 18 | "@cloudflare/vite-plugin": "1.6.0", 19 | "dayjs": "^1.11.13", 20 | "drizzle-orm": "^0.42.0", 21 | "hono": "^4.7.5", 22 | "i18next": "^25.3.2", 23 | "linkedom": "^0.18.10", 24 | "postal-mime": "^2.4.3", 25 | "resend": "^6.4.1", 26 | "ua-parser-js": "^2.0.3", 27 | "uuid": "^11.1.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/code/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";tinymce.util.Tools.resolve("tinymce.PluginManager").add("code",(e=>((e=>{e.addCommand("mceCodeEditor",(()=>{(e=>{const o=(e=>e.getContent({source_view:!0}))(e);e.windowManager.open({title:"Source Code",size:"large",body:{type:"panel",items:[{type:"textarea",name:"code"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{code:o},onSubmit:o=>{((e,o)=>{e.focus(),e.undoManager.transact((()=>{e.setContent(o)})),e.selection.setCursorLocation(),e.nodeChanged()})(e,o.getData().code),o.close()}})})(e)}))})(e),(e=>{const o=()=>e.execCommand("mceCodeEditor");e.ui.registry.addButton("code",{icon:"sourcecode",tooltip:"Source code",onAction:o}),e.ui.registry.addMenuItem("code",{icon:"sourcecode",text:"Source code",onAction:o})})(e),{})))}(); -------------------------------------------------------------------------------- /mail-vue/src/request/email.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js'; 2 | 3 | export function emailList(accountId, emailId, timeSort, size, type) { 4 | return http.get('/email/list', {params: {accountId, emailId, timeSort, size, type}}) 5 | } 6 | 7 | export function emailDelete(emailIds) { 8 | return http.delete('/email/delete?emailIds=' + emailIds) 9 | } 10 | 11 | export function emailLatest(emailId, accountId) { 12 | return http.get('/email/latest', {params: {emailId, accountId}, noMsg: true }) 13 | } 14 | 15 | export function emailRead(emailIds) { 16 | return http.put('/email/read', {emailIds}, {noMsg: true}) 17 | } 18 | 19 | export function emailSend(form,progress) { 20 | return http.post('/email/send', form,{ 21 | onUploadProgress: (e) => { 22 | progress(e) 23 | }, 24 | noMsg: true 25 | }) 26 | } -------------------------------------------------------------------------------- /mail-worker/src/entity/att.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | 4 | export const att = sqliteTable('attachments', { 5 | attId: integer('att_id').primaryKey({ autoIncrement: true }), 6 | userId: integer('user_id').notNull(), 7 | emailId: integer('email_id').notNull(), 8 | accountId: integer('account_id').notNull(), 9 | key: text('key').notNull(), 10 | filename: text('filename'), 11 | mimeType: text('mime_type'), 12 | size: integer('size'), 13 | status: text('status').default(0).notNull(), 14 | type: integer('type').default(0).notNull(), 15 | disposition: text('disposition'), 16 | related: text('related'), 17 | contentId: text('content_id'), 18 | encoding: text('encoding'), 19 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 20 | }); 21 | 22 | -------------------------------------------------------------------------------- /mail-worker/src/utils/email-utils.js: -------------------------------------------------------------------------------- 1 | import { parseHTML } from 'linkedom'; 2 | 3 | const emailUtils = { 4 | 5 | getDomain(email) { 6 | if (typeof email !== 'string') return ''; 7 | const parts = email.split('@'); 8 | return parts.length === 2 ? parts[1] : ''; 9 | }, 10 | 11 | getName(email) { 12 | if (typeof email !== 'string') return ''; 13 | const parts = email.trim().split('@'); 14 | return parts.length === 2 ? parts[0] : ''; 15 | }, 16 | 17 | htmlToText(content) { 18 | if (!content) return '' 19 | try { 20 | const { document } = parseHTML(content); 21 | document.querySelectorAll('style, script, title').forEach(el => el.remove()); 22 | let text = document.body.innerText; 23 | return text.trim(); 24 | } catch (e) { 25 | console.error(e) 26 | return '' 27 | } 28 | } 29 | }; 30 | 31 | export default emailUtils; 32 | -------------------------------------------------------------------------------- /mail-vue/src/store/ui.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useUiStore = defineStore('ui', { 4 | state: () => ({ 5 | asideShow: window.innerWidth > 1024, 6 | accountShow: false, 7 | backgroundLoading: true, 8 | changeNotice: 0, 9 | writerRef: null, 10 | changePreview: 0, 11 | previewData: {}, 12 | key: 0, 13 | dark: false, 14 | asideCount: { 15 | email: 0, 16 | send: 0, 17 | sysEmail: 0 18 | } 19 | }), 20 | actions: { 21 | showNotice() { 22 | this.changeNotice ++ 23 | }, 24 | previewNotice(data) { 25 | this.previewData = data 26 | this.changePreview ++ 27 | } 28 | }, 29 | persist: { 30 | pick: ['accountShow','dark'], 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /mail-worker/src/entity/role.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const role = sqliteTable('role', { 4 | roleId: integer('role_id').primaryKey({ autoIncrement: true }), 5 | name: text('name').notNull(), 6 | key: text('key').notNull(), 7 | description: text('description'), 8 | banEmail: text('ban_email').notNull().default(''), 9 | banEmailType: integer('ban_email_type').notNull().default(0), 10 | availDomain: text('avail_domain').default(''), 11 | sort: integer('sort'), 12 | isDefault: integer('is_default').default(0), 13 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 14 | userId: integer('user_id'), 15 | sendCount: integer('send_count'), 16 | sendType: text('send_type').default('count'), 17 | accountCount: integer('account_count') 18 | }); 19 | export default role 20 | -------------------------------------------------------------------------------- /mail-worker/src/template/email-text.js: -------------------------------------------------------------------------------- 1 | export default function emailTextTemplate(text) { 2 | return ` 3 | 4 | 5 | 6 | 7 | 30 | 31 | 32 | ${text} 33 | 34 | ` 35 | } 36 | -------------------------------------------------------------------------------- /mail-worker/src/service/kv-obj-service.js: -------------------------------------------------------------------------------- 1 | const kvObjService = { 2 | 3 | async putObj(c, key, content, metadata) { 4 | await c.env.kv.put(key, content, { metadata: metadata }); 5 | }, 6 | 7 | async deleteObj(c, keys) { 8 | 9 | if (typeof keys === 'string') { 10 | keys = [keys]; 11 | } 12 | 13 | if (keys.length === 0) { 14 | return; 15 | } 16 | 17 | await Promise.all(keys.map( key => c.env.kv.delete(key))); 18 | }, 19 | 20 | async toObjResp(c, key) { 21 | 22 | const obj = await c.env.kv.getWithMetadata(key, { type: "arrayBuffer"}); 23 | 24 | return new Response(obj.value, { 25 | headers: { 26 | 'Content-Type': obj.metadata?.contentType || 'application/octet-stream', 27 | 'Content-Disposition': obj.metadata?.contentDisposition || null, 28 | 'Cache-Control': obj.metadata?.cacheControl || null 29 | } 30 | }); 31 | 32 | } 33 | 34 | }; 35 | 36 | export default kvObjService; 37 | -------------------------------------------------------------------------------- /mail-worker/src/entity/user.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | const user = sqliteTable('user', { 4 | userId: integer('user_id').primaryKey({ autoIncrement: true }), 5 | email: text('email').notNull(), 6 | type: integer('type').default(1).notNull(), 7 | password: text('password').notNull(), 8 | salt: text('salt').notNull(), 9 | status: integer('status').default(0).notNull(), 10 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`), 11 | activeTime: text('active_time'), 12 | createIp: text('create_ip'), 13 | activeIp: text('active_ip'), 14 | os: text('os'), 15 | browser: text('browser'), 16 | device: text('device'), 17 | sort: text('sort').default(0), 18 | sendCount: text('send_count').default(0), 19 | regKeyId: integer('reg_key_id').default(0).notNull(), 20 | isDel: integer('is_del').default(0).notNull() 21 | }); 22 | export default user 23 | -------------------------------------------------------------------------------- /mail-worker/src/template/email-msg.js: -------------------------------------------------------------------------------- 1 | import emailUtils from '../utils/email-utils'; 2 | 3 | export default function emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText) { 4 | 5 | let template = `${email.subject}` 6 | 7 | if (tgMsgFrom === 'only-name') { 8 | template += ` 9 | 10 | 发件人:${email.name}` 11 | } 12 | 13 | if (tgMsgFrom === 'show') { 14 | template += ` 15 | 16 | 发件人:${email.name} <${email.sendEmail}>` 17 | } 18 | 19 | if(tgMsgTo === 'show' && tgMsgFrom === 'hide') { 20 | template += ` 21 | 22 | 收件人:\u200B${email.toEmail}` 23 | 24 | } else if(tgMsgTo === 'show') { 25 | template += ` 26 | 收件人:\u200B${email.toEmail}` 27 | } 28 | 29 | const text = (email.text || emailUtils.htmlToText(email.content)) 30 | .replace(//g, '>'); 32 | 33 | if(tgMsgText === 'show') { 34 | template += ` 35 | 36 | ${text}` 37 | } 38 | 39 | return template; 40 | 41 | } 42 | -------------------------------------------------------------------------------- /mail-worker/src/api/account-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import accountService from '../service/account-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.get('/account/list', async (c) => { 7 | const list = await accountService.list(c, c.req.query(), userContext.getUserId(c)); 8 | return c.json(result.ok(list)); 9 | }); 10 | 11 | app.delete('/account/delete', async (c) => { 12 | await accountService.delete(c, c.req.query(), userContext.getUserId(c)); 13 | return c.json(result.ok()); 14 | }); 15 | 16 | app.post('/account/add', async (c) => { 17 | const account = await accountService.add(c, await c.req.json(), userContext.getUserId(c)); 18 | return c.json(result.ok(account)); 19 | }); 20 | 21 | app.put('/account/setName', async (c) => { 22 | await accountService.setName(c, await c.req.json(), userContext.getUserId(c)); 23 | return c.json(result.ok()); 24 | }); 25 | -------------------------------------------------------------------------------- /mail-vue/src/utils/convert.js: -------------------------------------------------------------------------------- 1 | import {useSettingStore} from "@/store/setting.js"; 2 | export function cvtR2Url(key) { 3 | 4 | if (!key) { 5 | return + 'https://' + '' 6 | } 7 | 8 | if (key.startsWith('https://')) { 9 | return key 10 | } 11 | 12 | const { settings } = useSettingStore(); 13 | 14 | let domain = settings.r2Domain 15 | 16 | if (!domain.startsWith('http')) { 17 | return 'https://' + domain + '/' + key 18 | } 19 | 20 | if (domain.endsWith("/")) { 21 | domain = domain.slice(0, -1); 22 | } 23 | return domain + '/' + key 24 | } 25 | 26 | export function toOssDomain(domain) { 27 | 28 | if (!domain) { 29 | return null 30 | } 31 | 32 | if (!domain.startsWith('http')) { 33 | return 'https://' + domain 34 | } 35 | 36 | if (domain.endsWith("/")) { 37 | domain = domain.slice(0, -1); 38 | } 39 | 40 | return domain 41 | } 42 | -------------------------------------------------------------------------------- /mail-worker/src/api/setting-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import result from '../model/result'; 3 | import settingService from '../service/setting-service'; 4 | 5 | app.put('/setting/set', async (c) => { 6 | await settingService.set(c, await c.req.json()); 7 | return c.json(result.ok()); 8 | }); 9 | 10 | app.get('/setting/query', async (c) => { 11 | const setting = await settingService.get(c); 12 | return c.json(result.ok(setting)); 13 | }); 14 | 15 | app.get('/setting/websiteConfig', async (c) => { 16 | const setting = await settingService.websiteConfig(c); 17 | return c.json(result.ok(setting)); 18 | }) 19 | 20 | app.put('/setting/setBackground', async (c) => { 21 | const key = await settingService.setBackground(c, await c.req.json()); 22 | return c.json(result.ok(key)); 23 | }); 24 | 25 | app.delete('/setting/deleteBackground', async (c) => { 26 | await settingService.deleteBackground(c); 27 | return c.json(result.ok()); 28 | }); 29 | 30 | -------------------------------------------------------------------------------- /mail-worker/src/service/turnstile-service.js: -------------------------------------------------------------------------------- 1 | import BizError from '../error/biz-error'; 2 | import settingService from './setting-service'; 3 | import { t } from '../i18n/i18n' 4 | 5 | const turnstileService = { 6 | 7 | async verify(c, token) { 8 | 9 | if (!token) { 10 | throw new BizError(t('emptyBotToken'),400); 11 | } 12 | 13 | const settingRow = await settingService.query(c) 14 | 15 | const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/x-www-form-urlencoded' 19 | }, 20 | body: new URLSearchParams({ 21 | secret: settingRow.secretKey, 22 | response: token, 23 | remoteip: c.req.header('cf-connecting-ip') 24 | }) 25 | }); 26 | 27 | const result = await res.json(); 28 | 29 | if (!result.success) { 30 | throw new BizError(t('botVerifyFail'),400) 31 | } 32 | } 33 | }; 34 | 35 | export default turnstileService; 36 | -------------------------------------------------------------------------------- /mail-worker/src/hono/hono.js: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | const app = new Hono(); 3 | 4 | import result from '../model/result'; 5 | import { cors } from 'hono/cors'; 6 | 7 | app.use('*', cors()); 8 | 9 | app.onError((err, c) => { 10 | if (err.name === 'BizError') { 11 | console.log(err.message); 12 | } else { 13 | console.error(err); 14 | } 15 | 16 | if (err.message === `Cannot read properties of undefined (reading 'get')`) { 17 | return c.json(result.fail('KV数据库未绑定
KV database not bound',502)); 18 | } 19 | 20 | if (err.message === `Cannot read properties of undefined (reading 'put')`) { 21 | return c.json(result.fail('KV数据库未绑定
KV database not bound',502)); 22 | } 23 | 24 | if (err.message === `Cannot read properties of undefined (reading 'prepare')`) { 25 | return c.json(result.fail('D1数据库未绑定
D1 database not bound',502)); 26 | } 27 | 28 | return c.json(result.fail(err.message, err.code)); 29 | }); 30 | 31 | export default app; 32 | 33 | 34 | -------------------------------------------------------------------------------- /mail-worker/src/api/reg-key-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import result from '../model/result'; 3 | import regKeyService from '../service/reg-key-service'; 4 | import userContext from '../security/user-context'; 5 | 6 | app.post('/regKey/add', async (c) => { 7 | await regKeyService.add(c, await c.req.json(), await userContext.getUserId(c)); 8 | return c.json(result.ok()); 9 | }) 10 | 11 | app.get('/regKey/list', async (c) => { 12 | const list = await regKeyService.list(c, c.req.query()); 13 | return c.json(result.ok(list)); 14 | }) 15 | 16 | app.delete('/regKey/delete', async (c) => { 17 | await regKeyService.delete(c, c.req.query()); 18 | return c.json(result.ok()); 19 | }) 20 | 21 | app.delete('/regKey/clearNotUse', async (c) => { 22 | await regKeyService.clearNotUse(c); 23 | return c.json(result.ok()); 24 | }) 25 | 26 | app.get('/regKey/history', async (c) => { 27 | const list = await regKeyService.history(c, c.req.query()); 28 | return c.json(result.ok(list)); 29 | }) 30 | -------------------------------------------------------------------------------- /mail-worker/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; 2 | import { describe, it, expect } from 'vitest'; 3 | import worker from '../src'; 4 | 5 | describe('Hello World worker', () => { 6 | it('responds with Hello World! (unit style)', async () => { 7 | const request = new Request('http://example.com'); 8 | // Create an empty context to pass to `worker.fetch()`. 9 | const ctx = createExecutionContext(); 10 | const response = await worker.fetch(request, env, ctx); 11 | // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions 12 | await waitOnExecutionContext(ctx); 13 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 14 | }); 15 | 16 | it('responds with Hello World! (integration style)', async () => { 17 | const response = await SELF.fetch('http://example.com'); 18 | expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /mail-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail" 2 | main = "src/index.js" 3 | compatibility_date = "2025-06-04" 4 | keep_vars = true 5 | 6 | [observability] 7 | enabled = true 8 | 9 | #[[d1_databases]] 10 | #binding = "db" #d1数据库绑定名默认不可修改 11 | #database_name = "" #d1数据库名字 12 | #database_id = "" #d1数据库id 13 | 14 | #[[kv_namespaces]] 15 | #binding = "kv" #kv绑定名默认不可修改 16 | #id = "" #kv数据库id 17 | 18 | #[[r2_buckets]] 19 | #binding = "r2" #r2对象存储绑定名默认不可修改 20 | #bucket_name = "" #r2对象存储桶的名字 21 | 22 | [assets] 23 | binding = "assets" #静态资源绑定名默认不可修改 24 | directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist 25 | not_found_handling = "single-page-application" 26 | run_worker_first = true 27 | 28 | [triggers] 29 | crons = ["0 16 * * *"] #定时任务每天晚上12点执行 30 | 31 | 32 | #[vars] 33 | #orm_log = false 34 | #domain = [] #邮件域名可可配置多个 示例: ["example1.com","example2.com"] 35 | #admin = "" #管理员的邮箱 示例: admin@example.com 36 | #jwt_secret = "" #jwt令牌的密钥,随便填一串字符串 37 | 38 | [build] 39 | command = "pnpm --prefix ../mail-vue install && pnpm --prefix ../mail-vue run build" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 eoao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /mail-worker/src/index.js: -------------------------------------------------------------------------------- 1 | import app from './hono/webs'; 2 | import { email } from './email/email'; 3 | import userService from './service/user-service'; 4 | import verifyRecordService from './service/verify-record-service'; 5 | import emailService from './service/email-service'; 6 | import kvObjService from './service/kv-obj-service'; 7 | import oauthService from "./service/oauth-service"; 8 | export default { 9 | async fetch(req, env, ctx) { 10 | 11 | const url = new URL(req.url) 12 | 13 | if (url.pathname.startsWith('/api/')) { 14 | url.pathname = url.pathname.replace('/api', '') 15 | req = new Request(url.toString(), req) 16 | return app.fetch(req, env, ctx); 17 | } 18 | 19 | if (['/static/','/attachments/'].some(p => url.pathname.startsWith(p))) { 20 | return await kvObjService.toObjResp( { env }, url.pathname.substring(1)); 21 | } 22 | 23 | return env.assets.fetch(req); 24 | }, 25 | email: email, 26 | async scheduled(c, env, ctx) { 27 | await verifyRecordService.clearRecord({ env }) 28 | await userService.resetDaySendCount({ env }) 29 | await emailService.completeReceiveAll({ env }) 30 | await oauthService.clearNoBindOathUser({ env }) 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/visualblocks/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const o=(t,o,e)=>{t.dom.toggleClass(t.getBody(),"mce-visualblocks"),e.set(!e.get()),((t,o)=>{t.dispatch("VisualBlocks",{state:o})})(t,e.get())},e=t=>t.options.get("visualblocks_default_state");const s=(t,o)=>e=>{e.setActive(o.get());const s=t=>e.setActive(t.state);return t.on("VisualBlocks",s),()=>t.off("VisualBlocks",s)};t.add("visualblocks",((t,l)=>{(t=>{(0,t.options.register)("visualblocks_default_state",{processor:"boolean",default:!1})})(t);const a=(()=>{let t=!1;return{get:()=>t,set:o=>{t=o}}})();((t,e,s)=>{t.addCommand("mceVisualBlocks",(()=>{o(t,0,s)}))})(t,0,a),((t,o)=>{const e=()=>t.execCommand("mceVisualBlocks");t.ui.registry.addToggleButton("visualblocks",{icon:"visualblocks",tooltip:"Show blocks",onAction:e,onSetup:s(t,o),context:"any"}),t.ui.registry.addToggleMenuItem("visualblocks",{text:"Show blocks",icon:"visualblocks",onAction:e,onSetup:s(t,o),context:"any"})})(t,a),((t,s,l)=>{t.on("PreviewFormats AfterPreviewFormats",(o=>{l.get()&&t.dom.toggleClass(t.getBody(),"mce-visualblocks","afterpreviewformats"===o.type)})),t.on("init",(()=>{e(t)&&o(t,0,l)}))})(t,0,a)}))}(); -------------------------------------------------------------------------------- /mail-vue/src/request/user.js: -------------------------------------------------------------------------------- 1 | import http from '@/axios/index.js' 2 | 3 | 4 | export function userList(params) { 5 | return http.get('/user/list', {params: {...params}}) 6 | } 7 | 8 | export function userSetPwd(params) { 9 | return http.put('/user/setPwd', params) 10 | } 11 | 12 | export function userSetStatus(params) { 13 | return http.put('/user/setStatus', params) 14 | } 15 | 16 | export function userSetType(params) { 17 | return http.put('/user/setType', params) 18 | } 19 | 20 | 21 | export function userDelete(userId) { 22 | return http.delete('/user/delete', {params:{userId}}) 23 | } 24 | 25 | export function userAdd(form) { 26 | return http.post('/user/add', form) 27 | } 28 | 29 | export function userRestSendCount(userId) { 30 | return http.put('/user/resetSendCount', {userId}) 31 | } 32 | 33 | export function userRestore(userId,type) { 34 | return http.put('/user/restore', {userId,type}) 35 | } 36 | 37 | export function userAllAccount(userId, num, size) { 38 | return http.get('/user/allAccount', {params:{userId,num,size}}) 39 | } 40 | 41 | export function userDeleteAccount(accountId) { 42 | return http.delete('/user/deleteAccount', {params:{accountId}}) 43 | } 44 | -------------------------------------------------------------------------------- /mail-worker/wrangler-test.toml: -------------------------------------------------------------------------------- 1 | name = "test-mail" 2 | main = "src/index.js" 3 | compatibility_date = "2025-06-04" 4 | keep_vars = true 5 | 6 | [observability] 7 | enabled = true 8 | 9 | [[d1_databases]] 10 | binding = "db" #d1数据库绑定名默认不可修改 11 | database_name = "test-email" #d1数据库名字 12 | database_id = "e65f099d-796d-4eaa-8dff-529b368b20db" #d1数据库id 13 | 14 | [[kv_namespaces]] 15 | binding = "kv" #kv绑定名默认不可修改 16 | id = "9be10cd058e04c55b526f8c0c116f50c" #kv数据库id 17 | 18 | #(可选) 19 | #[[r2_buckets]] 20 | #binding = "r2" #r2对象存储绑定名默认不可修改 21 | #bucket_name = "test-email" #r2对象存储桶的名字 22 | 23 | [assets] 24 | binding = "assets" #静态资源绑定名默认不可修改 25 | directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist 26 | not_found_handling = "single-page-application" 27 | run_worker_first = true 28 | 29 | [triggers] 30 | crons = ["0 16 * * *"] #定时任务每天晚上12点执行 31 | 32 | 33 | [vars] 34 | orm_log = false 35 | domain = ["example.com"] #邮件域名可可配置多个 示例: ["example1.com","example2.com"] 36 | admin = "admin@example.com" #管理员的邮箱 示例: admin@example.com 37 | jwt_secret = "b7f29a1d-18e2-4d3b-941f-f6b2c97c02fd" #jwt令牌的密钥,随便填一串字符串 38 | 39 | [build] 40 | command = "pnpm --prefix ../mail-vue install && pnpm --prefix ../mail-vue run build" 41 | -------------------------------------------------------------------------------- /mail-worker/src/entity/email.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | import { sql } from 'drizzle-orm'; 3 | export const email = sqliteTable('email', { 4 | emailId: integer('email_id').primaryKey({ autoIncrement: true }), 5 | sendEmail: text('send_email'), 6 | name: text('name'), 7 | accountId: integer('account_id').notNull(), 8 | userId: integer('user_id').notNull(), 9 | subject: text('subject'), 10 | text: text('text'), 11 | content: text('content'), 12 | cc: text('cc').default('[]'), 13 | bcc: text('bcc').default('[]'), 14 | recipient: text('recipient'), 15 | toEmail: text('to_email').default('').notNull(), 16 | toName: text('to_name').default('').notNull(), 17 | inReplyTo: text('in_reply_to').default(''), 18 | relation: text('relation').default(''), 19 | messageId: text('message_id').default(''), 20 | type: integer('type').default(0).notNull(), 21 | status: integer('status').default(0).notNull(), 22 | resendEmailId: text('resend_email_id'), 23 | message: text('message'), 24 | unread: integer('unread').default(0).notNull(), 25 | createTime: text('create_time').default(sql`CURRENT_TIMESTAMP`).notNull(), 26 | isDel: integer('is_del').default(0).notNull() 27 | }); 28 | export default email 29 | -------------------------------------------------------------------------------- /mail-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mail-vue", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode dev", 8 | "remote": "vite --mode remote", 9 | "build": "vite build --mode release", 10 | "eo": "vite build --mode eo", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@iconify/vue": "^4.3.0", 15 | "@vueuse/core": "^12.0.0", 16 | "axios": "^1.7.8", 17 | "compressorjs": "^1.2.1", 18 | "date-time-format-timezone": "^1.0.22", 19 | "dayjs": "^1.11.13", 20 | "dexie": "^4.0.11", 21 | "echarts": "^5.6.0", 22 | "element-plus": "^2.9.11", 23 | "lodash-es": "^4.17.21", 24 | "nprogress": "^0.2.0", 25 | "path": "^0.12.7", 26 | "pinia": "^3.0.2", 27 | "pinia-plugin-persistedstate": "^4.2.0", 28 | "vue": "^3.5.13", 29 | "vue-i18n": "^11.1.10", 30 | "vue-router": "^4.5.0" 31 | }, 32 | "devDependencies": { 33 | "@vitejs/plugin-vue": "^5.2.1", 34 | "less": "^4.2.2", 35 | "sass": "^1.82.0", 36 | "terser": "^5.39.0", 37 | "unplugin-auto-import": "^19.3.0", 38 | "unplugin-vue-components": "^28.7.0", 39 | "vite": "7.1.5", 40 | "vite-plugin-pwa": "^1.0.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mail-worker/src/utils/req-utils.js: -------------------------------------------------------------------------------- 1 | import { UAParser } from 'ua-parser-js'; 2 | const reqUtils = { 3 | getIp(c) { 4 | return c.req.header('CF-Connecting-IP') || 5 | c.req.header('X-Forwarded-For') || 6 | 'Unknown'; 7 | }, 8 | 9 | getUserAgent(c) { 10 | const ua = c.req.header('user-agent') || ''; 11 | 12 | const parser = new UAParser(ua); 13 | const { browser, device, os } = parser.getResult(); 14 | 15 | let browserInfo = null; 16 | let osInfo = null; 17 | 18 | if (browser.name) { 19 | browserInfo = browser.name + ' ' + browser.version; 20 | } 21 | 22 | if (os.name) { 23 | osInfo = os.name + os.version; 24 | } 25 | 26 | let deviceInfo = 'Desktop'; 27 | 28 | const hasVendor = !!device?.vendor; 29 | const hasModel = !!device?.model; 30 | 31 | if (hasVendor || hasModel) { 32 | const vendor = device.vendor || ''; 33 | const model = device.model || ''; 34 | const type = device.type || ''; 35 | 36 | const namePart = [vendor, model].filter(Boolean).join(' '); 37 | const typePart = type ? ` (${type})` : ''; 38 | deviceInfo = (namePart + typePart).trim(); 39 | } 40 | 41 | return {browser: browserInfo || '', device: deviceInfo || '', os: osInfo || ''} 42 | } 43 | } 44 | 45 | export default reqUtils 46 | -------------------------------------------------------------------------------- /mail-worker/wrangler-action.toml: -------------------------------------------------------------------------------- 1 | name = "cloud-mail" 2 | main = "src/index.js" 3 | compatibility_date = "2025-06-04" 4 | 5 | 6 | [observability] 7 | enabled = true 8 | 9 | [[d1_databases]] 10 | binding = "db" 11 | database_name = "cloud-mail" # 数据库的名称 12 | database_id = "${D1_DATABASE_ID}" # 使用占位符引用环境变量 13 | 14 | [[kv_namespaces]] 15 | binding = "kv" 16 | id = "${KV_NAMESPACE_ID}" # 使用占位符引用环境变量 17 | 18 | [[r2_buckets]] 19 | binding = "r2" 20 | bucket_name = "${R2_BUCKET_NAME}" # 使用占位符引用环境变量 21 | 22 | [assets] 23 | binding = "assets" #静态资源绑定名默认不可修改 24 | directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist 25 | not_found_handling = "single-page-application" 26 | run_worker_first = true 27 | 28 | [triggers] 29 | crons = ["0 16 * * *"] #定时任务每天晚上12点执行 30 | 31 | 32 | [vars] 33 | #orm_log = false 34 | domain = "${DOMAIN}" #邮件域名可可配置多个 示例: ["example1.com","example2.com"] 35 | admin = "${ADMIN}" #管理员的邮箱 示例: admin@example.com 36 | jwt_secret = "${JWT_SECRET}" #jwt令牌的密钥,随便填一串字符串 37 | 38 | linuxdo_client_id = "${LINUXDO_CLIENT_ID}" 39 | linuxdo_client_secret = "${LINUXDO_CLIENT_SECRET}" 40 | linuxdo_callback_url = "${LINUXDO_CALLBACK_URL}" 41 | linuxdo_switch = "${LINUXDO_SWITCH}" 42 | 43 | [build] 44 | command = "pnpm --prefix ../mail-vue install && pnpm --prefix ../mail-vue run build" 45 | -------------------------------------------------------------------------------- /mail-worker/src/utils/crypto-utils.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | 3 | const saltHashUtils = { 4 | 5 | generateSalt(length = 16) { 6 | const array = new Uint8Array(length); 7 | crypto.getRandomValues(array); 8 | return btoa(String.fromCharCode(...array)); 9 | }, 10 | 11 | 12 | async hashPassword(password) { 13 | const salt = this.generateSalt(); 14 | const hash = await this.genHashPassword(password, salt); 15 | return { salt, hash }; 16 | }, 17 | 18 | async genHashPassword(password, salt) { 19 | const data = encoder.encode(salt + password); 20 | const hashBuffer = await crypto.subtle.digest('SHA-256', data); 21 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 22 | return btoa(String.fromCharCode(...hashArray)); 23 | }, 24 | 25 | async verifyPassword(inputPassword, salt, storedHash) { 26 | const hash = await this.genHashPassword(inputPassword, salt); 27 | return hash === storedHash; 28 | }, 29 | 30 | genRandomPwd(length = 8) { 31 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 32 | let result = ''; 33 | for (let i = 0; i < length; i++) { 34 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 35 | } 36 | return result; 37 | } 38 | }; 39 | 40 | export default saltHashUtils; 41 | -------------------------------------------------------------------------------- /mail-worker/src/api/role-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import roleService from '../service/role-service'; 3 | import userContext from '../security/user-context'; 4 | import result from '../model/result'; 5 | import permService from '../service/perm-service'; 6 | 7 | app.post('/role/add', async (c) => { 8 | await roleService.add(c, await c.req.json(), userContext.getUserId(c)); 9 | return c.json(result.ok()); 10 | }); 11 | 12 | app.put('/role/setDefault', async (c) => { 13 | await roleService.setDefault(c, await c.req.json()); 14 | return c.json(result.ok()); 15 | }); 16 | 17 | app.put('/role/set', async (c) => { 18 | await roleService.setRole(c, await c.req.json()); 19 | return c.json(result.ok()); 20 | }); 21 | 22 | app.get('/role/permTree', async (c) => { 23 | const tree = await permService.tree(c); 24 | return c.json(result.ok(tree)); 25 | }); 26 | 27 | app.delete('/role/delete', async (c) => { 28 | await roleService.delete(c, c.req.query()); 29 | return c.json(result.ok()); 30 | }); 31 | 32 | app.get('/role/list', async (c) => { 33 | const roleList = await roleService.roleList(c); 34 | return c.json(result.ok(roleList)); 35 | }); 36 | 37 | app.get('/role/selectUse', async (c) => { 38 | const roleList = await roleService.roleSelectUse(c); 39 | return c.json(result.ok(roleList)); 40 | }); 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /mail-worker/src/api/email-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import emailService from '../service/email-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | import attService from '../service/att-service'; 6 | 7 | app.get('/email/list', async (c) => { 8 | const data = await emailService.list(c, c.req.query(), userContext.getUserId(c)); 9 | return c.json(result.ok(data)); 10 | }); 11 | 12 | app.get('/email/latest', async (c) => { 13 | const list = await emailService.latest(c, c.req.query(), userContext.getUserId(c)); 14 | return c.json(result.ok(list)); 15 | }); 16 | 17 | app.delete('/email/delete', async (c) => { 18 | await emailService.delete(c, c.req.query(), userContext.getUserId(c)); 19 | return c.json(result.ok()); 20 | }); 21 | 22 | app.get('/email/attList', async (c) => { 23 | const attList = await attService.list(c, c.req.query(), userContext.getUserId(c)); 24 | return c.json(result.ok(attList)); 25 | }); 26 | 27 | app.post('/email/send', async (c) => { 28 | const email = await emailService.send(c, await c.req.json(), userContext.getUserId(c)); 29 | return c.json(result.ok(email)); 30 | }); 31 | 32 | app.put('/email/read', async (c) => { 33 | await emailService.read(c, await c.req.json(), userContext.getUserId(c)); 34 | return c.json(result.ok()); 35 | }) 36 | 37 | -------------------------------------------------------------------------------- /mail-vue/src/components/hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | 31 | 43 | -------------------------------------------------------------------------------- /mail-worker/src/service/perm-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import perm from '../entity/perm'; 3 | import { eq, ne, and, asc } from 'drizzle-orm'; 4 | import rolePerm from '../entity/role-perm'; 5 | import user from '../entity/user'; 6 | import role from '../entity/role'; 7 | import { permConst } from '../const/entity-const'; 8 | import { t } from '../i18n/i18n' 9 | 10 | const permService = { 11 | async tree(c) { 12 | const pList = await orm(c).select().from(perm).where(eq(perm.pid, 0)).orderBy(asc(perm.sort)).all(); 13 | const cList = await orm(c).select().from(perm).where(ne(perm.pid, 0)).orderBy(asc(perm.sort)).all(); 14 | 15 | cList.forEach(cItem => { 16 | cItem.name = t('perms.' + cItem.name) 17 | }) 18 | 19 | pList.forEach(pItem => { 20 | pItem.name = t('perms.' + pItem.name) 21 | pItem.children = cList.filter(cItem => cItem.pid === pItem.permId) 22 | }) 23 | return pList; 24 | }, 25 | 26 | async userPermKeys(c, userId) { 27 | const userPerms = await orm(c).select({permKey: perm.permKey}).from(user) 28 | .leftJoin(role, eq(role.roleId,user.type)) 29 | .rightJoin(rolePerm, eq(rolePerm.roleId,role.roleId)) 30 | .leftJoin(perm, eq(rolePerm.permId,perm.permId)) 31 | .where(and(eq(user.userId,userId),eq(perm.type,permConst.type.BUTTON))) 32 | .all(); 33 | return userPerms.map(perm => perm.permKey); 34 | } 35 | } 36 | 37 | export default permService 38 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/default/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/tinymce-5/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/src/views/star/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 48 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/writer/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem auto;max-width:900px}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/css/index.css: -------------------------------------------------------------------------------- 1 | .tox-dialog__body-content { 2 | margin: 0 !important; 3 | } 4 | 5 | img { 6 | max-width: 100% !important; 7 | height: auto !important; 8 | } 9 | 10 | p { 11 | margin: 0 !important; 12 | } 13 | 14 | a { 15 | text-decoration: none; 16 | color: #0E70DF; 17 | } 18 | 19 | body { 20 | margin: 10px 8px 0 5px !important; 21 | font-size: 14px; 22 | } 23 | 24 | :root { 25 | --scrollbar-thumb-color: #FFFFFF; 26 | --scrollbar-track-color: #A8ABB2; 27 | } 28 | 29 | .mceNonEditable { 30 | color: #303133; 31 | background: #FFFFFF; 32 | } 33 | 34 | @media (pointer: fine) and (hover: hover) { 35 | ::-webkit-scrollbar { 36 | width: 6px; 37 | height: 6px; 38 | } 39 | 40 | 41 | ::-webkit-scrollbar-track { 42 | background: var(--scrollbar-track-color); 43 | } 44 | 45 | 46 | ::-webkit-scrollbar-thumb { 47 | background: var(--scrollbar-thumb-color); 48 | border-radius: 10px; 49 | cursor: pointer; 50 | } 51 | } 52 | 53 | .mce-item-table:not([border]), .mce-item-table:not([border]) caption, .mce-item-table:not([border]) td, .mce-item-table:not([border]) th, .mce-item-table[border="0"], .mce-item-table[border="0"] caption, .mce-item-table[border="0"] td, .mce-item-table[border="0"] th, table[style*="border-width: 0px"], table[style*="border-width: 0px"] caption, table[style*="border-width: 0px"] td, table[style*="border-width: 0px"] th { 54 | border: none; 55 | } 56 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/default/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/default/content.css', `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/tinymce-5/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/tinymce-5/content.css', `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/writer/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/writer/content.css', `body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem auto;max-width:900px}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/nonbreaking/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var n=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=n=>e=>typeof e===n,o=e("boolean"),a=e("number"),t=n=>e=>e.options.get(n),i=t("nonbreaking_force_tab"),s=t("nonbreaking_wrap"),r=(n,e)=>{let o="";for(let a=0;a{const o=s(n)||n.plugins.visualchars?`${r(" ",e)}`:r(" ",e);n.undoManager.transact((()=>n.insertContent(o)))};var l=tinymce.util.Tools.resolve("tinymce.util.VK");const u=n=>e=>{const o=()=>{e.setEnabled(n.selection.isEditable())};return n.on("NodeChange",o),o(),()=>{n.off("NodeChange",o)}};n.add("nonbreaking",(n=>{(n=>{const e=n.options.register;e("nonbreaking_force_tab",{processor:n=>o(n)?{value:n?3:0,valid:!0}:a(n)?{value:n,valid:!0}:{valid:!1,message:"Must be a boolean or number."},default:!1}),e("nonbreaking_wrap",{processor:"boolean",default:!0})})(n),(n=>{n.addCommand("mceNonBreaking",(()=>{c(n,1)}))})(n),(n=>{const e=()=>n.execCommand("mceNonBreaking");n.ui.registry.addButton("nonbreaking",{icon:"non-breaking",tooltip:"Nonbreaking space",onAction:e,onSetup:u(n)}),n.ui.registry.addMenuItem("nonbreaking",{icon:"non-breaking",text:"Nonbreaking space",onAction:e,onSetup:u(n)})})(n),(n=>{const e=i(n);e>0&&n.on("keydown",(o=>{if(o.keyCode===l.TAB&&!o.isDefaultPrevented()){if(o.shiftKey)return;o.preventDefault(),o.stopImmediatePropagation(),c(n,e)}}))})(n)}))}(); -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/dark/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | body{background-color: #141414;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/tinymce-5-dark/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | body{background-color:#2f3742;color:#dfe0e4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/save/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const o=e=>"function"==typeof e;var t=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),n=tinymce.util.Tools.resolve("tinymce.util.Tools");const a=e=>o=>o.options.get(e),c=a("save_enablewhendirty"),i=a("save_onsavecallback"),s=a("save_oncancelcallback"),r=(e,o)=>{e.notificationManager.open({text:o,type:"error"})},l=e=>o=>{const t=()=>{o.setEnabled(!c(e)||e.isDirty())};return t(),e.on("NodeChange dirty",t),()=>e.off("NodeChange dirty",t)};e.add("save",(e=>{(e=>{const o=e.options.register;o("save_enablewhendirty",{processor:"boolean",default:!0}),o("save_onsavecallback",{processor:"function"}),o("save_oncancelcallback",{processor:"function"})})(e),(e=>{e.ui.registry.addButton("save",{icon:"save",tooltip:"Save",enabled:!1,onAction:()=>e.execCommand("mceSave"),onSetup:l(e),shortcut:"Meta+S"}),e.ui.registry.addButton("cancel",{icon:"cancel",tooltip:"Cancel",enabled:!1,onAction:()=>e.execCommand("mceCancel"),onSetup:l(e)}),e.addShortcut("Meta+S","","mceSave")})(e),(e=>{e.addCommand("mceSave",(()=>{(e=>{const n=t.DOM.getParent(e.id,"form");if(c(e)&&!e.isDirty())return;e.save();const a=i(e);if(o(a))return a.call(e,e),void e.nodeChanged();n?(e.setDirty(!1),n.onsubmit&&!n.onsubmit()||("function"==typeof n.submit?n.submit():r(e,"Error: Form submit field collision.")),e.nodeChanged()):r(e,"Error: No form element found.")})(e)})),e.addCommand("mceCancel",(()=>{(e=>{const t=n.trim(e.startContent),a=s(e);o(a)?a.call(e,e):e.resetContent(t)})(e)}))})(e)}))}(); -------------------------------------------------------------------------------- /mail-worker/src/service/resend-service.js: -------------------------------------------------------------------------------- 1 | import emailService from './email-service'; 2 | import { emailConst } from '../const/entity-const'; 3 | import BizError from '../error/biz-error'; 4 | 5 | const resendService = { 6 | 7 | async webhooks(c, body) { 8 | 9 | const params = {} 10 | console.error(body) 11 | if (body.type === 'email.delivered') { 12 | params.status = emailConst.status.DELIVERED 13 | params.resendEmailId = body.data.email_id 14 | params.message = null 15 | } 16 | 17 | if (body.type === 'email.complained') { 18 | params.status = emailConst.status.COMPLAINED 19 | params.resendEmailId = body.data.email_id 20 | params.message = null 21 | } 22 | 23 | if (body.type === 'email.bounced') { 24 | let bounce = body.data.bounce 25 | bounce = JSON.stringify(bounce); 26 | params.status = emailConst.status.BOUNCED 27 | params.resendEmailId = body.data.email_id 28 | params.message = bounce 29 | } 30 | 31 | if (body.type === 'email.delivery_delayed') { 32 | params.status = emailConst.status.DELAYED 33 | params.resendEmailId = body.data.email_id 34 | params.message = null 35 | } 36 | 37 | if (body.type === 'email.failed') { 38 | params.status = emailConst.status.FAILED 39 | params.resendEmailId = body.data.email_id 40 | params.message = body.data.failed.reason 41 | } 42 | 43 | const emailRow = await emailService.updateEmailStatus(c, params) 44 | 45 | if (!emailRow) { 46 | throw new BizError('更新邮件状态记录失败'); 47 | } 48 | 49 | } 50 | } 51 | 52 | export default resendService 53 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/document/content.min.css: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | @media screen{html{background:#f4f4f4;min-height:100%}}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}@media screen{body{background-color:#fff;box-shadow:0 0 4px rgba(0,0,0,.15);box-sizing:border-box;margin:1rem auto 0;max-width:820px;min-height:calc(100vh - 1rem);padding:4rem 6rem 6rem 6rem}}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure figcaption{color:#999;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} 11 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/dark/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/dark/content.css', `body{background-color:#222f3e;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/tinymce-5-dark/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/tinymce-5-dark/content.css', `body{background-color:#2f3742;color:#dfe0e4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/pagebreak/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),a=tinymce.util.Tools.resolve("tinymce.Env");const t=e=>a=>a.options.get(e),n=t("pagebreak_separator"),o=t("pagebreak_split_block"),r="mce-pagebreak",s=e=>{const t=``;return e?`

${t}

`:t},c=e=>a=>{const t=()=>{a.setEnabled(e.selection.isEditable())};return e.on("NodeChange",t),t(),()=>{e.off("NodeChange",t)}};e.add("pagebreak",(e=>{(e=>{const a=e.options.register;a("pagebreak_separator",{processor:"string",default:"\x3c!-- pagebreak --\x3e"}),a("pagebreak_split_block",{processor:"boolean",default:!1})})(e),(e=>{e.addCommand("mcePageBreak",(()=>{e.insertContent(s(o(e)))}))})(e),(e=>{const a=()=>e.execCommand("mcePageBreak");e.ui.registry.addButton("pagebreak",{icon:"page-break",tooltip:"Page break",onAction:a,onSetup:c(e)}),e.ui.registry.addMenuItem("pagebreak",{text:"Page break",icon:"page-break",onAction:a,onSetup:c(e)})})(e),(e=>{const a=n(e),t=()=>o(e),c=new RegExp(a.replace(/[\?\.\*\[\]\(\)\{\}\+\^\$\:]/g,(e=>"\\"+e)),"gi");e.on("BeforeSetContent",(e=>{e.content=e.content.replace(c,s(t()))})),e.on("PreInit",(()=>{e.serializer.addNodeFilter("img",(n=>{let o,s,c=n.length;for(;c--;)if(o=n[c],s=o.attr("class"),s&&-1!==s.indexOf(r)){const n=o.parent;if(n&&e.schema.getBlockElements()[n.name]&&t()){n.type=3,n.value=a,n.raw=!0,o.remove();continue}o.type=3,o.value=a,o.raw=!0}}))}))})(e),(e=>{e.on("ResolveName",(a=>{"IMG"===a.target.nodeName&&e.dom.hasClass(a.target,r)&&(a.name="pagebreak")}))})(e)}))}(); -------------------------------------------------------------------------------- /mail-vue/public/tinymce/skins/content/document/content.js: -------------------------------------------------------------------------------- 1 | /* This file is bundled with the code from the following third party libraries */ 2 | 3 | /** 4 | * http://prismjs.com/ 5 | * Dracula Theme originally by Zeno Rocha [@zenorocha] 6 | * https://draculatheme.com/ 7 | * 8 | * Ported for PrismJS by Albert Vallverdu [@byverdu] 9 | */ 10 | tinymce.Resource.add('content/document/content.css', `@media screen{html{background:#f4f4f4;min-height:100%}}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}@media screen{body{background-color:#fff;box-shadow:0 0 4px rgba(0,0,0,.15);box-sizing:border-box;margin:1rem auto 0;max-width:820px;min-height:calc(100vh - 1rem);padding:4rem 6rem 6rem 6rem}}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure figcaption{color:#999;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}`) -------------------------------------------------------------------------------- /mail-worker/src/service/r2-service.js: -------------------------------------------------------------------------------- 1 | import s3Service from './s3-service'; 2 | import settingService from './setting-service'; 3 | import kvObjService from './kv-obj-service'; 4 | import { settingConst } from '../const/entity-const'; 5 | 6 | const r2Service = { 7 | 8 | async hasOSS(c) { 9 | 10 | const setting = await settingService.query(c); 11 | const { kvStorage, bucket, endpoint, s3AccessKey, s3SecretKey } = setting; 12 | 13 | if (kvStorage === settingConst.kvStorage.OPEN) { 14 | return true; 15 | } 16 | 17 | if (c.env.r2) { 18 | return true; 19 | } 20 | 21 | return !!(bucket && endpoint && s3AccessKey && s3SecretKey); 22 | }, 23 | 24 | async putObj(c, key, content, metadata) { 25 | 26 | const { kvStorage } = await settingService.query(c); 27 | 28 | if (kvStorage === settingConst.kvStorage.OPEN) { 29 | 30 | await kvObjService.putObj(c, key, content, metadata); 31 | 32 | } else if (c.env.r2) { 33 | 34 | await c.env.r2.put(key, content, { 35 | httpMetadata: { ...metadata } 36 | }); 37 | 38 | } else { 39 | 40 | await s3Service.putObj(c, key, content, metadata); 41 | 42 | } 43 | 44 | }, 45 | 46 | async getObj(c, key) { 47 | return await c.env.r2.get(key); 48 | }, 49 | 50 | async delete(c, key) { 51 | 52 | const { kvStorage } = await settingService.query(c); 53 | 54 | if (kvStorage === settingConst.kvStorage.OPEN) { 55 | 56 | await kvObjService.deleteObj(c, key); 57 | 58 | } else if (c.env.r2) { 59 | 60 | await c.env.r2.delete(key); 61 | 62 | } else { 63 | 64 | await s3Service.deleteObj(c, key); 65 | 66 | } 67 | 68 | } 69 | 70 | }; 71 | export default r2Service; 72 | -------------------------------------------------------------------------------- /mail-worker/src/api/user-api.js: -------------------------------------------------------------------------------- 1 | import app from '../hono/hono'; 2 | import userService from '../service/user-service'; 3 | import result from '../model/result'; 4 | import userContext from '../security/user-context'; 5 | import accountService from '../service/account-service'; 6 | 7 | app.delete('/user/delete', async (c) => { 8 | await userService.physicsDelete(c, c.req.query()); 9 | return c.json(result.ok()); 10 | }); 11 | 12 | app.put('/user/setPwd', async (c) => { 13 | await userService.setPwd(c, await c.req.json()); 14 | return c.json(result.ok()); 15 | }); 16 | 17 | app.put('/user/setStatus', async (c) => { 18 | await userService.setStatus(c, await c.req.json()); 19 | return c.json(result.ok()); 20 | }); 21 | 22 | app.put('/user/setType', async (c) => { 23 | await userService.setType(c, await c.req.json()); 24 | return c.json(result.ok()); 25 | }); 26 | 27 | app.get('/user/list', async (c) => { 28 | const data = await userService.list(c, c.req.query(), userContext.getUserId(c)); 29 | return c.json(result.ok(data)); 30 | }); 31 | 32 | app.post('/user/add', async (c) => { 33 | await userService.add(c, await c.req.json()); 34 | return c.json(result.ok()); 35 | }); 36 | 37 | app.put('/user/resetSendCount', async (c) => { 38 | await userService.resetSendCount(c, await c.req.json()); 39 | return c.json(result.ok()); 40 | }); 41 | 42 | app.put('/user/restore', async (c) => { 43 | await userService.restore(c, await c.req.json()); 44 | return c.json(result.ok()); 45 | }); 46 | 47 | app.get('/user/allAccount', async (c) => { 48 | const data = await accountService.allAccount(c, c.req.query()); 49 | return c.json(result.ok(data)); 50 | }); 51 | 52 | app.delete('/user/deleteAccount', async (c) => { 53 | await accountService.physicsDelete(c, c.req.query()); 54 | return c.json(result.ok()); 55 | }); 56 | 57 | 58 | -------------------------------------------------------------------------------- /doc/github-action.md: -------------------------------------------------------------------------------- 1 | ## Github Action 部署 2 | 3 | **配置 Github 仓库** 4 | 5 | 1. Fork 或克隆仓库 [https://github.com/eoao/cloud-mail](https://github.com/eoao/cloud-mail) 6 | 2. 进入您的 GitHub 仓库设置 7 | 3. 转到 Settings → Secrets and variables → Actions → New Repository secrets 8 | 4. 添加以下 Secrets: 9 | 10 | | Secret 名称 | 必需 | 用途 | 11 | | ----------------------- | :--: | ----------------------------------------------------- | 12 | | `CLOUDFLARE_API_TOKEN` | ✅ | Cloudflare API 令牌(需要 Workers 和相关资源权限) | 13 | | `CLOUDFLARE_ACCOUNT_ID` | ✅ | Cloudflare 账户 ID | 14 | | `D1_DATABASE_ID` | ✅ | 您的 D1 数据库的 ID | 15 | | `KV_NAMESPACE_ID` | ✅ | 您的 KV 命名空间的 ID | 16 | | `R2_BUCKET_NAME` | ✅ | 您的 R2 存储桶的名称 | 17 | | `DOMAIN` | ✅ | 您要用于邮件服务的域名(例如 `["xx.xx"],多域名用,分隔`) | 18 | | `ADMIN` | ✅ | 您的管理员邮箱地址(例如 `admin@example.com`) | 19 | | `JWT_SECRET` | ✅ | 用于生成和验证 JWT 的随机长字符串 | 20 | | `INIT_URL` | ❌ | (可选)部署后用于初始化数据库的 Worker URL(格式参考下述手动初始化) | 21 | 22 | --- 23 | 24 | **获取 Cloudflare API 令牌** 25 | 26 | 1. 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/profile/api-tokens) 27 | 2. 创建新的 API 令牌 28 | 3. 选择"编辑 Cloudflare Workers"模板,并参照下表添加相应权限 29 | ![dc2e1dc8dcd217644759c46c6c705de1](https://i.miji.bid/2025/07/07/dc2e1dc8dcd217644759c46c6c705de1.png) 30 | 4. 保存令牌并复制到 GitHub Secrets 中的 `CLOUDFLARE_API_TOKEN` 31 | 32 | **获取 Cloudflare 账户 ID** 33 | 1. 账户 ID 可以在 Cloudflare 仪表盘的账户设置中找到。 34 | 2. 复制到 GitHub Secrets 中的 `CLOUDFLARE_ACCOUNT_ID` 35 | 36 | **运行工作流** 37 | 1. 然后在Action页面手动运行工作流,后续同步上游后会自动部署到 Cloudflare Workers。如未配置 `INIT_URL`,则需要手动访问 `https://你的项目域名/api/init/你的jwt_secret` 进行数据库初始化。 38 | 2. 自动同步上游可使用bot或者手动点击Sync Upstream按钮。 -------------------------------------------------------------------------------- /mail-vue/src/utils/file-utils.js: -------------------------------------------------------------------------------- 1 | import Compressor from "compressorjs"; 2 | 3 | export function getExtName(fileName) { 4 | const index = fileName.lastIndexOf('.') 5 | return index !== -1 ? fileName.slice(index + 1).toLowerCase() : '' 6 | } 7 | 8 | export function formatBytes(bytes) { 9 | if (bytes === 0) return '0 B'; 10 | const k = 1024; 11 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 12 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 13 | const size = (bytes / Math.pow(k, i)).toFixed(2); 14 | return `${size} ${units[i]}`; 15 | } 16 | 17 | export function fileToBase64(file, type = false) { 18 | return new Promise((resolve, reject) => { 19 | const reader = new FileReader(); 20 | reader.readAsDataURL(file); 21 | reader.onload = () => { 22 | if (type) { 23 | const base64 = reader.result; 24 | resolve(base64); 25 | } else { 26 | const base64 = reader.result.split(',')[1]; 27 | resolve(base64); 28 | } 29 | }; 30 | reader.onerror = reject; 31 | }); 32 | } 33 | 34 | export function base64Size(base64String) { 35 | const padding = (base64String.match(/=*$/) || [''])[0].length; 36 | const base64Length = base64String.length; 37 | return (base64Length * 3) / 4 - padding; 38 | } 39 | 40 | export function compressImage(file, config = {}) { 41 | return new Promise((resolve, reject) => { 42 | 43 | if (file.size < (config.convertSize || 1024 * 1024)) { 44 | resolve(file) 45 | } 46 | 47 | new Compressor(file, { 48 | quality: config.quality || 0.8, 49 | mimeType: 'image/jpeg', 50 | success(result) { 51 | resolve(result); 52 | }, 53 | error(err) { 54 | reject(err); 55 | }, 56 | }); 57 | }); 58 | } -------------------------------------------------------------------------------- /mail-vue/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig, loadEnv} from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | import AutoImport from 'unplugin-auto-import/vite' 5 | import Components from 'unplugin-vue-components/vite' 6 | import {ElementPlusResolver} from 'unplugin-vue-components/resolvers' 7 | import {VitePWA} from 'vite-plugin-pwa'; 8 | 9 | export default defineConfig(({mode}) => { 10 | const env = loadEnv(mode, process.cwd(), 'VITE') 11 | return { 12 | server: { 13 | host: true, 14 | port: 3001, 15 | hmr: true, 16 | }, 17 | base: env.VITE_STATIC_URL || '/', 18 | plugins: [vue(), 19 | VitePWA({ 20 | injectRegister: 'script-defer', 21 | manifest: { 22 | name: env.VITE_PWA_NAME, 23 | short_name: env.VITE_PWA_NAME, 24 | background_color: '#FFFFFF', 25 | theme_color: '#FFFFFF', 26 | icons: [ 27 | { 28 | src: 'mail-pwa.png', 29 | sizes: '192x192', 30 | type: 'image/png', 31 | } 32 | ], 33 | }, 34 | workbox: { 35 | disableDevLogs: true, 36 | globPatterns: [], 37 | runtimeCaching: [], 38 | navigateFallback: null, 39 | cleanupOutdatedCaches: true, 40 | } 41 | }), 42 | AutoImport({ 43 | resolvers: [ElementPlusResolver()], 44 | }), 45 | Components({ 46 | resolvers: [ElementPlusResolver()], 47 | }) 48 | ], 49 | resolve: { 50 | alias: { 51 | '@': path.resolve(__dirname, 'src') 52 | } 53 | }, 54 | build: { 55 | target: 'es2022', 56 | outDir: env.VITE_OUT_DIR || 'dist', 57 | emptyOutDir: true, 58 | assetsInclude: ['**/*.json'] 59 | } 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /mail-vue/src/utils/icon-utils.js: -------------------------------------------------------------------------------- 1 | import {getExtName} from "@/utils/file-utils.js"; 2 | 3 | export function getIconByName(filename) { 4 | const extName = getExtName(filename) 5 | if (['zip', 'rar', '7z', 'tar', 'tgz'].includes(extName)) return { 6 | icon: 'mdi:zip-box', 7 | width: '24px', 8 | height: '24px', 9 | color: '#FBBD08', 10 | }; 11 | if (['png', 'jpg', 'jpeg','gif','webp','jfif'].includes(extName)) return { 12 | icon: 'fluent-color:image-24', 13 | width: '24px', 14 | height: '24px', 15 | color: '' 16 | }; 17 | if (['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'].includes(extName)) return { 18 | icon: 'fluent:video-clip-20-filled', 19 | width: '24px', 20 | height: '24px', 21 | color: '#658bff' 22 | }; 23 | if (['txt','md','ini','conf'].includes(extName)) return { 24 | icon: 'fluent-color:document-48', 25 | width: '24px', 26 | height: '24px', 27 | color: '' 28 | }; 29 | if (['doc', 'docx'].includes(extName)) return { 30 | icon: 'vscode-icons:file-type-word', 31 | width: '23px', 32 | height: '23px', 33 | color: '' 34 | }; 35 | if (['xls', 'csv', 'xlsx'].includes(extName)) return { 36 | icon: 'vscode-icons:file-type-excel', 37 | width: '23px', 38 | height: '23px', 39 | color: '' 40 | }; 41 | if (['mp3', 'wav', 'aac', 'ogg', 'flac', 'm4a'].includes(extName)) return { 42 | icon: 'lineicons:apple-music', 43 | width: '24px', 44 | height: '24px', 45 | color: '#e91e63' 46 | }; 47 | if (['ppt', 'pptx', 'pps', 'potx', 'pot'].includes(extName)) return { 48 | icon: 'vscode-icons:file-type-powerpoint', 49 | width: '24px', 50 | height: '24px', 51 | color: '' 52 | }; 53 | if (extName === 'pdf') return { 54 | icon: 'material-icon-theme:pdf', 55 | width: '24px', 56 | height: '24px', 57 | color: '' 58 | }; 59 | return { 60 | icon: "solar:paperclip-rounded-2-bold", 61 | width: '24px', 62 | height: '24px', 63 | color: '#1CBBF0' 64 | }; 65 | 66 | } 67 | -------------------------------------------------------------------------------- /mail-worker/src/const/entity-const.js: -------------------------------------------------------------------------------- 1 | import verifyRecordService from '../service/verify-record-service'; 2 | 3 | export const userConst = { 4 | status: { 5 | NORMAL: 0, 6 | BAN: 1 7 | } 8 | } 9 | 10 | export const roleConst = { 11 | isDefault: { 12 | CLOSE: 0, 13 | OPEN: 1 14 | }, 15 | banEmailType: { 16 | ALL: 0, 17 | CONTENT: 1 18 | }, 19 | sendType: { 20 | COUNT: 'count', 21 | DAY: 'day' 22 | } 23 | } 24 | 25 | export const permConst = { 26 | type: { 27 | BUTTON: 2, 28 | } 29 | } 30 | 31 | export const emailConst = { 32 | type: { 33 | SEND: 1, 34 | RECEIVE: 0 35 | }, 36 | status: { 37 | RECEIVE: 0, 38 | SENT: 1, 39 | DELIVERED: 2, 40 | BOUNCED: 3, 41 | COMPLAINED: 4, 42 | DELAYED: 5, 43 | SAVING: 6, 44 | NOONE: 7, 45 | FAILED: 8 46 | }, 47 | unread: { 48 | UNREAD: 0, 49 | READ: 1 50 | } 51 | } 52 | 53 | export const attConst = { 54 | status: { 55 | NORMAL: 0, 56 | UNUSED: 1 57 | }, 58 | type: { 59 | ATT: 0, 60 | EMBED: 1 61 | } 62 | } 63 | 64 | export const settingConst = { 65 | register: { 66 | OPEN: 0, 67 | CLOSE: 1, 68 | }, 69 | regKey: { 70 | OPEN: 0, 71 | CLOSE: 1, 72 | OPTIONAL: 2, 73 | }, 74 | receive: { 75 | OPEN: 0, 76 | CLOSE: 1, 77 | }, 78 | send: { 79 | OPEN: 0, 80 | CLOSE: 1 81 | }, 82 | addEmail: { 83 | OPEN: 0, 84 | CLOSE: 1 85 | }, 86 | manyEmail: { 87 | OPEN: 0, 88 | CLOSE: 1, 89 | }, 90 | registerVerify: { 91 | OPEN: 0, 92 | CLOSE: 1, 93 | COUNT: 2, 94 | }, 95 | addEmailVerify: { 96 | OPEN: 0, 97 | CLOSE: 1, 98 | COUNT: 2, 99 | }, 100 | forwardStatus: { 101 | OPEN: 0, 102 | CLOSE: 1, 103 | }, 104 | tgBotStatus: { 105 | OPEN: 0, 106 | CLOSE: 1, 107 | }, 108 | ruleType: { 109 | ALL: 0, 110 | RULE: 1 111 | }, 112 | noRecipient: { 113 | OPEN: 0, 114 | CLOSE: 1, 115 | }, 116 | kvStorage: { 117 | OPEN: 0, 118 | CLOSE: 1 119 | }, 120 | forcePathStyle: { 121 | OPEN: 0, 122 | CLOSE: 1 123 | } 124 | } 125 | 126 | export const verifyRecordType = { 127 | REG: 0, 128 | ADD: 1, 129 | } 130 | 131 | 132 | export const isDel = { 133 | DELETE: 1, 134 | NORMAL: 0 135 | } 136 | -------------------------------------------------------------------------------- /mail-worker/src/utils/file-utils.js: -------------------------------------------------------------------------------- 1 | const fileUtils = { 2 | getExtFileName(filename) { 3 | try { 4 | const index = filename.lastIndexOf('.'); 5 | return index !== -1 ? filename.slice(index) : ''; 6 | } catch (e) { 7 | return '' 8 | } 9 | }, 10 | 11 | async getBuffHash(buff) { 12 | const hashBuffer = await crypto.subtle.digest('SHA-256', buff); 13 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 14 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 15 | }, 16 | 17 | base64ToDataStr(base64) { 18 | return base64.split(',')[1] || base64; 19 | }, 20 | 21 | base64ToUint8Array(base64) { 22 | const binaryStr = atob(base64); 23 | const len = binaryStr.length; 24 | const bytes = new Uint8Array(len); 25 | for (let i = 0; i < len; i++) { 26 | bytes[i] = binaryStr.charCodeAt(i); 27 | } 28 | return bytes; 29 | }, 30 | 31 | /** 32 | * 将 Base64 数据转换为 File 对象(自动识别 MIME 类型和文件扩展名) 33 | * @param {string} base64Data 带有 data: 前缀的 base64 数据 34 | * @param {string} [customFilename] 可选,传入自定义文件名(不含扩展名) 35 | * @returns {File} File 对象 36 | */ 37 | base64ToFile(base64Data, customFilename) { 38 | const match = base64Data.match(/^data:(image|jpeg|video)\/([a-zA-Z0-9.+-]+);base64,/); 39 | if (!match) { 40 | throw new Error('Invalid base64 data format'); 41 | } 42 | 43 | const type = match[1]; // image 或 video 44 | const ext = match[2]; // jpg, png, mp4 等 45 | const mimeType = `${type}/${ext}`; 46 | const cleanBase64 = base64Data.replace(/^data:(image|jpeg|video)\/[a-zA-Z0-9.+-]+;base64,/, ''); 47 | 48 | const byteCharacters = atob(cleanBase64); 49 | const byteArrays = []; 50 | 51 | for (let offset = 0; offset < byteCharacters.length; offset += 1024) { 52 | const slice = byteCharacters.slice(offset, offset + 1024); 53 | const byteNumbers = new Array(slice.length); 54 | for (let i = 0; i < slice.length; i++) { 55 | byteNumbers[i] = slice.charCodeAt(i); 56 | } 57 | byteArrays.push(new Uint8Array(byteNumbers)); 58 | } 59 | 60 | const blob = new Blob(byteArrays, { type: mimeType }); 61 | 62 | const filename = `${customFilename || `${type}_${Date.now()}`}.${ext}`; 63 | return new File([blob], filename, { type: mimeType }); 64 | } 65 | }; 66 | 67 | 68 | export default fileUtils; 69 | 70 | -------------------------------------------------------------------------------- /mail-vue/src/init/init.js: -------------------------------------------------------------------------------- 1 | import {useUserStore} from "@/store/user.js"; 2 | import {useSettingStore} from "@/store/setting.js"; 3 | import {useAccountStore} from "@/store/account.js"; 4 | import {loginUserInfo} from "@/request/my.js"; 5 | import {permsToRouter} from "@/perm/perm.js"; 6 | import router from "@/router"; 7 | import {websiteConfig} from "@/request/setting.js"; 8 | import i18n from "@/i18n/index.js"; 9 | 10 | export async function init() { 11 | document.title = '\u200B' 12 | 13 | const settingStore = useSettingStore(); 14 | const userStore = useUserStore(); 15 | const accountStore = useAccountStore(); 16 | 17 | const token = localStorage.getItem('token'); 18 | if (!settingStore.lang) { 19 | let lang = navigator.language.split('-')[0] 20 | lang = lang === 'zh' ? lang : 'en' 21 | settingStore.lang = lang 22 | } 23 | 24 | i18n.global.locale.value = settingStore.lang 25 | 26 | let setting = null; 27 | 28 | if (token) { 29 | const userPromise = loginUserInfo().catch(e => { 30 | console.error(e); 31 | return null; 32 | }); 33 | 34 | const [s, user] = await Promise.all([websiteConfig(), userPromise]); 35 | setting = s; 36 | settingStore.settings = setting; 37 | settingStore.domainList = setting.domainList; 38 | document.title = setting.title; 39 | 40 | if (user) { 41 | accountStore.currentAccountId = user.accountId; 42 | userStore.user = user; 43 | 44 | const routers = permsToRouter(user.permKeys); 45 | routers.forEach(routerData => { 46 | router.addRoute('layout', routerData); 47 | }); 48 | } 49 | 50 | } else { 51 | setting = await websiteConfig(); 52 | settingStore.settings = setting; 53 | settingStore.domainList = setting.domainList; 54 | document.title = setting.title; 55 | } 56 | 57 | removeLoading(); 58 | } 59 | 60 | function removeLoading() { 61 | if (window.innerWidth < 1025) { 62 | document.documentElement.style.setProperty('--loading-hide-transition', 'none') 63 | } 64 | const doc = document.getElementById('loading-first'); 65 | doc.classList.add('loading-hide') 66 | setTimeout(() => { 67 | doc.remove() 68 | },1000) 69 | } 70 | 71 | -------------------------------------------------------------------------------- /mail-worker/src/service/star-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import { star } from '../entity/star'; 3 | import emailService from './email-service'; 4 | import BizError from '../error/biz-error'; 5 | import { and, desc, eq, lt, sql, inArray } from 'drizzle-orm'; 6 | import email from '../entity/email'; 7 | import { isDel } from '../const/entity-const'; 8 | import attService from "./att-service"; 9 | import { t } from '../i18n/i18n' 10 | const starService = { 11 | 12 | async add(c, params, userId) { 13 | const { emailId } = params; 14 | const email = await emailService.selectById(c, emailId); 15 | if (!email) { 16 | throw new BizError(t('starNotExistEmail')); 17 | } 18 | if (!email.userId === userId) { 19 | throw new BizError(t('starNotExistEmail')); 20 | } 21 | const exist = await orm(c).select().from(star).where( 22 | and( 23 | eq(star.userId, userId), 24 | eq(star.emailId, emailId))) 25 | .get() 26 | 27 | if (exist) { 28 | return 29 | } 30 | 31 | await orm(c).insert(star).values({ userId, emailId }).run(); 32 | }, 33 | 34 | async cancel(c, params, userId) { 35 | const { emailId } = params; 36 | await orm(c).delete(star).where( 37 | and( 38 | eq(star.userId, userId), 39 | eq(star.emailId, emailId))) 40 | .run(); 41 | }, 42 | 43 | async list(c, params, userId) { 44 | let { emailId, size } = params; 45 | emailId = Number(emailId); 46 | size = Number(size); 47 | 48 | if (!emailId) { 49 | emailId = 9999999999; 50 | } 51 | 52 | const list = await orm(c).select({ 53 | isStar: sql`1`.as('isStar'), 54 | starId: star.starId 55 | , ...email 56 | }).from(star) 57 | .leftJoin(email, eq(email.emailId, star.emailId)) 58 | .where( 59 | and( 60 | eq(star.userId, userId), 61 | eq(email.isDel, isDel.NORMAL), 62 | lt(star.emailId, emailId))) 63 | .orderBy(desc(star.emailId)) 64 | .limit(size) 65 | .all(); 66 | 67 | const emailIds = list.map(item => item.emailId); 68 | 69 | const attsList = await attService.selectByEmailIds(c, emailIds); 70 | 71 | list.forEach(emailRow => { 72 | const atts = attsList.filter(attsRow => attsRow.emailId === emailRow.emailId); 73 | emailRow.attList = atts; 74 | }); 75 | 76 | return { list }; 77 | }, 78 | async removeByEmailIds(c, emailIds) { 79 | await orm(c).delete(star).where(inArray(star.emailId, emailIds)).run(); 80 | } 81 | }; 82 | 83 | export default starService; 84 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/autoresize/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env");const o=e=>t=>t.options.get(e),n=o("min_height"),s=o("max_height"),i=o("autoresize_overflow_padding"),r=o("autoresize_bottom_margin"),g=(e,t)=>{const o=e.getBody();o&&(o.style.overflowY=t?"":"hidden",t||(o.scrollTop=0))},l=(e,t,o,n)=>{var s;const i=parseInt(null!==(s=e.getStyle(t,o,n))&&void 0!==s?s:"",10);return isNaN(i)?0:i},a=(e,o,r,c)=>{var d;const u=e.dom,h=e.getDoc();if(!h)return;if((e=>e.plugins.fullscreen&&e.plugins.fullscreen.isFullscreen())(e))return void g(e,!0);const m=h.documentElement,f=c?c():i(e),p=null!==(d=n(e))&&void 0!==d?d:e.getElement().offsetHeight;let y=p;const S=l(u,m,"margin-top",!0),v=l(u,m,"margin-bottom",!0);let C=m.offsetHeight+S+v+f;C<0&&(C=0);const H=e.getContainer().offsetHeight-e.getContentAreaContainer().offsetHeight;C+H>p&&(y=C+H);const b=s(e);b&&y>b?(y=b,g(e,!0)):g(e,!1);const w=o.get();if(w.set&&(e.dom.setStyles(e.getDoc().documentElement,{"min-height":0}),e.dom.setStyles(e.getBody(),{"min-height":"inherit"})),y!==w.totalHeight&&(C-f!==w.contentHeight||!w.set)){const n=y-w.totalHeight;if(u.setStyle(e.getContainer(),"height",y+"px"),o.set({totalHeight:y,contentHeight:C,set:!0}),(e=>{e.dispatch("ResizeEditor")})(e),t.browser.isSafari()&&(t.os.isMacOS()||t.os.isiOS())){const t=e.getWin();t.scrollTo(t.pageXOffset,t.pageYOffset)}e.hasFocus()&&(e=>{if("setcontent"===(null==e?void 0:e.type.toLowerCase())){const t=e;return!0===t.selection||!0===t.paste}return!1})(r)&&e.selection.scrollIntoView(),(t.browser.isSafari()||t.browser.isChromium())&&n<0&&a(e,o,r,c)}};e.add("autoresize",(e=>{if((e=>{const t=e.options.register;t("autoresize_overflow_padding",{processor:"number",default:1}),t("autoresize_bottom_margin",{processor:"number",default:50})})(e),e.options.isSet("resize")||e.options.set("resize",!1),!e.inline){const o=(()=>{let e={totalHeight:0,contentHeight:0,set:!1};return{get:()=>e,set:t=>{e=t}}})();((e,t)=>{e.addCommand("mceAutoResize",(()=>{a(e,t)}))})(e,o),((e,o)=>{const n=()=>r(e);e.on("init",(s=>{const r=i(e),g=e.dom;g.setStyles(e.getDoc().documentElement,{height:"auto"}),t.browser.isEdge()||t.browser.isIE()?g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r,"min-height":0}):g.setStyles(e.getBody(),{paddingLeft:r,paddingRight:r}),a(e,o,s,n)})),e.on("NodeChange SetContent keyup FullscreenStateChanged ResizeContent",(t=>{a(e,o,t,n)}))})(e,o)}}))}(); -------------------------------------------------------------------------------- /mail-worker/src/utils/jwt-utils.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | const decoder = new TextDecoder(); 3 | 4 | const base64url = (input) => { 5 | const str = btoa(String.fromCharCode(...new Uint8Array(input))); 6 | return str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 7 | }; 8 | 9 | const base64urlDecode = (str) => { 10 | str = str.replace(/-/g, '+').replace(/_/g, '/'); 11 | while (str.length % 4) str += '='; 12 | return Uint8Array.from(atob(str), c => c.charCodeAt(0)); 13 | }; 14 | 15 | const jwtUtils = { 16 | async generateToken(c, payload, expiresInSeconds) { 17 | const header = { 18 | alg: 'HS256', 19 | typ: 'JWT' 20 | }; 21 | 22 | const now = Math.floor(Date.now() / 1000); 23 | const exp = expiresInSeconds ? now + expiresInSeconds : undefined; 24 | 25 | const fullPayload = { 26 | ...payload, 27 | iat: now, 28 | ...(exp ? { exp } : {}) 29 | }; 30 | 31 | const headerStr = base64url(encoder.encode(JSON.stringify(header))); 32 | const payloadStr = base64url(encoder.encode(JSON.stringify(fullPayload))); 33 | const data = `${headerStr}.${payloadStr}`; 34 | 35 | const key = await crypto.subtle.importKey( 36 | 'raw', 37 | encoder.encode(c.env.jwt_secret), 38 | { name: 'HMAC', hash: 'SHA-256' }, 39 | false, 40 | ['sign'] 41 | ); 42 | 43 | const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); 44 | const signatureStr = base64url(signature); 45 | 46 | return `${data}.${signatureStr}`; 47 | }, 48 | 49 | async verifyToken(c, token) { 50 | try { 51 | const [headerB64, payloadB64, signatureB64] = token.split('.'); 52 | 53 | if (!headerB64 || !payloadB64 || !signatureB64) return null; 54 | 55 | const data = `${headerB64}.${payloadB64}`; 56 | const key = await crypto.subtle.importKey( 57 | 'raw', 58 | encoder.encode(c.env.jwt_secret), 59 | { name: 'HMAC', hash: 'SHA-256' }, 60 | false, 61 | ['verify'] 62 | ); 63 | 64 | const valid = await crypto.subtle.verify( 65 | 'HMAC', 66 | key, 67 | base64urlDecode(signatureB64), 68 | encoder.encode(data) 69 | ); 70 | 71 | if (!valid) return null; 72 | 73 | const payloadJson = decoder.decode(base64urlDecode(payloadB64)); 74 | const payload = JSON.parse(payloadJson); 75 | 76 | const now = Math.floor(Date.now() / 1000); 77 | if (payload.exp && payload.exp < now) return null; 78 | 79 | return payload; 80 | 81 | } catch (err) { 82 | console.log(err) 83 | return null; 84 | } 85 | } 86 | }; 87 | 88 | export default jwtUtils; 89 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/anchor/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.dom.RangeUtils"),o=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=e=>e.options.get("allow_html_in_named_anchor");const a="a:not([href])",r=e=>!e,i=e=>e.getAttribute("id")||e.getAttribute("name")||"",l=e=>(e=>"a"===e.nodeName.toLowerCase())(e)&&!e.getAttribute("href")&&""!==i(e),s=e=>e.dom.getParent(e.selection.getStart(),a),d=(e,a)=>{const r=s(e);r?((e,t,o)=>{o.removeAttribute("name"),o.id=t,e.addVisual(),e.undoManager.add()})(e,a,r):((e,a)=>{e.undoManager.transact((()=>{n(e)||e.selection.collapse(!0),e.selection.isCollapsed()?e.insertContent(e.dom.createHTML("a",{id:a})):((e=>{const n=e.dom;t(n).walk(e.selection.getRng(),(e=>{o.each(e,(e=>{var t;l(t=e)&&!t.firstChild&&n.remove(e,!1)}))}))})(e),e.formatter.remove("namedAnchor",void 0,void 0,!0),e.formatter.apply("namedAnchor",{value:a}),e.addVisual())}))})(e,a),e.focus()},c=e=>(e=>r(e.attr("href"))&&!r(e.attr("id")||e.attr("name")))(e)&&!e.firstChild,m=e=>t=>{for(let o=0;ot=>{const o=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",o),o(),()=>{e.off("NodeChange",o)}};e.add("anchor",(e=>{(e=>{(0,e.options.register)("allow_html_in_named_anchor",{processor:"boolean",default:!1})})(e),(e=>{e.on("PreInit",(()=>{e.parser.addNodeFilter("a",m("false")),e.serializer.addNodeFilter("a",m(null))}))})(e),(e=>{e.addCommand("mceAnchor",(()=>{(e=>{const t=(e=>{const t=s(e);return t?i(t):""})(e);e.windowManager.open({title:"Anchor",size:"normal",body:{type:"panel",items:[{name:"id",type:"input",label:"ID",placeholder:"example"}]},buttons:[{type:"cancel",name:"cancel",text:"Cancel"},{type:"submit",name:"save",text:"Save",primary:!0}],initialData:{id:t},onSubmit:t=>{((e,t)=>/^[A-Za-z][A-Za-z0-9\-:._]*$/.test(t)?(d(e,t),!0):(e.windowManager.alert("ID should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores."),!1))(e,t.getData().id)&&t.close()}})})(e)}))})(e),(e=>{const t=()=>e.execCommand("mceAnchor");e.ui.registry.addToggleButton("anchor",{icon:"bookmark",tooltip:"Anchor",onAction:t,onSetup:t=>{const o=e.selection.selectorChangedWithUnbind("a:not([href])",t.setActive).unbind,n=u(e)(t);return()=>{o(),n()}}}),e.ui.registry.addMenuItem("anchor",{icon:"bookmark",text:"Anchor...",onAction:t,onSetup:u(e)})})(e),e.on("PreInit",(()=>{(e=>{e.formatter.register("namedAnchor",{inline:"a",selector:a,remove:"all",split:!0,deep:!0,attributes:{id:"%value"},onmatch:(e,t,o)=>l(e)})})(e)}))}))}(); -------------------------------------------------------------------------------- /mail-worker/src/service/s3-service.js: -------------------------------------------------------------------------------- 1 | import { S3Client, PutObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3"; 2 | import settingService from './setting-service'; 3 | import domainUtils from '../utils/domain-uitls'; 4 | import { settingConst } from '../const/entity-const'; 5 | const s3Service = { 6 | 7 | async putObj(c, key, content, metadata) { 8 | 9 | const client = await this.client(c); 10 | 11 | const { bucket } = await settingService.query(c); 12 | 13 | let obj = { Bucket: bucket, Key: key, Body: content, 14 | CacheControl: metadata.cacheControl 15 | } 16 | 17 | if (metadata.cacheControl) { 18 | obj.CacheControl = metadata.cacheControl 19 | } 20 | 21 | if (metadata.contentDisposition) { 22 | obj.ContentDisposition = metadata.contentDisposition 23 | } 24 | 25 | if (metadata.contentType) { 26 | obj.ContentType = metadata.contentType 27 | } 28 | 29 | await client.send(new PutObjectCommand(obj)) 30 | }, 31 | 32 | async deleteObj(c, keys) { 33 | 34 | if (typeof keys === 'string') { 35 | keys = [keys]; 36 | } 37 | 38 | if (keys.length === 0) { 39 | return; 40 | } 41 | 42 | const client = await this.client(c); 43 | const { bucket } = await settingService.query(c); 44 | 45 | 46 | client.middlewareStack.add( 47 | (next) => async (args) => { 48 | 49 | const body = args.request.body 50 | 51 | // 计算 MD5 校验和并转换为 Base64 编码 52 | const encoder = new TextEncoder(); 53 | const data = encoder.encode(body); 54 | 55 | // 使用 Web Crypto API 计算 MD5 校验和 56 | const hashBuffer = await crypto.subtle.digest('MD5', data); 57 | const hashArray = new Uint8Array(hashBuffer); 58 | const contentMD5 = btoa(String.fromCharCode.apply(null, hashArray)); 59 | 60 | args.request.headers["Content-MD5"] = contentMD5; 61 | 62 | return next(args); 63 | }, 64 | { step: "build", name: "inspectRequestMiddleware" } 65 | ); 66 | 67 | 68 | await client.send( 69 | new DeleteObjectsCommand({ 70 | Bucket: bucket, 71 | Delete: { 72 | Objects: keys.map(key => ({ Key: key })) 73 | } 74 | }) 75 | ); 76 | }, 77 | 78 | 79 | async client(c) { 80 | const { region, endpoint, s3AccessKey, s3SecretKey, forcePathStyle } = await settingService.query(c); 81 | return new S3Client({ 82 | region: region || 'auto', 83 | endpoint: domainUtils.toOssDomain(endpoint), 84 | forcePathStyle: forcePathStyle === settingConst.forcePathStyle.OPEN, 85 | credentials: { 86 | accessKeyId: s3AccessKey, 87 | secretAccessKey: s3SecretKey, 88 | } 89 | }); 90 | } 91 | } 92 | 93 | export default s3Service 94 | -------------------------------------------------------------------------------- /mail-vue/src/views/send/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 78 | 79 | -------------------------------------------------------------------------------- /mail-worker/src/service/analysis-service.js: -------------------------------------------------------------------------------- 1 | import analysisDao from '../dao/analysis-dao'; 2 | import orm from '../entity/orm'; 3 | import email from '../entity/email'; 4 | import { desc, count, eq, and, ne, isNotNull } from 'drizzle-orm'; 5 | import { emailConst } from '../const/entity-const'; 6 | import kvConst from '../const/kv-const'; 7 | import dayjs from 'dayjs'; 8 | import { toUtc } from '../utils/date-uitil'; 9 | const analysisService = { 10 | 11 | async echarts(c, params) { 12 | 13 | 14 | const { timeZone } = params; 15 | 16 | let utcDate = toUtc().startOf('day'); 17 | 18 | let localDate = utcDate.tz(timeZone); 19 | 20 | utcDate = dayjs(utcDate.format('YYYY-MM-DD HH:mm:ss')) 21 | 22 | localDate = dayjs(localDate.format('YYYY-MM-DD HH:mm:ss')) 23 | 24 | //获取时差 25 | const diffHours = localDate.diff(utcDate, 'hour',true); 26 | 27 | 28 | const [ 29 | numberCount, 30 | nameRatio, 31 | userDayCountRaw, 32 | receiveDayCountRaw, 33 | sendDayCountRaw, 34 | daySendTotalRaw 35 | ] = await Promise.all([ 36 | analysisDao.numberCount(c), 37 | 38 | orm(c) 39 | .select({ name: email.name, total: count() }) 40 | .from(email) 41 | .where(and(eq(email.type, emailConst.type.RECEIVE), isNotNull(email.name),ne(email.name,'noreply'), ne(email.name,''))) 42 | .groupBy(email.name) 43 | .orderBy(desc(count())) 44 | .limit(6), 45 | 46 | 47 | analysisDao.userDayCount(c, diffHours), 48 | analysisDao.receiveDayCount(c, diffHours), 49 | analysisDao.sendDayCount(c, diffHours), 50 | 51 | c.env.kv.get(kvConst.SEND_DAY_COUNT + dayjs().format('YYYY-MM-DD')), 52 | ]); 53 | 54 | 55 | const userDayCount = this.filterEmptyDay(userDayCountRaw, timeZone); 56 | const receiveDayCount = this.filterEmptyDay(receiveDayCountRaw, timeZone); 57 | const sendDayCount = this.filterEmptyDay(sendDayCountRaw, timeZone); 58 | 59 | const daySendTotal = daySendTotalRaw || 0; 60 | 61 | return { 62 | numberCount, 63 | userDayCount, 64 | receiveRatio: { 65 | nameRatio 66 | }, 67 | emailDayCount: { 68 | receiveDayCount, 69 | sendDayCount 70 | }, 71 | daySendTotal: Number(daySendTotal) 72 | }; 73 | }, 74 | 75 | filterEmptyDay(data, timeZone) { 76 | const today = toUtc().tz(timeZone).subtract(1, 'day'); 77 | const previousDays = Array.from({ length: 15 }, (_, i) => { 78 | return today.subtract(i, 'day').format('YYYY-MM-DD'); 79 | }).reverse(); 80 | 81 | return previousDays.map(day => { 82 | const index = data.findIndex(item => item.date === day) 83 | const total = index > - 1 ? data[index].total : 0 84 | return {date: day,total} 85 | }) 86 | 87 | } 88 | } 89 | 90 | export default analysisService 91 | -------------------------------------------------------------------------------- /mail-vue/src/utils/day.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import 'dayjs/locale/zh-cn' 3 | import utc from 'dayjs/plugin/utc' 4 | import timezone from 'dayjs/plugin/timezone' 5 | import {useSettingStore} from "@/store/setting.js"; 6 | const settingStore = useSettingStore(); 7 | dayjs.extend(utc) 8 | dayjs.extend(timezone) 9 | dayjs.locale(settingStore.lang === 'en' ? 'en' : 'zh-cn') 10 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; 11 | 12 | export function fromNow(date) { 13 | const d = dayjs.utc(date).tz(timeZone); 14 | const now = dayjs(); 15 | const diffSeconds = now.diff(d, 'second'); 16 | const diffMinutes = now.diff(d, 'minute'); 17 | const diffHours = now.diff(d, 'hour'); 18 | const isToday = now.isSame(d, 'day'); 19 | if (settingStore.lang === 'en') { 20 | 21 | if (isToday) { 22 | if (diffSeconds < 60) return `Just now`; 23 | if (diffMinutes < 60) return `${diffMinutes} min ago`; 24 | if (diffHours < 2) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; 25 | return d.format('hh:mm A'); 26 | } 27 | 28 | if (now.subtract(1, 'day').isSame(d, 'day')) { 29 | return d.format('MMM D'); 30 | } 31 | 32 | return d.year() === now.year() 33 | ? d.format('MMM D') 34 | : d.format('YYYY/MM/DD'); 35 | 36 | 37 | } else { 38 | 39 | if (isToday) { 40 | if (diffSeconds < 60) return `几秒前`; 41 | if (diffMinutes < 60) return `${diffMinutes}分钟前`; 42 | if (diffHours >= 1 && diffHours < 2) return '1小时前'; 43 | return d.format('HH:mm'); 44 | } 45 | else if (now.subtract(1, 'day').isSame(d, 'day')) { 46 | return `昨天 ${d.format('HH:mm')}`; 47 | } 48 | else if (now.subtract(2, 'day').isSame(d, 'day')) { 49 | return `前天 ${d.format('HH:mm')}`; 50 | } 51 | return d.year() === now.year() 52 | ? d.format('M月D日') 53 | : d.format('YYYY/M/D'); 54 | 55 | } 56 | 57 | } 58 | 59 | 60 | export function formatDetailDate(time) { 61 | const d = dayjs.utc(time).tz(timeZone); 62 | const now = dayjs(); 63 | 64 | const isSameYear = now.year() === d.year(); 65 | 66 | if (settingStore.lang === 'en') { 67 | return isSameYear 68 | ? d.format('ddd, MMM D, h:mm A') 69 | : d.format('ddd, MMM D, YYYY, h:mm A'); 70 | } else { 71 | return d.format('YYYY年M月D日 ddd AH:mm'); 72 | } 73 | } 74 | 75 | export function tzDayjs(time) { 76 | return dayjs.utc(time).tz(timeZone) 77 | } 78 | 79 | export function toUtc(time) { 80 | return dayjs(time).utc() 81 | } 82 | 83 | export function setExtend(lang) { 84 | dayjs.locale(lang) 85 | } 86 | -------------------------------------------------------------------------------- /mail-worker/src/service/verify-record-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import verifyRecord from '../entity/verify-record'; 3 | import { eq, sql, and } from 'drizzle-orm'; 4 | import dayjs from 'dayjs'; 5 | import reqUtils from '../utils/req-utils'; 6 | import { verifyRecordType } from '../const/entity-const'; 7 | 8 | const verifyRecordService = { 9 | 10 | async selectListByIP(c) { 11 | const ip = reqUtils.getIp(c) 12 | return orm(c).select().from(verifyRecord).where(eq(verifyRecord.ip, ip)).all(); 13 | }, 14 | 15 | async clearRecord(c) { 16 | await orm(c).delete(verifyRecord).run(); 17 | }, 18 | 19 | async isOpenRegVerify(c, regVerifyCount) { 20 | 21 | const ip = reqUtils.getIp(c) 22 | 23 | const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get(); 24 | 25 | if (row) { 26 | if (row.count >= regVerifyCount){ 27 | return true 28 | } 29 | 30 | } 31 | 32 | return false 33 | 34 | }, 35 | 36 | async isOpenAddVerify(c, addVerifyCount) { 37 | 38 | const ip = reqUtils.getIp(c) 39 | 40 | const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get(); 41 | 42 | if (row) { 43 | 44 | if (row.count >= addVerifyCount){ 45 | return true 46 | } 47 | 48 | } 49 | 50 | return false 51 | 52 | }, 53 | 54 | async increaseRegCount(c) { 55 | 56 | const ip = reqUtils.getIp(c) 57 | 58 | const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get(); 59 | const now = dayjs().format('YYYY-MM-DD HH:mm:ss'); 60 | 61 | if (row) { 62 | return orm(c).update(verifyRecord).set({ 63 | count: sql`${verifyRecord.count} 64 | + 1`, updateTime: now 65 | }).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).returning().get(); 66 | } else { 67 | return orm(c).insert(verifyRecord).values({ip, type: verifyRecordType.REG}).returning().run(); 68 | } 69 | }, 70 | 71 | async increaseAddCount(c) { 72 | 73 | const ip = reqUtils.getIp(c) 74 | 75 | const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get(); 76 | const now = dayjs().format('YYYY-MM-DD HH:mm:ss'); 77 | 78 | if (row) { 79 | return orm(c).update(verifyRecord).set({ 80 | count: sql`${verifyRecord.count} 81 | + 1`, updateTime: now 82 | }).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).returning().get(); 83 | } else { 84 | return orm(c).insert(verifyRecord).values({ip, type: verifyRecordType.ADD}).returning().get(); 85 | } 86 | } 87 | 88 | }; 89 | 90 | export default verifyRecordService; 91 | -------------------------------------------------------------------------------- /mail-worker/src/service/telegram-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import email from '../entity/email'; 3 | import settingService from './setting-service'; 4 | import dayjs from 'dayjs'; 5 | import utc from 'dayjs/plugin/utc'; 6 | import timezone from 'dayjs/plugin/timezone'; 7 | dayjs.extend(utc); 8 | dayjs.extend(timezone); 9 | import { eq } from 'drizzle-orm'; 10 | import jwtUtils from '../utils/jwt-utils'; 11 | import emailMsgTemplate from '../template/email-msg'; 12 | import emailTextTemplate from '../template/email-text'; 13 | import emailHtmlTemplate from '../template/email-html'; 14 | import verifyUtils from '../utils/verify-utils'; 15 | import domainUtils from "../utils/domain-uitls"; 16 | 17 | const telegramService = { 18 | 19 | async getEmailContent(c, params) { 20 | 21 | const { token } = params 22 | 23 | const result = await jwtUtils.verifyToken(c, token); 24 | 25 | if (!result) { 26 | return emailTextTemplate('Access denied') 27 | } 28 | 29 | const emailRow = await orm(c).select().from(email).where(eq(email.emailId, result.emailId)).get(); 30 | 31 | if (emailRow) { 32 | 33 | if (emailRow.content) { 34 | const { r2Domain } = await settingService.query(c); 35 | return emailHtmlTemplate(emailRow.content || '', r2Domain) 36 | } else { 37 | return emailTextTemplate(emailRow.text || '') 38 | } 39 | 40 | } else { 41 | return emailTextTemplate('The email does not exist') 42 | } 43 | 44 | }, 45 | 46 | async sendEmailToBot(c, email) { 47 | 48 | const { tgBotToken, tgChatId, customDomain, tgMsgTo, tgMsgFrom, tgMsgText } = await settingService.query(c); 49 | 50 | const tgChatIds = tgChatId.split(','); 51 | 52 | const jwtToken = await jwtUtils.generateToken(c, { emailId: email.emailId }) 53 | 54 | const webAppUrl = customDomain ? `${domainUtils.toOssDomain(customDomain)}/api/telegram/getEmail/${jwtToken}` : 'https://www.cloudflare.com/404' 55 | 56 | await Promise.all(tgChatIds.map(async chatId => { 57 | try { 58 | const res = await fetch(`https://api.telegram.org/bot${tgBotToken}/sendMessage`, { 59 | method: 'POST', 60 | headers: { 61 | 'Content-Type': 'application/json' 62 | }, 63 | body: JSON.stringify({ 64 | chat_id: chatId, 65 | parse_mode: 'HTML', 66 | text: emailMsgTemplate(email, tgMsgTo, tgMsgFrom, tgMsgText), 67 | reply_markup: { 68 | inline_keyboard: [ 69 | [ 70 | { 71 | text: '查看', 72 | web_app: { url: webAppUrl } 73 | } 74 | ] 75 | ] 76 | } 77 | }) 78 | }); 79 | if (!res.ok) { 80 | console.error(`转发 Telegram 失败: chatId=${chatId}, 状态码=${res.status}`); 81 | } 82 | } catch (e) { 83 | console.error(`转发 Telegram 失败: chatId=${chatId}`, e.message); 84 | } 85 | })); 86 | 87 | } 88 | 89 | } 90 | 91 | export default telegramService 92 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/insertdatetime/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>t.options.get(e),a=t("insertdatetime_dateformat"),n=t("insertdatetime_timeformat"),r=t("insertdatetime_formats"),s=t("insertdatetime_element"),i="Sun Mon Tue Wed Thu Fri Sat Sun".split(" "),o="Sunday Monday Tuesday Wednesday Thursday Friday Saturday Sunday".split(" "),l="Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),m="January February March April May June July August September October November December".split(" "),c=(e,t)=>{if((e=""+e).length(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=(t=t.replace("%D","%m/%d/%Y")).replace("%r","%I:%M:%S %p")).replace("%Y",""+a.getFullYear())).replace("%y",""+a.getYear())).replace("%m",c(a.getMonth()+1,2))).replace("%d",c(a.getDate(),2))).replace("%H",""+c(a.getHours(),2))).replace("%M",""+c(a.getMinutes(),2))).replace("%S",""+c(a.getSeconds(),2))).replace("%I",""+((a.getHours()+11)%12+1))).replace("%p",a.getHours()<12?"AM":"PM")).replace("%B",""+e.translate(m[a.getMonth()]))).replace("%b",""+e.translate(l[a.getMonth()]))).replace("%A",""+e.translate(o[a.getDay()]))).replace("%a",""+e.translate(i[a.getDay()]))).replace("%%","%"),u=(e,t)=>{if(s(e)&&e.selection.isEditable()){const a=d(e,t);let n;n=/%[HMSIp]/.test(t)?d(e,"%Y-%m-%dT%H:%M"):d(e,"%Y-%m-%d");const r=e.dom.getParent(e.selection.getStart(),"time");r?((e,t,a,n)=>{const r=e.dom.create("time",{datetime:a},n);e.dom.replace(r,t),e.selection.select(r,!0),e.selection.collapse(!1)})(e,r,n,a):e.insertContent('")}else e.insertContent(d(e,t))};var p=tinymce.util.Tools.resolve("tinymce.util.Tools");const g=e=>t=>{const a=()=>{t.setEnabled(e.selection.isEditable())};return e.on("NodeChange",a),a(),()=>{e.off("NodeChange",a)}};e.add("insertdatetime",(e=>{(e=>{const t=e.options.register;t("insertdatetime_dateformat",{processor:"string",default:e.translate("%Y-%m-%d")}),t("insertdatetime_timeformat",{processor:"string",default:e.translate("%H:%M:%S")}),t("insertdatetime_formats",{processor:"string[]",default:["%H:%M:%S","%Y-%m-%d","%I:%M:%S %p","%D"]}),t("insertdatetime_element",{processor:"boolean",default:!1})})(e),(e=>{e.addCommand("mceInsertDate",((t,n)=>{u(e,null!=n?n:a(e))})),e.addCommand("mceInsertTime",((t,a)=>{u(e,null!=a?a:n(e))}))})(e),(e=>{const t=r(e),a=(e=>{let t=e;return{get:()=>t,set:e=>{t=e}}})((e=>{const t=r(e);return t.length>0?t[0]:n(e)})(e)),s=t=>e.execCommand("mceInsertDate",!1,t);e.ui.registry.addSplitButton("insertdatetime",{icon:"insert-time",tooltip:"Insert date/time",select:e=>e===a.get(),fetch:a=>{a(p.map(t,(t=>({type:"choiceitem",text:d(e,t),value:t}))))},onAction:e=>{s(a.get())},onItemAction:(e,t)=>{a.set(t),s(t)},onSetup:g(e)});const i=e=>()=>{a.set(e),s(e)};e.ui.registry.addNestedMenuItem("insertdatetime",{icon:"insert-time",text:"Date/time",getSubmenuItems:()=>p.map(t,(t=>({type:"menuitem",text:d(e,t),onAction:i(t)}))),onSetup:g(e)})})(e)}))}(); -------------------------------------------------------------------------------- /mail-worker/src/entity/setting.js: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; 2 | export const setting = sqliteTable('setting', { 3 | register: integer('register').default(0).notNull(), 4 | receive: integer('receive').default(0).notNull(), 5 | title: text('title').default('').notNull(), 6 | manyEmail: integer('many_email').default(0).notNull(), 7 | addEmail: integer('add_email').default(0).notNull(), 8 | autoRefreshTime: integer('auto_refresh_time').default(0).notNull(), 9 | addEmailVerify: integer('add_email_verify').default(1).notNull(), 10 | registerVerify: integer('register_verify').default(1).notNull(), 11 | regVerifyCount: integer('reg_verify_count').default(1).notNull(), 12 | addVerifyCount: integer('add_verify_count').default(1).notNull(), 13 | send: integer('send').default(1).notNull(), 14 | r2Domain: text('r2_domain'), 15 | secretKey: text('secret_key'), 16 | siteKey: text('site_key'), 17 | regKey: integer('reg_key').default(1).notNull(), 18 | background: text('background'), 19 | tgBotToken: text('tg_bot_token').default('').notNull(), 20 | tgChatId: text('tg_chat_id').default('').notNull(), 21 | tgBotStatus: integer('tg_bot_status').default(1).notNull(), 22 | forwardEmail: text('forward_email').default('').notNull(), 23 | forwardStatus: integer('forward_status').default(1).notNull(), 24 | ruleEmail: text('rule_email').default('').notNull(), 25 | ruleType: integer('rule_type').default(0).notNull(), 26 | loginOpacity: integer('login_opacity').default(0.88), 27 | resendTokens: text('resend_tokens').default("{}").notNull(), 28 | noticeTitle: text('notice_title').default('').notNull(), 29 | noticeContent: text('notice_content').default('').notNull(), 30 | noticeType: text('notice_type').default('').notNull(), 31 | noticeDuration: integer('notice_duration').default(0).notNull(), 32 | noticePosition: text('notice_position').default('').notNull(), 33 | noticeOffset: integer('notice_offset').default(0).notNull(), 34 | noticeWidth: integer('notice_width').default(400).notNull(), 35 | notice: integer('notice').default(0).notNull(), 36 | noRecipient: integer('no_recipient').default(1).notNull(), 37 | loginDomain: integer('login_domain').default(0).notNull(), 38 | bucket: text('bucket').default('').notNull(), 39 | region: text('region').default('').notNull(), 40 | endpoint: text('endpoint').default('').notNull(), 41 | s3AccessKey: text('s3_access_key').default('').notNull(), 42 | s3SecretKey: text('s3_secret_key').default('').notNull(), 43 | kvStorage: integer('kv_storage').default(1).notNull(), 44 | forcePathStyle: integer('force_path_style').default(1).notNull(), 45 | customDomain: text('custom_domain').default('').notNull(), 46 | tgMsgFrom: text('tg_msg_from').default('only-name').notNull(), 47 | tgMsgTo: text('tg_msg_to').default('show').notNull(), 48 | tgMsgText: text('tg_msg_text').default('hide').notNull(), 49 | minEmailPrefix: integer('min_email_prefix').default(0).notNull(), 50 | emailPrefixFilter: text('email_prefix_filter').default('').notNull() 51 | }); 52 | export default setting 53 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/help/js/i18n/keynav/zh_CN.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('tinymce.html-i18n.help-keynav.zh_CN', 2 | '

开始键盘导航

\n' + 3 | '\n' + 4 | '
\n' + 5 | '
使菜单栏处于焦点
\n' + 6 | '
Windows 或 Linux:Alt+F9
\n' + 7 | '
macOS:⌥F9
\n' + 8 | '
使工具栏处于焦点
\n' + 9 | '
Windows 或 Linux:Alt+F10
\n' + 10 | '
macOS:⌥F10
\n' + 11 | '
使页脚处于焦点
\n' + 12 | '
Windows 或 Linux:Alt+F11
\n' + 13 | '
macOS:⌥F11
\n' + 14 | '
使通知处于焦点
\n' + 15 | '
Windows 或 Linux:Alt+F12
\n' + 16 | '
macOS:⌥F12
\n' + 17 | '
使上下文工具栏处于焦点
\n' + 18 | '
Windows、Linux 或 macOS:Ctrl+F9
\n' + 19 | '
\n' + 20 | '\n' + 21 | '

导航将在第一个 UI 项上开始,其中突出显示该项,或者对于页脚元素路径中的第一项,将为其添加下划线。

\n' + 22 | '\n' + 23 | '

在 UI 部分之间导航

\n' + 24 | '\n' + 25 | '

要从一个 UI 部分移至下一个,请按 Tab

\n' + 26 | '\n' + 27 | '

要从一个 UI 部分移至上一个,请按 Shift+Tab

\n' + 28 | '\n' + 29 | '

这些 UI 部分的 Tab 顺序为:

\n' + 30 | '\n' + 31 | '
    \n' + 32 | '
  1. 菜单栏
  2. \n' + 33 | '
  3. 每个工具栏组
  4. \n' + 34 | '
  5. 边栏
  6. \n' + 35 | '
  7. 页脚中的元素路径
  8. \n' + 36 | '
  9. 页脚中的字数切换按钮
  10. \n' + 37 | '
  11. 页脚中的品牌链接
  12. \n' + 38 | '
  13. 页脚中的编辑器调整大小图柄
  14. \n' + 39 | '
\n' + 40 | '\n' + 41 | '

如果不存在某个 UI 部分,则跳过它。

\n' + 42 | '\n' + 43 | '

如果键盘导航焦点在页脚,并且没有可见的边栏,则按 Shift+Tab 将焦点移至第一个工具栏组而非最后一个。

\n' + 44 | '\n' + 45 | '

在 UI 部分内导航

\n' + 46 | '\n' + 47 | '

要从一个 UI 元素移至下一个,请按相应的箭头键。

\n' + 48 | '\n' + 49 | '

箭头键

\n' + 50 | '\n' + 51 | '
    \n' + 52 | '
  • 在菜单栏中的菜单之间移动。
  • \n' + 53 | '
  • 打开菜单中的子菜单。
  • \n' + 54 | '
  • 在工具栏组中的按钮之间移动。
  • \n' + 55 | '
  • 在页脚的元素路径中的各项之间移动。
  • \n' + 56 | '
\n' + 57 | '\n' + 58 | '

箭头键

\n' + 59 | '\n' + 60 | '
    \n' + 61 | '
  • 在菜单中的菜单项之间移动。
  • \n' + 62 | '
  • 在工具栏弹出菜单中的各项之间移动。
  • \n' + 63 | '
\n' + 64 | '\n' + 65 | '

箭头键在具有焦点的 UI 部分内循环。

\n' + 66 | '\n' + 67 | '

要关闭打开的菜单、打开的子菜单或打开的弹出菜单,请按 Esc 键。

\n' + 68 | '\n' + 69 | '

如果当前的焦点在特定 UI 部分的“顶部”,则按 Esc 键还将完全退出键盘导航。

\n' + 70 | '\n' + 71 | '

执行菜单项或工具栏按钮

\n' + 72 | '\n' + 73 | '

当突出显示所需的菜单项或工具栏按钮时,按 ReturnEnter空格以执行该项。

\n' + 74 | '\n' + 75 | '

在非标签页式对话框中导航

\n' + 76 | '\n' + 77 | '

在非标签页式对话框中,当对话框打开时,第一个交互组件获得焦点。

\n' + 78 | '\n' + 79 | '

通过按 TabShift+Tab,在交互对话框组件之间导航。

\n' + 80 | '\n' + 81 | '

在标签页式对话框中导航

\n' + 82 | '\n' + 83 | '

在标签页式对话框中,当对话框打开时,标签页菜单中的第一个按钮获得焦点。

\n' + 84 | '\n' + 85 | '

通过按 TabShift+Tab,在此对话框的交互组件之间导航。

\n' + 86 | '\n' + 87 | '

通过将焦点移至另一对话框标签页的菜单,然后按相应的箭头键以在可用的标签页间循环,从而切换到该对话框标签页。

\n'); -------------------------------------------------------------------------------- /mail-vue/src/components/loading/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 110 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/help/js/i18n/keynav/zh_TW.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('tinymce.html-i18n.help-keynav.zh_TW', 2 | '

開始鍵盤瀏覽

\n' + 3 | '\n' + 4 | '
\n' + 5 | '
跳至功能表列
\n' + 6 | '
Windows 或 Linux:Alt+F9
\n' + 7 | '
macOS:⌥F9
\n' + 8 | '
跳至工具列
\n' + 9 | '
Windows 或 Linux:Alt+F10
\n' + 10 | '
macOS:⌥F10
\n' + 11 | '
跳至頁尾
\n' + 12 | '
Windows 或 Linux:Alt+F11
\n' + 13 | '
macOS:⌥F11
\n' + 14 | '
跳至通知
\n' + 15 | '
Windows 或 Linux:Alt+F12
\n' + 16 | '
macOS:⌥F12
\n' + 17 | '
跳至關聯式工具列
\n' + 18 | '
Windows、Linux 或 macOS:Ctrl+F9
\n' + 19 | '
\n' + 20 | '\n' + 21 | '

瀏覽會從第一個 UI 項目開始,該項目會反白顯示,但如果是「頁尾」元素路徑的第一項,\n' + 22 | ' 則加底線。

\n' + 23 | '\n' + 24 | '

在 UI 區段之間瀏覽

\n' + 25 | '\n' + 26 | '

從 UI 區段移至下一個,請按 Tab

\n' + 27 | '\n' + 28 | '

從 UI 區段移回上一個,請按 Shift+Tab

\n' + 29 | '\n' + 30 | '

這些 UI 區段的 Tab 順序如下:

\n' + 31 | '\n' + 32 | '
    \n' + 33 | '
  1. 功能表列
  2. \n' + 34 | '
  3. 各個工具列群組
  4. \n' + 35 | '
  5. 側邊欄
  6. \n' + 36 | '
  7. 頁尾中的元素路徑
  8. \n' + 37 | '
  9. 頁尾中字數切換按鈕
  10. \n' + 38 | '
  11. 頁尾中的品牌連結
  12. \n' + 39 | '
  13. 頁尾中編輯器調整大小控點
  14. \n' + 40 | '
\n' + 41 | '\n' + 42 | '

如果 UI 區段未顯示,表示已略過該區段。

\n' + 43 | '\n' + 44 | '

如果鍵盤瀏覽跳至頁尾,但沒有顯示側邊欄,則按下 Shift+Tab\n' + 45 | ' 會跳至第一個工具列群組,而不是最後一個。

\n' + 46 | '\n' + 47 | '

在 UI 區段之內瀏覽

\n' + 48 | '\n' + 49 | '

在兩個 UI 元素之間移動,請按適當的方向鍵。

\n' + 50 | '\n' + 51 | '

向左向右方向鍵

\n' + 52 | '\n' + 53 | '
    \n' + 54 | '
  • 在功能表列中的功能表之間移動。
  • \n' + 55 | '
  • 開啟功能表中的子功能表。
  • \n' + 56 | '
  • 在工具列群組中的按鈕之間移動。
  • \n' + 57 | '
  • 在頁尾的元素路徑中項目之間移動。
  • \n' + 58 | '
\n' + 59 | '\n' + 60 | '

向下向上方向鍵

\n' + 61 | '\n' + 62 | '
    \n' + 63 | '
  • 在功能表中的功能表項目之間移動。
  • \n' + 64 | '
  • 在工具列快顯功能表中的項目之間移動。
  • \n' + 65 | '
\n' + 66 | '\n' + 67 | '

方向鍵會在所跳至 UI 區段之內循環。

\n' + 68 | '\n' + 69 | '

若要關閉已開啟的功能表、已開啟的子功能表,或已開啟的快顯功能表,請按 Esc 鍵。

\n' + 70 | '\n' + 71 | '

如果目前已跳至特定 UI 區段的「頂端」,則按 Esc 鍵也會結束\n' + 72 | ' 整個鍵盤瀏覽。

\n' + 73 | '\n' + 74 | '

執行功能表列項目或工具列按鈕

\n' + 75 | '\n' + 76 | '

當想要的功能表項目或工具列按鈕已反白顯示時,按 ReturnEnter、\n' + 77 | ' 或空白鍵即可執行該項目。

\n' + 78 | '\n' + 79 | '

瀏覽非索引標籤式對話方塊

\n' + 80 | '\n' + 81 | '

在非索引標籤式對話方塊中,開啟對話方塊時會跳至第一個互動元件。

\n' + 82 | '\n' + 83 | '

TabShift+Tab 即可在互動式對話方塊元件之間瀏覽。

\n' + 84 | '\n' + 85 | '

瀏覽索引標籤式對話方塊

\n' + 86 | '\n' + 87 | '

在索引標籤式對話方塊中,開啟對話方塊時會跳至索引標籤式功能表中的第一個按鈕。

\n' + 88 | '\n' + 89 | '

若要在此對話方塊的互動式元件之間瀏覽,請按 Tab 或\n' + 90 | ' Shift+Tab

\n' + 91 | '\n' + 92 | '

先跳至索引標籤式功能表,然後按適當的方向鍵,即可切換至另一個對話方塊索引標籤,\n' + 93 | ' 以循環瀏覽可用的索引標籤。

\n'); -------------------------------------------------------------------------------- /mail-vue/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 51 | 52 | 126 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/autolink/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>"string"===(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(n=o=e,(r=String).prototype.isPrototypeOf(n)||(null===(a=o.constructor)||void 0===a?void 0:a.name)===r.name)?"string":t;var n,o,r,a})(e);const n=e=>undefined===e;const o=e=>!(e=>null==e)(e),r=Object.hasOwnProperty,a=e=>"\ufeff"===e,s=e=>t=>t.options.get(e),l=s("autolink_pattern"),i=s("link_default_target"),c=s("link_default_protocol"),d=s("allow_unsafe_link_target");var u=tinymce.util.Tools.resolve("tinymce.dom.TextSeeker");const f=e=>/^[(\[{ \u00a0]$/.test(e),g=(e,t,n)=>{for(let o=t-1;o>=0;o--){const t=e.charAt(o);if(!a(t)&&n(t))return o}return-1},m=(e,t)=>{var o;const a=e.schema.getVoidElements(),s=l(e),{dom:i,selection:d}=e;if(null!==i.getParent(d.getNode(),"a[href]")||e.mode.isReadOnly())return null;const m=d.getRng(),k=u(i,(e=>{return i.isBlock(e)||(t=a,n=e.nodeName.toLowerCase(),r.call(t,n))||"false"===i.getContentEditable(e)||null!==i.getParent(e,"a[href]");var t,n})),{container:p,offset:y}=((e,t)=>{let n=e,o=t;for(;1===n.nodeType&&n.childNodes[o];)n=n.childNodes[o],o=3===n.nodeType?n.data.length:n.childNodes.length;return{container:n,offset:o}})(m.endContainer,m.endOffset),w=null!==(o=i.getParent(p,i.isBlock))&&void 0!==o?o:i.getRoot(),h=k.backwards(p,y+t,((e,t)=>{const n=e.data,o=g(n,t,(r=f,e=>!r(e)));var r,a;return-1===o||(a=n[o],/[?!,.;:]/.test(a))?o:o+1}),w);if(!h)return null;let v=h.container;const _=k.backwards(h.container,h.offset,((e,t)=>{v=e;const n=g(e.data,t,f);return-1===n?n:n+1}),w),A=i.createRng();_?A.setStart(_.container,_.offset):A.setStart(v,0),A.setEnd(h.container,h.offset);const C=A.toString().replace(/\uFEFF/g,"").match(s);if(C){let t=C[0];return P="www.",(b=t).length>=4&&b.substr(0,4)===P?t=c(e)+"://"+t:((e,t,o=0,r)=>{const a=e.indexOf(t,o);return-1!==a&&(!!n(r)||a+t.length<=r)})(t,"@")&&!(e=>/^([A-Za-z][A-Za-z\d.+-]*:\/\/)|mailto:/.test(e))(t)&&(t="mailto:"+t),{rng:A,url:t}}var b,P;return null},k=(e,n)=>{const{dom:o,selection:r}=e,{rng:a,url:s}=n,l=r.getBookmark();r.setRng(a);const c="createlink",u={command:c,ui:!1,value:s};if(!e.dispatch("BeforeExecCommand",u).isDefaultPrevented()){e.getDoc().execCommand(c,!1,s),e.dispatch("ExecCommand",u);const n=i(e);if(t(n)){const t=r.getNode();o.setAttrib(t,"target",n),"_blank"!==n||d(e)||o.setAttrib(t,"rel","noopener")}}r.moveToBookmark(l),e.nodeChanged()},p=e=>{const t=m(e,-1);o(t)&&k(e,t)},y=p;e.add("autolink",(e=>{(e=>{const t=e.options.register;t("autolink_pattern",{processor:"regexp",default:new RegExp("^"+/(?:[A-Za-z][A-Za-z\d.+-]{0,14}:\/\/(?:[-.~*+=!&;:'%@?^${}(),\w]+@)?|www\.|[-;:&=+$,.\w]+@)[A-Za-z\d-]+(?:\.[A-Za-z\d-]+)*(?::\d+)?(?:\/(?:[-.~*+=!;:'%@$(),\/\w]*[-~*+=%@$()\/\w])?)?(?:\?(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?(?:#(?:[-.~*+=!&;:'%@?^${}(),\/\w]+))?/g.source+"$","i")}),t("link_default_target",{processor:"string"}),t("link_default_protocol",{processor:"string",default:"https"})})(e),(e=>{e.on("keydown",(t=>{13!==t.keyCode||t.isDefaultPrevented()||(e=>{const t=m(e,0);o(t)&&k(e,t)})(e)})),e.on("keyup",(t=>{32===t.keyCode?p(e):(48===t.keyCode&&t.shiftKey||221===t.keyCode)&&y(e)}))})(e)}))}(); -------------------------------------------------------------------------------- /mail-worker/src/i18n/zh.js: -------------------------------------------------------------------------------- 1 | const zh = { 2 | IncorrectPwd: '密码输入错误', 3 | addAccountDisabled: '添加邮箱功能已关闭', 4 | regDisabled: '注册功能已关闭', 5 | emptyEmail: '邮箱不能为空', 6 | notEmail: '非法邮箱', 7 | notExistDomain: '不存在的邮箱域名', 8 | isDelAccount: '该邮箱已被注销', 9 | isRegAccount: '该邮箱已被注册', 10 | accountLimit: '添加邮箱数量到达限制', 11 | delMyAccount: '不可以删除自己的邮箱', 12 | noUserAccount: '该邮箱不属于当前用户', 13 | usernameLengthLimit: '用户名长度超出限制', 14 | noOsDomainSendPic: '对象存储域名未配置不能发送正文图片', 15 | noOsSendPic: '对象存储未配置不能发送正文图片', 16 | noOsDomainSendAtt: '对象存储域名未配置不能发送附件', 17 | noOsSendAtt: '对象存储未配置不能发送附件', 18 | disabledSend: '邮件发送功能已停用', 19 | noSeparateSend: '分别发送暂时不支持附件', 20 | daySendLimit: '发送次数已到达每日限制', 21 | totalSendLimit: '发送次数已到达限制', 22 | daySendLack: '当日剩余发送次数不足', 23 | totalSendLack: '剩余发送次数不足', 24 | senderAccountNotExist: '发件人邮箱不存在', 25 | noResendToken: 'resend密钥未配置', 26 | sendEmailNotCurUser: '发件人邮箱非当前用户所有', 27 | notExistEmailReply: '邮件不存在无法回复', 28 | pwdLengthLimit: '密码长度超出限制', 29 | emailLengthLimit: '邮箱长度超出限制', 30 | minEmailPrefix: '邮箱名至少{{msg}}位', 31 | banEmailPrefix: '邮箱名包含非法字符', 32 | pwdMinLength: '密码至少六位', 33 | notEmailDomain: '非法邮箱域名', 34 | emptyRegKey: '注册码不能为空', 35 | notExistRegKey: '注册码不存在', 36 | noRegKeyTotal: '注册码使用次数已耗尽', 37 | regKeyExpire: '注册码已过期', 38 | emailAndPwdEmpty: '邮箱和密码不能为空', 39 | notExistUser: '输入的邮箱不存在', 40 | isDelUser: '该邮箱已被注销', 41 | isBanUser: '该邮箱已被禁用', 42 | regKeyUseCount: '使用次数不能为空', 43 | emptyRegKeyExpire: '有效时间不能为空', 44 | isExistRegKye: '注册码已存在', 45 | roleNotExist: '权限身份不存在', 46 | emptyRoleName: '身份名不能为空', 47 | roleNameExist: '身份名已存在', 48 | delDefRole: '默认身份不能删除', 49 | notJsonDomain: '环境变量domain必须是JSON类型', 50 | noDomainVariable: '环境变量domain不能为空', 51 | noOsUpBack: '对象存储未配置不能上传背景', 52 | noOsDomainUpBack: '对象存储域名未配置不能上传背景', 53 | starNotExistEmail: '星标的邮件不存在', 54 | emptyBotToken: '需要进行人机验证', 55 | botVerifyFail: '人机验证失败,请重试', 56 | authExpired: '身份认证失效,请重新登录', 57 | unauthorized: '权限不足', 58 | bannedSend: '你已被禁止发送邮件', 59 | initSuccess: '初始化成功', 60 | noDomainPermAdd: '你没有权限添加该域名邮箱', 61 | noDomainPermReg: '你没有权限注册该域名邮箱', 62 | noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱', 63 | noDomainPermSend: '你没有权限使用该域名邮箱发送邮件', 64 | JWTMismatch: 'jwt_secret 不匹配', 65 | publicTokenFail: 'token验证失败', 66 | notAdmin: '输入的邮箱不是管理员邮箱', 67 | emailExistDatabase: '有邮箱已存在数据库中', 68 | notConfigOss: '对象存储未配置', 69 | perms: { 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 | export default zh 107 | -------------------------------------------------------------------------------- /mail-worker/src/service/reg-key-service.js: -------------------------------------------------------------------------------- 1 | import orm from '../entity/orm'; 2 | import regKey from '../entity/reg-key'; 3 | import { inArray, like, eq, desc, sql, or } from 'drizzle-orm'; 4 | import roleService from './role-service'; 5 | import BizError from '../error/biz-error'; 6 | import { formatDetailDate, toUtc } from '../utils/date-uitil'; 7 | import userService from './user-service'; 8 | import { t } from '../i18n/i18n.js'; 9 | 10 | const regKeyService = { 11 | 12 | async add(c, params, userId) { 13 | 14 | let {code,roleId,count,expireTime} = params; 15 | 16 | if (!code) { 17 | throw new BizError(t('emptyRegKey')); 18 | } 19 | 20 | if (!count) { 21 | throw new BizError(t('emptyRegKey')); 22 | } 23 | 24 | if (!expireTime) { 25 | throw new BizError(t('emptyRegKeyExpire')); 26 | } 27 | 28 | const regKeyRow = await orm(c).select().from(regKey).where(eq(regKey.code, code)).get(); 29 | 30 | if (regKeyRow) { 31 | throw new BizError(t('isExistRegKye')); 32 | } 33 | 34 | const roleRow = roleService.selectById(c, roleId); 35 | if (!roleRow) { 36 | throw new BizError(t('roleNotExist')); 37 | } 38 | 39 | expireTime = formatDetailDate(expireTime) 40 | 41 | await orm(c).insert(regKey).values({code,roleId,count,userId,expireTime}).run(); 42 | }, 43 | 44 | async delete(c, params) { 45 | let {regKeyIds} = params; 46 | regKeyIds = regKeyIds.split(',').map(id => Number(id)); 47 | await orm(c).delete(regKey).where(inArray(regKey.regKeyId,regKeyIds)).run(); 48 | }, 49 | 50 | async clearNotUse(c) { 51 | let now = formatDetailDate(toUtc().tz('Asia/Shanghai').startOf('day')) 52 | await orm(c).delete(regKey).where(or(eq(regKey.count, 0),sql`datetime(${regKey.expireTime}, '+8 hours') < datetime(${now})`)).run(); 53 | }, 54 | 55 | selectByCode(c, code) { 56 | return orm(c).select().from(regKey).where(eq(regKey.code, code)).get(); 57 | }, 58 | 59 | async list(c, params) { 60 | 61 | const {code} = params 62 | let query = orm(c).select().from(regKey) 63 | 64 | if (code) { 65 | query = query.where(like(regKey.code, `${code}%`)) 66 | } 67 | 68 | const regKeyList = await query.orderBy(desc(regKey.regKeyId)).all(); 69 | const roleList = await roleService.roleSelectUse(c); 70 | 71 | const today = toUtc().tz('Asia/Shanghai').startOf('day') 72 | 73 | regKeyList.forEach(regKeyRow => { 74 | 75 | const index = roleList.findIndex(roleRow => roleRow.roleId === regKeyRow.roleId) 76 | regKeyRow.roleName = index > -1 ? roleList[index].name : '' 77 | 78 | const expireTime = toUtc(regKeyRow.expireTime).tz('Asia/Shanghai').startOf('day'); 79 | 80 | if (expireTime.isBefore(today)) { 81 | regKeyRow.expireTime = null 82 | } 83 | }) 84 | 85 | return regKeyList; 86 | }, 87 | 88 | async reduceCount(c, code, count) { 89 | await orm(c).update(regKey).set({ 90 | count: sql`${regKey.count} 91 | - 92 | ${count}` 93 | }).where(eq(regKey.code, code)).run(); 94 | }, 95 | 96 | async history(c, params) { 97 | const { regKeyId } = params; 98 | return userService.listByRegKeyId(c, regKeyId); 99 | } 100 | } 101 | 102 | export default regKeyService; 103 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/autosave/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var t=tinymce.util.Tools.resolve("tinymce.PluginManager");const e=t=>"string"===(t=>{const e=typeof t;return null===t?"null":"object"===e&&Array.isArray(t)?"array":"object"===e&&(r=o=t,(a=String).prototype.isPrototypeOf(r)||(null===(s=o.constructor)||void 0===s?void 0:s.name)===a.name)?"string":e;var r,o,a,s})(t);const r=t=>undefined===t;var o=tinymce.util.Tools.resolve("tinymce.util.Delay"),a=tinymce.util.Tools.resolve("tinymce.util.LocalStorage"),s=tinymce.util.Tools.resolve("tinymce.util.Tools");const n=t=>{const e=/^(\d+)([ms]?)$/.exec(t);return(e&&e[2]?{s:1e3,m:6e4}[e[2]]:1)*parseInt(t,10)},i=t=>e=>e.options.get(t),u=i("autosave_ask_before_unload"),l=i("autosave_restore_when_empty"),c=i("autosave_interval"),d=i("autosave_retention"),m=t=>{const e=document.location;return t.options.get("autosave_prefix").replace(/{path}/g,e.pathname).replace(/{query}/g,e.search).replace(/{hash}/g,e.hash).replace(/{id}/g,t.id)},v=(t,e)=>{if(r(e))return t.dom.isEmpty(t.getBody());{const r=s.trim(e);if(""===r)return!0;{const e=(new DOMParser).parseFromString(r,"text/html");return t.dom.isEmpty(e)}}},f=t=>{var e;const r=parseInt(null!==(e=a.getItem(m(t)+"time"))&&void 0!==e?e:"0",10)||0;return!((new Date).getTime()-r>d(t)&&(p(t,!1),1))},p=(t,e)=>{const r=m(t);a.removeItem(r+"draft"),a.removeItem(r+"time"),!1!==e&&(t=>{t.dispatch("RemoveDraft")})(t)},y=t=>{const e=m(t);!v(t)&&t.isDirty()&&(a.setItem(e+"draft",t.getContent({format:"raw",no_events:!0})),a.setItem(e+"time",(new Date).getTime().toString()),(t=>{t.dispatch("StoreDraft")})(t))},g=t=>{var e;const r=m(t);f(t)&&(t.setContent(null!==(e=a.getItem(r+"draft"))&&void 0!==e?e:"",{format:"raw"}),(t=>{t.dispatch("RestoreDraft")})(t))};var D=tinymce.util.Tools.resolve("tinymce.EditorManager");const h=t=>e=>{const r=()=>f(t)&&!t.mode.isReadOnly();e.setEnabled(r());const o=()=>e.setEnabled(r());return t.on("StoreDraft RestoreDraft RemoveDraft",o),()=>t.off("StoreDraft RestoreDraft RemoveDraft",o)};t.add("autosave",(t=>((t=>{const r=t.options.register,o=t=>{const r=e(t);return r?{value:n(t),valid:r}:{valid:!1,message:"Must be a string."}};r("autosave_ask_before_unload",{processor:"boolean",default:!0}),r("autosave_prefix",{processor:"string",default:"tinymce-autosave-{path}{query}{hash}-{id}-"}),r("autosave_restore_when_empty",{processor:"boolean",default:!1}),r("autosave_interval",{processor:o,default:"30s"}),r("autosave_retention",{processor:o,default:"20m"})})(t),(t=>{t.editorManager.on("BeforeUnload",(t=>{let e;s.each(D.get(),(t=>{t.plugins.autosave&&t.plugins.autosave.storeDraft(),!e&&t.isDirty()&&u(t)&&(e=t.translate("You have unsaved changes are you sure you want to navigate away?"))})),e&&(t.preventDefault(),t.returnValue=e)}))})(t),(t=>{(t=>{const e=c(t);o.setEditorInterval(t,(()=>{y(t)}),e)})(t);const e=()=>{(t=>{t.undoManager.transact((()=>{g(t),p(t)})),t.focus()})(t)};t.ui.registry.addButton("restoredraft",{tooltip:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)}),t.ui.registry.addMenuItem("restoredraft",{text:"Restore last draft",icon:"restore-draft",onAction:e,onSetup:h(t)})})(t),t.on("init",(()=>{l(t)&&t.dom.isEmpty(t.getBody())&&g(t)})),(t=>({hasDraft:()=>f(t),storeDraft:()=>y(t),restoreDraft:()=>g(t),removeDraft:e=>p(t,e),isEmpty:e=>v(t,e)}))(t))))}(); -------------------------------------------------------------------------------- /mail-vue/src/components/shadow-html/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 111 | 112 | 125 | -------------------------------------------------------------------------------- /mail-vue/src/views/draft/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 103 | 108 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/help/js/i18n/keynav/ja.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('tinymce.html-i18n.help-keynav.ja', 2 | '

キーボード ナビゲーションの開始

\n' + 3 | '\n' + 4 | '
\n' + 5 | '
メニュー バーをフォーカス
\n' + 6 | '
Windows または Linux: Alt+F9
\n' + 7 | '
macOS: ⌥F9
\n' + 8 | '
ツール バーをフォーカス
\n' + 9 | '
Windows または Linux: Alt+F10
\n' + 10 | '
macOS: ⌥F10
\n' + 11 | '
フッターをフォーカス
\n' + 12 | '
Windows または Linux: Alt+F11
\n' + 13 | '
macOS: ⌥F11
\n' + 14 | '
通知にフォーカス
\n' + 15 | '
Windows または Linux: Alt+F12
\n' + 16 | '
macOS: ⌥F12
\n' + 17 | '
コンテキスト ツール バーをフォーカス
\n' + 18 | '
Windows、Linux または macOS: Ctrl+F9
\n' + 19 | '
\n' + 20 | '\n' + 21 | '

ナビゲーションは最初の UI 項目から開始され、強調表示されるか、フッターの要素パスにある最初の項目の場合は\n' + 22 | ' 下線が引かれます。

\n' + 23 | '\n' + 24 | '

UI セクション間の移動

\n' + 25 | '\n' + 26 | '

次の UI セクションに移動するには、Tab を押します。

\n' + 27 | '\n' + 28 | '

前の UI セクションに移動するには、Shift+Tab を押します。

\n' + 29 | '\n' + 30 | '

これらの UI セクションの Tab の順序:

\n' + 31 | '\n' + 32 | '
    \n' + 33 | '
  1. メニュー バー
  2. \n' + 34 | '
  3. 各ツール バー グループ
  4. \n' + 35 | '
  5. サイド バー
  6. \n' + 36 | '
  7. フッターの要素パス
  8. \n' + 37 | '
  9. フッターの単語数切り替えボタン
  10. \n' + 38 | '
  11. フッターのブランド リンク
  12. \n' + 39 | '
  13. フッターのエディター サイズ変更ハンドル
  14. \n' + 40 | '
\n' + 41 | '\n' + 42 | '

UI セクションが存在しない場合は、スキップされます。

\n' + 43 | '\n' + 44 | '

フッターにキーボード ナビゲーション フォーカスがあり、表示可能なサイド バーがない場合、Shift+Tab を押すと、\n' + 45 | ' フォーカスが最後ではなく最初のツール バー グループに移動します。

\n' + 46 | '\n' + 47 | '

UI セクション内の移動

\n' + 48 | '\n' + 49 | '

次の UI 要素に移動するには、適切な矢印キーを押します。

\n' + 50 | '\n' + 51 | '

左矢印右矢印のキー

\n' + 52 | '\n' + 53 | '
    \n' + 54 | '
  • メニュー バーのメニュー間で移動します。
  • \n' + 55 | '
  • メニュー内のサブメニューを開きます。
  • \n' + 56 | '
  • ツール バー グループのボタン間で移動します。
  • \n' + 57 | '
  • フッターの要素パスの項目間で移動します。
  • \n' + 58 | '
\n' + 59 | '\n' + 60 | '

下矢印上矢印のキー

\n' + 61 | '\n' + 62 | '
    \n' + 63 | '
  • メニュー内のメニュー項目間で移動します。
  • \n' + 64 | '
  • ツール バー ポップアップ メニュー内のメニュー項目間で移動します。
  • \n' + 65 | '
\n' + 66 | '\n' + 67 | '

矢印キーで、フォーカスされた UI セクション内で循環します。

\n' + 68 | '\n' + 69 | '

開いたメニュー、開いたサブメニュー、開いたポップアップ メニューを閉じるには、Esc キーを押します。

\n' + 70 | '\n' + 71 | '

現在のフォーカスが特定の UI セクションの「一番上」にある場合、Esc キーを押すと\n' + 72 | ' キーボード ナビゲーションも完全に閉じられます。

\n' + 73 | '\n' + 74 | '

メニュー項目またはツール バー ボタンの実行

\n' + 75 | '\n' + 76 | '

目的のメニュー項目やツール バー ボタンが強調表示されている場合、リターンEnter、\n' + 77 | ' またはスペース キーを押して項目を実行します。

\n' + 78 | '\n' + 79 | '

タブのないダイアログの移動

\n' + 80 | '\n' + 81 | '

タブのないダイアログでは、ダイアログが開くと最初の対話型コンポーネントがフォーカスされます。

\n' + 82 | '\n' + 83 | '

Tab または Shift+Tab を押して、対話型ダイアログ コンポーネント間で移動します。

\n' + 84 | '\n' + 85 | '

タブ付きダイアログの移動

\n' + 86 | '\n' + 87 | '

タブ付きダイアログでは、ダイアログが開くとタブ メニューの最初のボタンがフォーカスされます。

\n' + 88 | '\n' + 89 | '

Tab または\n' + 90 | ' Shift+Tab を押して、このダイアログ タブの対話型コンポーネント間で移動します。

\n' + 91 | '\n' + 92 | '

タブ メニューをフォーカスしてから適切な矢印キーを押して表示可能なタブを循環して、\n' + 93 | ' 別のダイアログに切り替えます。

\n'); -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/help/js/i18n/keynav/ko_KR.js: -------------------------------------------------------------------------------- 1 | tinymce.Resource.add('tinymce.html-i18n.help-keynav.ko_KR', 2 | '

키보드 탐색 시작

\n' + 3 | '\n' + 4 | '
\n' + 5 | '
메뉴 모음 포커스 표시
\n' + 6 | '
Windows 또는 Linux: Alt+F9
\n' + 7 | '
macOS: ⌥F9
\n' + 8 | '
도구 모음 포커스 표시
\n' + 9 | '
Windows 또는 Linux: Alt+F10
\n' + 10 | '
macOS: ⌥F10
\n' + 11 | '
푸터 포커스 표시
\n' + 12 | '
Windows 또는 Linux: Alt+F11
\n' + 13 | '
macOS: ⌥F11
\n' + 14 | '
알림 포커스
\n' + 15 | '
Windows 또는 Linux: Alt+F12
\n' + 16 | '
macOS: ⌥F12
\n' + 17 | '
컨텍스트 도구 모음에 포커스 표시
\n' + 18 | '
Windows, Linux 또는 macOS: Ctrl+F9
\n' + 19 | '
\n' + 20 | '\n' + 21 | '

첫 번째 UI 항목에서 탐색이 시작되며, 이때 첫 번째 항목이 강조 표시되거나 푸터 요소 경로에 있는\n' + 22 | ' 경우 밑줄 표시됩니다.

\n' + 23 | '\n' + 24 | '

UI 섹션 간 탐색

\n' + 25 | '\n' + 26 | '

한 UI 섹션에서 다음 UI 섹션으로 이동하려면 Tab(탭)을 누릅니다.

\n' + 27 | '\n' + 28 | '

한 UI 섹션에서 이전 UI 섹션으로 돌아가려면 Shift+Tab(시프트+탭)을 누릅니다.

\n' + 29 | '\n' + 30 | '

이 UI 섹션의 Tab(탭) 순서는 다음과 같습니다.

\n' + 31 | '\n' + 32 | '
    \n' + 33 | '
  1. 메뉴 바
  2. \n' + 34 | '
  3. 각 도구 모음 그룹
  4. \n' + 35 | '
  5. 사이드바
  6. \n' + 36 | '
  7. 푸터의 요소 경로
  8. \n' + 37 | '
  9. 푸터의 단어 수 토글 버튼
  10. \n' + 38 | '
  11. 푸터의 브랜딩 링크
  12. \n' + 39 | '
  13. 푸터의 에디터 크기 변경 핸들
  14. \n' + 40 | '
\n' + 41 | '\n' + 42 | '

UI 섹션이 없는 경우 건너뛰기합니다.

\n' + 43 | '\n' + 44 | '

푸터에 키보드 탐색 포커스가 있고 사이드바는 보이지 않는 경우 Shift+Tab(시프트+탭)을 누르면\n' + 45 | ' 포커스 표시가 마지막이 아닌 첫 번째 도구 모음 그룹으로 이동합니다.

\n' + 46 | '\n' + 47 | '

UI 섹션 내 탐색

\n' + 48 | '\n' + 49 | '

한 UI 요소에서 다음 UI 요소로 이동하려면 적절한 화살표 키를 누릅니다.

\n' + 50 | '\n' + 51 | '

왼쪽오른쪽 화살표 키의 용도:

\n' + 52 | '\n' + 53 | '
    \n' + 54 | '
  • 메뉴 모음에서 메뉴 항목 사이를 이동합니다.
  • \n' + 55 | '
  • 메뉴에서 하위 메뉴를 엽니다.
  • \n' + 56 | '
  • 도구 모음 그룹에서 버튼 사이를 이동합니다.
  • \n' + 57 | '
  • 푸터의 요소 경로에서 항목 간에 이동합니다.
  • \n' + 58 | '
\n' + 59 | '\n' + 60 | '

아래 화살표 키의 용도:

\n' + 61 | '\n' + 62 | '
    \n' + 63 | '
  • 메뉴에서 메뉴 항목 사이를 이동합니다.
  • \n' + 64 | '
  • 도구 모음 팝업 메뉴에서 메뉴 항목 사이를 이동합니다.
  • \n' + 65 | '
\n' + 66 | '\n' + 67 | '

화살표 키는 포커스 표시 UI 섹션 내에서 순환됩니다.

\n' + 68 | '\n' + 69 | '

열려 있는 메뉴, 열려 있는 하위 메뉴 또는 열려 있는 팝업 메뉴를 닫으려면 Esc 키를 누릅니다.

\n' + 70 | '\n' + 71 | "

현재 포커스 표시가 특정 UI 섹션 '상단'에 있는 경우 이때도 Esc 키를 누르면\n" + 72 | ' 키보드 탐색이 완전히 종료됩니다.

\n' + 73 | '\n' + 74 | '

메뉴 항목 또는 도구 모음 버튼 실행

\n' + 75 | '\n' + 76 | '

원하는 메뉴 항목 또는 도구 모음 버튼이 강조 표시되어 있을 때 Return(리턴), Enter(엔터),\n' + 77 | ' 또는 Space bar(스페이스바)를 눌러 해당 항목을 실행합니다.

\n' + 78 | '\n' + 79 | '

탭이 없는 대화 탐색

\n' + 80 | '\n' + 81 | '

탭이 없는 대화의 경우, 첫 번째 대화형 요소가 포커스 표시된 상태로 대화가 열립니다.

\n' + 82 | '\n' + 83 | '

대화형 요소들 사이를 이동할 때는 Tab(탭) 또는 Shift+Tab(시프트+탭)을 누릅니다.

\n' + 84 | '\n' + 85 | '

탭이 있는 대화 탐색

\n' + 86 | '\n' + 87 | '

탭이 있는 대화의 경우, 탭 메뉴에서 첫 번째 버튼이 포커스 표시된 상태로 대화가 열립니다.

\n' + 88 | '\n' + 89 | '

이 대화 탭의 대화형 요소들 사이를 이동할 때는 Tab(탭) 또는\n' + 90 | ' Shift+Tab(시프트+탭)을 누릅니다.

\n' + 91 | '\n' + 92 | '

다른 대화 탭으로 이동하려면 탭 메뉴를 포커스 표시한 다음 적절한 화살표\n' + 93 | ' 키를 눌러 사용 가능한 탭들을 지나 원하는 탭으로 이동합니다.

\n'); -------------------------------------------------------------------------------- /mail-vue/src/perm/perm.js: -------------------------------------------------------------------------------- 1 | import {useUserStore} from "@/store/user.js"; 2 | 3 | export default { 4 | mounted(el, binding) { 5 | const userStore = useUserStore(); 6 | const permKeys = userStore.user.permKeys; 7 | const value = binding.value; 8 | 9 | if (permKeys.includes('*')) { 10 | return; 11 | } 12 | 13 | const hasPermission = Array.isArray(value) 14 | ? value.some(key => permKeys.includes(key)) 15 | : permKeys.includes(value); 16 | 17 | if (!hasPermission) { 18 | el.parentNode && el.parentNode.removeChild(el); 19 | } 20 | } 21 | } 22 | 23 | export function hasPerm(permKey) { 24 | const {permKeys} = useUserStore().user; 25 | return permKeys.includes('*') || permKeys.includes(permKey); 26 | } 27 | 28 | 29 | export function permsToRouter(permKeys) { 30 | const routerList = [] 31 | Object.keys(routers).forEach(perm => { 32 | if (permKeys.includes(perm) || permKeys.includes('*')) { 33 | routerList.push(...routers[perm]) 34 | } 35 | }) 36 | return routerList; 37 | } 38 | 39 | const routers = { 40 | 'email:send': [ 41 | { 42 | path: '/sent', 43 | name: 'send', 44 | component: () => import('@/views/send/index.vue'), 45 | meta: { 46 | title: 'sent', 47 | name: 'send', 48 | menu: true 49 | } 50 | }, 51 | { 52 | path: '/drafts', 53 | name: 'draft', 54 | component: () => import('@/views/draft/index.vue'), 55 | meta: { 56 | title: 'drafts', 57 | name: 'draft', 58 | menu: true 59 | } 60 | } 61 | ], 62 | 'user:query': [{ 63 | path: '/all-users', 64 | name: 'user', 65 | component: () => import('@/views/user/index.vue'), 66 | meta: { 67 | title: 'allUsers', 68 | name: 'user', 69 | menu: true 70 | } 71 | }], 72 | 'role:query': [{ 73 | path: '/role', 74 | name: 'role', 75 | component: () => import('@/views/role/index.vue'), 76 | meta: { 77 | title: 'permissions', 78 | name: 'role', 79 | menu: true 80 | } 81 | }], 82 | 'setting:query': [{ 83 | path: '/system-setting', 84 | name: 'sys-setting', 85 | component: () => import('@/views/sys-setting/index.vue'), 86 | meta: { 87 | title: 'SystemSettings', 88 | name: 'sys-setting', 89 | menu: true 90 | } 91 | }], 92 | 'reg-key:query': [{ 93 | path: '/invite-code', 94 | name: 'reg-key', 95 | component: () => import('@/views/reg-key/index.vue'), 96 | meta: { 97 | title: 'inviteCode', 98 | name: 'reg-key', 99 | menu: true 100 | } 101 | }], 102 | 'all-email:query': [{ 103 | path: '/all-mail', 104 | name: 'all-email', 105 | component: () => import('@/views/all-email/index.vue'), 106 | meta: { 107 | title: 'allMail', 108 | name: 'all-email', 109 | menu: true 110 | } 111 | }], 112 | 'analysis:query': [{ 113 | path: '/analysis', 114 | name: 'analysis', 115 | component: () => import('@/views/analysis/index.vue'), 116 | meta: { 117 | title: 'analytics', 118 | name: 'analysis', 119 | menu: true 120 | } 121 | }] 122 | } -------------------------------------------------------------------------------- /mail-worker/src/service/oauth-service.js: -------------------------------------------------------------------------------- 1 | import BizError from "../error/biz-error"; 2 | import orm from "../entity/orm"; 3 | import {oauth} from "../entity/oauth"; 4 | import { eq } from 'drizzle-orm'; 5 | import userService from "./user-service"; 6 | import loginService from "./login-service"; 7 | import cryptoUtils from "../utils/crypto-utils"; 8 | 9 | const oauthService = { 10 | 11 | async bindUser(c, params) { 12 | 13 | const { email, oauthUserId, code } = params; 14 | 15 | const oauthRow = await this.getById(c, oauthUserId); 16 | 17 | let userRow = await userService.selectByIdIncludeDel(c, oauthRow.userId); 18 | 19 | if (userRow) { 20 | throw new BizError('用户已绑定有邮箱') 21 | } 22 | 23 | await loginService.register(c, { email, password: cryptoUtils.genRandomPwd(), code }, true); 24 | 25 | userRow = await userService.selectByEmail(c, email); 26 | 27 | orm(c).update(oauth).set({ userId: userRow.userId }).where(eq(oauth.oauthUserId, oauthUserId)).run(); 28 | const jwtToken = await loginService.login(c, { email, password: null }, true); 29 | 30 | return { userInfo: oauthRow, token: jwtToken} 31 | }, 32 | 33 | async linuxDoLogin(c, params) { 34 | 35 | const { code } = params; 36 | 37 | let token = ''; 38 | let userInfo = {} 39 | 40 | const reqParams = new URLSearchParams() 41 | reqParams.append('client_id', c.env.linuxdo_client_id) 42 | reqParams.append('client_secret', c.env.linuxdo_client_secret) 43 | reqParams.append('code', code) 44 | reqParams.append('redirect_uri', c.env.linuxdo_callback_url) 45 | reqParams.append('grant_type', 'authorization_code') 46 | 47 | const tokenRes = await fetch("https://connect.linux.do/oauth2/token", { 48 | method: "POST", 49 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 50 | body: reqParams.toString() 51 | }) 52 | 53 | if (!tokenRes.ok) { 54 | throw new BizError(tokenRes.statusText) 55 | } 56 | 57 | token = await tokenRes.json() 58 | 59 | const userRes = await fetch('https://connect.linux.do/api/user', { 60 | headers: { 61 | Authorization: 'Bearer ' + token.access_token 62 | } 63 | }); 64 | 65 | if (!userRes.ok) { 66 | throw new BizError(userRes.statusText) 67 | } 68 | 69 | userInfo = await userRes.json(); 70 | 71 | userInfo.oauthUserId = String(userInfo.id); 72 | userInfo.active = userInfo.active ? 0 : 1; 73 | userInfo.silenced = userInfo.active ? 0 : 1; 74 | userInfo.trustLevel = userInfo.trust_level; 75 | userInfo.avatar = userInfo.avatar_url; 76 | 77 | const oauthRow = await this.saveUser(c, userInfo); 78 | const userRow = await userService.selectByIdIncludeDel(c, oauthRow.userId); 79 | 80 | if (!userRow) { 81 | return { userInfo: oauthRow, token: null } 82 | } 83 | 84 | const JwtToken = await loginService.login(c, { email: userRow.email, password: null }, true); 85 | return { userInfo: oauthRow, token: JwtToken } 86 | }, 87 | 88 | async saveUser(c, userInfo) { 89 | 90 | const userInfoRow = await this.getById(c, userInfo.oauthUserId); 91 | 92 | if (!userInfoRow) { 93 | return await orm(c).insert(oauth).values(userInfo).returning().get(); 94 | } else { 95 | return await orm(c).update(oauth).set(userInfo).where(eq(oauth.oauthUserId, userInfo.oauthUserId)).returning().get(); 96 | } 97 | 98 | }, 99 | 100 | async getById(c, oauthUserId) { 101 | return await orm(c).select().from(oauth).where(eq(oauth.oauthUserId, oauthUserId)).get(); 102 | }, 103 | 104 | async deleteByUserId(c, userId) { 105 | await orm(c).delete(oauth).where(eq(oauth.userId, userId)).run(); 106 | }, 107 | 108 | //定时任务凌晨清除未绑定邮箱的oauth用户 109 | async clearNoBindOathUser(c) { 110 | await orm(c).delete(oauth).where(eq(oauth.userId, 0)).run(); 111 | }, 112 | 113 | } 114 | 115 | export default oauthService 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |
6 |

Cloud Mail

7 |
8 |
9 |

Serverless 响应式邮箱服务,支持邮件发送,可部署到Cloudflare平台 🎉

10 |
11 |
12 | 简体中文 | English 13 |
14 | 15 | ## 项目简介 16 | 17 | 只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台,本项目可部署到 Cloudflare Workers ,降低服务器成本,搭建自己的邮箱服务 18 | 19 | ## 项目展示 20 | 21 | - [在线演示](https://skymail.ink)
22 | - [部署文档](https://doc.skymail.ink)
23 | - [界面部署](https://doc.skymail.ink/guide/via-ui.html) 24 | 25 | | ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) | 26 | |-----------------------|-----------------------| 27 | | ![](/doc/demo/demo3.png) | ![](/doc/demo/demo4.png) | 28 | 29 | 30 | 31 | 32 | ## 功能介绍 33 | 34 | - **💰 低成本使用**: 部署到 Cloudflare Workers 降低服务器成本 35 | 36 | - **💻 响应式设计**:响应式布局自动适配PC和大部分手机端浏览器 37 | 38 | - **📧 邮件发送**:集成Resend发送邮件,支持群发,内嵌图片和附件发送,发送状态查看 39 | 40 | - **🛡️ 管理员功能**:可以对用户,邮件进行管理,RABC权限控制对功能及使用资源限制 41 | 42 | - **📦 附件收发**:支持收发附件,使用R2对象存储保存和下载文件 43 | 44 | - **🔔 邮件推送**:接收邮件后可以转发到TG机器人或其他服务商邮箱 45 | 46 | - **📡 开放API**:支持使用API批量生成用户,多条件查询邮件 47 | 48 | - **📈 数据可视化**:使用Echarts对系统数据详情,用户邮件增长可视化显示 49 | 50 | - **🎨 个性化设置**:可以自定义网站标题,登录背景,透明度 51 | 52 | - **🤖 人机验证**:集成Turnstile人机验证,防止人机批量注册 53 | 54 | - **📜 更多功能**:正在开发中... 55 | 56 | 57 | 58 | ## 技术栈 59 | 60 | - **Serverless**:[Cloudflare Workers](https://developers.cloudflare.com/workers/) 61 | 62 | - **Web框架**:[Hono](https://hono.dev/) 63 | 64 | - **ORM:**[Drizzle](https://orm.drizzle.team/) 65 | 66 | - **前端框架**:[Vue3](https://vuejs.org/) 67 | 68 | - **UI框架**:[Element Plus](https://element-plus.org/) 69 | 70 | - **邮件推送:** [Resend](https://resend.com/) 71 | 72 | - **缓存**:[Cloudflare KV](https://developers.cloudflare.com/kv/) 73 | 74 | - **数据库**:[Cloudflare D1](https://developers.cloudflare.com/d1/) 75 | 76 | - **文件存储**:[Cloudflare R2](https://developers.cloudflare.com/r2/) 77 | 78 | ## 目录结构 79 | 80 | ``` 81 | cloud-mail 82 | ├── mail-worker # worker后端项目 83 | │ ├── src 84 | │ │ ├── api # api接口层 85 | │ │ ├── const # 项目常量 86 | │ │ ├── dao # 数据访问层 87 | │ │ ├── email # 邮件处理接收 88 | │ │ ├── entity # 数据库实体 89 | │ │ ├── error # 自定义异常 90 | │ │ ├── hono # web框架配置、拦截器、全局异常等 91 | │ │ ├── i18n # 语言国际化 92 | │ │ ├── init # 数据库缓存初始化 93 | │ │ ├── model # 响应体数据封装 94 | │ │ ├── security # 身份权限认证 95 | │ │ ├── service # 业务服务层 96 | │ │ ├── template # 消息模板 97 | │ │ ├── utils # 工具类 98 | │ │ └── index.js # 入口文件 99 | │ ├── pageckge.json # 项目依赖 100 | │ └── wrangler.toml # 项目配置 101 | │ 102 | ├── mail-vue # vue前端项目 103 | │ ├── src 104 | │ │ ├── axios # axios配置 105 | │ │ ├── components # 自定义组件 106 | │ │ ├── echarts # echarts组件导入 107 | │ │ ├── i18n # 语言国际化 108 | │ │ ├── init # 入站初始化 109 | │ │ ├── layout # 主体布局组件 110 | │ │ ├── perm # 权限认证 111 | │ │ ├── request # api接口 112 | │ │ ├── router # 路由配置 113 | │ │ ├── store # 全局状态管理 114 | │ │ ├── utils # 工具类 115 | │ │ ├── views # 页面组件 116 | │ │ ├── app.vue # 入口组件 117 | │ │ ├── main.js # 入口js 118 | │ │ └── style.css # 全局css 119 | │ ├── package.json # 项目依赖 120 | └── └── env.release # 项目配置 121 | ``` 122 | 123 | ## 赞助 124 | 125 | 126 | 127 | 128 | 129 | ## 许可证 130 | 131 | 本项目采用 [MIT](LICENSE) 许可证 132 | 133 | 134 | ## 交流 135 | 136 | [Telegram](https://t.me/cloud_mail_tg) 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /mail-vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Cloud Mail 9 | 10 | 11 | 12 | 23 | 24 | 106 | 107 |
108 |
109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
118 |
119 |
120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /mail-vue/public/tinymce/plugins/importcss/plugin.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager");const t=e=>t=>(e=>{const t=typeof e;return null===e?"null":"object"===t&&Array.isArray(e)?"array":"object"===t&&(s=r=e,(o=String).prototype.isPrototypeOf(s)||(null===(n=r.constructor)||void 0===n?void 0:n.name)===o.name)?"string":t;var s,r,o,n})(t)===e,s=t("string"),r=t("object"),o=t("array"),n=e=>"function"==typeof e;Array.prototype.slice;const c=Array.prototype.push;n(Array.from)&&Array.from;var i=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),l=tinymce.util.Tools.resolve("tinymce.EditorManager"),a=tinymce.util.Tools.resolve("tinymce.Env"),p=tinymce.util.Tools.resolve("tinymce.util.Tools");const u=e=>t=>t.options.get(e),m=u("importcss_merge_classes"),f=u("importcss_exclusive"),y=u("importcss_selector_converter"),d=u("importcss_selector_filter"),h=u("importcss_groups"),g=u("importcss_append"),_=u("importcss_file_filter"),v=u("skin"),b=u("skin_url"),x=/^\.(?:ephox|tiny-pageembed|mce)(?:[.-]+\w+)+$/,T=e=>s(e)?t=>-1!==t.indexOf(e):e instanceof RegExp?t=>e.test(t):e,S=(e,t)=>{let s={};const r=/^(?:([a-z0-9\-_]+))?(\.[a-z0-9_\-\.]+)$/i.exec(t);if(!r)return;const o=r[1],n=r[2].substr(1).split(".").join(" "),c=p.makeMap("a,img");return r[1]?(s={title:t},e.schema.getTextBlockElements()[o]?s.block=o:e.schema.getBlockElements()[o]||c[o.toLowerCase()]?s.selector=o:s.inline=o):r[2]&&(s={inline:"span",title:t.substr(1),classes:n}),m(e)?s.classes=n:s.attributes={class:n},s},A=(e,t)=>null===t||f(e),k=e=>{e.on("init",(()=>{const t=(()=>{const e=[],t=[],s={};return{addItemToGroup:(e,r)=>{s[e]?s[e].push(r):(t.push(e),s[e]=[r])},addItem:t=>{e.push(t)},toFormats:()=>{return(r=t,n=e=>{const t=s[e];return 0===t.length?[]:[{title:e,items:t}]},(e=>{const t=[];for(let s=0,r=e.length;s{const s=e.length,r=new Array(s);for(let o=0;op.map(e,(e=>p.extend({},e,{original:e,selectors:{},filter:T(e.filter)}))))(h(e)),m=(t,s)=>{if(((e,t,s,r)=>!(A(e,s)?t in r:t in s.selectors))(e,t,s,r)){((e,t,s,r)=>{A(e,s)?r[t]=!0:s.selectors[t]=!0})(e,t,s,r);const o=((e,t,s,r)=>{let o;const n=y(e);return o=r&&r.selector_converter?r.selector_converter:n||(()=>S(e,s)),o.call(t,s,r)})(e,e.plugins.importcss,t,s);if(o){const t=o.name||i.DOM.uniqueId();return e.formatter.register(t,o),{title:o.title,format:t}}}return null};p.each(((e,t,r)=>{const o=[],n={},c=(t,n)=>{let i,u=t.href;if(u=(e=>{const t=a.cacheSuffix;return s(e)&&(e=e.replace("?"+t,"").replace("&"+t,"")),e})(u),u&&(!r||r(u,n))&&!((e,t)=>{const s=v(e);if(s){const r=b(e),o=r?e.documentBaseURI.toAbsolute(r):l.baseURL+"/skins/ui/"+s,n=l.baseURL+"/skins/content/",c=e.editorManager.suffix;return t===o+"/content"+(e.inline?".inline":"")+`${c}.css`||-1!==t.indexOf(n)}return!1})(e,u)){p.each(t.imports,(e=>{c(e,!0)}));try{i=t.cssRules||t.rules}catch(e){}p.each(i,(e=>{e.styleSheet&&e.styleSheet?c(e.styleSheet,!0):e.selectorText&&p.each(e.selectorText.split(","),(e=>{o.push(p.trim(e))}))}))}};p.each(e.contentCSS,(e=>{n[e]=!0})),r||(r=(e,t)=>t||n[e]);try{p.each(t.styleSheets,(e=>{c(e)}))}catch(e){}return o})(e,e.getDoc(),T(_(e))),(e=>{if(!x.test(e)&&(!n||n(e))){const s=((e,t)=>p.grep(e,(e=>!e.filter||e.filter(t))))(u,e);if(s.length>0)p.each(s,(s=>{const r=m(e,s);r&&t.addItemToGroup(s.title,r)}));else{const s=m(e,null);s&&t.addItem(s)}}}));const f=t.toFormats();e.dispatch("addStyleModifications",{items:f,replace:!g(e)})}))};e.add("importcss",(e=>((e=>{const t=e.options.register,o=e=>s(e)||n(e)||r(e);t("importcss_merge_classes",{processor:"boolean",default:!0}),t("importcss_exclusive",{processor:"boolean",default:!0}),t("importcss_selector_converter",{processor:"function"}),t("importcss_selector_filter",{processor:o}),t("importcss_file_filter",{processor:o}),t("importcss_groups",{processor:"object[]"}),t("importcss_append",{processor:"boolean",default:!1})})(e),k(e),(e=>({convertSelectorToFormat:t=>S(e,t)}))(e))))}(); --------------------------------------------------------------------------------