Compare commits

..

41 Commits

Author SHA1 Message Date
Dream Hunter
c5d01e09e8 feat: change version (#294) 2024-06-01 21:31:30 +08:00
Dream Hunter
201c7658be feat: UI: admin mail page style add margin-top: 10px (#293) 2024-06-01 21:27:45 +08:00
Dream Hunter
77155299e0 feat: add mailbox multi delete and download (#292) 2024-06-01 21:23:17 +08:00
Dream Hunter
9725407c77 feat: add s3 attachment (#291) 2024-06-01 20:08:42 +08:00
Dream Hunter
e91bbe273a feat: UI i18n depends on router (#290) 2024-06-01 12:13:44 +08:00
Dream Hunter
b792c196c1 feat: UI i18n depends on router (#289) 2024-06-01 12:12:13 +08:00
Dream Hunter
7a368d7b23 feat: add global forward address list (#288) 2024-05-31 23:21:12 +08:00
Dream Hunter
f882e4cf97 feat: add Local Address Manage (#285) 2024-05-29 13:40:02 +08:00
Dream Hunter
00abf79417 fix: cannot delete addres when not bind KV (#284) 2024-05-29 12:08:56 +08:00
Dream Hunter
1f8edbc295 feat: add TITLE in worker for custom website title (#276) 2024-05-26 16:21:27 +08:00
Dream Hunter
268f3d6446 Update CHANGELOG.md 2024-05-26 15:35:18 +08:00
Dream Hunter
8dc9d32a7e feat: add resend for send mail (#275) 2024-05-26 15:30:18 +08:00
Dream Hunter
3b6736924b feat: add resend for send mail (#274) 2024-05-26 12:37:11 +08:00
Dream Hunter
dc14338b69 fix: telegram bot golbalPush (#273) 2024-05-25 17:37:39 +08:00
Dream Hunter
954ae2dfb1 fix: telegram bot golbalPush (#272) 2024-05-25 14:38:33 +08:00
Dream Hunter
6d55acdd42 fix: telegram bot golbalPush (#271) 2024-05-25 14:34:16 +08:00
Dream Hunter
03bb210016 fix: telegram bot golbalPush (#270) 2024-05-25 14:20:34 +08:00
Dream Hunter
bf3c372d8c feat: telegram bot global push (#269) 2024-05-25 14:07:00 +08:00
Dream Hunter
9414f7a977 Update README.md 2024-05-25 11:53:23 +08:00
Dream Hunter
32440706d2 feat: add sendmail sunset in readme (#267) 2024-05-23 12:32:07 +08:00
Dream Hunter
c976664f4e feat: UI: lazy load (#266) 2024-05-23 12:23:43 +08:00
Dream Hunter
aa04dc4efa feat: smtp_proxy_server use httpx (#265) 2024-05-22 22:24:59 +08:00
Dream Hunter
02e3e755e7 feat: docs: Telegram Mini App (#264) 2024-05-22 20:57:30 +08:00
Dream Hunter
37ed2955ff fix: webhook JSON.stringify (#263) 2024-05-22 20:48:03 +08:00
Dream Hunter
dd49768cfc feat: smtp_proxy_server update package (#262) 2024-05-21 23:53:32 +08:00
Dream Hunter
9ec11f7040 fix: telegram bot/miniapp bugs (#261) 2024-05-21 22:45:48 +08:00
Dream Hunter
2533257b68 fix: telegram bot/miniapp bugs (#259) 2024-05-21 13:32:47 +08:00
Dream Hunter
96ea81e055 fix: telegram bot/miniapp bugs (#258) 2024-05-21 13:28:02 +08:00
Dream Hunter
8459e0c306 fix: telegram bot/miniapp bugs (#257) 2024-05-21 13:18:15 +08:00
Dream Hunter
91d7896e65 feat: telegram mini app open mail from bot (#256) 2024-05-21 02:03:06 +08:00
Dream Hunter
69771fc1d1 feat: telegram bot unbind && delete address (#254) 2024-05-20 13:23:41 +08:00
Dream Hunter
c00382259a fix: telegram mini app pipeline (#253) 2024-05-19 11:37:06 +08:00
Dream Hunter
8ac96bff1f fix: telegram mini app pipeline (#252) 2024-05-19 11:34:30 +08:00
Dream Hunter
9f3ff7b980 fix: telegram mini app (#251) 2024-05-19 11:32:57 +08:00
Dream Hunter
870b7b9198 feat: add telegram mini app (#250) 2024-05-19 00:35:10 +08:00
Dream Hunter
46576316e6 Update CHANGELOG.md 2024-05-18 17:08:41 +08:00
Dream Hunter
a5ff4f2d90 feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view (#248) 2024-05-18 17:02:21 +08:00
Dream Hunter
745e36f838 feat: UI changes (#247) 2024-05-18 14:46:24 +08:00
Dream Hunter
a351839408 fix build (#245) 2024-05-18 14:07:52 +08:00
Dream Hunter
ca00a877ad feat: telegram bot TelegramSettings && webhook (#244)
* feat: telegram bot TelegramSettings

* feat: webhook
2024-05-18 14:02:18 +08:00
Dream Hunter
53a06fc9d6 Update CHANGELOG.md 2024-05-17 00:12:14 +08:00
101 changed files with 6772 additions and 1512 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -62,6 +62,7 @@
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
## Reference

View File

@@ -1,2 +1,3 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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;

View File

@@ -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')

View File

@@ -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: '/'
}
]
})

View File

@@ -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,
}
},
)

View File

@@ -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) {

View File

@@ -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}`;
}

View File

@@ -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 />

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View 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>

View File

@@ -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})',

View File

@@ -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',

View File

@@ -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}`);
}

View File

@@ -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");

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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)

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": []
},
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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"

View File

@@ -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' },

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -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]]

View File

@@ -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`

View File

@@ -1,5 +1,9 @@
# 搭建 SMTP IMAP 代理服务
::: warning
如果你使用了 `resend`, 可直接使用 `resend``SMTP` 服务,不需要使用此服务
:::
## 为什么需要 SMTP IMAP 代理服务
`SMTP` `IMAP` 的应用场景更加广泛

View File

@@ -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": "<你的网站密码>", # 如果启用了自定义密码

View 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
```
## 使用
保存附件
![S3 save](/feature/s3-save.png)
下载附件
![S3 download](/public/feature/s3-download.png)

View File

@@ -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
- 可设置白名单用户
- 点击`初始化`即可完成配置。
- 点击`查看状态`,可以查看当前配置的状态。
![telegram](/feature/telegram.png)
## 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 的链接

View File

@@ -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
View 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",
}
}
];

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 });
},

View File

@@ -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));

View File

@@ -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);

View 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,
}

View File

@@ -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,
});
})

View File

@@ -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
View 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 });
}

View File

@@ -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",
}

View File

@@ -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
View 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 }

View File

@@ -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)
}

View File

@@ -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 }

View 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 }))
}
);
},
}

View File

@@ -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 }

View 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,
}

View File

@@ -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
View 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;
}
}

View File

@@ -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
);

View 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;
}

View File

@@ -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);

View 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,
}

View 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,
}

View File

@@ -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
View 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;
}

View File

@@ -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");

View File

@@ -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 }

View File

@@ -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(

View File

@@ -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(

View File

@@ -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")
}

View File

@@ -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