mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-12 19:49:52 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d01e09e8 | ||
|
|
201c7658be | ||
|
|
77155299e0 | ||
|
|
9725407c77 | ||
|
|
e91bbe273a | ||
|
|
b792c196c1 | ||
|
|
7a368d7b23 | ||
|
|
f882e4cf97 | ||
|
|
00abf79417 | ||
|
|
1f8edbc295 | ||
|
|
268f3d6446 | ||
|
|
8dc9d32a7e | ||
|
|
3b6736924b | ||
|
|
dc14338b69 | ||
|
|
954ae2dfb1 | ||
|
|
6d55acdd42 | ||
|
|
03bb210016 | ||
|
|
bf3c372d8c | ||
|
|
9414f7a977 | ||
|
|
32440706d2 | ||
|
|
c976664f4e | ||
|
|
aa04dc4efa | ||
|
|
02e3e755e7 | ||
|
|
37ed2955ff | ||
|
|
dd49768cfc | ||
|
|
9ec11f7040 | ||
|
|
2533257b68 | ||
|
|
96ea81e055 | ||
|
|
8459e0c306 | ||
|
|
91d7896e65 | ||
|
|
69771fc1d1 | ||
|
|
c00382259a | ||
|
|
8ac96bff1f | ||
|
|
9f3ff7b980 | ||
|
|
870b7b9198 | ||
|
|
46576316e6 | ||
|
|
a5ff4f2d90 | ||
|
|
745e36f838 | ||
|
|
a351839408 | ||
|
|
ca00a877ad | ||
|
|
53a06fc9d6 |
7
.github/workflows/frontend_deploy.yaml
vendored
7
.github/workflows/frontend_deploy.yaml
vendored
@@ -38,6 +38,13 @@ jobs:
|
||||
pnpm run deploy --project-name=$project_name
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
if [ -n "$tg_mini_app_project_name" ]; then
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,5 +1,70 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
## v0.5.0
|
||||
|
||||
- UI: 增加本地缓存进行地址管理
|
||||
- worker: 增加 `FORWARD_ADDRESS_LIST` 全局邮件转发地址(等同于 `catch all`)
|
||||
- UI: 多语言使用路由进行切换
|
||||
- 添加保存附件到 S3 的功能
|
||||
- UI: 增加收取邮件列表 `批量删除` 和 `批量下载`
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- worker 配置文件添加 `TITLE = "Custom Title"`, 可自定义网站标题
|
||||
- 修复 KV 未绑定无法删除地址的问题
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- UI lazy load 懒加载
|
||||
- telegram bot 添加用户全局推送功能(admin 用户)
|
||||
- 增加对 cloudflare verified 用户发送邮件
|
||||
- 增加使用 `resend` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- 增加 telegram mini app
|
||||
- telegram bot 增加 `ubind`, `delete` 指令
|
||||
- 修复 webhook 多行文本的问题
|
||||
|
||||
## v0.4.3
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
配置文件 `main = "src/worker.js"` 改为 `main = "src/worker.ts"`
|
||||
|
||||
### Changes
|
||||
|
||||
- `telegram bot` 白名单配置
|
||||
- `ENABLE_WEBHOOK` 添加 webhook
|
||||
- UI: admin 页面使用双层 tab
|
||||
- UI: 登录后可直接主页切换地址
|
||||
- UI: 发件箱也采用左右分栏显示(类似收件箱)
|
||||
- `SMTP IMAP Proxy` 添加发件箱查看
|
||||
|
||||
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
|
||||
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
|
||||
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
|
||||
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- 修复 smtp imap proxy sever 的一些 bug
|
||||
- 修复 UI 界面文字错误, 界面增加版本号
|
||||
- 增加 telegram bot 文档 https://temp-mail-docs.awsl.uk/zh/guide/feature/telegram.html
|
||||
|
||||
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
|
||||
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
|
||||
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
|
||||
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
|
||||
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
|
||||
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
|
||||
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
|
||||
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
|
||||
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
|
||||
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
|
||||
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- 用户名限制最长30个字符
|
||||
@@ -157,7 +222,6 @@ set
|
||||
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
|
||||
- 一些 bug 修复
|
||||
|
||||
---
|
||||
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
|
||||
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
|
||||
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
VITE_IS_TELEGRAM=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,21 +8,25 @@
|
||||
"build": "vite build -m prod --emptyOutDir",
|
||||
"build:release": "vite build -m example --emptyOutDir",
|
||||
"build:pages": "vite build -m pages --emptyOutDir",
|
||||
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@unhead/vue": "^1.9.11",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.8",
|
||||
"axios": "^1.7.2",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.1.6",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postal-mime": "^2.2.5",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.26",
|
||||
"vue": "^3.4.27",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.3.2"
|
||||
@@ -30,13 +34,13 @@
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.1.0",
|
||||
"wrangler": "^3.53.1"
|
||||
"wrangler": "^3.57.1"
|
||||
}
|
||||
}
|
||||
|
||||
881
frontend/pnpm-lock.yaml
generated
881
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -8,16 +8,15 @@ import Header from './views/Header.vue';
|
||||
import Footer from './views/Footer.vue';
|
||||
|
||||
|
||||
const { localeCache, isDark, loading, useSideMargin } = useGlobalState()
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const { locale } = useI18n({});
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
|
||||
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
|
||||
const isMobile = useIsMobile()
|
||||
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||
|
||||
const { locale } = useI18n({
|
||||
useScope: 'global',
|
||||
});
|
||||
locale.value = localeCache.value;
|
||||
|
||||
onMounted(async () => {
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
@@ -31,6 +30,23 @@ onMounted(async () => {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
if (
|
||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||
||
|
||||
(typeof enableTelegram === 'string' && enableTelegram === 'true')
|
||||
) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
telegramApp.value = window.Telegram?.WebApp || {};
|
||||
isTelegram.value = !!window.Telegram?.WebApp?.initData;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const getOpenSettings = async (message) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
Object.assign(openSettings.value, {
|
||||
title: res["title"] || "",
|
||||
prefix: res["prefix"] || "",
|
||||
needAuth: res["needAuth"] || false,
|
||||
domains: res["domains"].map((domain) => {
|
||||
@@ -69,6 +70,8 @@ const getOpenSettings = async (message) => {
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
@@ -10,7 +9,6 @@ import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
@@ -37,11 +35,21 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
}
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
},
|
||||
saveToS3: {
|
||||
type: Function,
|
||||
default: (mail_id, filename, blob) => { },
|
||||
requried: false
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
localeCache, isDark, mailboxSplitSize, indexTab,
|
||||
isDark, mailboxSplitSize, indexTab, loading,
|
||||
useIframeShowMail, sendMailModel, preferShowTextMail
|
||||
} = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
@@ -58,8 +66,13 @@ const curAttachments = ref([])
|
||||
const curMail = ref(null);
|
||||
const showTextMail = ref(preferShowTextMail.value)
|
||||
|
||||
const multiActionMode = ref(false)
|
||||
const showMultiActionDownload = ref(false)
|
||||
const showMultiActionDelete = ref(false)
|
||||
const multiActionDownloadZip = ref({})
|
||||
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
@@ -68,12 +81,17 @@ const { t } = useI18n({
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
pleaseSelectMail: "Please select mail",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete this mail?',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail'
|
||||
showHtmlMail: 'Show Html Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
multiAction: 'Multi Action',
|
||||
cancelMultiAction: 'Cancel Multi Action',
|
||||
selectAll: 'Select All',
|
||||
unselectAll: 'Unselect All',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
@@ -82,12 +100,17 @@ const { t } = useI18n({
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除这封邮件吗?',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件'
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
multiAction: '多选',
|
||||
cancelMultiAction: '取消多选',
|
||||
selectAll: '全选',
|
||||
unselectAll: '取消全选',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -125,12 +148,14 @@ const refresh = async () => {
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (!isMobile.value && !curMail.value && data.value.length > 0) {
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -140,6 +165,10 @@ const refresh = async () => {
|
||||
};
|
||||
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
@@ -186,6 +215,92 @@ const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
const attachmentLoding = ref(false)
|
||||
const saveToS3Proxy = async (filename, blob) => {
|
||||
attachmentLoding.value = true
|
||||
try {
|
||||
await props.saveToS3(curMail.value.id, filename, blob);
|
||||
} finally {
|
||||
attachmentLoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionModeClick = (enableMulti) => {
|
||||
if (enableMulti) {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
multiActionMode.value = true;
|
||||
} else {
|
||||
multiActionMode.value = false;
|
||||
data.value.forEach((item) => {
|
||||
item.checked = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionSelectAll = (checked) => {
|
||||
data.value.forEach((item) => {
|
||||
item.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionDeleteMail = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedMails = data.value.filter((item) => item.checked);
|
||||
if (selectedMails.length === 0) {
|
||||
message.error(t('pleaseSelectMail'));
|
||||
return;
|
||||
}
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: 0,
|
||||
tip: `0/${selectedMails.length}`
|
||||
};
|
||||
for (const [index, mail] of selectedMails.entries()) {
|
||||
await props.deleteMail(mail.id);
|
||||
showMultiActionDelete.value = true;
|
||||
multiActionDeleteProgress.value = {
|
||||
percentage: Math.floor((index + 1) / selectedMails.length * 100),
|
||||
tip: `${index + 1}/${selectedMails.length}`
|
||||
};
|
||||
}
|
||||
message.success(t("success"));
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
showMultiActionDelete.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionDownload = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedMails = data.value.filter((item) => item.checked);
|
||||
if (selectedMails.length === 0) {
|
||||
message.error(t('pleaseSelectMail'));
|
||||
return;
|
||||
}
|
||||
const JSZipModlue = await import('jszip');
|
||||
const JSZip = JSZipModlue.default;
|
||||
const zip = new JSZip();
|
||||
for (const mail of selectedMails) {
|
||||
zip.file(`${mail.id}.eml`, mail.raw);
|
||||
}
|
||||
multiActionDownloadZip.value = {
|
||||
url: URL.createObjectURL(await zip.generateAsync({ type: "blob" })),
|
||||
filename: `mails-${new Date().toISOString().replace(/:/g, '-')}.zip`
|
||||
}
|
||||
showMultiActionDownload.value = true;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
@@ -197,14 +312,38 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
|
||||
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div class="center">
|
||||
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
<div v-if="!isMobile" class="left">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<n-space v-if="multiActionMode">
|
||||
<n-button @click="multiActionModeClick(false)" tertiary>
|
||||
{{ t('cancelMultiAction') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(true)" tertiary>
|
||||
{{ t('selectAll') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(false)" tertiary>
|
||||
{{ t('unselectAll') }}
|
||||
</n-button>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button @click="multiActionDownload" tertiary type="info">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-space v-else>
|
||||
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
|
||||
{{ t('multiAction') }}
|
||||
</n-button>
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
|
||||
show-size-picker />
|
||||
<n-switch v-model:value="autoRefresh" :round="false">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
@@ -212,91 +351,99 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" size="small" type="primary" tertiary>
|
||||
<n-button @click="refresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ row.created_at }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ curMail.created_at }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
|
||||
style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
|
||||
style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
</div>
|
||||
<div class="left" v-else>
|
||||
<div class="center">
|
||||
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
|
||||
<n-space justify="center">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
@@ -307,10 +454,10 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
<n-button @click="refresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-space>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
@@ -320,7 +467,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ row.created_at }}
|
||||
{{ `${row.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
@@ -342,7 +489,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ curMail.created_at }}
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
@@ -388,25 +535,51 @@ onBeforeUnmount(() => {
|
||||
<template #header>
|
||||
<div>{{ t("attachments") }}</div>
|
||||
</template>
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<n-spin v-model:show="attachmentLoding">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
<n-button v-if="showSaveS3" @click="saveToS3Proxy(row.filename, row.blob)" ghost type="info"
|
||||
size="small">
|
||||
{{ t('saveToS3') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
|
||||
<n-tag type="info">
|
||||
{{ multiActionDownloadZip.filename }}
|
||||
</n-tag>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="multiActionDownloadZip.filename"
|
||||
:href="multiActionDownloadZip.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') + " zip" }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showMultiActionDelete" preset="dialog" :title="t('delete') + t('success')"
|
||||
negative-text="OK">
|
||||
<n-space justify="center">
|
||||
<n-progress type="circle" status="error" :percentage="multiActionDeleteProgress.percentage">
|
||||
<span style="text-align: center">
|
||||
{{ multiActionDeleteProgress.tip }}
|
||||
</span>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
269
frontend/src/components/SendBox.vue
Normal file
269
frontend/src/components/SendBox.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const props = defineProps({
|
||||
showEMailFrom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: true
|
||||
},
|
||||
})
|
||||
|
||||
const { isDark, mailboxSplitSize } = useGlobalState()
|
||||
const data = ref([])
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const curMail = ref(null);
|
||||
const showCode = ref(false)
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
refresh: 'Refresh',
|
||||
showCode: 'Change View Original Code',
|
||||
pleaseSelectMail: "Please select a mail to view."
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
refresh: '刷新',
|
||||
showCode: '切换查看元数据',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
await refresh();
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
data.value = results.map((item) => {
|
||||
try {
|
||||
const data = JSON.parse(item.raw);
|
||||
if (data.version == "v2") {
|
||||
item.to_mail = data.to_name ? `${data.to_name} <${data.to_mail}>` : data.to_mail;
|
||||
item.subject = data.subject;
|
||||
item.is_html = data.is_html;
|
||||
item.content = data.content;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
} else {
|
||||
item.to_mail = data?.personalizations?.map(
|
||||
(p) => p.to?.map((t) => t.email).join(',')
|
||||
).join(';');
|
||||
item.subject = data.subject;
|
||||
item.is_html = (data.content[0]?.type != 'text/plain');
|
||||
item.content = data.content[0]?.value;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (!isMobile.value && !curMail.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const clickRow = async (row) => {
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
|
||||
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div class="center">
|
||||
<div style="display: inline-block; margin-right: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-button @click="refresh" size="small" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ row.to_mail }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ curMail.to_mail }}
|
||||
</n-tag>
|
||||
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
|
||||
{{ t('showCode') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
<div class="left" v-else>
|
||||
<div class="center">
|
||||
<div style="display: inline-block; margin-right: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${row.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailFrom" type="info">
|
||||
FROM: {{ row.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ row.to_mail }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 80vh;">
|
||||
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
|
||||
<n-card style="overflow: auto;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ curMail.to_mail }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
|
||||
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.overlay-dark-backgroud {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-light-backgroud {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mail-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,11 @@
|
||||
import { ref, watch, defineModel, onMounted } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { localeCache, openSettings, isDark } = useGlobalState()
|
||||
const { openSettings, isDark } = useGlobalState()
|
||||
|
||||
const cfToken = defineModel('value')
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
refresh: 'Refresh'
|
||||
@@ -42,7 +41,7 @@ const checkCfTurnstile = async (remove) => {
|
||||
"#cf-turnstile",
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
callback: function (token) {
|
||||
cfToken.value = token;
|
||||
|
||||
@@ -3,6 +3,7 @@ import App from './App.vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import router from './router'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { createHead } from '@unhead/vue'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
const i18n = createI18n({
|
||||
@@ -16,7 +17,19 @@ const i18n = createI18n({
|
||||
messages: {}
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
|
||||
i18n.global.locale.value = to.params.lang
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const head = createHead()
|
||||
const app = createApp(App)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
app.use(head)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import UserLogin from '../views/user/UserLogin.vue'
|
||||
import User from '../views/User.vue'
|
||||
import SendMail from '../views/index/SendMail.vue'
|
||||
import Admin from '../views/Admin.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
alias: "/:lang/",
|
||||
component: Index
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
alias: "/:lang/user",
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Admin
|
||||
alias: "/:lang/admin",
|
||||
component: () => import('../views/Admin.vue')
|
||||
},
|
||||
{
|
||||
path: '/telegram_mail',
|
||||
alias: "/:lang/telegram_mail",
|
||||
component: () => import('../views/telegram/Mail.vue')
|
||||
},
|
||||
{
|
||||
name: 'not-found',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ export const useGlobalState = createGlobalState(
|
||||
const toggleDark = useToggle(isDark)
|
||||
const loading = ref(false);
|
||||
const openSettings = ref({
|
||||
title: '',
|
||||
prefix: '',
|
||||
needAuth: false,
|
||||
adminContact: '',
|
||||
@@ -17,6 +18,8 @@ export const useGlobalState = createGlobalState(
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -44,7 +47,6 @@ export const useGlobalState = createGlobalState(
|
||||
const auth = useStorage('auth', '');
|
||||
const adminAuth = useStorage('adminAuth', '');
|
||||
const jwt = useStorage('jwt', '');
|
||||
const localeCache = useStorage('locale', 'zh');
|
||||
const adminTab = ref("account");
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
@@ -68,6 +70,8 @@ export const useGlobalState = createGlobalState(
|
||||
/** @type {number} */
|
||||
user_id: 0,
|
||||
});
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
@@ -79,7 +83,6 @@ export const useGlobalState = createGlobalState(
|
||||
showAddressCredential,
|
||||
auth,
|
||||
jwt,
|
||||
localeCache,
|
||||
adminAuth,
|
||||
showAdminAuth,
|
||||
adminTab,
|
||||
@@ -95,6 +98,8 @@ export const useGlobalState = createGlobalState(
|
||||
userSettings,
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
telegramApp,
|
||||
isTelegram,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -16,11 +16,11 @@ export async function processItem(item) {
|
||||
item.message = parsedEmail.body_html || parsedEmail.text || '';
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.content_type || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.content_type || 'application/octet-stream' }
|
||||
);
|
||||
const blob_url = URL.createObjectURL(blob);
|
||||
if (a_item.content_id && a_item.content_id.length > 0) {
|
||||
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ export async function processItem(item) {
|
||||
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.content_id || "",
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
@@ -49,11 +50,11 @@ export async function processItem(item) {
|
||||
item.message = parsedEmail.html || parsedEmail.text || item.raw;
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
);
|
||||
const blob_url = URL.createObjectURL(blob)
|
||||
if (a_item.contentId && a_item.contentId.length > 0) {
|
||||
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
|
||||
}
|
||||
@@ -61,7 +62,8 @@ export async function processItem(item) {
|
||||
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.contentId || "",
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const hashPassword = async (password) => {
|
||||
export const hashPassword = async (password: string) => {
|
||||
// user crypto to hash password
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
|
||||
const hashArray = Array.from(new Uint8Array(digest));
|
||||
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export const getRouterPathWithLang = (path: string, lang: string) => {
|
||||
if (!lang || lang === 'zh') {
|
||||
return path;
|
||||
}
|
||||
return `/${lang}${path}`;
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import About from './common/About.vue';
|
||||
import Maintenance from './admin/Maintenance.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Telegram from './admin/Telegram.vue';
|
||||
import Webhook from './admin/Webhook.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
|
||||
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -33,7 +34,6 @@ const authFunc = async () => {
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
accessHeader: 'Admin Password',
|
||||
@@ -42,12 +42,14 @@ const { t } = useI18n({
|
||||
account: 'Account',
|
||||
account_create: 'Create Account',
|
||||
account_settings: 'Account Settings',
|
||||
user: 'User',
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
telegram: 'Telegram Bot',
|
||||
webhook: 'Webhook',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
appearance: 'Appearance',
|
||||
@@ -61,12 +63,14 @@ const { t } = useI18n({
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user: '用户',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
telegram: '电报机器人',
|
||||
webhook: 'Webhook',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
appearance: '外观',
|
||||
@@ -98,28 +102,43 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
||||
<CreateAccount />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="webhook" :tab="t('webhook')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
||||
<CreateAccount />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
<n-tab-pane name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { localeCache, openSettings } = useGlobalState()
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
copyright: "Copyright"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, h, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import {
|
||||
@@ -11,11 +12,13 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const {
|
||||
localeCache, toggleDark, isDark, openSettings,
|
||||
showAuth, adminAuth, auth, loading
|
||||
toggleDark, isDark, isTelegram,
|
||||
showAuth, adminAuth, auth, loading, openSettings
|
||||
} = useGlobalState()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -36,13 +39,15 @@ const authFunc = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const changeLocale = (locale) => {
|
||||
localeCache.value = locale;
|
||||
location.reload()
|
||||
const changeLocale = async (lang) => {
|
||||
if (lang == 'zh') {
|
||||
await router.push(route.fullPath.replace('/en', ''));
|
||||
} else {
|
||||
await router.push(`/${lang}${route.fullPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'Cloudflare Temp Email',
|
||||
@@ -79,7 +84,10 @@ const menuOptions = computed(() => [
|
||||
size: "small",
|
||||
type: menuValue.value == "home" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => { await router.push('/'); showMobileMenu.value = false; }
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang('/', locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('home'),
|
||||
@@ -95,7 +103,10 @@ const menuOptions = computed(() => [
|
||||
size: "small",
|
||||
type: menuValue.value == "user" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; }
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
@@ -103,6 +114,7 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
),
|
||||
key: "user",
|
||||
show: !isTelegram.value
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
@@ -112,7 +124,10 @@ const menuOptions = computed(() => [
|
||||
size: "small",
|
||||
type: menuValue.value == "admin" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => "Admin",
|
||||
@@ -147,13 +162,13 @@ const menuOptions = computed(() => [
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => {
|
||||
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
|
||||
onClick: async () => {
|
||||
locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => localeCache.value == 'zh' ? "English" : "中文",
|
||||
default: () => locale.value == 'zh' ? "English" : "中文",
|
||||
icon: () => h(
|
||||
NIcon, { component: Language }
|
||||
)
|
||||
@@ -181,6 +196,13 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
]);
|
||||
|
||||
useHead({
|
||||
title: () => openSettings.value.title || t('title'),
|
||||
meta: [
|
||||
{ name: "description", content: openSettings.value.description || t('title') },
|
||||
]
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getOpenSettings(message);
|
||||
});
|
||||
@@ -190,7 +212,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<n-page-header>
|
||||
<template #title>
|
||||
<h3>{{ t('title') }}</h3>
|
||||
<h3>{{ openSettings.title || t('title') }}</h3>
|
||||
</template>
|
||||
<template #avatar>
|
||||
<n-avatar style="margin-left: 10px;" src="/logo.png" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
@@ -6,16 +7,18 @@ import { api } from '../api'
|
||||
|
||||
import AddressBar from './index/AddressBar.vue';
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import SendBox from '../components/SendBox.vue';
|
||||
import AutoReply from './index/AutoReply.vue';
|
||||
import SendBox from './index/SendBox.vue';
|
||||
import SendMail from './index/SendMail.vue';
|
||||
import AccountSettings from './index/AccountSettings.vue';
|
||||
import Webhook from './index/Webhook.vue';
|
||||
import Attachment from './index/Attachment.vue';
|
||||
import About from './common/About.vue';
|
||||
|
||||
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
|
||||
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
mailbox: 'Mail Box',
|
||||
@@ -24,6 +27,8 @@ const { t } = useI18n({
|
||||
auto_reply: 'Auto Reply',
|
||||
accountSettings: 'Account Settings',
|
||||
about: 'About',
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
@@ -32,6 +37,8 @@ const { t } = useI18n({
|
||||
auto_reply: '自动回复',
|
||||
accountSettings: '账户设置',
|
||||
about: '关于',
|
||||
s3Attachment: 'S3附件',
|
||||
saveToS3Success: '保存到s3成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -43,6 +50,30 @@ const fetchMailData = async (limit, offset) => {
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const fetchSenboxData = async (limit, offset) => {
|
||||
return await api.fetch(`/api/sendbox?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
const saveToS3 = async (mail_id, filename, blob) => {
|
||||
try {
|
||||
const { url } = await api.fetch(`/api/attachment/put_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: `${mail_id}/${filename}` })
|
||||
});
|
||||
// upload to s3 by formdata
|
||||
const formData = new FormData();
|
||||
formData.append(filename, blob);
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
message.success(t('saveToS3Success'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "save to s3 error");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,11 +81,12 @@ const deleteMail = async (curMailId) => {
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
<MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox />
|
||||
<SendBox :fetchMailData="fetchSenboxData" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
@@ -65,6 +97,12 @@ const deleteMail = async (curMailId) => {
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
|
||||
<Attachment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -9,11 +9,10 @@ import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
|
||||
const {
|
||||
localeCache, userTab, globalTabplacement, userSettings
|
||||
userTab, globalTabplacement, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address_management: 'Address Management',
|
||||
|
||||
@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth, loading,
|
||||
adminAuth, showAdminAuth, loading,
|
||||
adminTab, adminMailTabAddress, adminSendBoxTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
name: 'Name',
|
||||
@@ -261,7 +260,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("addressCredential") }}</div>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
@@ -17,6 +16,7 @@ const { t } = useI18n({
|
||||
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
|
||||
address_block_list_placeholder: 'Please enter the keywords you want to block',
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
@@ -24,18 +24,21 @@ const { t } = useI18n({
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
const verifiedAddressList = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
verifiedAddressList.value = res.verifiedAddressList || []
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
@@ -47,7 +50,8 @@ const save = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || []
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || []
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
@@ -73,6 +77,10 @@ onMounted(async () => {
|
||||
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('verified_address_list')">
|
||||
<n-select v-model:value="verifiedAddressList" filterable multiple tag
|
||||
:placeholder="t('verified_address_list')" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="save" type="primary" block :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
|
||||
@@ -6,12 +6,11 @@ import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const {
|
||||
localeCache, loading, openSettings,
|
||||
loading, openSettings,
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
|
||||
@@ -7,12 +7,11 @@ import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth,
|
||||
adminAuth, showAdminAuth,
|
||||
adminMailTabAddress
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
@@ -58,7 +57,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
|
||||
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
|
||||
@@ -66,6 +65,7 @@ onMounted(async () => {
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="adminAuth">
|
||||
<div v-if="adminAuth" style="margin-top: 10px;">
|
||||
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CleaningServicesFilled } from '@vicons/material'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
|
||||
const { adminAuth, showAdminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const cleanupModel = ref({
|
||||
enableMailsAutoCleanup: false,
|
||||
@@ -20,7 +20,6 @@ const cleanupModel = ref({
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'Please input the cleanup days',
|
||||
|
||||
@@ -1,153 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import SendBox from '../../components/SendBox.vue';
|
||||
|
||||
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const { adminSendBoxTabAddress } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
success: 'Success',
|
||||
to_mail: 'To Mail',
|
||||
subject: 'Subject',
|
||||
created_at: 'Created At',
|
||||
action: 'Action',
|
||||
query: 'Query',
|
||||
itemCount: 'itemCount',
|
||||
view: 'View',
|
||||
queryTip: 'Please input address to query, leave blank to query all',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
to_mail: '收件人邮箱',
|
||||
subject: '主题',
|
||||
created_at: '创建时间',
|
||||
action: '操作',
|
||||
query: '查询',
|
||||
itemCount: '总数',
|
||||
view: '查看',
|
||||
queryTip: '请输入地址查询, 留空则查询所有',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const curRow = ref({})
|
||||
const showModal = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/admin/sendbox`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
|
||||
);
|
||||
data.value = results.map((item) => {
|
||||
try {
|
||||
const data = JSON.parse(item.raw);
|
||||
item.to_mail = data?.personalizations?.map(
|
||||
(p) => p.to?.map((t) => t.email).join(',')
|
||||
).join(';');
|
||||
item.subject = data.subject;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
const fetchData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('to_mail'),
|
||||
key: "to_mail"
|
||||
},
|
||||
{
|
||||
title: t('subject'),
|
||||
key: "subject"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('action'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
tertiary: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
}
|
||||
},
|
||||
{ default: () => t('view') }
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!adminAuth.value) {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
|
||||
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminSendBoxTabAddress" />
|
||||
<n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
<SendBox style="margin-top: 10px;" :fetchMailData="fetchData" :showEMailFrom="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
|
||||
@@ -7,11 +7,10 @@ import { SendOutlined } from '@vicons/material'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, adminAuth } = useGlobalState()
|
||||
const { adminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
userCount: 'Account Count',
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
init: 'Init',
|
||||
successTip: 'Success',
|
||||
status: 'Check Status',
|
||||
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
|
||||
enable: 'Enable',
|
||||
telegramAllowList: 'Telegram Allow List',
|
||||
save: 'Save',
|
||||
miniAppUrl: 'Telegram Mini App URL',
|
||||
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
|
||||
globalMailPushList: 'Global Mail Push List',
|
||||
},
|
||||
zh: {
|
||||
init: '初始化',
|
||||
successTip: '成功',
|
||||
status: '查看状态',
|
||||
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID)',
|
||||
enable: '启用',
|
||||
telegramAllowList: 'Telegram 白名单',
|
||||
save: '保存',
|
||||
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
|
||||
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID)',
|
||||
globalMailPushList: '全局邮件推送用户列表',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -27,35 +42,107 @@ const status = ref({
|
||||
fetched: false,
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/telegram/status`)
|
||||
Object.assign(status.value, res)
|
||||
status.value.fetched = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/telegram/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
class TelegramSettings {
|
||||
enableAllowList: boolean;
|
||||
allowList: string[];
|
||||
miniAppUrl: string;
|
||||
enableGlobalMailPush: boolean;
|
||||
globalMailPushList: string[];
|
||||
|
||||
constructor(
|
||||
enableAllowList: boolean, allowList: string[], miniAppUrl: string,
|
||||
enableGlobalMailPush: boolean, globalMailPushList: string[]
|
||||
) {
|
||||
this.enableAllowList = enableAllowList;
|
||||
this.allowList = allowList;
|
||||
this.miniAppUrl = miniAppUrl;
|
||||
this.enableGlobalMailPush = enableGlobalMailPush;
|
||||
this.globalMailPushList = globalMailPushList;
|
||||
}
|
||||
}
|
||||
|
||||
const settings = ref(new TelegramSettings(false, [], '', false, []))
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/telegram/settings`)
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/telegram/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card style="max-width: 800px; overflow: auto;">
|
||||
<n-button @click="save" type="primary" block>
|
||||
<n-card>
|
||||
<n-form-item-row :label="t('enableTelegramAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="settings.enableAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
|
||||
:placeholder="t('telegramAllowList')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableGlobalMailPush')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="settings.enableGlobalMailPush" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="settings.globalMailPushList" filterable multiple tag
|
||||
style="width: 80%;" :placeholder="t('globalMailPushList')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('miniAppUrl')">
|
||||
<n-input v-model:value="settings.miniAppUrl"></n-input>
|
||||
</n-form-item-row>
|
||||
<n-button @click="saveSettings" type="primary" block>
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-button @click="init" type="primary" block>
|
||||
{{ t('init') }}
|
||||
</n-button>
|
||||
<n-button @click="fetchData" secondary block>
|
||||
<n-button @click="fetchStatus" secondary block>
|
||||
{{ t('status') }}
|
||||
</n-button>
|
||||
<pre v-if="status.fetched">{{ JSON.stringify(status, null, 2) }}</pre>
|
||||
|
||||
@@ -8,11 +8,10 @@ import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils';
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
@@ -223,7 +222,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
|
||||
84
frontend/src/views/admin/Webhook.vue
Normal file
84
frontend/src/views/admin/Webhook.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
|
||||
save: 'Save',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址)',
|
||||
save: '保存',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
allowList: string[];
|
||||
|
||||
constructor(allowList: string[]) {
|
||||
this.allowList = allowList;
|
||||
}
|
||||
}
|
||||
|
||||
const webhookSettings = ref(new WebhookSettings([]))
|
||||
|
||||
const getSettings = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/webhook/settings`)
|
||||
Object.assign(webhookSettings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card style="max-width: 800px; overflow: auto;">
|
||||
<n-form-item-row :label="t('webhookAllowList')">
|
||||
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
|
||||
:placeholder="t('webhookAllowList')" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="saveSettings" type="primary" block>
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
const { localeCache, openSettings } = useGlobalState()
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
adminContact: 'If you need help, please contact the administrator ({msg})',
|
||||
|
||||
@@ -5,13 +5,12 @@ import { useIsMobile } from '../../utils/composables'
|
||||
import { useGlobalState } from '../../store'
|
||||
|
||||
const {
|
||||
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail,
|
||||
globalTabplacement, useSideMargin
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
mailboxSplitSize: 'Mailbox Split Size',
|
||||
|
||||
@@ -9,11 +9,35 @@ import Turnstile from '../../components/Turnstile.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const props = defineProps({
|
||||
bindUserAddress: {
|
||||
type: Function,
|
||||
default: async () => { await api.bindUserAddress(); },
|
||||
requried: true
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
default: async (address_name, domain, cf_token) => {
|
||||
return await api.fetch("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: address_name,
|
||||
domain: domain,
|
||||
cf_token: cf_token,
|
||||
}),
|
||||
});
|
||||
},
|
||||
requried: true
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, localeCache, loading, openSettings,
|
||||
jwt, loading, openSettings,
|
||||
showAddressCredential, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
@@ -32,18 +56,17 @@ const login = async () => {
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
await api.bindUserAddress();
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
await router.push("/");
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
@@ -98,20 +121,17 @@ const generateName = async () => {
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/api/new_address`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
cf_token: cfToken.value,
|
||||
}),
|
||||
});
|
||||
const res = await props.newAddressPath(
|
||||
emailName.value,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
await api.getSettings();
|
||||
await router.push("/");
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
showAddressCredential.value = true;
|
||||
try {
|
||||
await api.bindUserAddress();
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,17 @@ import { useRouter } from 'vue-router'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Appearance from '../common/Appearance.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const {
|
||||
jwt, localeCache, settings, showAddressCredential, loading
|
||||
jwt, settings, showAddressCredential, loading
|
||||
} = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
const showDelteAccount = ref(false)
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: "Logout",
|
||||
@@ -39,7 +39,7 @@ const { t } = useI18n({
|
||||
|
||||
const logout = async () => {
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const deleteAccount = async () => {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Copy, User } from '@vicons/fa'
|
||||
import { Copy, User, ExchangeAlt } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
import AddressManagement from '../user/AddressManagement.vue'
|
||||
import TelegramAddress from './TelegramAddress.vue'
|
||||
import LocalAddress from './LocalAddress.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, localeCache, settings, showAddressCredential, openSettings
|
||||
jwt, settings, showAddressCredential, userJwt,
|
||||
isTelegram
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
yourAddress: 'Your email address is',
|
||||
addressManage: 'Address Manage',
|
||||
changeAddress: 'Change Address',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
@@ -31,7 +36,8 @@ const { t } = useI18n({
|
||||
userLogin: 'User Login',
|
||||
},
|
||||
zh: {
|
||||
yourAddress: '你的邮箱地址是',
|
||||
addressManage: '地址管理',
|
||||
changeAddress: '更换地址',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
@@ -43,6 +49,10 @@ const { t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const showChangeAddress = ref(false)
|
||||
const showTelegramChangeAddress = ref(false)
|
||||
const showLocalAddress = ref(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
@@ -52,6 +62,10 @@ const copy = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onUserLogin = async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings();
|
||||
});
|
||||
@@ -65,13 +79,28 @@ onMounted(async () => {
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
<span>
|
||||
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
|
||||
<b>{{ settings.address }}</b>
|
||||
<n-button v-if="isTelegram" style="margin-left: 10px" @click="showTelegramChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button v-else-if="userJwt" style="margin-left: 10px" @click="showChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('changeAddress') }}
|
||||
</n-button>
|
||||
<n-button v-else style="margin-left: 10px" @click="showLocalAddress = true" size="small" tertiary
|
||||
type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else-if="isTelegram">
|
||||
<TelegramAddress />
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
|
||||
@@ -79,7 +108,7 @@ onMounted(async () => {
|
||||
</n-alert>
|
||||
<Login />
|
||||
<n-divider />
|
||||
<n-button @click="router.push('/user')" type="primary" block secondary strong>
|
||||
<n-button @click="onUserLogin" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
@@ -87,6 +116,15 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showTelegramChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<TelegramAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<AddressManagement />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
|
||||
<LocalAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
|
||||
91
frontend/src/views/index/Attachment.vue
Normal file
91
frontend/src/views/index/Attachment.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
download: 'Download',
|
||||
action: 'Action',
|
||||
},
|
||||
zh: {
|
||||
download: '下载',
|
||||
action: '操作',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const showDownload = ref(false)
|
||||
const curRow = ref({})
|
||||
const curDownloadUrl = ref('')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/api/attachment/list`
|
||||
);
|
||||
data.value = results;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "key",
|
||||
key: "key"
|
||||
},
|
||||
{
|
||||
title: t('action'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
tertiary: true,
|
||||
onClick: async () => {
|
||||
try {
|
||||
const { url } = await api.fetch(`/api/attachment/get_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: row.key })
|
||||
});
|
||||
curDownloadUrl.value = url;
|
||||
curRow.value = row;
|
||||
showDownload.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ default: () => t('download') }
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showDownload" preset="dialog" :title="t('download')">
|
||||
<n-tag type="info">{{ curRow.key }}</n-tag>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curRow.key.replace('/', '_')"
|
||||
:href="curDownloadUrl">
|
||||
{{ t('download') }}
|
||||
</n-button>
|
||||
</n-modal>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
</div>
|
||||
</template>
|
||||
159
frontend/src/views/index/LocalAddress.vue
Normal file
159
frontend/src/views/index/LocalAddress.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed } from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'These addresses are stored in your browser, maybe loss if you clear the browser cache.',
|
||||
success: 'success',
|
||||
address: 'Address',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
unbindMailAddress: 'Unbind Mail Address credential',
|
||||
bind: 'Bind',
|
||||
bindAddressSuccess: 'Bind Address Success',
|
||||
},
|
||||
zh: {
|
||||
tip: '这些地址存储在您的浏览器中,如果您清除浏览器缓存,可能会丢失。',
|
||||
success: '成功',
|
||||
address: '地址',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
unbindMailAddress: '解绑邮箱地址',
|
||||
bind: '绑定',
|
||||
bindAddressSuccess: '绑定地址成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tabValue = ref('address')
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
||||
const data = computed(() => {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
return localAddressCache.value.map((curJwt: string) => {
|
||||
try {
|
||||
var payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return {
|
||||
valid: true,
|
||||
address: payload.address,
|
||||
jwt: curJwt
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
address: `invalid jwt [${curJwt}]`,
|
||||
jwt: curJwt
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row: any) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
jwt.value = row.jwt
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
if (jwt.value === row.jwt) {
|
||||
return;
|
||||
}
|
||||
localAddressCache.value = localAddressCache.value.filter(
|
||||
(curJwt: string) => curJwt !== row.jwt
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
disabled: jwt.value === row.jwt,
|
||||
type: "warning",
|
||||
},
|
||||
{ default: () => t('unbindMailAddress') }
|
||||
),
|
||||
default: () => `${t('unbindMailAddress')}?`
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert type="warning" :show-icon="false">
|
||||
<span>{{ t('tip') }}</span>
|
||||
</n-alert>
|
||||
<n-tabs type="segment" v-model:value="tabValue">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bind" :tab="t('bind')">
|
||||
<Login :bindUserAddress="bindAddress" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, settings } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
success: 'Success',
|
||||
to_mail: 'To Mail',
|
||||
subject: 'Subject',
|
||||
created_at: 'Created At',
|
||||
action: 'Action',
|
||||
refresh: 'Refresh',
|
||||
itemCount: 'itemCount',
|
||||
view: 'View',
|
||||
ok: 'OK'
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
to_mail: '收件人邮箱',
|
||||
subject: '主题',
|
||||
created_at: '创建时间',
|
||||
action: '操作',
|
||||
refresh: '刷新',
|
||||
itemCount: '总数',
|
||||
view: '查看',
|
||||
ok: '确定'
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const curRow = ref({})
|
||||
const showModal = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/api/sendbox`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
);
|
||||
data.value = results.map((item) => {
|
||||
try {
|
||||
const data = JSON.parse(item.raw);
|
||||
item.to_mail = data?.personalizations?.map(
|
||||
(p) => p.to?.map((t) => t.email).join(',')
|
||||
).join(';');
|
||||
item.subject = data.subject;
|
||||
item.raw = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('to_mail'),
|
||||
key: "to_mail"
|
||||
},
|
||||
{
|
||||
title: t('subject'),
|
||||
key: "subject"
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('action'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
}
|
||||
},
|
||||
{ default: () => t('view') }
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
|
||||
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
|
||||
</n-modal>
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-button @click="fetchData" type="primary" size="small" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/views/index/TelegramAddress.vue
Normal file
158
frontend/src/views/index/TelegramAddress.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
// @ts-ignore
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
const { jwt, telegramApp } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
address: 'Address',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
unbindMailAddress: 'Unbind Mail Address',
|
||||
bind: 'Bind',
|
||||
bindAddressSuccess: 'Bind Address Success',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
address: '地址',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
unbindMailAddress: '解绑邮箱地址',
|
||||
bind: '绑定',
|
||||
bindAddressSuccess: '绑定地址成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
data.value = await api.fetch(`/telegram/get_bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
|
||||
return await api.fetch("/telegram/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: `${address_name}@${domain}`,
|
||||
cf_token: cf_token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
await api.fetch(`/telegram/bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
jwt: jwt.value
|
||||
})
|
||||
});
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('address'),
|
||||
key: "address"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row: any) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
jwt.value = row.jwt
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => {
|
||||
api.fetch(`/telegram/unbind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: row.address
|
||||
})
|
||||
});
|
||||
jwt.value = ""
|
||||
location.reload()
|
||||
}
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "warning",
|
||||
},
|
||||
{ default: () => t('unbindMailAddress') }
|
||||
),
|
||||
default: () => `${t('unbindMailAddress')}?`
|
||||
}
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
if (!telegramApp.value?.initData || data.value.length > 0) {
|
||||
return
|
||||
}
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-tabs type="segment">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bind" :tab="t('bind')">
|
||||
<Login :newAddressPath="newAddressPath" :bindUserAddress="bindAddress" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
129
frontend/src/views/index/Webhook.vue
Normal file
129
frontend/src/views/index/Webhook.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// @ts-ignore
|
||||
import { useGlobalState } from '../../store'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
const { settings } = useGlobalState()
|
||||
// @ts-ignore
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
successTip: 'Success',
|
||||
test: 'Test',
|
||||
save: 'Save',
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
},
|
||||
zh: {
|
||||
successTip: '成功',
|
||||
test: '测试',
|
||||
save: '保存',
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class WebhookSettings {
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({}, null, 2)
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
const enableWebhook = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/api/webhook/settings`)
|
||||
Object.assign(webhookSettings.value, res)
|
||||
enableWebhook.value = true
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/api/webhook/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const testSettings = async () => {
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/api/webhook/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookSettings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-form-item-row label="URL">
|
||||
<n-input v-model:value="webhookSettings.url" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="METHOD">
|
||||
<n-select v-model:value="webhookSettings.method" tag :options='[
|
||||
{ label: "POST", value: "POST" }
|
||||
]' />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="HEADERS">
|
||||
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="BODY">
|
||||
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="testSettings" secondary block strong>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary" block>
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
70
frontend/src/views/telegram/Mail.vue
Normal file
70
frontend/src/views/telegram/Mail.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
|
||||
const { telegramApp } = useGlobalState()
|
||||
const route = useRoute()
|
||||
|
||||
const curMail = ref({});
|
||||
|
||||
watch(telegramApp, async () => {
|
||||
if (telegramApp.value.initData) {
|
||||
curMail.value = await fetchMailData();
|
||||
}
|
||||
});
|
||||
|
||||
const fetchMailData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/telegram/get_mail`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
mailId: route.query.mail_id
|
||||
})
|
||||
});
|
||||
return await processItem(res);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
curMail.value = await fetchMailData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card v-if="curMail.message" style="max-width: 800px; overflow: auto;">
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
Date: {{ curMail.created_at }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<div v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -6,13 +6,13 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const { localeCache, jwt } = useGlobalState()
|
||||
const { jwt } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
@@ -48,7 +48,7 @@ const changeMailAddress = async (address_id) => {
|
||||
return;
|
||||
}
|
||||
jwt.value = res.jwt;
|
||||
await router.push('/');
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useRouter } from 'vue-router'
|
||||
import { useGlobalState } from '../../store'
|
||||
import Login from '../common/Login.vue'
|
||||
|
||||
const { userJwt, localeCache, userSettings, } = useGlobalState()
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
|
||||
@@ -11,11 +11,10 @@ const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
localeCache, userSettings, userJwt, userOpenSettings
|
||||
userSettings, userJwt, userOpenSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
currentUser: 'Current Login User',
|
||||
|
||||
@@ -10,12 +10,11 @@ import { hashPassword } from '../../utils';
|
||||
|
||||
import Turnstile from '../../components/Turnstile.vue';
|
||||
|
||||
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState()
|
||||
const { userJwt, userTab, userOpenSettings } = useGlobalState()
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
|
||||
@@ -6,14 +6,13 @@ import { useRouter } from 'vue-router'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { userJwt, localeCache, userSettings, } = useGlobalState()
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
}
|
||||
@@ -13,15 +13,6 @@ import topLevelAwait from "vite-plugin-top-level-await";
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: './dist',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('wangeditor')) {
|
||||
return 'vendor-wangeditor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from io import BytesIO
|
||||
from twisted.mail import imap4
|
||||
@@ -10,7 +10,7 @@ from twisted.internet import protocol, reactor, defer
|
||||
from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword
|
||||
|
||||
from config import settings
|
||||
from parse_email import parse_email
|
||||
from parse_email import generate_email_model, parse_email
|
||||
from models import EmailModel
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -58,7 +58,8 @@ class SimpleMessage:
|
||||
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
|
||||
class SimpleMailbox:
|
||||
|
||||
def __init__(self, password):
|
||||
def __init__(self, name, password):
|
||||
self.name = name
|
||||
self.password = password
|
||||
self.listeners = []
|
||||
self.addListener = self.listeners.append
|
||||
@@ -72,7 +73,7 @@ class SimpleMailbox:
|
||||
return 0
|
||||
|
||||
def getMessageCount(self):
|
||||
return 2 ** 31 - 1
|
||||
return self.message_count or 1000
|
||||
|
||||
def getRecentCount(self):
|
||||
return 0
|
||||
@@ -98,18 +99,27 @@ class SimpleMailbox:
|
||||
if "UIDNEXT" in names:
|
||||
r["UIDNEXT"] = self.getMessageCount() + 1
|
||||
if "UIDVALIDITY" in names:
|
||||
r["UIDVALIDITY"] = self.getUID()
|
||||
r["UIDVALIDITY"] = self.getUIDValidity()
|
||||
if "UNSEEN" in names:
|
||||
r["UNSEEN"] = self.getUnseenCount()
|
||||
return defer.succeed(r)
|
||||
|
||||
def fetch(self, messages, uid):
|
||||
if self.name == "INBOX":
|
||||
return self.fetchINBOX(messages)
|
||||
if self.name == "SENT":
|
||||
return self.fetchSENT(messages)
|
||||
return []
|
||||
|
||||
def fetchINBOX(self, messages):
|
||||
start, end = messages.ranges[0]
|
||||
start = max(start, 1)
|
||||
limit = min(20, end - start + 1) if end and end >= start else 20
|
||||
if self.message_count > 0 and start > self.message_count:
|
||||
return []
|
||||
res = requests.get(
|
||||
f"{settings.proxy_url}/api/mails?limit=20&offset={start - 1}", headers={
|
||||
res = httpx.get(
|
||||
f"{settings.proxy_url}/api/mails?limit={limit}&offset={start - 1}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.password}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
@@ -127,6 +137,32 @@ class SimpleMailbox:
|
||||
for uid, item in enumerate(reversed(res.json()["results"]))
|
||||
]
|
||||
|
||||
def fetchSENT(self, messages):
|
||||
start, end = messages.ranges[0]
|
||||
start = max(start, 1)
|
||||
limit = min(20, end - start + 1) if end and end >= start else 20
|
||||
if self.message_count > 0 and start > self.message_count:
|
||||
return []
|
||||
res = httpx.get(
|
||||
f"{settings.proxy_url}/api/sendbox?limit={limit}&offset={start - 1}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.password}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res.status_code != 200:
|
||||
_logger.error(
|
||||
"Failed: "
|
||||
f"code=[{res.status_code}] text=[{res.text}]"
|
||||
)
|
||||
raise Exception("Failed to fetch emails")
|
||||
if res.json()["count"] > 0:
|
||||
self.message_count = res.json()["count"]
|
||||
return [
|
||||
(start + uid, SimpleMessage(start + uid, generate_email_model(item)))
|
||||
for uid, item in enumerate(reversed(res.json()["results"]))
|
||||
]
|
||||
|
||||
def getUID(self, message):
|
||||
return message.uid
|
||||
|
||||
@@ -141,11 +177,16 @@ class Account(imap4.MemoryAccount):
|
||||
self.password = password
|
||||
super().__init__(user)
|
||||
|
||||
def isSubscribed(self, name):
|
||||
return name.upper() in ["INBOX", "SENT"]
|
||||
|
||||
def _emptyMailbox(self, name, id):
|
||||
_logger.info(f"New mailbox: {name}, {id}")
|
||||
if name != "INBOX":
|
||||
raise imap4.NoSuchMailbox(name.encode("utf-8"))
|
||||
return SimpleMailbox(self.password)
|
||||
if name == "INBOX":
|
||||
return SimpleMailbox(name, self.password)
|
||||
if name == "SENT":
|
||||
return SimpleMailbox(name, self.password)
|
||||
raise imap4.NoSuchMailbox(name.encode("utf-8"))
|
||||
|
||||
def select(self, name, rw=1):
|
||||
return imap4.MemoryAccount.select(self, name)
|
||||
@@ -157,8 +198,13 @@ class SimpleIMAPServer(imap4.IMAP4Server):
|
||||
self.factory = factory
|
||||
|
||||
def lineReceived(self, line):
|
||||
# _logger.info(f"Received: {line}")
|
||||
super().lineReceived(line)
|
||||
|
||||
def sendLine(self, line):
|
||||
# _logger.info(f"Sent: {line}")
|
||||
super().sendLine(line)
|
||||
|
||||
|
||||
@implementer(IRealm)
|
||||
class SimpleRealm:
|
||||
@@ -166,6 +212,7 @@ class SimpleRealm:
|
||||
res = json.loads(avatarId)
|
||||
account = Account(res["username"], res["password"])
|
||||
account.addMailbox("INBOX")
|
||||
account.addMailbox("SENT")
|
||||
return imap4.IAccount, account, lambda: None
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import email
|
||||
from email.message import Message
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import email
|
||||
|
||||
from email.message import Message
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
|
||||
from models import EmailModel
|
||||
|
||||
@@ -36,3 +42,21 @@ def parse_email(raw: str) -> EmailModel:
|
||||
size=len("could not parse email"),
|
||||
subparts=[],
|
||||
)
|
||||
|
||||
|
||||
def generate_email_model(item: dict) -> EmailModel:
|
||||
email_json = json.loads(item["raw"])
|
||||
message = MIMEMultipart()
|
||||
message['From'] = f"{email_json["from"]['name']} <{
|
||||
email_json["from"]['email']}>"
|
||||
message['To'] = ", ".join(
|
||||
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
|
||||
message['Subject'] = email_json["subject"]
|
||||
message["Date"] = datetime.datetime.strptime(
|
||||
item["created_at"], "%Y-%m-%d %H:%M:%S"
|
||||
).strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
message.attach(MIMEText(
|
||||
email_json["content"][0]["value"],
|
||||
"html" if "html" in email_json["content"][0]["type"] else "plain"
|
||||
))
|
||||
return parse_email(message.as_string())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiosmtpd==1.4.5
|
||||
aiosmtpd==1.4.6
|
||||
pydantic-settings==2.2.1
|
||||
requests==2.31.0
|
||||
requests==2.32.0
|
||||
twisted==24.3.0
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import email
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from aiosmtpd.controller import Controller
|
||||
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
|
||||
@@ -102,7 +102,7 @@ class CustomSMTPHandler:
|
||||
}
|
||||
_logger.info(f"Send mail {dict(send_body, token='***')}")
|
||||
try:
|
||||
res = requests.post(
|
||||
res = httpx.post(
|
||||
f"{settings.proxy_url}/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Content-Type": "application/json"
|
||||
|
||||
@@ -96,7 +96,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过命令行部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '命令行部署准备', link: 'cli/pre-requisite' },
|
||||
{ text: 'D1 数据库', link: 'cli/d1' },
|
||||
@@ -109,7 +109,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过用户界面部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'D1 数据库', link: 'ui/d1' },
|
||||
{ text: '配置 DKIM', link: 'dkim' },
|
||||
@@ -121,25 +121,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过 Github Actions 部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '通过 Github Actions 部署', link: 'github-action' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '附加功能',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Admin 控制台', link: 'feature/admin' },
|
||||
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
|
||||
|
||||
@@ -60,7 +60,7 @@ pnpm run deploy
|
||||
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-08-14"
|
||||
node_compat = true
|
||||
|
||||
@@ -68,7 +68,13 @@ node_compat = true
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
# send mail by cf mail
|
||||
# send_email = [
|
||||
# { name = "SEND_MAIL" },
|
||||
# ]
|
||||
|
||||
[vars]
|
||||
# TITLE = "Custom Title" # The title of the site
|
||||
PREFIX = "tmp" # The mailbox name prefix to be processed
|
||||
# If you want your site to be private, uncomment below and change your password
|
||||
# PASSWORDS = ["123", "456"]
|
||||
@@ -85,6 +91,8 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Allow webhook
|
||||
# ENABLE_WEBHOOK = true
|
||||
# Footer text
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# default send balance, if not set, it will be 0
|
||||
@@ -97,6 +105,8 @@ ENABLE_AUTO_REPLY = false
|
||||
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
|
||||
# telegram bot
|
||||
# TG_MAX_ACCOUNTS = 5
|
||||
# global forward address list, if set, all emails will be forwarded to these addresses
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
BIN
vitepress-docs/docs/public/feature/s3-download.png
Normal file
BIN
vitepress-docs/docs/public/feature/s3-download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
vitepress-docs/docs/public/feature/s3-save.png
Normal file
BIN
vitepress-docs/docs/public/feature/s3-save.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -24,7 +24,7 @@ wrangler kv:namespace create DEV
|
||||
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-12-01"
|
||||
# 如果你想使用自定义域名,你需要添加 routes 配置
|
||||
# routes = [
|
||||
@@ -36,7 +36,13 @@ node_compat = true
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
# 通过 Cloudflare 发送邮件
|
||||
# send_email = [
|
||||
# { name = "SEND_MAIL" },
|
||||
# ]
|
||||
|
||||
[vars]
|
||||
# TITLE = "Custom Title" # 自定义网站标题
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
|
||||
# 如果你想要你的网站私有,取消下面的注释,并修改密码
|
||||
# PASSWORDS = ["123", "456"]
|
||||
@@ -53,6 +59,8 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# 允许自动回复邮件
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# 是否启用 webhook
|
||||
# ENABLE_WEBHOOK = true
|
||||
# 前端界面页脚文本
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# 默认发送邮件余额,如果不设置,将为 0
|
||||
@@ -65,6 +73,8 @@ ENABLE_AUTO_REPLY = false
|
||||
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
|
||||
# telegram bot 最多绑定邮箱数量
|
||||
# TG_MAX_ACCOUNTS = 5
|
||||
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
|
||||
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
|
||||
[[d1_databases]]
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
|
||||
# 配置发送邮件
|
||||
|
||||
## 使用 Cloudflare Workers 给已认证的邮箱发送邮件
|
||||
|
||||
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
|
||||
|
||||
## 使用 resend 发送邮件
|
||||
|
||||
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
|
||||
|
||||
`API KEYS` 页面创建 `api key`
|
||||
|
||||
使用 cli 或者直接添加到 `wrangler.toml` 的 `vars`,或者在 cloudflare worker 页面的变量中添加 `RESEND_TOKEN`
|
||||
|
||||
```bash
|
||||
wrangler secret put RESEND_TOKEN
|
||||
```
|
||||
|
||||
如果你有多个域名,对应不同的 `api key`,可以在 `wrangler.toml` 中添加多个 secret, 名称为 `RESEND_TOKEN_` + `<. 换成 _ 的 大写域名>`,例如
|
||||
|
||||
```bash
|
||||
wrangler secret put RESEND_TOKEN_XXX_COM
|
||||
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
|
||||
```
|
||||
|
||||
## 使用 mailchannels 发送邮件
|
||||
|
||||
::: warning
|
||||
[Mail Channels 免费电子邮件发送 API 将于2024年6月30日结束](https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers)
|
||||
:::
|
||||
|
||||
1. 找到域名 `DNS` 记录的 `TXT` 的 `SPF` 记录, 增加 `include:relay.mailchannels.net`
|
||||
|
||||
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 搭建 SMTP IMAP 代理服务
|
||||
|
||||
::: warning
|
||||
如果你使用了 `resend`, 可直接使用 `resend` 的 `SMTP` 服务,不需要使用此服务
|
||||
:::
|
||||
|
||||
## 为什么需要 SMTP IMAP 代理服务
|
||||
|
||||
`SMTP` `IMAP` 的应用场景更加广泛
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
limit = 10
|
||||
offset = 0
|
||||
res = requests.get(
|
||||
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}`;",
|
||||
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
|
||||
34
vitepress-docs/docs/zh/guide/feature/s3-attachment.md
Normal file
34
vitepress-docs/docs/zh/guide/feature/s3-attachment.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 配置 S3 附件
|
||||
|
||||
## 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 S3 附件, 可跳过此步骤
|
||||
|
||||
在 Cloudflare 创建一个 R2 bucket, 你也可以使用其他的 S3 服务(如有 bug 请提 issue)
|
||||
|
||||
参考: [配置 Cloudflare R2 的 cors](https://developers.cloudflare.com/r2/buckets/cors/#add-cors-policies-from-the-dashboard)
|
||||
|
||||
参考 [Cloudflare R2 s3 toke](https://developers.cloudflare.com/r2/api/s3/tokens/) 创建 token, 拿到 `ENDPOINT`, `Access Key ID` 和 `Secret Access Key`,然后执行下面的命令添加到 secrets 中
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以在 Cloudflare worker 的 UI 界面中添加 `secrets`
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
pnpm wrangler secret put S3_ENDPOINT
|
||||
pnpm wrangler secret put S3_ACCESS_KEY_ID
|
||||
pnpm wrangler secret put S3_SECRET_ACCESS_KEY
|
||||
# 请注意这里的 bucket 是你的 bucket 名称
|
||||
pnpm wrangler secret put S3_BUCKET
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
保存附件
|
||||
|
||||

|
||||
|
||||
下载附件
|
||||
|
||||

|
||||
@@ -1,6 +1,38 @@
|
||||
# 配置 Telegram Bot
|
||||
|
||||
## Telegram Bot 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
请先创建一个 Telegram Bot,然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
|
||||
|
||||
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
|
||||
|
||||
```bash
|
||||
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
```
|
||||
|
||||
## Bot
|
||||
|
||||
- 可设置白名单用户
|
||||
- 点击`初始化`即可完成配置。
|
||||
- 点击`查看状态`,可以查看当前配置的状态。
|
||||
|
||||

|
||||
|
||||
## Mini App
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
cp .env.example .env.prod
|
||||
# --project-name 可以单独为 mini app 创建一个 pages, 你也可以公用一个 pages,但是可能遇到 js 加载不了的问题
|
||||
pnpm run deploy:telegram --project-name=<你的项目名称>
|
||||
```
|
||||
|
||||
部署完成后,请在 admin 后台的 `设置` -> `电报小程序` 页面 `电报小程序 URL`。
|
||||
|
||||
请在 `@BotFather` 处执行 `/setmenubutton`,然后输入你的网页地址,设置左下角的 `Open App` 按钮。
|
||||
|
||||
你也可以在 `@BotFather` 处执行 `/newapp` 新建 app 来获得 mini app 的链接
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
|
||||
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
|
||||
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
|
||||
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
|
||||
|
||||
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
|
||||
19
worker/eslint.config.js
Normal file
19
worker/eslint.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
export default [
|
||||
{
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -5,18 +5,26 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"lint": "eslint src",
|
||||
"deploy": "wrangler deploy --minify",
|
||||
"start": "wrangler dev",
|
||||
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"wrangler": "^3.55.0"
|
||||
"@eslint/js": "8.56.0",
|
||||
"eslint": "8.56.0",
|
||||
"globals": "^15.3.0",
|
||||
"typescript-eslint": "^7.10.0",
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.3.6",
|
||||
"@aws-sdk/client-s3": "^3.588.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.588.0",
|
||||
"hono": "^4.3.9",
|
||||
"mimetext": "^3.0.24",
|
||||
"postal-mime": "^2.2.5",
|
||||
"resend": "^3.2.0",
|
||||
"telegraf": "4.16.3"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
2726
worker/pnpm-lock.yaml
generated
2726
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,27 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
|
||||
import { UserSettings, GeoData, UserInfo } from "../models";
|
||||
import { handleListQuery } from '../common'
|
||||
import { HonoCustomType } from '../types';
|
||||
|
||||
export default {
|
||||
getSetting: async (c) => {
|
||||
getSetting: async (c: Context<HonoCustomType>) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
return c.json(settings)
|
||||
},
|
||||
saveSetting: async (c) => {
|
||||
saveSetting: async (c: Context<HonoCustomType>) => {
|
||||
const value = await c.req.json();
|
||||
const settings = new UserSettings(value);
|
||||
if (settings.enableMailVerify && !c.env.KV) {
|
||||
return c.text("Please enable KV first if you want to enable mail verify", 403)
|
||||
}
|
||||
if (settings.enableMailVerify) {
|
||||
if (settings.enableMailVerify && !settings.verifyMailSender) {
|
||||
return c.text("Please provide verifyMailSender", 400)
|
||||
}
|
||||
if (settings.enableMailVerify && settings.verifyMailSender) {
|
||||
const mailDomain = settings.verifyMailSender.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
@@ -28,7 +34,7 @@ export default {
|
||||
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getUsers: async (c) => {
|
||||
getUsers: async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
if (query) {
|
||||
return await handleListQuery(c,
|
||||
@@ -48,15 +54,15 @@ export default {
|
||||
[], limit, offset
|
||||
);
|
||||
},
|
||||
createUser: async (c) => {
|
||||
createUser: async (c: Context<HonoCustomType>) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) {
|
||||
return c.text("Invalid email or password", 400)
|
||||
}
|
||||
// geo data
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
const userInfo = new UserInfo(geoData);
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf as any);
|
||||
const userInfo = new UserInfo(geoData, email);
|
||||
try {
|
||||
checkUserPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
@@ -69,14 +75,15 @@ export default {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
const errorMsg = (e as Error).message;
|
||||
if (errorMsg && errorMsg.includes("UNIQUE")) {
|
||||
return c.text("User already exists", 400)
|
||||
}
|
||||
return c.text(`Failed to register: ${e.message}`, 500)
|
||||
return c.text(`Failed to register: ${errorMsg}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
deleteUser: async (c) => {
|
||||
deleteUser: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.req.param();
|
||||
if (!user_id) return c.text("Invalid user_id", 400);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
@@ -90,7 +97,7 @@ export default {
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
resetPassword: async (c) => {
|
||||
resetPassword: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
if (!user_id) return c.text("Invalid user_id", 400);
|
||||
@@ -103,7 +110,7 @@ export default {
|
||||
return c.text("Failed to reset password", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
return c.text(`Failed to reset password: ${e.message}`, 500)
|
||||
return c.text(`Failed to reset password: ${(e as Error).message}`, 500)
|
||||
}
|
||||
return c.json({ success: true });
|
||||
},
|
||||
@@ -1,25 +1,28 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { cleanup } from '../common';
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { getJsonSetting, saveSetting } from '../utils';
|
||||
import { CleanupSettings } from '../models';
|
||||
import { HonoCustomType } from '../types';
|
||||
|
||||
export default {
|
||||
cleanup: async (c) => {
|
||||
cleanup: async (c: Context<HonoCustomType>) => {
|
||||
const { cleanType, cleanDays } = await c.req.json();
|
||||
try {
|
||||
await cleanup(c, cleanType, cleanDays);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.text(`Failed to cleanup ${error.message}`, 500)
|
||||
return c.text(`Failed to cleanup ${(error as Error).message}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getCleanup: async (c) => {
|
||||
getCleanup: async (c: Context<HonoCustomType>) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY);
|
||||
const cleanupSetting = new CleanupSettings(value);
|
||||
return c.json(cleanupSetting)
|
||||
},
|
||||
saveCleanup: async (c) => {
|
||||
saveCleanup: async (c: Context<HonoCustomType>) => {
|
||||
const value = await c.req.json();
|
||||
const cleanupSetting = new CleanupSettings(value);
|
||||
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { HonoCustomType } from '../types'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
import webhook_settings from './webhook_settings'
|
||||
|
||||
const api = new Hono()
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
@@ -32,7 +35,7 @@ api.get('/admin/address', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
let { name, domain, enablePrefix } = await c.req.json();
|
||||
const { name, domain, enablePrefix } = await c.req.json();
|
||||
if (!name) {
|
||||
return c.text("Please provide a name", 400)
|
||||
}
|
||||
@@ -40,7 +43,7 @@ api.post('/admin/new_address', async (c) => {
|
||||
const res = await newAddress(c, name, domain, enablePrefix);
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${e.message}`, 400)
|
||||
return c.text(`Failed create address: ${(e as Error).message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,7 +144,9 @@ api.get('/admin/address_sender', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/admin/address_sender', async (c) => {
|
||||
/* eslint-disable prefer-const */
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
/* eslint-enable prefer-const */
|
||||
if (!address_id) {
|
||||
return c.text("Invalid address_id", 400)
|
||||
}
|
||||
@@ -178,18 +183,18 @@ api.get('/admin/sendbox', async (c) => {
|
||||
})
|
||||
|
||||
api.get('/admin/statistics', async (c) => {
|
||||
const { count: mailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM raw_mails`
|
||||
).first();
|
||||
const { count: addressCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address`
|
||||
).first();
|
||||
const { count: activeUserCount7days } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first();
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM sendbox`
|
||||
).first();
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM raw_mails`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: activeUserCount7days } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first<{ count: number }>() || {};
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM sendbox`
|
||||
).first<{ count: number }>() || {};
|
||||
return c.json({
|
||||
mailCount: mailCount,
|
||||
userCount: addressCount,
|
||||
@@ -200,13 +205,13 @@ api.get('/admin/statistics', async (c) => {
|
||||
|
||||
api.get('/admin/account_settings', async (c) => {
|
||||
try {
|
||||
/** @type {Array<string>|undefined|null} */
|
||||
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
/** @type {Array<string>|undefined|null} */
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || []
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -216,10 +221,13 @@ api.get('/admin/account_settings', async (c) => {
|
||||
|
||||
api.post('/admin/account_settings', async (c) => {
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const { blockList, sendBlockList } = await c.req.json();
|
||||
if (!blockList || !sendBlockList) {
|
||||
const { blockList, sendBlockList, verifiedAddressList } = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text("Invalid blockList or sendBlockList", 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text("Please enable SEND_MAIL to use verifiedAddressList", 400)
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||
JSON.stringify(blockList)
|
||||
@@ -228,6 +236,10 @@ api.post('/admin/account_settings', async (c) => {
|
||||
c, CONSTANTS.SEND_BLOCK_LIST_KEY,
|
||||
JSON.stringify(sendBlockList)
|
||||
);
|
||||
await saveSetting(
|
||||
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(verifiedAddressList)
|
||||
)
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
@@ -242,5 +254,5 @@ api.get('/admin/users', admin_user_api.getUsers)
|
||||
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
|
||||
api.post('/admin/users', admin_user_api.createUser)
|
||||
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
|
||||
|
||||
export { api }
|
||||
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
|
||||
api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);
|
||||
20
worker/src/admin_api/webhook_settings.ts
Normal file
20
worker/src/admin_api/webhook_settings.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { AdminWebhookSettings } from "../models";
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
return c.json(settings || new AdminWebhookSettings([]));
|
||||
}
|
||||
|
||||
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<AdminWebhookSettings>();
|
||||
await c.env.KV.put(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
export default {
|
||||
getWebhookSettings,
|
||||
saveWebhookSettings,
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { Hono } from 'hono'
|
||||
|
||||
import { getDomains, getPasswords, getBooleanValue } from './utils';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { HonoCustomType } from './types';
|
||||
import { isS3Enabled } from './mails_api/s3_attachment';
|
||||
|
||||
const api = new Hono()
|
||||
const api = new Hono<HonoCustomType>
|
||||
|
||||
api.get('/open_api/settings', async (c) => {
|
||||
// check header x-custom-auth
|
||||
@@ -11,9 +13,10 @@ api.get('/open_api/settings', async (c) => {
|
||||
const passwords = getPasswords(c);
|
||||
if (passwords && passwords.length > 0) {
|
||||
const auth = c.req.raw.headers.get("x-custom-auth");
|
||||
needAuth = !passwords.includes(auth);
|
||||
needAuth = !auth || !passwords.includes(auth);
|
||||
}
|
||||
return c.json({
|
||||
"title": c.env.TITLE,
|
||||
"prefix": c.env.PREFIX,
|
||||
"domains": getDomains(c),
|
||||
"needAuth": needAuth,
|
||||
@@ -24,6 +27,8 @@ api.get('/open_api/settings', async (c) => {
|
||||
"enableIndexAbout": getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
|
||||
"copyright": c.env.COPYRIGHT,
|
||||
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
|
||||
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
|
||||
"isS3Enabled": isS3Enabled(c),
|
||||
"version": CONSTANTS.VERSION,
|
||||
});
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getDomains, getStringValue } from './utils';
|
||||
|
||||
export const newAddress = async (c, name, domain, enablePrefix) => {
|
||||
// remove special characters
|
||||
name = name.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
// check name length
|
||||
if (name.length < 0) {
|
||||
throw new Error("Name too short")
|
||||
}
|
||||
// create address
|
||||
if (enablePrefix) {
|
||||
name = getStringValue(c.env.PREFIX) + name;
|
||||
}
|
||||
if (name.length >= 30) {
|
||||
throw new Error("Name too long (max 30)")
|
||||
}
|
||||
// check domain, generate random domain
|
||||
const domains = getDomains(c);
|
||||
if (!domain || !domains.includes(domain)) {
|
||||
domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
}
|
||||
// create address
|
||||
name = name + "@" + domain;
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!success) {
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
throw new Error("Address already exists")
|
||||
}
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
let address_id = 0;
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first("id");
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanup = async (c, cleanType, cleanDays) => {
|
||||
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
|
||||
throw new Error("Invalid cleanType or cleanDays")
|
||||
}
|
||||
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
|
||||
switch (cleanType) {
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid cleanType")
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} c context
|
||||
* @param {*} query @type {string} query
|
||||
* @param {*} countQuery @type {string} countQuery
|
||||
* @param {*} limit @type {number} limit
|
||||
* @param {*} offset @type {number} offset
|
||||
* @returns {Promise} Promise
|
||||
*/
|
||||
export const handleListQuery = async (
|
||||
c, query, countQuery, params, limit, offset
|
||||
) => {
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const resultsQuery = `${query} order by id desc limit ? offset ?`;
|
||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results, count });
|
||||
}
|
||||
168
worker/src/common.ts
Normal file
168
worker/src/common.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue } from './utils';
|
||||
import { HonoCustomType } from './types';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
|
||||
export const newAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
name: string, domain: string | undefined | null,
|
||||
enablePrefix: boolean
|
||||
): Promise<{ address: string, jwt: string }> => {
|
||||
// remove special characters
|
||||
name = name.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
// check name length
|
||||
if (name.length <= 0) {
|
||||
throw new Error("Name too short")
|
||||
}
|
||||
// create address
|
||||
if (enablePrefix) {
|
||||
name = getStringValue(c.env.PREFIX) + name;
|
||||
}
|
||||
if (name.length >= 30) {
|
||||
throw new Error("Name too long (max 30)")
|
||||
}
|
||||
// check domain, generate random domain
|
||||
const domains = getDomains(c);
|
||||
if (!domain || !domains.includes(domain)) {
|
||||
domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
}
|
||||
// create address
|
||||
name = name + "@" + domain;
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!success) {
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
if (message && message.includes("UNIQUE")) {
|
||||
throw new Error("Address already exists")
|
||||
}
|
||||
throw new Error("Failed to create address")
|
||||
}
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first<number>("id");
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanup = async (
|
||||
c: Context<HonoCustomType>,
|
||||
cleanType: string | undefined | null,
|
||||
cleanDays: number | undefined | null
|
||||
): Promise<boolean> => {
|
||||
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
|
||||
throw new Error("Invalid cleanType or cleanDays")
|
||||
}
|
||||
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
|
||||
switch (cleanType) {
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid cleanType")
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: need senbox delete?
|
||||
*/
|
||||
export const deleteAddressWithData = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string | undefined | null,
|
||||
address_id: number | undefined | null
|
||||
): Promise<boolean> => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
throw new Error("Delete email is disabled")
|
||||
}
|
||||
if (!address && !address_id) {
|
||||
throw new Error("Address or address_id required")
|
||||
}
|
||||
// get address_id or address
|
||||
if (!address_id) {
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(address).first<number>("id");
|
||||
} else if (!address) {
|
||||
address = await c.env.DB.prepare(
|
||||
`SELECT name FROM address where id = ?`
|
||||
).bind(address_id).first<string>("name");
|
||||
}
|
||||
// check address again
|
||||
if (!address || !address_id) {
|
||||
throw new Error("Can't find address");
|
||||
}
|
||||
// unbind telegram
|
||||
await unbindTelegramByAddress(c, address);
|
||||
// delete address and related data
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: addressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ? `
|
||||
).bind(address_id).run();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE name = ? `
|
||||
).bind(address).run();
|
||||
if (!success || !mailSuccess || !addressSuccess || !sendAccess) {
|
||||
throw new Error("Failed to delete address")
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const handleListQuery = async (
|
||||
c: Context<HonoCustomType>,
|
||||
query: string, countQuery: string, params: string[],
|
||||
limit: string | number | undefined | null,
|
||||
offset: string | number | undefined | null
|
||||
): Promise<Response> => {
|
||||
if (typeof limit === "string") {
|
||||
limit = parseInt(limit);
|
||||
}
|
||||
if (typeof offset === "string") {
|
||||
offset = parseInt(offset);
|
||||
}
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (offset == null || offset == undefined || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const resultsQuery = `${query} order by id desc limit ? offset ?`;
|
||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results, count });
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v0.4.2',
|
||||
VERSION: 'v0.5.0',
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
||||
|
||||
// KV
|
||||
TG_KV_PREFIX: "temp-mail-telegram"
|
||||
TG_KV_PREFIX: "temp-mail-telegram",
|
||||
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
|
||||
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
|
||||
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
|
||||
}
|
||||
|
||||
@@ -1,44 +1,18 @@
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { getBooleanValue } from "./utils";
|
||||
import { sendMailToTelegram } from "./telegram_api";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import { Bindings } from "../types";
|
||||
|
||||
async function email(message, env, ctx) {
|
||||
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
|
||||
message.setReject("Missing from address");
|
||||
console.log(`Reject message from ${message.from} to ${message.to}`);
|
||||
return;
|
||||
}
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, rawEmail, message_id
|
||||
).run();
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${message.to}`);
|
||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||
}
|
||||
|
||||
// send email to telegram
|
||||
try {
|
||||
await sendMailToTelegram({
|
||||
env: env,
|
||||
}, message.to, rawEmail);
|
||||
} catch (error) {
|
||||
console.log("send mail to telegram error", error);
|
||||
}
|
||||
|
||||
// auto reply email
|
||||
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) {
|
||||
if (getBooleanValue(env.ENABLE_AUTO_REPLY) && message_id) {
|
||||
try {
|
||||
const results = await env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
||||
).bind(message.to).first();
|
||||
).bind(message.to).first<Record<string, string>>();
|
||||
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
|
||||
const msg = createMimeMessage();
|
||||
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
|
||||
msg.setHeader("In-Reply-To", message_id);
|
||||
msg.setSender({
|
||||
name: results.name || results.address,
|
||||
addr: results.address
|
||||
@@ -55,6 +29,7 @@ async function email(message, env, ctx) {
|
||||
message.from,
|
||||
msg.asRaw()
|
||||
);
|
||||
// @ts-ignore
|
||||
await message.reply(replyMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -62,5 +37,3 @@ async function email(message, env, ctx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { email }
|
||||
62
worker/src/email/index.ts
Normal file
62
worker/src/email/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { getEnvStringList } from "../utils";
|
||||
import { sendMailToTelegram } from "../telegram_api";
|
||||
import { Bindings, HonoCustomType } from "../types";
|
||||
import { auto_reply } from "./auto_reply";
|
||||
import { trigerWebhook } from "../mails_api/webhook_settings";
|
||||
|
||||
|
||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
|
||||
message.setReject("Missing from address");
|
||||
console.log(`Reject message from ${message.from} to ${message.to}`);
|
||||
return;
|
||||
}
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, rawEmail, message_id
|
||||
).run();
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${message.to}`);
|
||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||
}
|
||||
|
||||
// forward email
|
||||
try {
|
||||
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST)
|
||||
for (const forwardAddress of forwardAddressList) {
|
||||
await message.forward(forwardAddress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("forward email error", error);
|
||||
}
|
||||
|
||||
// send email to telegram
|
||||
try {
|
||||
await sendMailToTelegram(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
message.to, rawEmail, message_id);
|
||||
} catch (error) {
|
||||
console.log("send mail to telegram error", error);
|
||||
}
|
||||
|
||||
// send webhook
|
||||
try {
|
||||
await trigerWebhook(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
message.to, rawEmail
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("send webhook error", error);
|
||||
}
|
||||
|
||||
// auto reply email
|
||||
await auto_reply(message, env);
|
||||
}
|
||||
|
||||
export { email }
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Context } from "hono";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import { HonoCustomType } from "../types";
|
||||
|
||||
|
||||
export default {
|
||||
getAutoReply: async (c) => {
|
||||
getAutoReply: async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
|
||||
return c.text("Auto reply is disabled", 403)
|
||||
}
|
||||
@@ -21,7 +23,7 @@ export default {
|
||||
name: results.name,
|
||||
})
|
||||
},
|
||||
saveAutoReply: async (c) => {
|
||||
saveAutoReply: async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
|
||||
return c.text("Auto reply is disabled", 403)
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { HonoCustomType } from "../types";
|
||||
import { getBooleanValue, getJsonSetting, checkCfTurnstile } from '../utils';
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { newAddress, handleListQuery, deleteAddressWithData } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import auto_reply from './auto_reply'
|
||||
import webhook_settings from './webhook_settings';
|
||||
import s3_attachment from './s3_attachment';
|
||||
|
||||
const api = new Hono()
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
api.get('/api/auto_reply', auto_reply.getAutoReply)
|
||||
api.post('/api/auto_reply', auto_reply.saveAutoReply)
|
||||
api.get('/api/webhook/settings', webhook_settings.getWebhookSettings)
|
||||
api.post('/api/webhook/settings', webhook_settings.saveWebhookSettings)
|
||||
api.post('/api/webhook/test', webhook_settings.testWebhookSettings)
|
||||
api.get('/api/attachment/list', s3_attachment.list)
|
||||
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
|
||||
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
|
||||
|
||||
api.get('/api/mails', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
@@ -85,6 +94,7 @@ api.post('/api/new_address', async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||
return c.text("New address is disabled", 403)
|
||||
}
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { name, domain, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
@@ -99,7 +109,7 @@ api.post('/api/new_address', async (c) => {
|
||||
// check name block list
|
||||
try {
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const blockList = value || [];
|
||||
const blockList = (value || []) as string[];
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
return c.text(`Name[${name}]is blocked`, 400)
|
||||
}
|
||||
@@ -110,37 +120,14 @@ api.post('/api/new_address', async (c) => {
|
||||
const res = await newAddress(c, name, domain, true);
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`Failed create address: ${e.message}`, 400)
|
||||
return c.text(`Failed create address: ${(e as Error).message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
api.delete('/api/delete_address', async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text("User delete email is disabled", 403)
|
||||
}
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
let name = address;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE name = ? `
|
||||
).bind(name).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to delete address", 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? `
|
||||
).bind(address).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text("Failed to delete mails", 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: addressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ? `
|
||||
).bind(address_id).run();
|
||||
const success = await deleteAddressWithData(c, address, address_id);
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && addressSuccess
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
export { api }
|
||||
84
worker/src/mails_api/s3_attachment.ts
Normal file
84
worker/src/mails_api/s3_attachment.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { HonoCustomType } from "../types";
|
||||
import { Context } from "hono";
|
||||
import {
|
||||
S3Client,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export const isS3Enabled = (c: Context<HonoCustomType>) => {
|
||||
return !(!c.env.S3_ENDPOINT ||
|
||||
!c.env.S3_ACCESS_KEY_ID ||
|
||||
!c.env.S3_SECRET_ACCESS_KEY ||
|
||||
!c.env.S3_BUCKET);
|
||||
}
|
||||
|
||||
const getS3Client = (c: Context<HonoCustomType>) => {
|
||||
if (
|
||||
!c.env.S3_ENDPOINT ||
|
||||
!c.env.S3_ACCESS_KEY_ID ||
|
||||
!c.env.S3_SECRET_ACCESS_KEY ||
|
||||
!c.env.S3_BUCKET
|
||||
) {
|
||||
throw new Error("S3 config is not set");
|
||||
}
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: c.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: c.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: c.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getSignedGetUrl: async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { key } = await c.req.json()
|
||||
const client = getS3Client(c);
|
||||
const url = await getSignedUrl(
|
||||
client,
|
||||
new GetObjectCommand({
|
||||
Bucket: c.env.S3_BUCKET,
|
||||
Key: `${address}/${key}`
|
||||
}),
|
||||
{ expiresIn: c.env.S3_URL_EXPIRES || 360 }
|
||||
);
|
||||
return c.json({ url });
|
||||
},
|
||||
getSignedPutUrl: async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { key } = await c.req.json()
|
||||
const client = getS3Client(c);
|
||||
const url = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: c.env.S3_BUCKET,
|
||||
Key: `${address}/${key}`
|
||||
}),
|
||||
{ expiresIn: c.env.S3_URL_EXPIRES || 360 }
|
||||
);
|
||||
return c.json({ url });
|
||||
},
|
||||
list: async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const client = getS3Client(c);
|
||||
const data = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: c.env.S3_BUCKET,
|
||||
Prefix: `${address}/`
|
||||
})
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
results: data?.Contents
|
||||
?.map((v) => v.Key?.replace(`${address}/`, ""))
|
||||
?.filter(k => k)
|
||||
?.map((k) => ({ key: k }))
|
||||
}
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Context, Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { createMimeMessage } from 'mimetext';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getJsonSetting, getDomains } from '../utils';
|
||||
import { getJsonSetting, getDomains, getIntValue } from '../utils';
|
||||
import { GeoData } from '../models'
|
||||
import { handleListQuery } from '../common'
|
||||
import { HonoCustomType } from '../types';
|
||||
|
||||
|
||||
const api = new Hono()
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
api.post('/api/requset_send_mail_access', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
@@ -14,7 +18,7 @@ api.post('/api/requset_send_mail_access', async (c) => {
|
||||
return c.text("No address", 400)
|
||||
}
|
||||
try {
|
||||
const default_balance = c.env.DEFAULT_SEND_BALANCE || 0;
|
||||
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO address_sender (address, balance, enabled) VALUES (?, ?, ?)`
|
||||
).bind(
|
||||
@@ -24,7 +28,8 @@ api.post('/api/requset_send_mail_access', async (c) => {
|
||||
return c.text("Failed to request send mail access", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
const message = (e as Error).message;
|
||||
if (message && message.includes("UNIQUE")) {
|
||||
return c.text("Already requested", 400)
|
||||
}
|
||||
return c.text("Failed to request send mail access", 500)
|
||||
@@ -32,44 +37,73 @@ api.post('/api/requset_send_mail_access', async (c) => {
|
||||
return c.json({ status: "ok" })
|
||||
})
|
||||
|
||||
export const sendMail = async (c, address, reqJson) => {
|
||||
if (!address) {
|
||||
throw new Error("No address")
|
||||
export const sendMailToVerifyAddress = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
// check domain
|
||||
): Promise<void> => {
|
||||
const {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
const msg = createMimeMessage();
|
||||
msg.setSender(from_name ? { name: from_name, addr: address } : address);
|
||||
msg.setRecipient(to_name ? { name: to_name, addr: to_mail } : to_mail);
|
||||
msg.setSubject(subject);
|
||||
msg.addMessage({
|
||||
contentType: is_html ? 'text/html' : 'text/plain',
|
||||
data: content
|
||||
});
|
||||
const { EmailMessage } = await import('cloudflare:email');
|
||||
const message = new EmailMessage(address, to_mail, msg.asRaw());
|
||||
await c.env.SEND_MAIL.send(message);
|
||||
}
|
||||
|
||||
const sendMailByResend = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
const mailDomain = address.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
throw new Error("Invalid domain")
|
||||
const token = c.env[
|
||||
`RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}`
|
||||
] || c.env.RESEND_TOKEN;
|
||||
const resend = new Resend(token);
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: reqJson.from_name ? `${reqJson.from_name} <${address}>` : address,
|
||||
to: reqJson.to_name ? `${reqJson.to_name} <${reqJson.to_mail}>` : reqJson.to_mail,
|
||||
subject: reqJson.subject,
|
||||
...(reqJson.is_html ? {
|
||||
html: reqJson.content,
|
||||
} : {
|
||||
text: reqJson.content,
|
||||
})
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(`Resend error: ${error.name} ${error.message}`);
|
||||
}
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first("balance");
|
||||
if (!balance || balance <= 0) {
|
||||
throw new Error("No balance")
|
||||
console.log(`Resend success: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const sendMailByMailChannels = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
/* eslint-disable prefer-const */
|
||||
let {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
if (!to_mail) {
|
||||
throw new Error("Invalid to mail")
|
||||
}
|
||||
// check SEND_BLOCK_LIST_KEY
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
|
||||
throw new Error("to_mail address is blocked")
|
||||
}
|
||||
/* eslint-enable prefer-const */
|
||||
from_name = from_name || address;
|
||||
to_name = to_name || to_mail;
|
||||
if (!subject) {
|
||||
throw new Error("Invalid subject")
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error("Invalid content")
|
||||
}
|
||||
let dmikBody = {}
|
||||
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
|
||||
dmikBody = {
|
||||
@@ -98,7 +132,7 @@ export const sendMail = async (c, address, reqJson) => {
|
||||
"value": content,
|
||||
}],
|
||||
};
|
||||
let send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
|
||||
const send_request = new Request("https://api.mailchannels.net/tx/v1/send", {
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
@@ -111,25 +145,95 @@ export const sendMail = async (c, address, reqJson) => {
|
||||
if (resp.status >= 300) {
|
||||
throw new Error(`Mailchannels error: ${resp.status} ${respText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendMail = async (
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
reqJson: {
|
||||
from_name: string, to_mail: string, to_name: string,
|
||||
subject: string, content: string, is_html: boolean
|
||||
}
|
||||
): Promise<void> => {
|
||||
if (!address) {
|
||||
throw new Error("No address")
|
||||
}
|
||||
// check domain
|
||||
const mailDomain = address.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
throw new Error("Invalid domain")
|
||||
}
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first<number>("balance");
|
||||
if (!balance || balance <= 0) {
|
||||
throw new Error("No balance")
|
||||
}
|
||||
const {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = reqJson;
|
||||
if (!to_mail) {
|
||||
throw new Error("Invalid to mail")
|
||||
}
|
||||
// check SEND_BLOCK_LIST_KEY
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY) as string[];
|
||||
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
|
||||
throw new Error("to_mail address is blocked")
|
||||
}
|
||||
if (!subject) {
|
||||
throw new Error("Invalid subject")
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error("Invalid content")
|
||||
}
|
||||
// send to verified address list, do not update balance
|
||||
const resendEnabled = c.env.RESEND_TOKEN || c.env[
|
||||
`RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}`
|
||||
];
|
||||
let sendByVerifiedAddressList = false;
|
||||
if (c.env.SEND_MAIL) {
|
||||
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY) || [];
|
||||
if (verifiedAddressList.includes(to_mail)) {
|
||||
await sendMailToVerifyAddress(c, address, reqJson);
|
||||
sendByVerifiedAddressList = true;
|
||||
}
|
||||
}
|
||||
if (sendByVerifiedAddressList) {
|
||||
// do not update balance
|
||||
}
|
||||
// send by resend
|
||||
else if (resendEnabled) {
|
||||
await sendMailByResend(c, address, reqJson);
|
||||
}
|
||||
// send by mailchannels
|
||||
else {
|
||||
await sendMailByMailChannels(c, address, reqJson);
|
||||
}
|
||||
// update balance
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET balance = balance - 1 where address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
if (!sendByVerifiedAddressList) {
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET balance = balance - 1 where address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
console.warn(`Failed to update balance for ${address}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to update balance for ${address}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to update balance for ${address}`);
|
||||
}
|
||||
// save to sendbox
|
||||
try {
|
||||
if (body?.personalizations?.[0]?.dkim_private_key) {
|
||||
delete body.personalizations[0].dkim_private_key;
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
body.geoData = geoData;
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf as any);
|
||||
const body = {
|
||||
version: "v2",
|
||||
...reqJson,
|
||||
geoData: geoData,
|
||||
};
|
||||
const { success: success2 } = await c.env.DB.prepare(
|
||||
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
|
||||
).bind(address, JSON.stringify(body)).run();
|
||||
@@ -148,7 +252,7 @@ api.post('/api/send_mail', async (c) => {
|
||||
await sendMail(c, address, reqJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to send mail", e);
|
||||
return c.text(`Failed to send mail ${e.message}`, 400)
|
||||
return c.text(`Failed to send mail ${(e as Error).message}`, 400)
|
||||
}
|
||||
return c.json({ status: "ok" })
|
||||
})
|
||||
@@ -161,15 +265,18 @@ api.post('/external/api/send_mail', async (c) => {
|
||||
return c.text("No address", 400)
|
||||
}
|
||||
const reqJson = await c.req.json();
|
||||
await sendMail(c, address, reqJson);
|
||||
await sendMail(c, address as string, reqJson);
|
||||
return c.json({ status: "ok" })
|
||||
} catch (e) {
|
||||
console.error("Failed to send mail", e);
|
||||
return c.text(`Failed to send mail ${e.message}`, 400)
|
||||
return c.text(`Failed to send mail ${(e as Error).message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
const getSendbox = async (c, address, limit, offset) => {
|
||||
export const getSendbox = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string, limit: string, offset: string
|
||||
): Promise<Response> => {
|
||||
if (!address) {
|
||||
return c.json({ "error": "No address" }, 400)
|
||||
}
|
||||
@@ -185,5 +292,3 @@ api.get('/api/sendbox', async (c) => {
|
||||
const { limit, offset } = c.req.query();
|
||||
return getSendbox(c, address, limit, offset);
|
||||
})
|
||||
|
||||
export { api, getSendbox }
|
||||
141
worker/src/mails_api/webhook_settings.ts
Normal file
141
worker/src/mails_api/webhook_settings.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { AdminWebhookSettings, WebhookMail } from "../models";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
|
||||
class WebhookSettings {
|
||||
url: string = ''
|
||||
method: string = 'POST'
|
||||
headers: string = JSON.stringify({
|
||||
"Content-Type": "application/json"
|
||||
}, null, 2)
|
||||
body: string = JSON.stringify({
|
||||
"from": "${from}",
|
||||
"to": "${to}",
|
||||
"headers": "${headers}",
|
||||
"subject": "${subject}",
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is not available", 400);
|
||||
}
|
||||
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return c.text("Webhook is disabled", 403);
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (!adminSettings?.allowList.includes(address)) {
|
||||
return c.text("Webhook settings is not allowed for this user", 403);
|
||||
}
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
|
||||
) || new WebhookSettings();
|
||||
return c.json(settings);
|
||||
}
|
||||
|
||||
|
||||
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (!adminSettings?.allowList.includes(address)) {
|
||||
return c.text("Webhook settings is not allowed for this user", 403);
|
||||
}
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
await c.env.KV.put(
|
||||
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`,
|
||||
JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
async function sendWebhook(settings: WebhookSettings, formatMap: WebhookMail): Promise<{ success: boolean, message?: string }> {
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
/* eslint-disable no-useless-escape */
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
).replace(/^"(.*)"$/, '\$1')
|
||||
);
|
||||
/* eslint-enable no-useless-escape */
|
||||
}
|
||||
const response = await fetch(settings.url, {
|
||||
method: settings.method,
|
||||
headers: JSON.parse(settings.headers),
|
||||
body: body
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log("send webhook error", response.status, response.statusText);
|
||||
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function trigerWebhook(
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
raw_mail: string
|
||||
): Promise<void> {
|
||||
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return
|
||||
}
|
||||
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
|
||||
if (!adminSettings?.allowList.includes(address)) {
|
||||
return;
|
||||
}
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
|
||||
);
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
const res = await sendWebhook(settings, {
|
||||
from: parsedEmail.from.address || "",
|
||||
to: address,
|
||||
headers: JSON.stringify(parsedEmail.headers),
|
||||
subject: parsedEmail.subject || "",
|
||||
raw: raw_mail,
|
||||
parsedText: parsedEmail.text || parsedEmail.html || ""
|
||||
});
|
||||
if (!res.success) {
|
||||
console.log(res.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
const { address } = c.get("jwtPayload");
|
||||
// random raw email
|
||||
const raw = await c.env.DB.prepare(
|
||||
`SELECT raw FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
|
||||
).bind(address).first<string>("raw");
|
||||
const parsedEmail = raw ? await PostalMime.parse(raw) : {} as any;
|
||||
const res = await sendWebhook(settings, {
|
||||
from: parsedEmail?.from?.address || "test@test.com",
|
||||
to: address,
|
||||
headers: JSON.stringify(parsedEmail?.headers || { "X-Test": "test" }),
|
||||
subject: parsedEmail?.subject || "test subject",
|
||||
raw: raw || "test raw email",
|
||||
parsedText: parsedEmail?.text || parsedEmail?.html || "test parsed text"
|
||||
});
|
||||
if (!res.success) {
|
||||
return c.text(res.message || "send webhook error", 400);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
}
|
||||
|
||||
export default {
|
||||
getWebhookSettings,
|
||||
saveWebhookSettings,
|
||||
testWebhookSettings,
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
export class UserSettings {
|
||||
/** @param {UserSettings|undefined|null} data */
|
||||
constructor(data) {
|
||||
if (data === null) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
enable, enableMailVerify, verifyMailSender,
|
||||
enableMailAllowList, mailAllowList, maxAddressCount
|
||||
} = data || {};
|
||||
/** @type {boolean|undefined} */
|
||||
this.enable = enable;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailVerify = enableMailVerify;
|
||||
/** @type {string|undefined} */
|
||||
this.verifyMailSender = verifyMailSender;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailAllowList = enableMailAllowList;
|
||||
/** @type {Array<string>|undefined} */
|
||||
this.mailAllowList = mailAllowList;
|
||||
/** @type {number|undefined} */
|
||||
this.maxAddressCount = maxAddressCount || 5;
|
||||
}
|
||||
}
|
||||
|
||||
export class CleanupSettings {
|
||||
/** @param {CleanupSettings|undefined|null} data */
|
||||
constructor(data) {
|
||||
const {
|
||||
enableMailsAutoCleanup, cleanMailsDays,
|
||||
enableUnknowMailsAutoCleanup, cleanUnknowMailsDays,
|
||||
enableSendBoxAutoCleanup, cleanSendBoxDays
|
||||
} = data || {};
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailsAutoCleanup = enableMailsAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanMailsDays = cleanMailsDays;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableUnknowMailsAutoCleanup = enableUnknowMailsAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanUnknowMailsDays = cleanUnknowMailsDays;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableSendBoxAutoCleanup = enableSendBoxAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanSendBoxDays = cleanSendBoxDays;
|
||||
}
|
||||
}
|
||||
|
||||
export class GeoData {
|
||||
/** @param {string} ip @param {GeoData|undefined|null} data */
|
||||
constructor(ip, data) {
|
||||
const {
|
||||
country, city, timezone, postalCode, region,
|
||||
latitude, longitude, regionCode, asOrganization
|
||||
} = data || {};
|
||||
/** @type {string} */
|
||||
this.ip = ip;
|
||||
/** @type {string|undefined} */
|
||||
this.country = country;
|
||||
/** @type {string|undefined} */
|
||||
this.city = city;
|
||||
/** @type {string|undefined} */
|
||||
this.timezone = timezone;
|
||||
/** @type {string|undefined} */
|
||||
this.postalCode = postalCode;
|
||||
/** @type {string|undefined} */
|
||||
this.region = region;
|
||||
/** @type {number|undefined} */
|
||||
this.latitude = latitude;
|
||||
/** @type {number|undefined} */
|
||||
this.longitude = longitude;
|
||||
/** @type {string|undefined} */
|
||||
this.regionCode = regionCode;
|
||||
/** @type {string|undefined} */
|
||||
this.asOrganization = asOrganization;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfo {
|
||||
/** @param {GeoData} geoData @param {string} userEmail */
|
||||
constructor(geoData, userEmail) {
|
||||
/** @type {geoData} */
|
||||
this.geoData = geoData;
|
||||
/** @type {string} */
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
}
|
||||
106
worker/src/models/index.ts
Normal file
106
worker/src/models/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export class AdminWebhookSettings {
|
||||
allowList: string[];
|
||||
|
||||
constructor(allowList: string[]) {
|
||||
this.allowList = allowList;
|
||||
}
|
||||
}
|
||||
|
||||
export type WebhookMail = {
|
||||
from: string;
|
||||
to: string;
|
||||
headers: string;
|
||||
subject: string;
|
||||
raw: string;
|
||||
parsedText: string;
|
||||
}
|
||||
|
||||
export class CleanupSettings {
|
||||
|
||||
enableMailsAutoCleanup: boolean | undefined;
|
||||
cleanMailsDays: number;
|
||||
enableUnknowMailsAutoCleanup: boolean | undefined;
|
||||
cleanUnknowMailsDays: number;
|
||||
enableSendBoxAutoCleanup: boolean | undefined;
|
||||
cleanSendBoxDays: number;
|
||||
|
||||
constructor(data: CleanupSettings | undefined | null) {
|
||||
const {
|
||||
enableMailsAutoCleanup, cleanMailsDays,
|
||||
enableUnknowMailsAutoCleanup, cleanUnknowMailsDays,
|
||||
enableSendBoxAutoCleanup, cleanSendBoxDays
|
||||
} = data || {};
|
||||
this.enableMailsAutoCleanup = enableMailsAutoCleanup;
|
||||
this.cleanMailsDays = cleanMailsDays || 0;
|
||||
this.enableUnknowMailsAutoCleanup = enableUnknowMailsAutoCleanup;
|
||||
this.cleanUnknowMailsDays = cleanUnknowMailsDays || 0;
|
||||
this.enableSendBoxAutoCleanup = enableSendBoxAutoCleanup;
|
||||
this.cleanSendBoxDays = cleanSendBoxDays || 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class GeoData {
|
||||
|
||||
ip: string;
|
||||
country: string | undefined;
|
||||
city: string | undefined;
|
||||
timezone: string | undefined;
|
||||
postalCode: string | undefined;
|
||||
region: string | undefined;
|
||||
latitude: number | undefined;
|
||||
longitude: number | undefined;
|
||||
regionCode: string | undefined;
|
||||
asOrganization: string | undefined;
|
||||
|
||||
constructor(ip: string | null, data: GeoData | undefined | null) {
|
||||
const {
|
||||
country, city, timezone, postalCode, region,
|
||||
latitude, longitude, regionCode, asOrganization
|
||||
} = data || {};
|
||||
this.ip = ip || "unknown";
|
||||
this.country = country;
|
||||
this.city = city;
|
||||
this.timezone = timezone;
|
||||
this.postalCode = postalCode;
|
||||
this.region = region;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
this.regionCode = regionCode;
|
||||
this.asOrganization = asOrganization;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserSettings {
|
||||
|
||||
enable: boolean | undefined;
|
||||
enableMailVerify: boolean | undefined;
|
||||
verifyMailSender: string | undefined;
|
||||
enableMailAllowList: boolean | undefined;
|
||||
mailAllowList: string[] | undefined;
|
||||
maxAddressCount: number;
|
||||
|
||||
constructor(data: UserSettings | undefined | null) {
|
||||
const {
|
||||
enable, enableMailVerify, verifyMailSender,
|
||||
enableMailAllowList, mailAllowList, maxAddressCount
|
||||
} = data || {};
|
||||
this.enable = enable;
|
||||
this.enableMailVerify = enableMailVerify;
|
||||
this.verifyMailSender = verifyMailSender;
|
||||
this.enableMailAllowList = enableMailAllowList;
|
||||
this.mailAllowList = mailAllowList;
|
||||
this.maxAddressCount = maxAddressCount || 5;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfo {
|
||||
|
||||
geoData: GeoData;
|
||||
userEmail: string;
|
||||
|
||||
constructor(geoData: GeoData, userEmail: string) {
|
||||
this.geoData = geoData;
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,35 @@
|
||||
import { Context } from 'hono';
|
||||
import { cleanup } from './common'
|
||||
import { CONSTANTS } from './constants'
|
||||
import { getJsonSetting } from './utils';
|
||||
import { CleanupSettings } from './models';
|
||||
import { Bindings, HonoCustomType } from './types';
|
||||
|
||||
export async function scheduled(event, env, ctx) {
|
||||
export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any) {
|
||||
console.log("Scheduled event: ", event);
|
||||
const value = await getJsonSetting(
|
||||
{ env: env, },
|
||||
{ env: env, } as Context<HonoCustomType>,
|
||||
CONSTANTS.AUTO_CLEANUP_KEY
|
||||
);
|
||||
const autoCleanupSetting = new CleanupSettings(value);
|
||||
console.log("autoCleanupSetting:", JSON.stringify(autoCleanupSetting));
|
||||
if (autoCleanupSetting.enableMailsAutoCleanup && autoCleanupSetting.cleanMailsDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
{ env: env, } as Context<HonoCustomType>,
|
||||
"mails",
|
||||
autoCleanupSetting.cleanMailsDays
|
||||
);
|
||||
}
|
||||
if (autoCleanupSetting.enableUnknowMailsAutoCleanup && autoCleanupSetting.cleanUnknowMailsDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
{ env: env, } as Context<HonoCustomType>,
|
||||
"mails_unknow",
|
||||
autoCleanupSetting.cleanUnknowMailsDays
|
||||
);
|
||||
}
|
||||
if (autoCleanupSetting.enableSendBoxAutoCleanup && autoCleanupSetting.cleanSendBoxDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
{ env: env, } as Context<HonoCustomType>,
|
||||
"sendbox",
|
||||
autoCleanupSetting.cleanSendBoxDays
|
||||
);
|
||||
124
worker/src/telegram_api/common.ts
Normal file
124
worker/src/telegram_api/common.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Context } from "hono";
|
||||
import { Jwt } from "hono/utils/jwt";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { getIntValue, getJsonSetting } from "../utils";
|
||||
import { deleteAddressWithData, newAddress } from "../common";
|
||||
|
||||
export const tgUserNewAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string
|
||||
): Promise<{ address: string, jwt: string }> => {
|
||||
if (c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
|
||||
)
|
||||
if (!success) {
|
||||
throw Error("Rate limit exceeded")
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
address = address || Math.random().toString(36).substring(2, 15);
|
||||
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
throw Error("绑定地址数量已达上限");
|
||||
}
|
||||
// check name block list
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const blockList = (value || []) as string[];
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
throw Error(`Name[${name}]is blocked`);
|
||||
}
|
||||
const res = await newAddress(c,
|
||||
name || Math.random().toString(36).substring(2, 15),
|
||||
domain, true
|
||||
);
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
|
||||
return res;
|
||||
}
|
||||
|
||||
export const jwtListToAddressData = async (
|
||||
c: Context<HonoCustomType>, jwtList: string[]
|
||||
): Promise<{ addressList: string[], addressIdMap: Record<string, number> }> => {
|
||||
const addressList = [] as string[];
|
||||
const addressIdMap = {} as Record<string, number>;
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
addressList.push(address as string);
|
||||
addressIdMap[address as string] = address_id as number;
|
||||
} catch (e) {
|
||||
addressList.push("无效凭证");
|
||||
console.log(`获取地址列表失败: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { addressList, addressIdMap };
|
||||
}
|
||||
|
||||
export const bindTelegramAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, jwt: string
|
||||
): Promise<string> => {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
if (!address) {
|
||||
throw Error("无效凭证");
|
||||
}
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList);
|
||||
if (address as string in addressIdMap) {
|
||||
return address as string;
|
||||
}
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
throw Error("绑定地址数量已达上限");
|
||||
}
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
|
||||
return address as string;
|
||||
}
|
||||
|
||||
export const unbindTelegramAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string
|
||||
): Promise<boolean> => {
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const newJwtList = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address: kvAddress } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
if (kvAddress == address) {
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`解绑失败: ${(e as Error).message}`);
|
||||
}
|
||||
newJwtList.push(jwt);
|
||||
}
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList));
|
||||
await c.env.KV.delete(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const unbindTelegramByAddress = async (
|
||||
c: Context<HonoCustomType>, address: string
|
||||
): Promise<boolean> => {
|
||||
if (!c.env.KV) return true;
|
||||
const userId = await c.env.KV.get<string>(`${CONSTANTS.TG_KV_PREFIX}:${address}`)
|
||||
if (userId) {
|
||||
return await unbindTelegramAddress(c, userId, address);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export const deleteTelegramAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string
|
||||
): Promise<boolean> => {
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList);
|
||||
if (!(address in addressIdMap)) {
|
||||
throw Error("此地址不属于您");
|
||||
}
|
||||
await deleteAddressWithData(c, null, addressIdMap[address])
|
||||
return true;
|
||||
}
|
||||
@@ -1,12 +1,36 @@
|
||||
import { Hono, Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { ServerResponse } from 'node:http'
|
||||
import { Writable } from 'node:stream'
|
||||
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
|
||||
|
||||
export const api = new Hono()
|
||||
import { HonoCustomType } from '../types'
|
||||
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
|
||||
import settings from './settings'
|
||||
import miniapp from './miniapp'
|
||||
|
||||
export const api = new Hono<HonoCustomType>();
|
||||
export { sendMailToTelegram }
|
||||
|
||||
api.post("/telegram/webhook", async (c: Context) => {
|
||||
api.use("/telegram/*", async (c, next) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
|
||||
}
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is required", 400);
|
||||
}
|
||||
return await next();
|
||||
});
|
||||
|
||||
api.use("/admin/telegram/*", async (c, next) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
|
||||
}
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is required", 400);
|
||||
}
|
||||
return await next();
|
||||
});
|
||||
|
||||
api.post("/telegram/webhook", async (c) => {
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const bot = newTelegramBot(c, token);
|
||||
let body = null;
|
||||
@@ -21,10 +45,7 @@ api.post("/telegram/webhook", async (c: Context) => {
|
||||
return c.body(body);
|
||||
});
|
||||
|
||||
api.post("/admin/telegram/init", async (c: Context) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
|
||||
}
|
||||
api.post("/admin/telegram/init", async (c) => {
|
||||
const domain = new URL(c.req.url).host;
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const webhookUrl = `https://${domain}/telegram/webhook`;
|
||||
@@ -37,13 +58,18 @@ api.post("/admin/telegram/init", async (c: Context) => {
|
||||
});
|
||||
});
|
||||
|
||||
api.get("/admin/telegram/status", async (c: Context) => {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return c.text("TELEGRAM_BOT_TOKEN and KV are required", 400);
|
||||
}
|
||||
api.get("/admin/telegram/status", async (c) => {
|
||||
const token = c.env.TELEGRAM_BOT_TOKEN;
|
||||
const bot = newTelegramBot(c, token);
|
||||
const info = await bot.telegram.getWebhookInfo()
|
||||
const commands = await bot.telegram.getMyCommands()
|
||||
return c.json({ info, commands });
|
||||
});
|
||||
|
||||
api.get("/admin/telegram/settings", settings.getTelegramSettings);
|
||||
api.post("/admin/telegram/settings", settings.saveTelegramSettings);
|
||||
api.post("/telegram/get_bind_address", miniapp.getTelegramBindAddress);
|
||||
api.post("/telegram/new_address", miniapp.newTelegramAddress);
|
||||
api.post("/telegram/bind_address", miniapp.bindAddress);
|
||||
api.post("/telegram/unbind_address", miniapp.unbindAddress);
|
||||
api.post("/telegram/get_mail", miniapp.getMail);
|
||||
|
||||
164
worker/src/telegram_api/miniapp.ts
Normal file
164
worker/src/telegram_api/miniapp.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Context } from "hono";
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
||||
import { checkCfTurnstile } from "../utils";
|
||||
import { TelegramSettings } from "./settings";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const TG_AUTH_TIMEOUT = 300;
|
||||
|
||||
const checkTelegramAuth = async (
|
||||
c: Context<HonoCustomType>, initData: string
|
||||
): Promise<string> => {
|
||||
// check if the request is from telegram
|
||||
const initDataObj = new URLSearchParams(initData);
|
||||
initDataObj.sort()
|
||||
const hash = initDataObj.get('hash');
|
||||
initDataObj.delete("hash");
|
||||
const dataToCheck = [...initDataObj.entries()].map(([key, value]) => key + "=" + value).join("\n");
|
||||
const auth_date = Number(initDataObj.get('auth_date'));
|
||||
if (auth_date + TG_AUTH_TIMEOUT < (new Date().getTime() / 1000)) {
|
||||
throw Error("Auth date expired");
|
||||
}
|
||||
const user = initDataObj.get('user');
|
||||
if (!hash || !user) {
|
||||
throw Error("Invalid initData");
|
||||
}
|
||||
const { id: userId } = JSON.parse(user);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode("WebAppData"),
|
||||
{ name: "HMAC", hash: { name: "SHA-256" } },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const secretKeyBuffer = await crypto.subtle.sign(
|
||||
"HMAC", cryptoKey, encoder.encode(c.env.TELEGRAM_BOT_TOKEN)
|
||||
);
|
||||
const secretKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
secretKeyBuffer,
|
||||
{ name: "HMAC", hash: { name: "SHA-256" } },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
const calcHmac = await crypto.subtle.sign(
|
||||
"HMAC", secretKey, encoder.encode(dataToCheck)
|
||||
);
|
||||
const calcHash = Array.from(new Uint8Array(calcHmac))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (calcHash != hash) {
|
||||
throw Error("Invalid initData");
|
||||
}
|
||||
if (typeof userId === "number") {
|
||||
return userId.toString();
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData } = await c.req.json();
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
// get the address list from the KV
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const res = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
res.push({ address, jwt });
|
||||
} catch (e) {
|
||||
console.error(`failed to verify jwt with error: ${e}`)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return c.json(res);
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData, address, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text("Failed to check cf turnstile", 500)
|
||||
}
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
// get the address list from the KV
|
||||
const res = await tgUserNewAddress(c, userId, address)
|
||||
return c.json(res);
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function bindAddress(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData, jwt } = await c.req.json();
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
await bindTelegramAddress(c, userId, jwt);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function unbindAddress(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData, address } = await c.req.json();
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
await unbindTelegramAddress(c, userId, address);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function getMail(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData, mailId } = await c.req.json();
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList);
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where id = ?`
|
||||
).bind(mailId).first();
|
||||
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
|
||||
const superUser = settings?.enableGlobalMailPush && settings?.globalMailPushList.includes(userId);
|
||||
if (!superUser) {
|
||||
if (result?.address && !(result.address as string in addressIdMap)) {
|
||||
return c.text("无权查看此邮件", 403);
|
||||
}
|
||||
const address_id = addressIdMap[result?.address as string];
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ? `
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text("无权查看此邮件", 403);
|
||||
}
|
||||
}
|
||||
return c.json(result);
|
||||
}
|
||||
catch (e) {
|
||||
return c.text((e as Error).message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getTelegramBindAddress,
|
||||
newTelegramAddress,
|
||||
bindAddress,
|
||||
unbindAddress,
|
||||
getMail,
|
||||
}
|
||||
39
worker/src/telegram_api/settings.ts
Normal file
39
worker/src/telegram_api/settings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Context } from "hono";
|
||||
import { HonoCustomType } from "../types";
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
export class TelegramSettings {
|
||||
enableAllowList: boolean;
|
||||
allowList: string[];
|
||||
miniAppUrl: string;
|
||||
enableGlobalMailPush: boolean;
|
||||
globalMailPushList: string[];
|
||||
|
||||
constructor(
|
||||
enableAllowList: boolean, allowList: string[], miniAppUrl: string,
|
||||
enableGlobalMailPush: boolean, globalMailPushList: string[]
|
||||
) {
|
||||
this.enableAllowList = enableAllowList;
|
||||
this.allowList = allowList;
|
||||
this.miniAppUrl = miniAppUrl;
|
||||
this.enableGlobalMailPush = enableGlobalMailPush;
|
||||
this.globalMailPushList = globalMailPushList;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTelegramSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
|
||||
return c.json(settings || new TelegramSettings(false, [], "", false, []));
|
||||
}
|
||||
|
||||
|
||||
async function saveTelegramSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<TelegramSettings>();
|
||||
await c.env.KV.put(CONSTANTS.TG_KV_SETTINGS_KEY, JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
export default {
|
||||
getTelegramSettings,
|
||||
saveTelegramSettings,
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { callbackQuery } from "telegraf/filters";
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
// @ts-ignore
|
||||
import { getIntValue, getDomains, getStringValue } from '../utils';
|
||||
// @ts-ignore
|
||||
import { newAddress } from '../common'
|
||||
import { getDomains, getStringValue } from '../utils';
|
||||
import { HonoCustomType } from "../types";
|
||||
import { TelegramSettings } from "./settings";
|
||||
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
|
||||
|
||||
const COMMANDS = [
|
||||
{
|
||||
@@ -28,58 +28,69 @@ const COMMANDS = [
|
||||
command: "bind",
|
||||
description: "绑定邮箱地址, 请输入 /bind <邮箱地址凭证>"
|
||||
},
|
||||
{
|
||||
command: "unbind",
|
||||
description: "解绑邮箱地址, 请输入 /unbind <邮箱地址>"
|
||||
},
|
||||
{
|
||||
command: "delete",
|
||||
description: "删除邮箱地址, 请输入 /delete <邮箱地址>"
|
||||
},
|
||||
{
|
||||
command: "mails",
|
||||
description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址"
|
||||
},
|
||||
]
|
||||
|
||||
export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
export function newTelegramBot(c: Context<HonoCustomType>, token: string): Telegraf {
|
||||
const bot = new Telegraf(token);
|
||||
bot.command("start", async (ctx: TgContext) => {
|
||||
|
||||
bot.use(async (ctx, next) => {
|
||||
// check if in private chat
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = ctx?.message?.from?.id || ctx.callbackQuery?.message?.chat?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
|
||||
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
|
||||
if (settings?.enableAllowList && settings?.enableAllowList
|
||||
&& !settings.allowList.includes(userId.toString())
|
||||
) {
|
||||
return await ctx.reply("您没有权限使用此机器人");
|
||||
}
|
||||
try {
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error}`);
|
||||
return await ctx.reply(`Error: ${error}`);
|
||||
}
|
||||
})
|
||||
|
||||
bot.command("start", async (ctx: TgContext) => {
|
||||
const prefix = getStringValue(c.env.PREFIX)
|
||||
const domains = getDomains(c);
|
||||
return await ctx.reply(
|
||||
"欢迎使用本机器人\n\n"
|
||||
"欢迎使用本机器人, 您可以打开 mini app \n\n"
|
||||
+ (prefix ? `当前已启用前缀: ${prefix}\n` : '')
|
||||
+ "新建邮箱地址, 如果要自定义邮箱地址, "
|
||||
+ "请输入 /new <name>@<domain>, name [a-zA-Z0-9.] 有效\n"
|
||||
+ `当前可用域名: ${JSON.stringify(domains)}\n`
|
||||
+ "请使用以下命令:\n"
|
||||
+ COMMANDS.map(c => `/${c.command}: ${c.description}`).join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
bot.command("new", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
if (c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
|
||||
)
|
||||
if (!success) {
|
||||
return await ctx.reply("操作过于频繁");
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
const address = ctx?.message?.text.slice("/new".length).trim() || Math.random().toString(36).substring(2, 15);
|
||||
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
return await ctx.reply("绑定地址数量已达上限");
|
||||
}
|
||||
const res = await newAddress(c, name, domain, true);
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId);
|
||||
const address = ctx?.message?.text.slice("/new".length).trim();
|
||||
const res = await tgUserNewAddress(c, userId.toString(), address);
|
||||
return await ctx.reply(`创建地址成功:\n`
|
||||
+ `地址: ${res.address}\n`
|
||||
+ `凭证: ${res.jwt}\n`
|
||||
@@ -90,9 +101,6 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
});
|
||||
|
||||
bot.command("bind", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
@@ -103,17 +111,7 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
if (!jwt) {
|
||||
return await ctx.reply("请输入凭证");
|
||||
}
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
if (!address) {
|
||||
return await ctx.reply("凭证无效");
|
||||
}
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||
return await ctx.reply("绑定地址数量已达上限");
|
||||
}
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
|
||||
// for mail push to telegram
|
||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId);
|
||||
const address = await bindTelegramAddress(c, userId.toString(), jwt);
|
||||
return await ctx.reply(`绑定成功:\n`
|
||||
+ `地址: ${address}`
|
||||
);
|
||||
@@ -123,26 +121,52 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("address", async (ctx: TgContext) => {
|
||||
if (ctx.chat?.type !== "private") {
|
||||
return await ctx.reply("请在私聊中使用");
|
||||
}
|
||||
bot.command("unbind", async (ctx: TgContext) => {
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
const addressList = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
addressList.push(address);
|
||||
} catch (e) {
|
||||
addressList.push("此凭证无效");
|
||||
continue;
|
||||
}
|
||||
// @ts-ignore
|
||||
const address = ctx?.message?.text.slice("/unbind".length).trim();
|
||||
if (!address) {
|
||||
return await ctx.reply("请输入地址");
|
||||
}
|
||||
await unbindTelegramAddress(c, userId.toString(), address);
|
||||
return await ctx.reply(`解绑成功:\n地址: ${address}`
|
||||
);
|
||||
}
|
||||
catch (e) {
|
||||
return await ctx.reply(`解绑失败: ${(e as Error).message}`);
|
||||
}
|
||||
})
|
||||
|
||||
bot.command("delete", async (ctx: TgContext) => {
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const address = ctx?.message?.text.slice("/unbind".length).trim();
|
||||
if (!address) {
|
||||
return await ctx.reply("请输入地址");
|
||||
}
|
||||
await deleteTelegramAddress(c, userId.toString(), address);
|
||||
return await ctx.reply(`删除成功: ${address}`);
|
||||
} catch (e) {
|
||||
return await ctx.reply(`删除失败: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("address", async (ctx) => {
|
||||
const userId = ctx?.message?.from?.id;
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
try {
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressList } = await jwtListToAddressData(c, jwtList);
|
||||
return await ctx.reply(`地址列表:\n\n`
|
||||
+ addressList.map(a => `地址: ${a}`).join("\n")
|
||||
);
|
||||
@@ -156,35 +180,42 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
if (!userId) {
|
||||
return await ctx.reply("无法获取用户信息");
|
||||
}
|
||||
const jwtList = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, { type: 'json' }) || [];
|
||||
const addressList = [];
|
||||
for (const jwt of jwtList) {
|
||||
try {
|
||||
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
|
||||
addressList.push(address);
|
||||
} catch (e) {
|
||||
addressList.push("此凭证无效");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList);
|
||||
if (!queryAddress && addressList.length > 0) {
|
||||
queryAddress = addressList[0];
|
||||
}
|
||||
if (!addressList.includes(queryAddress)) {
|
||||
if (!(queryAddress in addressIdMap)) {
|
||||
return await ctx.reply(`未绑定此地址 ${queryAddress}`);
|
||||
}
|
||||
const raw = await c.env.DB.prepare(
|
||||
const address_id = addressIdMap[queryAddress];
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ? `
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return await ctx.reply("无效地址");
|
||||
}
|
||||
const { raw, id: mailId } = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where address = ? `
|
||||
+ ` order by id desc limit 1 offset ?`
|
||||
).bind(
|
||||
queryAddress, mailIndex
|
||||
).first("raw");
|
||||
const { mail } = await parseMail(raw);
|
||||
).first<{ raw: string, id: string }>() || {};
|
||||
const { mail } = raw ? await parseMail(raw) : { mail: "已经没有邮件了" };
|
||||
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
|
||||
const miniAppButtons = []
|
||||
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
|
||||
const url = new URL(settings.miniAppUrl);
|
||||
url.pathname = "/telegram_mail"
|
||||
url.searchParams.set("mail_id", mailId);
|
||||
miniAppButtons.push(Markup.button.webApp("查看邮件", url.toString()));
|
||||
}
|
||||
if (edit) {
|
||||
return await ctx.editMessageText(mail || "无邮件",
|
||||
{
|
||||
...Markup.inlineKeyboard([
|
||||
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
|
||||
...miniAppButtons,
|
||||
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
|
||||
])
|
||||
},
|
||||
@@ -194,6 +225,7 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
{
|
||||
...Markup.inlineKeyboard([
|
||||
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
|
||||
...miniAppButtons,
|
||||
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
|
||||
])
|
||||
},
|
||||
@@ -230,23 +262,25 @@ export function newTelegramBot(c: Context, token: string): Telegraf {
|
||||
|
||||
|
||||
export async function initTelegramBotCommands(bot: Telegraf) {
|
||||
bot.telegram.sendMessage
|
||||
await bot.telegram.setMyCommands(COMMANDS);
|
||||
}
|
||||
|
||||
const parseMail = async (raw_mail: string) => {
|
||||
const parseMail = async (raw_mail: string | undefined | null) => {
|
||||
if (!raw_mail) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
if (parsedEmail?.text?.length && parsedEmail?.text?.length > 1000) {
|
||||
parsedEmail.text = parsedEmail.text.substring(0, 1000) + "...消息过长请到miniapp查看";
|
||||
}
|
||||
return {
|
||||
isHtml: false,
|
||||
mail: `From: ${parsedEmail.from ? `${parsedEmail.from.name}[${parsedEmail.from.address}]` : "无发件人"}\n`
|
||||
+ `To: ${parsedEmail.to?.map(t => `${t.name}[${t.address}]`).join(" ")}\n`
|
||||
+ `Subject: ${parsedEmail.subject}\n`
|
||||
+ `Date: ${parsedEmail.date}\n`
|
||||
+ `Content:\n${parsedEmail.text?.substring(0, 100) || "解析失败"}`
|
||||
+ `Content:\n${parsedEmail.text || "解析失败,请打开 mini app 查看"}`
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -257,18 +291,49 @@ const parseMail = async (raw_mail: string) => {
|
||||
}
|
||||
|
||||
|
||||
export async function sendMailToTelegram(c: Context, address: string, raw_mail: string) {
|
||||
export async function sendMailToTelegram(
|
||||
c: Context<HonoCustomType>, address: string,
|
||||
raw_mail: string, message_id: string | null
|
||||
) {
|
||||
if (!c.env.TELEGRAM_BOT_TOKEN || !c.env.KV) {
|
||||
return;
|
||||
}
|
||||
const userId = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const { mail } = await parseMail(raw_mail);
|
||||
if (!mail) {
|
||||
return;
|
||||
}
|
||||
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
|
||||
const golbalPush = settings?.enableGlobalMailPush && settings?.globalMailPushList;
|
||||
if (!userId && !golbalPush) {
|
||||
return;
|
||||
}
|
||||
const mailId = await c.env.DB.prepare(
|
||||
`SELECT id FROM raw_mails where address = ? and message_id = ?`
|
||||
).bind(address, message_id).first<string>("id");
|
||||
const bot = newTelegramBot(c, c.env.TELEGRAM_BOT_TOKEN);
|
||||
await bot.telegram.sendMessage(userId, mail);
|
||||
const miniAppButtons = []
|
||||
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
|
||||
const url = new URL(settings.miniAppUrl);
|
||||
url.pathname = "/telegram_mail"
|
||||
url.searchParams.set("mail_id", mailId);
|
||||
miniAppButtons.push(Markup.button.webApp("查看邮件", url.toString()));
|
||||
}
|
||||
if (golbalPush) {
|
||||
for (const pushId of settings.globalMailPushList) {
|
||||
await bot.telegram.sendMessage(pushId, mail, {
|
||||
...Markup.inlineKeyboard([
|
||||
...miniAppButtons,
|
||||
])
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
await bot.telegram.sendMessage(userId, mail, {
|
||||
...Markup.inlineKeyboard([
|
||||
...miniAppButtons,
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
70
worker/src/types.d.ts
vendored
Normal file
70
worker/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
export type Bindings = {
|
||||
// bindings
|
||||
DB: D1Database
|
||||
KV: KVNamespace
|
||||
RATE_LIMITER: any
|
||||
SEND_MAIL: any
|
||||
|
||||
// config
|
||||
TITLE: string | undefined
|
||||
PREFIX: string | undefined
|
||||
DOMAINS: string | string[] | undefined
|
||||
PASSWORDS: string | string[] | undefined
|
||||
ADMIN_PASSWORDS: string | string[] | undefined
|
||||
JWT_SECRET: string
|
||||
BLACK_LIST: string | undefined
|
||||
ENABLE_AUTO_REPLY: string | boolean | undefined
|
||||
ENABLE_WEBHOOK: string | boolean | undefined
|
||||
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
|
||||
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
|
||||
ENABLE_INDEX_ABOUT: string | boolean | undefined
|
||||
DEFAULT_SEND_BALANCE: number | string | undefined
|
||||
ADMIN_CONTACT: string | undefined
|
||||
COPYRIGHT: string | undefined
|
||||
FORWARD_ADDRESS_LIST: string | string[] | undefined
|
||||
|
||||
// s3 config
|
||||
S3_ENDPOINT: string | undefined
|
||||
S3_ACCESS_KEY_ID: string | undefined
|
||||
S3_SECRET_ACCESS_KEY: string | undefined
|
||||
S3_BUCKET: string | undefined
|
||||
S3_URL_EXPIRES: number | undefined
|
||||
|
||||
// dkim
|
||||
DKIM_SELECTOR: string | undefined
|
||||
DKIM_PRIVATE_KEY: string | undefined
|
||||
|
||||
// cf turnstile
|
||||
CF_TURNSTILE_SITE_KEY: string | undefined
|
||||
CF_TURNSTILE_SECRET_KEY: string | undefined
|
||||
|
||||
// resend
|
||||
RESEND_TOKEN: string | undefined
|
||||
[key: `RESEND_TOKEN_${string}`]: string | undefined;
|
||||
|
||||
// telegram config
|
||||
TELEGRAM_BOT_TOKEN: string
|
||||
TG_MAX_ADDRESS: number | undefined
|
||||
}
|
||||
|
||||
type JwtPayload = {
|
||||
address: string
|
||||
address_id: number
|
||||
}
|
||||
|
||||
type UserPayload = {
|
||||
user_email: string
|
||||
user_id: number
|
||||
exp: number
|
||||
iat: number
|
||||
}
|
||||
|
||||
type Variables = {
|
||||
userPayload: UserPayload,
|
||||
jwtPayload: JwtPayload
|
||||
}
|
||||
|
||||
type HonoCustomType = {
|
||||
"Bindings": Bindings;
|
||||
"Variables": Variables;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { HonoCustomType } from '../types';
|
||||
import { UserSettings } from "../models";
|
||||
import { getJsonSetting } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
export default {
|
||||
bind: async (c) => {
|
||||
bind: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address_id } = c.get("jwtPayload");
|
||||
if (!address_id || !user_id) {
|
||||
@@ -36,7 +38,7 @@ export default {
|
||||
if (settings.maxAddressCount > 0) {
|
||||
const { count } = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
|
||||
).bind(user_id).first();
|
||||
).bind(user_id).first<{ count: number }>() || { count: 0 };
|
||||
if (count >= settings.maxAddressCount) {
|
||||
return c.text("Max address count reached", 400)
|
||||
}
|
||||
@@ -50,14 +52,15 @@ export default {
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
const error = e as Error;
|
||||
if (error.message && error.message.includes("UNIQUE")) {
|
||||
return c.text("Address already binded, please unbind first", 400)
|
||||
}
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
unbind: async (c) => {
|
||||
unbind: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address_id } = await c.req.json();
|
||||
if (!address_id || !user_id) {
|
||||
@@ -90,7 +93,7 @@ export default {
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getBindedAddresses: async (c) => {
|
||||
getBindedAddresses: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
if (!user_id) {
|
||||
return c.text("No user token", 400)
|
||||
@@ -110,7 +113,7 @@ export default {
|
||||
results: results,
|
||||
})
|
||||
},
|
||||
getBindedAddressJwt: async (c) => {
|
||||
getBindedAddressJwt: async (c: Context<HonoCustomType>) => {
|
||||
const { address_id } = c.req.param();
|
||||
// check binded
|
||||
const { user_id } = c.get("userPayload");
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { HonoCustomType } from '../types';
|
||||
import settings from './settings';
|
||||
import user from './user';
|
||||
import bind_address from './bind_address';
|
||||
|
||||
const api = new Hono();
|
||||
export const api = new Hono<HonoCustomType>();
|
||||
|
||||
api.get('/user_api/open_settings', settings.openSettings);
|
||||
api.get('/user_api/settings', settings.settings);
|
||||
@@ -15,5 +16,3 @@ api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
||||
api.post('/user_api/bind_address', bind_address.bind);
|
||||
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
|
||||
api.post('/user_api/unbind_address', bind_address.unbind);
|
||||
|
||||
export { api }
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { HonoCustomType } from "../types";
|
||||
import { UserSettings } from "../models";
|
||||
import { getJsonSetting } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
export default {
|
||||
openSettings: async (c) => {
|
||||
openSettings: async (c: Context<HonoCustomType>) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
return c.json({
|
||||
@@ -11,7 +14,7 @@ export default {
|
||||
enableMailVerify: settings.enableMailVerify,
|
||||
})
|
||||
},
|
||||
settings: async (c) => {
|
||||
settings: async (c: Context<HonoCustomType>) => {
|
||||
const user = c.get("userPayload");
|
||||
// check if user exists
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { HonoCustomType } from '../types';
|
||||
import { checkCfTurnstile, getJsonSetting, checkUserPassword } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { GeoData, UserInfo, UserSettings } from "../models";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
|
||||
export default {
|
||||
verifyCode: async (c) => {
|
||||
verifyCode: async (c: Context<HonoCustomType>) => {
|
||||
const { email, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
) {
|
||||
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
|
||||
}
|
||||
if (!settings.verifyMailSender) {
|
||||
return c.text("Verify mail sender not set", 400)
|
||||
}
|
||||
// check if code exists in KV
|
||||
const tmpcode = await c.env.KV.get(`temp-mail:${email}`)
|
||||
if (tmpcode) {
|
||||
@@ -34,12 +39,15 @@ export default {
|
||||
// send code to email
|
||||
try {
|
||||
await sendMail(c, settings.verifyMailSender, {
|
||||
to_mail: email,
|
||||
from_name: "Temp Mail Verify",
|
||||
to_name: '',
|
||||
to_mail: email as string,
|
||||
subject: "Temp Mail Verify code",
|
||||
content: `Your verify code is ${code}`,
|
||||
is_html: false,
|
||||
})
|
||||
} catch (e) {
|
||||
return c.text(`Failed to send verify code: ${e.message}`, 500)
|
||||
return c.text(`Failed to send verify code: ${(e as Error).message}`, 500)
|
||||
}
|
||||
// save to KV
|
||||
await c.env.KV.put(`temp-mail:${email}`, code, { expirationTtl: 300 });
|
||||
@@ -48,7 +56,7 @@ export default {
|
||||
expirationTtl: 300
|
||||
})
|
||||
},
|
||||
register: async (c) => {
|
||||
register: async (c: Context<HonoCustomType>) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value)
|
||||
// check enable
|
||||
@@ -67,6 +75,7 @@ export default {
|
||||
// check mail domain allow list
|
||||
const mailDomain = email.split("@")[1];
|
||||
if (settings.enableMailAllowList
|
||||
&& settings.mailAllowList
|
||||
&& !settings.mailAllowList.includes(mailDomain)
|
||||
) {
|
||||
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
|
||||
@@ -80,8 +89,8 @@ export default {
|
||||
}
|
||||
// geo data
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
const userInfo = new UserInfo(geoData);
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf as any);
|
||||
const userInfo = new UserInfo(geoData, email);
|
||||
// if not enable mail verify, do not on conflict update
|
||||
if (!settings.enableMailVerify) {
|
||||
try {
|
||||
@@ -95,10 +104,11 @@ export default {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
const error = e as Error;
|
||||
if (error.message && error.message.includes("UNIQUE")) {
|
||||
return c.text("User already exists, please login", 400)
|
||||
}
|
||||
return c.text(`Failed to register: ${e.message}`, 500)
|
||||
return c.text(`Failed to register: ${error.message}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
}
|
||||
@@ -116,7 +126,7 @@ export default {
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
login: async (c) => {
|
||||
login: async (c: Context<HonoCustomType>) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) return c.text("Invalid email or password", 400);
|
||||
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Context } from "hono";
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { HonoCustomType } from "./types";
|
||||
|
||||
export const getJsonSetting = async (c, key) => {
|
||||
export const getJsonSetting = async (
|
||||
c: Context<HonoCustomType>, key: string
|
||||
): Promise<any> => {
|
||||
const value = await getSetting(c, key);
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -13,11 +17,13 @@ export const getJsonSetting = async (c, key) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getSetting = async (c, key) => {
|
||||
export const getSetting = async (
|
||||
c: Context<HonoCustomType>, key: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const value = await c.env.DB.prepare(
|
||||
`SELECT value FROM settings where key = ?`
|
||||
).bind(key).first("value");
|
||||
).bind(key).first<string>("value");
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`GetSetting: Failed to get ${key}`, error);
|
||||
@@ -25,7 +31,10 @@ export const getSetting = async (c, key) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const saveSetting = async (c, key, value) => {
|
||||
export const saveSetting = async (
|
||||
c: Context<HonoCustomType>,
|
||||
key: string, value: string
|
||||
) => {
|
||||
await c.env.DB.prepare(
|
||||
`INSERT or REPLACE INTO settings (key, value) VALUES (?, ?)`
|
||||
+ ` ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`
|
||||
@@ -33,14 +42,16 @@ export const saveSetting = async (c, key, value) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const getStringValue = (value) => {
|
||||
export const getStringValue = (value: any): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const getBooleanValue = (value) => {
|
||||
export const getBooleanValue = (
|
||||
value: boolean | string | any
|
||||
): boolean => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
@@ -51,7 +62,10 @@ export const getBooleanValue = (value) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getIntValue = (value, defaultValue = 0) => {
|
||||
export const getIntValue = (
|
||||
value: number | string | any,
|
||||
defaultValue: number = 0
|
||||
): number => {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
@@ -65,7 +79,7 @@ export const getIntValue = (value, defaultValue = 0) => {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export const getDomains = (c) => {
|
||||
export const getDomains = (c: Context<HonoCustomType>): string[] => {
|
||||
if (!c.env.DOMAINS) {
|
||||
return [];
|
||||
}
|
||||
@@ -81,14 +95,14 @@ export const getDomains = (c) => {
|
||||
return c.env.DOMAINS;
|
||||
}
|
||||
|
||||
export const getPasswords = (c) => {
|
||||
export const getPasswords = (c: Context<HonoCustomType>): string[] => {
|
||||
if (!c.env.PASSWORDS) {
|
||||
return [];
|
||||
}
|
||||
// check if PASSWORDS is an array, if not use json.parse
|
||||
if (!Array.isArray(c.env.PASSWORDS)) {
|
||||
try {
|
||||
let res = JSON.parse(c.env.PASSWORDS);
|
||||
const res = JSON.parse(c.env.PASSWORDS) as string[];
|
||||
return res.filter((item) => item.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse PASSWORDS", e);
|
||||
@@ -98,23 +112,43 @@ export const getPasswords = (c) => {
|
||||
return c.env.PASSWORDS.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export const getAdminPasswords = (c) => {
|
||||
export const getAdminPasswords = (c: Context<HonoCustomType>): string[] => {
|
||||
if (!c.env.ADMIN_PASSWORDS) {
|
||||
return [];
|
||||
}
|
||||
// check if ADMIN_PASSWORDS is an array, if not use json.parse
|
||||
if (!Array.isArray(c.env.ADMIN_PASSWORDS)) {
|
||||
try {
|
||||
return JSON.parse(c.env.ADMIN_PASSWORDS);
|
||||
const res = JSON.parse(c.env.ADMIN_PASSWORDS) as string[];
|
||||
return res.filter((item) => item.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ADMIN_PASSWORDS", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return c.env.ADMIN_PASSWORDS;
|
||||
return c.env.ADMIN_PASSWORDS.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export const sendAdminInternalMail = async (c, toMail, subject, text) => {
|
||||
export const getEnvStringList = (value: string | string[] | undefined): string[] => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
// check if is an array, if not use json.parse
|
||||
if (!Array.isArray(value)) {
|
||||
try {
|
||||
const res = JSON.parse(value) as string[];
|
||||
return res.filter((item) => item.length > 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ADMIN_PASSWORDS", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export const sendAdminInternalMail = async (
|
||||
c: Context<HonoCustomType>, toMail: string, subject: string, text: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
const msg = createMimeMessage();
|
||||
@@ -144,31 +178,33 @@ export const sendAdminInternalMail = async (c, toMail, subject, text) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const checkCfTurnstile = async (c, token) => {
|
||||
if (!c.env.CF_TURNSTILE_SITE_KEY) {
|
||||
export const checkCfTurnstile = async (
|
||||
c: Context<HonoCustomType>, token: string | undefined | null
|
||||
): Promise<void> => {
|
||||
if (!c.env.CF_TURNSTILE_SITE_KEY || !c.env.CF_TURNSTILE_SECRET_KEY) {
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("Captcha token is required");
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
let formData = new FormData();
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
|
||||
const formData = new FormData();
|
||||
formData.append('secret', c.env.CF_TURNSTILE_SECRET_KEY);
|
||||
formData.append('response', token);
|
||||
formData.append('remoteip', reqIp);
|
||||
if (reqIp) formData.append('remoteip', reqIp);
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const captchaRes = await result.json();
|
||||
const captchaRes: any = await result.json();
|
||||
if (!captchaRes.success) {
|
||||
console.log("Captcha failed", captchaRes);
|
||||
throw new Error("Captcha failed");
|
||||
}
|
||||
}
|
||||
|
||||
export const checkUserPassword = (password) => {
|
||||
export const checkUserPassword = (password: string) => {
|
||||
if (!password || password.length < 1 || password.length > 100) {
|
||||
throw new Error("Invalid password")
|
||||
}
|
||||
@@ -3,19 +3,22 @@ import { cors } from 'hono/cors';
|
||||
import { jwt } from 'hono/jwt'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
// @ts-ignore
|
||||
import { api as apiV1 } from './deprecated';
|
||||
|
||||
import { api as commonApi } from './commom_api';
|
||||
import { api as mailsApi } from './mails_api'
|
||||
import { api as userApi } from './user_api';
|
||||
import { api as adminApi } from './admin_api';
|
||||
import { api as apiV1 } from './deprecated';
|
||||
import { api as apiSendMail } from './mails_api/send_mail_api'
|
||||
import { api as telegramApi } from './telegram_api'
|
||||
|
||||
import { email } from './email';
|
||||
import { scheduled } from './scheduled';
|
||||
import { getAdminPasswords, getPasswords } from './utils';
|
||||
import { getAdminPasswords, getPasswords, getBooleanValue } from './utils';
|
||||
import { HonoCustomType } from './types';
|
||||
|
||||
const app = new Hono()
|
||||
const app = new Hono<HonoCustomType>()
|
||||
//cors
|
||||
app.use('/*', cors());
|
||||
// rate limit
|
||||
@@ -37,6 +40,17 @@ app.use('/*', async (c, next) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
c.req.path.startsWith("/api/webhook")
|
||||
|| c.req.path.startsWith("/admin/webhook")
|
||||
) {
|
||||
if (!c.env.KV) {
|
||||
return c.text("KV is not available", 400);
|
||||
}
|
||||
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return c.text("Webhook is disabled", 403);
|
||||
}
|
||||
}
|
||||
await next()
|
||||
});
|
||||
// api auth
|
||||
@@ -68,6 +82,7 @@ app.use('/user_api/*', async (c, next) => {
|
||||
}
|
||||
try {
|
||||
const token = c.req.raw.headers.get("x-user-token");
|
||||
if (!token) return c.text("Need User Token", 401)
|
||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
||||
// check expired
|
||||
if (!payload.exp) return c.text("Invalid Token", 401);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user