Compare commits

...

15 Commits

Author SHA1 Message Date
Dream Hunter
dd477fe2c8 Update CHANGELOG.md 2024-06-06 21:45:57 +08:00
Dream Hunter
0db611bb3e feat: add MIN_ADDRESS_LEN && MAX_ADDRESS_LEN (#304) 2024-06-06 21:44:22 +08:00
Dream Hunter
6225f6521a fix: parseMail tg bot (#302) 2024-06-04 22:51:28 +08:00
Dream Hunter
da2e72e523 feat: add mail-parser-wasm-worker (#301) 2024-06-04 21:57:42 +08:00
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
78 changed files with 3583 additions and 1278 deletions

View File

@@ -1,12 +1,31 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main branch
## v0.5.1
- UI lazy load
- telegram bot 添加用户全局推送功能
- 添加 `mail-parser-wasm-worker` 用于 worker 解析邮件, [文档](https://temp-mail-docs.awsl.uk/zh/guide/feature/mail_parser_wasm_worker.html)
- 添加校验用户邮箱长度配置 `MIN_ADDRESS_LEN``MAX_ADDRESS_LEN`
- 修复 `pages function` 未转发 `telegram` api 问题
## 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` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
## v0.4.4

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.4.5",
"version": "0.5.1",
"private": true,
"type": "module",
"scripts": {
@@ -15,12 +15,14 @@
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
},
"dependencies": {
"@unhead/vue": "^1.9.12",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0",
"@vueuse/core": "^10.10.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.2",
"mail-parser-wasm": "^0.1.6",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.1.8",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"vooks": "^0.2.12",
@@ -31,14 +33,14 @@
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue": "^5.0.5",
"unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11",
"vite": "^5.2.12",
"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.57.1"
"wrangler": "^3.58.0"
}
}

1828
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,14 @@ import Footer from './views/Footer.vue';
const {
localeCache, isDark, loading, useSideMargin,
telegramApp, isTelegram
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;

View File

@@ -54,7 +54,10 @@ const getOpenSettings = async (message) => {
try {
const res = await api.fetch("/open_api/settings");
Object.assign(openSettings.value, {
title: res["title"] || "",
prefix: res["prefix"] || "",
minAddressLen: res["minAddressLen"] || 1,
maxAddressLen: res["maxAddressLen"] || 30,
needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => {
return {
@@ -70,6 +73,7 @@ const getOpenSettings = async (message) => {
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;">
<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,90 +351,98 @@ 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">
<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>
@@ -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

@@ -20,7 +20,7 @@ const props = defineProps({
},
})
const { localeCache, isDark, mailboxSplitSize } = useGlobalState()
const { isDark, mailboxSplitSize } = useGlobalState()
const data = ref([])
const count = ref(0)
@@ -31,7 +31,6 @@ const curMail = ref(null);
const showCode = ref(false)
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
@@ -134,7 +133,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -155,7 +154,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
@@ -195,7 +194,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -217,7 +216,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}

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,26 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.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',
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("");
@@ -81,7 +83,6 @@ export const useGlobalState = createGlobalState(
showAddressCredential,
auth,
jwt,
localeCache,
adminAuth,
showAdminAuth,
adminTab,

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

@@ -21,7 +21,7 @@ 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()
@@ -34,7 +34,6 @@ const authFunc = async () => {
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
accessHeader: 'Admin Password',

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, isTelegram,
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'),
@@ -113,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",
@@ -148,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 }
)
@@ -182,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);
});
@@ -191,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

@@ -10,14 +10,15 @@ import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue';
import AccountSettings from './index/AccountSettings.vue';
import WenHook from './index/Webhook.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
mailbox: 'Mail Box',
@@ -26,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: '收件箱',
@@ -34,6 +37,8 @@ const { t } = useI18n({
auto_reply: '自动回复',
accountSettings: '账户设置',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
}
}
});
@@ -49,6 +54,26 @@ const deleteMail = async (curMailId) => {
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>
@@ -56,8 +81,9 @@ const fetchSenboxData = async (limit, offset) => {
<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 :fetchMailData="fetchSenboxData" />
@@ -72,7 +98,10 @@ const fetchSenboxData = async (limit, offset) => {
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
<WenHook />
<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 />

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

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

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

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

@@ -5,10 +5,9 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import SendBox from '../../components/SendBox.vue';
const { localeCache, adminSendBoxTabAddress } = useGlobalState()
const { adminSendBoxTabAddress } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
query: 'Query',

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

@@ -6,13 +6,10 @@ import { useI18n } from 'vue-i18n'
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',

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

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

@@ -6,13 +6,10 @@ import { useI18n } from 'vue-i18n'
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: {
successTip: 'Success',

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,6 +9,7 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const props = defineProps({
bindUserAddress: {
@@ -36,7 +37,7 @@ const message = useMessage()
const router = useRouter()
const {
jwt, localeCache, loading, openSettings,
jwt, loading, openSettings,
showAddressCredential, userSettings
} = useGlobalState()
@@ -59,14 +60,13 @@ const login = async () => {
} 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',
@@ -128,7 +128,7 @@ const newEmail = async () => {
);
jwt.value = res["jwt"];
await api.getSettings();
await router.push("/");
await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true;
try {
await props.bindUserAddress();
@@ -189,7 +189,8 @@ onMounted(async () => {
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />

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

@@ -10,18 +10,19 @@ 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, userJwt,
jwt, settings, showAddressCredential, userJwt,
isTelegram
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
addressManage: 'Address Manage',
@@ -50,6 +51,7 @@ const { t } = useI18n({
const showChangeAddress = ref(false)
const showTelegramChangeAddress = ref(false)
const showLocalAddress = ref(false)
const copy = async () => {
try {
@@ -60,6 +62,10 @@ const copy = async () => {
}
}
const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value))
}
onMounted(async () => {
await api.getSettings();
});
@@ -82,6 +88,10 @@ onMounted(async () => {
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>
@@ -98,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>
@@ -112,6 +122,9 @@ onMounted(async () => {
<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

@@ -10,12 +10,11 @@ import { api } from '../../api'
// @ts-ignore
import Login from '../common/Login.vue';
const { localeCache, jwt, telegramApp } = useGlobalState()
const { jwt, telegramApp } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'success',

View File

@@ -7,12 +7,11 @@ import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const { settings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
successTip: 'Success',

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

View File

@@ -12,3 +12,4 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
web/

View File

@@ -1,6 +1,6 @@
[package]
name = "mail-parser-wasm"
version = "0.1.6"
version = "0.1.8"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"

View File

@@ -1,16 +1,45 @@
# mail-parser-wasm
# mail-parser-wasm web and cf worker
## usage
## [mail-parser-wasm](https://www.npmjs.com/package/mail-parser-wasm)
### mail-parser-wasm usage
```bash
pnpm add mail-parser-wasm
```
```js
import { parse_message } from 'mail-parser-wasm'
const parsedEmail = parse_message(item.raw);
const parsedEmail = parse_message(rawEmail);
```
## build
### mail-parser-wasm build
```bash
wasm-pack build --release
wasm-pack publish
```
## [mail-parser-wasm-worker](https://www.npmjs.com/package/mail-parser-wasm-worker)
### mail-parser-wasm-worker usage
```bash
pnpm add mail-parser-wasm-worker
```
```js
import { parse_message_wrapper } from 'mail-parser-wasm-worker'
const parsedEmail = parse_message_wrapper(rawEmail);
```
### mail-parser-wasm-worker build
```bash
wasm-pack build --out-dir web --target web --release
find web/ -type f ! -name '*.json' ! -name '.gitignore' -exec cp {} worker/ \;
# modify worker/package.json version or whatever
pnpm publish worker --no-git-checks
```

9
mail-parser-wasm/worker/index.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import initAsync, { MessageResult } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
export { initAsync, MODULE };
export * from './mail_parser_wasm';
/**
* @param {string} raw_message
* @returns {MessageResult}
*/
export function parse_message_wrapper(raw_message: string): MessageResult;

View File

@@ -0,0 +1,12 @@
import initAsync, { initSync, parse_message } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
initSync(MODULE);
export { initAsync, MODULE };
export * from './mail_parser_wasm';
export const parse_message_wrapper = (raw_message) => {
initSync(MODULE);
return parse_message(raw_message);
}

View File

@@ -0,0 +1,24 @@
{
"name": "mail-parser-wasm-worker",
"description": "A simple mail parser for worker",
"homepage": "https://github.com/dreamhunter2333/cloudflare_temp_email/tree/main/mail-parser-wasm",
"repository": {
"type": "git",
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
"directory": "mail-parser-wasm"
},
"version": "0.1.8",
"license": "MIT",
"files": [
"mail_parser_wasm_bg.wasm",
"mail_parser_wasm.js",
"mail_parser_wasm.d.ts",
"index.js",
"index.d.ts"
],
"module": "index.js",
"types": "index.d.ts",
"sideEffects": [
"./snippets/*"
]
}

View File

@@ -1,4 +1,10 @@
const API_PATHS = ["/api/", "/open_api/", "/user_api/", "/admin/"]
const API_PATHS = [
"/api/",
"/open_api/",
"/user_api/",
"/admin/",
"/telegram/"
];
export async function onRequest(context) {
const reqPath = new URL(context.request.url).pathname;

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,27 @@ 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: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
]
},
{
text: '功能简介',
collapsed: false,
collapsed: true,
items: [
{ text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },

View File

@@ -74,7 +74,11 @@ node_compat = true
# ]
[vars]
# TITLE = "Custom Title" # The title of the site
PREFIX = "tmp" # The mailbox name prefix to be processed
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
@@ -104,6 +108,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

@@ -42,7 +42,11 @@ node_compat = true
# ]
[vars]
# TITLE = "Custom Title" # 自定义网站标题
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# (min, max) adderss的长度如果不设置默认为(1, 30)
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"]
# admin 控制台密码, 不配置则不允许访问控制台
@@ -72,6 +76,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,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,63 @@
# mail-parser-wasm-worker
> [!NOTE]
> 如果你使用了 webhook 转发,或者 telegram bot 接受邮件,但是邮件内容是乱码,或者无法解析,你对解析的需要更高的要求,可以使用这个功能。
## 修改代码
```bash
cd worker
pnpm add mail-parser-wasm-worker
```
编辑 `worker/src/common.ts`, 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件
```ts
export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{
sender: string,
subject: string,
text: string,
html: string
} | undefined> => {
if (!raw_mail) {
return undefined;
}
// 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件 start
// TODO: WASM parse email
try {
const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
const parsedEmail = parse_message_wrapper(raw_mail);
return {
sender: parsedEmail.sender || "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.body_html || "",
};
} catch (e) {
console.error("Failed use mail-parser-wasm-worker to parse email", e);
}
// 取消注释这段代码,使用 mail-parser-wasm-worker 来解析邮件 end
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);
return {
sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
};
}
catch (e) {
console.error("Failed use PostalMime to parse email", e);
}
return undefined;
}
```
## 部署
```bash
cd worker
pnpm run deploy
```

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,5 +1,18 @@
# 配置 Telegram Bot
## Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Bot
- 可设置白名单用户

View File

@@ -11,15 +11,17 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"@cloudflare/workers-types": "^4.20240603.0",
"@eslint/js": "8.56.0",
"eslint": "8.56.0",
"globals": "^15.3.0",
"typescript-eslint": "^7.10.0",
"wrangler": "^3.57.1"
"typescript-eslint": "^7.12.0",
"wrangler": "^3.58.0"
},
"dependencies": {
"hono": "^4.3.9",
"@aws-sdk/client-s3": "^3.588.0",
"@aws-sdk/s3-request-presigner": "^3.588.0",
"hono": "^4.4.3",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",
"resend": "^3.2.0",

1496
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ api.post('/admin/new_address', async (c) => {
return c.text("Please provide a name", 400)
}
try {
const res = await newAddress(c, name, domain, enablePrefix);
const res = await newAddress(c, name, domain, enablePrefix, false);
return c.json(res);
} catch (e) {
return c.text(`Failed create address: ${(e as Error).message}`, 400)

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono'
import { getDomains, getPasswords, getBooleanValue } from './utils';
import { getDomains, getPasswords, getBooleanValue, getIntValue } from './utils';
import { CONSTANTS } from './constants';
import { HonoCustomType } from './types';
import { isS3Enabled } from './mails_api/s3_attachment';
const api = new Hono<HonoCustomType>
@@ -15,7 +16,10 @@ api.get('/open_api/settings', async (c) => {
needAuth = !auth || !passwords.includes(auth);
}
return c.json({
"title": c.env.TITLE,
"prefix": c.env.PREFIX,
"minAddressLen": getIntValue(c.env.MIN_ADDRESS_LEN, 1),
"maxAddressLen": getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"domains": getDomains(c),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
@@ -26,6 +30,7 @@ api.get('/open_api/settings', async (c) => {
"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,28 +1,39 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue } from './utils';
import { getBooleanValue, getDomains, getStringValue, getIntValue } 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
enablePrefix: boolean,
checkLengthByConfig: boolean = true
): Promise<{ address: string, jwt: string }> => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// name min length min 1
const minAddressLength = Math.max(
checkLengthByConfig ? getIntValue(c.env.MIN_ADDRESS_LEN, 1) : 1,
1
);
// name max length min 1
const maxAddressLength = Math.max(
checkLengthByConfig ? getIntValue(c.env.MAX_ADDRESS_LEN, 30) : 30,
1
);
// check name length
if (name.length <= 0) {
throw new Error("Name too short")
if (name.length < minAddressLength) {
throw new Error(`Name too short (min ${minAddressLength})`);
}
if (name.length > maxAddressLength) {
throw new Error(`Name too long (max ${maxAddressLength})`);
}
// 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)) {
@@ -166,3 +177,43 @@ export const handleListQuery = async (
).bind(...params).first("count") : 0;
return c.json({ results, count });
}
export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{
sender: string,
subject: string,
text: string,
html: string
} | undefined> => {
if (!raw_mail) {
return undefined;
}
// TODO: WASM parse email
// try {
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
// const parsedEmail = parse_message_wrapper(raw_mail);
// return {
// sender: parsedEmail.sender || "",
// subject: parsedEmail.subject || "",
// text: parsedEmail.text || "",
// html: parsedEmail.body_html || "",
// };
// } catch (e) {
// console.error("Failed use mail-parser-wasm-worker to parse email", e);
// }
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);
return {
sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
};
}
catch (e) {
console.error("Failed use PostalMime to parse email", e);
}
return undefined;
}

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v0.4.5',
VERSION: 'v0.5.1',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',

View File

@@ -1,5 +1,6 @@
import { Context } from "hono";
import { getEnvStringList } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply";
@@ -25,6 +26,16 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
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(

View File

@@ -6,6 +6,7 @@ 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';
export const api = new Hono<HonoCustomType>()
@@ -14,6 +15,9 @@ 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")

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

@@ -3,7 +3,7 @@ import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookMail } from "../models";
import { getBooleanValue } from "../utils";
import PostalMime from 'postal-mime';
import { commonParseMail } from "../common";
class WebhookSettings {
@@ -15,10 +15,10 @@ class WebhookSettings {
body: string = JSON.stringify({
"from": "${from}",
"to": "${to}",
"headers": "${headers}",
"subject": "${subject}",
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
}, null, 2)
}
@@ -98,14 +98,14 @@ export async function trigerWebhook(
if (!settings) {
return;
}
const parsedEmail = await PostalMime.parse(raw_mail);
const parsedEmail = await commonParseMail(raw_mail);
const res = await sendWebhook(settings, {
from: parsedEmail.from.address || "",
from: parsedEmail?.sender || "",
to: address,
headers: JSON.stringify(parsedEmail.headers),
subject: parsedEmail.subject || "",
subject: parsedEmail?.subject || "",
raw: raw_mail,
parsedText: parsedEmail.text || parsedEmail.html || ""
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
});
if (!res.success) {
console.log(res.message);
@@ -119,14 +119,15 @@ async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response
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 parsedEmail = await commonParseMail(raw);
const res = await sendWebhook(settings, {
from: parsedEmail?.from?.address || "test@test.com",
from: parsedEmail?.sender || "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"
parsedText: parsedEmail?.text || "test parsed text",
parsedHtml: parsedEmail?.html || "test parsed html"
});
if (!res.success) {
return c.text(res.message || "send webhook error", 400);

View File

@@ -9,10 +9,10 @@ export class AdminWebhookSettings {
export type WebhookMail = {
from: string;
to: string;
headers: string;
subject: string;
raw: string;
parsedText: string;
parsedHtml: string;
}
export class CleanupSettings {

View File

@@ -102,6 +102,7 @@ export const unbindTelegramAddress = async (
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);

View File

@@ -1,15 +1,15 @@
import { Context } from "hono";
import { Jwt } from 'hono/utils/jwt'
import { Telegraf, Context as TgContext, Markup } from "telegraf";
import { callbackQuery } from "telegraf/filters";
import PostalMime from 'postal-mime';
import { CONSTANTS } from "../constants";
import { getDomains, getStringValue } from '../utils';
import { HonoCustomType } from "../types";
import { TelegramSettings } from "./settings";
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
import { commonParseMail } from "../common";
const COMMANDS = [
{
@@ -195,13 +195,13 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
if (!db_address_id) {
return await ctx.reply("无效地址");
}
const { raw, id: mailId } = await c.env.DB.prepare(
const { raw, id: mailId, created_at } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? `
+ ` order by id desc limit 1 offset ?`
).bind(
queryAddress, mailIndex
).first<{ raw: string, id: string }>() || {};
const { mail } = raw ? await parseMail(raw) : { mail: "已经没有邮件了" };
).first<{ raw: string, id: string, created_at: string }>() || {};
const { mail } = raw ? await parseMail(raw, queryAddress, created_at) : { 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) {
@@ -265,22 +265,26 @@ export async function initTelegramBotCommands(bot: Telegraf) {
await bot.telegram.setMyCommands(COMMANDS);
}
const parseMail = async (raw_mail: string | undefined | null) => {
const parseMail = async (
raw_mail: string | undefined | null,
address: string, created_at: 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查看";
const parsedEmail = await commonParseMail(raw_mail);
let parsedText = parsedEmail?.text || "";
if (parsedText.length && parsedText.length > 1000) {
parsedText = parsedEmail?.text.substring(0, 1000) + "\n\n...\n消息过长请到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 || "解析失败,请打开 mini app 查看"}`
mail: `From: ${parsedEmail?.sender || "无发件人"}\n`
+ `To: ${address}\n`
+ (created_at ? `Date: ${created_at}\n` : "")
+ `Subject: ${parsedEmail?.subject}\n`
+ `Content:\n${parsedText || "解析失败,请打开 mini app 查看"}`
};
} catch (e) {
return {
@@ -299,7 +303,7 @@ export async function sendMailToTelegram(
return;
}
const userId = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
const { mail } = await parseMail(raw_mail);
const { mail } = await parseMail(raw_mail, address, new Date().toUTCString());
if (!mail) {
return;
}

11
worker/src/types.d.ts vendored
View File

@@ -6,7 +6,10 @@ export type Bindings = {
SEND_MAIL: any
// config
TITLE: string | undefined
PREFIX: string | undefined
MIN_ADDRESS_LEN: string | number | undefined
MAX_ADDRESS_LEN: string | number | undefined
DOMAINS: string | string[] | undefined
PASSWORDS: string | string[] | undefined
ADMIN_PASSWORDS: string | string[] | undefined
@@ -20,6 +23,14 @@ export type Bindings = {
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

View File

@@ -129,6 +129,23 @@ export const getAdminPasswords = (c: Context<HonoCustomType>): string[] => {
return c.env.ADMIN_PASSWORDS.filter((item) => item.length > 0);
}
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> => {

View File

@@ -125,8 +125,14 @@ app.route('/', apiV1)
app.route('/', apiSendMail)
app.route('/', telegramApi)
app.get('/', async c => c.text("OK"))
app.get('/health_check', async c => c.text("OK"))
app.get('/', async c => {
if (!c.env.DB) { return c.text("DB is not available", 400); }
return c.text("OK");
})
app.get('/health_check', async c => {
if (!c.env.DB) { return c.text("DB is not available", 400); }
return c.text("OK");
})
app.all('/*', async c => c.text("Not Found", 404))

View File

@@ -16,7 +16,11 @@ node_compat = true
# ]
[vars]
# TITLE = "Custom Title" # custom title
PREFIX = "tmp"
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"]
# For admin panel
@@ -46,6 +50,8 @@ ENABLE_AUTO_REPLY = false
# DKIM_PRIVATE_KEY = ""
# 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"