Compare commits

..
16 Commits
Author SHA1 Message Date
dreamhunter2333andClaude 8e5293f010 fix: enhance input validation with trim() for address creation
- Add trim() handling in newAddress() function to prevent whitespace issues
- Add trim() handling for address prefixes to ensure consistent formatting
- Add trim() handling in Telegram API address parsing for robustness
- Prevents edge cases with whitespace-only or padded input strings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 02:06:53 +08:00
dreamhunter2333 711378cb11 feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST 2025-09-04 01:59:15 +08:00
Dream Hunter 648e9f7adf feat: add simplemode button at index (#714) 2025-08-27 20:22:52 +08:00
Dream Hunter ab2bfdd00f feat: 邮件页面增加 上一封/下一封 按钮 (#712) 2025-08-24 19:14:50 +08:00
Dream Hunter 0565978930 feat: 账号设置页面增加 邮件转发规则 和 禁止接收未知地址邮件 配置 (#710) 2025-08-23 16:19:53 +08:00
Dream Hunter 89d8944e60 Docs: update UI install docs (#709) 2025-08-23 01:14:41 +08:00
Dream Hunter 4084771621 feat: |UI| Optimized minimalist mode homepage, added all email page f… (#708)
feat: |UI| Optimized minimalist mode homepage, added all email page functions (delete/download/attachments/...)
2025-08-23 00:23:47 +08:00
Dream Hunter 840496c48f feat: telegram check addrss exists (#705) 2025-08-08 13:26:45 +08:00
Dream Hunter 9843b35f54 feat: telegram use the random domain when not set (#704) 2025-08-08 13:07:00 +08:00
Dream Hunter bfd66f5019 fix: worker node compat (#699) 2025-07-28 13:18:47 +08:00
Dream Hunter 0bc31360b0 feat: upgrade dependencies (#698) 2025-07-28 12:58:47 +08:00
Dream Hunter 267d9bb93e fix: oauth2 callback failed (#691) 2025-07-20 17:09:23 +08:00
Dream Hunter 2cc84d565c feat: upgrade dependencies (#690) 2025-07-19 13:26:10 +08:00
Dream Hunter c96d180591 feat(oauth2): add default role assignment for new OAuth2 users (#688)
- Add default role assignment logic in OAuth2 login flow
- Import getStringValue and getUserRoles utilities
- Validate default role exists in system before assignment
- Use ON CONFLICT DO NOTHING to preserve existing user roles
- Add proper error handling for role assignment failures
2025-07-14 23:55:36 +08:00
Dream Hunter 1303b0f2a9 feat: |UI| add simple index (#684) 2025-06-28 15:52:19 +08:00
Dream Hunter 9f535a0a90 feature: update dependencies (#682) 2025-06-24 18:27:45 +08:00
68 changed files with 3547 additions and 2642 deletions
+1
View File
@@ -1,3 +1,4 @@
.DS_Store
dist/ dist/
test/ test/
.vscode/ .vscode/
+27
View File
@@ -1,6 +1,33 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 --> <!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG # CHANGE LOG
## main(v1.0.5)
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
## v1.0.4
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
- feat: 邮件页面增加 上一封/下一封 按钮
## v1.0.3
- fix: 修复 github actions 部署问题
- feat: telegram /new 不指定域名时, 使用随机地址
## v1.0.2
- fix: 修复 oauth2 登录失败的问题
## v1.0.1
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
- fix: 修复 oauth2 登录时,default role 不生效的问题
## v1.0.0 ## v1.0.0
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效 - fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
+2 -2
View File
@@ -1,5 +1,5 @@
<!-- markdownlint-disable-file MD033 MD045 --> <!-- markdownlint-disable-file MD033 MD045 -->
# 🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务 # Cloudflare 临时邮箱 - 免费搭建临时邮件服务
<p align="center"> <p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank"> <a href="https://temp-mail-docs.awsl.uk" target="_blank">
@@ -81,7 +81,7 @@
<details open> <details open>
<summary>📖 目录(点击收缩/展开)</summary> <summary>📖 目录(点击收缩/展开)</summary>
- [🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#-cloudflare-临时邮箱---免费搭建临时邮件服务) - [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
- [📚 部署文档 - 快速开始](#-部署文档---快速开始) - [📚 部署文档 - 快速开始](#-部署文档---快速开始)
- [📝 更新日志](#-更新日志) - [📝 更新日志](#-更新日志)
- [🎯 在线体验](#-在线体验) - [🎯 在线体验](#-在线体验)
+10 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.0.0", "version": "1.0.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -24,15 +24,15 @@
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.10.0", "axios": "^1.11.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1", "mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.42.0", "naive-ui": "^2.42.0",
"postal-mime": "^2.4.3", "postal-mime": "^2.4.4",
"vooks": "^0.2.12", "vooks": "^0.2.12",
"vue": "^3.5.17", "vue": "^3.5.20",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.6", "vue-i18n": "^11.1.11",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
@@ -40,14 +40,14 @@
"@vicons/material": "^0.13.0", "@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0", "unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.7.0", "unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0", "vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.4.1", "vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0", "workbox-build": "^7.3.0",
"workbox-window": "^7.3.0", "workbox-window": "^7.3.0",
"wrangler": "^4.20.4" "wrangler": "^4.33.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+1037 -1016
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -78,6 +78,7 @@ const getOpenSettings = async (message, notification) => {
adminContact: res["adminContact"] || "", adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false, enableUserCreateEmail: res["enableUserCreateEmail"] || false,
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false, disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false, enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false, enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false, enableIndexAbout: res["enableIndexAbout"] || false,
+80 -150
View File
@@ -1,13 +1,13 @@
<script setup> <script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue"; import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled, ForwardFilled } from '@vicons/material' import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables' import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser' import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils'; import { utcToLocalDate } from '../utils';
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue"; import MailContentRenderer from "./MailContentRenderer.vue";
const message = useMessage() const message = useMessage()
const isMobile = useIsMobile() const isMobile = useIsMobile()
@@ -51,8 +51,8 @@ const props = defineProps({
}) })
const { const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate, autoRefresh, configAutoRefreshInterval, isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
useIframeShowMail, sendMailModel, preferShowTextMail autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState() } = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value) const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const data = ref([]) const data = ref([])
@@ -62,10 +62,49 @@ const count = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const showAttachments = ref(false) const canGoPrevMail = computed(() => {
const curAttachments = ref([]) if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex > 0 || page.value > 1
})
const canGoNextMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex < data.value.length - 1 || count.value > page.value * pageSize.value
})
const prevMail = async () => {
if (!canGoPrevMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
}
}
}
const nextMail = async () => {
if (!canGoNextMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) {
curMail.value = data.value[currentIndex + 1]
} else if (count.value > page.value * pageSize.value) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
}
}
}
const curMail = ref(null); const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const multiActionMode = ref(false) const multiActionMode = ref(false)
const showMultiActionDownload = ref(false) const showMultiActionDownload = ref(false)
@@ -94,6 +133,8 @@ const { t } = useI18n({
cancelMultiAction: 'Cancel Multi Action', cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page', selectAll: 'Select All of This Page',
unselectAll: 'Unselect All', unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
}, },
zh: { zh: {
success: '成功', success: '成功',
@@ -114,6 +155,8 @@ const { t } = useI18n({
cancelMultiAction: '取消多选', cancelMultiAction: '取消多选',
selectAll: '全选本页', selectAll: '全选本页',
unselectAll: '取消全选', unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
} }
} }
}); });
@@ -185,10 +228,6 @@ const clickRow = async (row) => {
curMail.value = row; curMail.value = row;
}; };
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => { const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : ''; return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
@@ -237,14 +276,8 @@ const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size; mailboxSplitSize.value = size;
} }
const attachmentLoding = ref(false)
const saveToS3Proxy = async (filename, blob) => { const saveToS3Proxy = async (filename, blob) => {
attachmentLoding.value = true await props.saveToS3(curMail.value.id, filename, blob);
try {
await props.saveToS3(curMail.value.id, filename, blob);
} finally {
attachmentLoding.value = false
}
} }
const multiActionModeClick = (enableMulti) => { const multiActionModeClick = (enableMulti) => {
@@ -413,59 +446,31 @@ onBeforeUnmount(() => {
</div> </div>
</template> </template>
<template #2> <template #2>
<div v-if="curMail" style="margin: 8px;">
<n-flex justify="space-between">
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
<template #icon>
<n-icon>
<ArrowForwardIosFilled />
</n-icon>
</template>
{{ t('nextMail') }}
</n-button>
</n-flex>
</div>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject" <n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;"> style="overflow: auto; max-height: 100vh;">
<n-space> <MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
<n-tag type="info"> :enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
ID: {{ curMail.id }} :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</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 v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forwardMail') }}
</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>
<ShadowHtmlComponent v-else :htmlContent="curMail.message" style="margin-top: 10px;" />
</n-card> </n-card>
<n-card :bordered="false" embedded class="mail-item" v-else> <n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')"> <n-result status="info" :title="t('pleaseSelectMail')">
@@ -517,89 +522,14 @@ onBeforeUnmount(() => {
style="height: 80vh;"> style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable> <n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card :bordered="false" embedded style="overflow: auto;"> <n-card :bordered="false" embedded style="overflow: auto;">
<n-space> <MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
<n-tag type="info"> :enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
ID: {{ curMail.id }} :useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
</n-tag> :onSaveToS3="saveToS3Proxy" />
<n-tag type="info">
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</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)">
<n-icon :component="CloudDownloadRound" />
{{ 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 v-if="showReply" size="small" tertiary type="info" @click="forwardMail">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forwardMail') }}
</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>
<ShadowHtmlComponent :key="curMail.id" v-else :htmlContent="curMail.message" style="margin-top: 10px;" />
</n-card> </n-card>
</n-drawer-content> </n-drawer-content>
</n-drawer> </n-drawer>
</div> </div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<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-list-item>
</n-list>
</n-spin>
</n-modal>
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')"> <n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
<n-tag type="info"> <n-tag type="info">
{{ multiActionDownloadZip.filename }} {{ multiActionDownloadZip.filename }}
@@ -0,0 +1,282 @@
<script setup>
import { ref } from "vue";
import { useI18n } from 'vue-i18n'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
const { t } = useI18n({
messages: {
en: {
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
attachments: 'View Attachments',
downloadMail: 'Download Mail',
reply: 'Reply',
forward: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show HTML Mail',
saveToS3: 'Save to S3',
size: 'Size',
fullscreen: 'Fullscreen',
},
zh: {
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
attachments: '查看附件',
downloadMail: '下载邮件',
reply: '回复',
forward: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
size: '大小',
fullscreen: '全屏',
}
}
});
const props = defineProps({
mail: {
type: Object,
required: true
},
showEMailTo: {
type: Boolean,
default: true
},
enableUserDeleteEmail: {
type: Boolean,
default: false
},
showReply: {
type: Boolean,
default: false
},
showSaveS3: {
type: Boolean,
default: false
},
// 回调函数 props
onDelete: {
type: Function,
default: () => { }
},
onReply: {
type: Function,
default: () => { }
},
onForward: {
type: Function,
default: () => { }
},
onSaveToS3: {
type: Function,
default: () => { }
}
});
const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const showFullscreen = ref(false);
const handleDelete = () => {
props.onDelete();
};
const handleViewAttachments = () => {
curAttachments.value = props.mail.attachments;
showAttachments.value = true;
};
const handleReply = () => {
props.onReply();
};
const handleForward = () => {
props.onForward();
};
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
try {
await props.onSaveToS3(filename, blob);
} finally {
attachmentLoding.value = false;
}
};
</script>
<template>
<div class="mail-content-renderer">
<!-- 邮件信息标签 -->
<n-space>
<n-tag type="info">
ID: {{ mail.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(mail.created_at, useUTCDate.value) }}
</n-tag>
<n-tag type="info">
FROM: {{ mail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ mail.address }}
</n-tag>
<!-- 操作按钮 -->
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="handleDelete">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="mail.attachments && mail.attachments.length > 0" size="small" tertiary type="info"
@click="handleViewAttachments">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleForward">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forward') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showFullscreen = true">
<template #icon>
<n-icon :component="FullscreenRound" />
</template>
{{ t('fullscreen') }}
</n-button>
</n-space>
<!-- 邮件内容 -->
<div class="mail-content">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
</div>
</div>
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 100vh;">
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
<!-- 附件模态框 -->
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('attachments') }}</div>
</template>
<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="handleSaveToS3(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-list-item>
</n-list>
</n-spin>
</n-modal>
</template>
<style scoped>
.mail-content-renderer {
display: flex;
flex-direction: column;
gap: 10px;
}
.mail-content {
margin-top: 10px;
flex: 1;
}
.mail-text {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
padding: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.mail-iframe {
width: 100%;
height: 100%;
border: none;
min-height: 400px;
}
.mail-html {
width: 100%;
height: 100%;
}
.center {
text-align: center;
}
.fullscreen-mail-content {
height: calc(100vh - 120px);
overflow: auto;
}
.fullscreen-mail-content .mail-iframe {
min-height: calc(100vh - 120px);
}
</style>
+3
View File
@@ -10,6 +10,7 @@ export const useGlobalState = createGlobalState(
const toggleDark = useToggle(isDark) const toggleDark = useToggle(isDark)
const loading = ref(false); const loading = ref(false);
const announcement = useLocalStorage('announcement', ''); const announcement = useLocalStorage('announcement', '');
const useSimpleIndex = useLocalStorage('useSimpleIndex', false);
const openSettings = ref({ const openSettings = ref({
fetched: false, fetched: false,
title: '', title: '',
@@ -21,6 +22,7 @@ export const useGlobalState = createGlobalState(
adminContact: '', adminContact: '',
enableUserCreateEmail: false, enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false, disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false, enableUserDeleteEmail: false,
enableAutoReply: false, enableAutoReply: false,
enableIndexAbout: false, enableIndexAbout: false,
@@ -142,6 +144,7 @@ export const useGlobalState = createGlobalState(
showAdminPage, showAdminPage,
userOauth2SessionState, userOauth2SessionState,
userOauth2SessionClientID, userOauth2SessionClientID,
useSimpleIndex,
} }
}, },
) )
+67 -40
View File
@@ -5,19 +5,24 @@ import { useRoute } from 'vue-router'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api' import { api } from '../api'
import { useIsMobile } from '../utils/composables'
import { FullscreenExitOutlined } from '@vicons/material'
import AddressBar from './index/AddressBar.vue'; import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue'; import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue'; import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue'; import AutoReply from './index/AutoReply.vue';
import AccountSettings from './index/AccountSettings.vue'; import AccountSettings from './index/AccountSettings.vue';
import Appearance from './common/Appearance.vue';
import Webhook from './index/Webhook.vue'; import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue'; import Attachment from './index/Attachment.vue';
import About from './common/About.vue'; import About from './common/About.vue';
import SimpleIndex from './index/SimpleIndex.vue';
const { loading, settings, openSettings, indexTab, globalTabplacement } = useGlobalState() const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
const message = useMessage() const message = useMessage()
const route = useRoute() const route = useRoute()
const isMobile = useIsMobile()
const SendMail = defineAsyncComponent(() => { const SendMail = defineAsyncComponent(() => {
loading.value = true; loading.value = true;
@@ -33,23 +38,27 @@ const { t } = useI18n({
sendmail: 'Send Mail', sendmail: 'Send Mail',
auto_reply: 'Auto Reply', auto_reply: 'Auto Reply',
accountSettings: 'Account Settings', accountSettings: 'Account Settings',
appearance: 'Appearance',
about: 'About', about: 'About',
s3Attachment: 'S3 Attachment', s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success', saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings', webhookSettings: 'Webhook Settings',
query: 'Query', query: 'Query',
enterSimpleMode: 'Simple Mode',
}, },
zh: { zh: {
mailbox: '收件箱', mailbox: '收件箱',
sendbox: '发件箱', sendbox: '发件箱',
sendmail: '发送邮件', sendmail: '发送邮件',
auto_reply: '自动回复', auto_reply: '自动回复',
accountSettings: '账户设置', accountSettings: '账户',
appearance: '外观',
about: '关于', about: '关于',
s3Attachment: 'S3附件', s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功', saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置', webhookSettings: 'Webhook 设置',
query: '查询', query: '查询',
enterSimpleMode: '极简模式',
} }
} }
}); });
@@ -122,43 +131,61 @@ onMounted(() => {
<template> <template>
<div> <div>
<AddressBar /> <div v-if="useSimpleIndex">
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement"> <SimpleIndex />
<n-tab-pane name="mailbox" :tab="t('mailbox')"> </div>
<div v-if="showMailIdQuery" style="margin-bottom: 10px;"> <div v-else>
<n-input-group> <AddressBar />
<n-input v-model:value="mailIdQuery" /> <n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<n-button @click="queryMail" type="primary" tertiary> <template #prefix v-if="!isMobile">
{{ t('query') }} <n-button @click="useSimpleIndex = true" tertiary size="small">
</n-button> <template #icon>
</n-input-group> <n-icon>
</div> <FullscreenExitOutlined />
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" </n-icon>
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" </template>
:fetchMailData="fetchMailData" :deleteMail="deleteMail" /> {{ t('enterSimpleMode') }}
</n-tab-pane> </n-button>
<n-tab-pane name="sendbox" :tab="t('sendbox')"> </template>
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" <n-tab-pane name="mailbox" :tab="t('mailbox')">
:deleteMail="deleteSenboxMail" /> <div v-if="showMailIdQuery" style="margin-bottom: 10px;">
</n-tab-pane> <n-input-group>
<n-tab-pane name="sendmail" :tab="t('sendmail')"> <n-input v-model:value="mailIdQuery" />
<SendMail /> <n-button @click="queryMail" type="primary" tertiary>
</n-tab-pane> {{ t('query') }}
<n-tab-pane name="accountSettings" :tab="t('accountSettings')"> </n-button>
<AccountSettings /> </n-input-group>
</n-tab-pane> </div>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')"> <MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
<AutoReply /> :saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
</n-tab-pane> :fetchMailData="fetchMailData" :deleteMail="deleteMail" />
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')"> </n-tab-pane>
<Webhook /> <n-tab-pane name="sendbox" :tab="t('sendbox')">
</n-tab-pane> <SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')"> :deleteMail="deleteSenboxMail" />
<Attachment /> </n-tab-pane>
</n-tab-pane> <n-tab-pane name="sendmail" :tab="t('sendmail')">
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')"> <SendMail />
<About /> </n-tab-pane>
</n-tab-pane> <n-tab-pane name="accountSettings" :tab="t('accountSettings')">
</n-tabs> <AccountSettings />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance :showUseSimpleIndex="true" />
</n-tab-pane>
<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('webhookSettings')">
<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>
</n-tabs>
</div>
</div> </div>
</template> </template>
+4 -4
View File
@@ -27,7 +27,7 @@ const { t } = useI18n({
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.', addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete', delete: 'Delete',
deleteTip: 'Are you sure to delete this email?', deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account', deleteAccount: 'Delete Account',
viewMails: 'View Mails', viewMails: 'View Mails',
viewSendBox: 'View SendBox', viewSendBox: 'View SendBox',
itemCount: 'itemCount', itemCount: 'itemCount',
@@ -46,7 +46,7 @@ const { t } = useI18n({
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。', addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除', delete: '删除',
deleteTip: '确定要删除这个邮箱吗?', deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱', deleteAccount: '删除邮箱',
viewMails: '查看邮件', viewMails: '查看邮件',
viewSendBox: '查看发件箱', viewSendBox: '查看发件箱',
itemCount: '总数', itemCount: '总数',
@@ -273,11 +273,11 @@ onMounted(async () => {
<template #action> <template #action>
</template> </template>
</n-modal> </n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')"> <n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p> <p>{{ t('deleteTip') }}</p>
<template #action> <template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error"> <n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
+150 -7
View File
@@ -1,11 +1,12 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { loading } = useGlobalState() const { loading, openSettings } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
@@ -20,6 +21,20 @@ const { t } = useI18n({
noLimitSendAddressList: 'No Balance Limit Send Address List', noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)', verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email', fromBlockList: 'Block Keywords for receive email',
block_receive_unknow_address_email: 'Block receive unknow address email',
email_forwarding_config: 'Email Forwarding Configuration',
domain_list: 'Domain List',
forward_address: 'Forward Address',
actions: 'Actions',
select_domain: 'Select Domain',
forward_placeholder: 'forward@example.com',
delete_rule: 'Delete',
delete_rule_confirm: 'Are you sure you want to delete this rule?',
delete_success: 'Delete Success',
forwarding_rule_warning: 'Each rule will run, if domains is empty, all emails will be forwarded, forward address needs to be a verified address',
add: 'Add',
cancel: 'Cancel',
config: 'Config',
}, },
zh: { zh: {
tip: '您可以手动输入以下多选输入框, 回车增加', tip: '您可以手动输入以下多选输入框, 回车增加',
@@ -31,6 +46,20 @@ const { t } = useI18n({
noLimitSendAddressList: '无余额限制发送地址列表', noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)', verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词', fromBlockList: '接收邮件地址屏蔽关键词',
block_receive_unknow_address_email: '禁止接收未知地址邮件',
email_forwarding_config: '邮件转发配置',
domain_list: '域名列表',
forward_address: '转发地址',
actions: '操作',
select_domain: '选择域名',
forward_placeholder: 'forward@example.com',
delete_rule: '删除',
delete_rule_confirm: '确定要删除这条规则吗?',
delete_success: '删除成功',
forwarding_rule_warning: '每条规则都会运行,如果 domains 为空,则转发所有邮件,转发地址需要为已验证的地址',
add: '添加',
cancel: '取消',
config: '配置',
} }
} }
}); });
@@ -40,6 +69,90 @@ const sendAddressBlockList = ref([])
const noLimitSendAddressList = ref([]) const noLimitSendAddressList = ref([])
const verifiedAddressList = ref([]) const verifiedAddressList = ref([])
const fromBlockList = ref([]) const fromBlockList = ref([])
const emailRuleSettings = ref({
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
})
const showEmailForwardingModal = ref(false)
const emailForwardingList = ref([])
const emailForwardingColumns = [
{
title: t('domain_list'),
key: 'domains',
render: (row, index) => {
return h(NSelect, {
value: Array.isArray(row.domains) ? row.domains : [],
onUpdateValue: (val) => {
emailForwardingList.value[index].domains = val
},
options: openSettings.value?.domains || [],
multiple: true,
filterable: true,
tag: true,
placeholder: t('select_domain')
})
}
},
{
title: t('forward_address'),
key: 'forward',
render: (row, index) => {
return h(NInput, {
value: row.forward,
onUpdateValue: (val) => {
emailForwardingList.value[index].forward = val
},
placeholder: 'forward@example.com'
})
}
},
{
title: t('actions'),
key: 'actions',
render: (row, index) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(NPopconfirm, {
onPositiveClick: () => {
emailForwardingList.value = emailForwardingList.value.filter((_, i) => i !== index)
message.success(t('delete_success'))
}
}, {
default: () => t('delete_rule_confirm'),
trigger: () => h(NButton, {
size: 'small',
type: 'error'
}, { default: () => t('delete_rule') })
})
])
}
}
]
const openEmailForwardingModal = () => {
// 从 emailRuleSettings 转换出列表数据
emailForwardingList.value = emailRuleSettings.value.emailForwardingList ?
[...emailRuleSettings.value.emailForwardingList] : []
showEmailForwardingModal.value = true
}
const addNewEmailForwardingItem = () => {
emailForwardingList.value = [
...emailForwardingList.value,
{
domains: [],
forward: ''
}
]
}
const saveEmailForwardingConfig = () => {
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
showEmailForwardingModal.value = false
}
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -49,6 +162,10 @@ const fetchData = async () => {
verifiedAddressList.value = res.verifiedAddressList || [] verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || [] fromBlockList.value = res.fromBlockList || []
noLimitSendAddressList.value = res.noLimitSendAddressList || [] noLimitSendAddressList.value = res.noLimitSendAddressList || []
emailRuleSettings.value = res.emailRuleSettings || {
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
}
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} }
@@ -64,6 +181,7 @@ const save = async () => {
verifiedAddressList: verifiedAddressList.value || [], verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [], fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [], noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
}) })
}) })
message.success(t('successTip')) message.success(t('successTip'))
@@ -81,9 +199,14 @@ onMounted(async () => {
<template> <template>
<div class="center"> <div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;"> <n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" style="margin-bottom: 10px;"> <n-alert :show-icon="false" :bordered="false" type="warning" style="margin-bottom: 10px;">
{{ t("tip") }} <span>{{ t("tip") }}</span>
</n-alert> </n-alert>
<n-flex justify="end">
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('address_block_list')"> <n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag <n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" /> :placeholder="t('address_block_list_placeholder')" />
@@ -103,11 +226,31 @@ onMounted(async () => {
<n-form-item-row :label="t('fromBlockList')"> <n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" /> <n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
</n-form-item-row> </n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading"> <n-form-item-row :label="t('block_receive_unknow_address_email')">
{{ t('save') }} <n-checkbox v-model:checked="emailRuleSettings.blockReceiveUnknowAddressEmail" />
</n-button> </n-form-item-row>
<n-form-item-row :label="t('email_forwarding_config')">
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
</n-form-item-row>
</n-card> </n-card>
</div> </div>
<!-- 邮件转发配置弹窗 -->
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
style="max-width: 800px;">
<n-space vertical>
<n-alert :show-icon="false" :bordered="false" type="warning">
<span>{{ t('forwarding_rule_warning') }}</span>
</n-alert>
<n-space justify="end">
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
</n-space>
<n-data-table :columns="emailForwardingColumns" :data="emailForwardingList" :bordered="false" striped />
<n-space justify="end">
<n-button @click="saveEmailForwardingConfig" type="primary">{{ t('save') }}</n-button>
</n-space>
</n-space>
</n-modal>
</template> </template>
<style scoped> <style scoped>
+12 -1
View File
@@ -3,16 +3,23 @@ import { useI18n } from 'vue-i18n'
import { useIsMobile } from '../../utils/composables' import { useIsMobile } from '../../utils/composables'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
const props = defineProps({
showUseSimpleIndex: {
type: Boolean,
default: false
}
})
const { const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval, mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
} = useGlobalState() } = useGlobalState()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
en: { en: {
useSimpleIndex: 'Use Simple Index',
mailboxSplitSize: 'Mailbox Split Size', mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show HTML Mail', useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default', preferShowTextMail: 'Display text Mail by default',
@@ -26,6 +33,7 @@ const { t } = useI18n({
autoRefreshInterval: 'Auto Refresh Interval(Sec)', autoRefreshInterval: 'Auto Refresh Interval(Sec)',
}, },
zh: { zh: {
useSimpleIndex: '使用极简主页',
mailboxSplitSize: '邮箱界面分栏大小', mailboxSplitSize: '邮箱界面分栏大小',
preferShowTextMail: '默认以文本显示邮件', preferShowTextMail: '默认以文本显示邮件',
useIframeShowMail: '使用iframe显示HTML邮件', useIframeShowMail: '使用iframe显示HTML邮件',
@@ -57,6 +65,9 @@ const { t } = useI18n({
60: '60', 120: '120', 180: '180', 240: '240' 60: '60', 120: '120', 180: '180', 240: '240'
}" /> }" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row v-if="props.showUseSimpleIndex" :label="t('useSimpleIndex')">
<n-switch v-model:value="useSimpleIndex" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('preferShowTextMail')"> <n-form-item-row :label="t('preferShowTextMail')">
<n-switch v-model:value="preferShowTextMail" :round="false" /> <n-switch v-model:value="preferShowTextMail" :round="false" />
</n-form-item-row> </n-form-item-row>
+10 -5
View File
@@ -84,6 +84,7 @@ const { locale, t } = useI18n({
credentialInput: 'Please input the Mail Address Credential', credentialInput: 'Please input the Mail Address Credential',
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user', bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user', bindUserAddressError: 'Error when bind email address to user',
autoGeneratedName: 'Auto-generated name',
}, },
zh: { zh: {
login: '登录', login: '登录',
@@ -100,6 +101,7 @@ const { locale, t } = useI18n({
credentialInput: '请输入邮箱地址凭据', credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户', bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误', bindUserAddressError: '绑定邮箱地址到用户时错误',
autoGeneratedName: '自动生成名称',
} }
} }
}); });
@@ -147,8 +149,10 @@ const generateName = async () => {
const newEmail = async () => { const newEmail = async () => {
try { try {
// If custom names are disabled, send empty name to trigger backend auto-generation
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
const res = await props.newAddressPath( const res = await props.newAddressPath(
emailName.value, nameToSend,
emailDomain.value, emailDomain.value,
cfToken.value cfToken.value
); );
@@ -240,19 +244,20 @@ onMounted(async () => {
<n-spin :show="generateNameLoading"> <n-spin :show="generateNameLoading">
<n-form> <n-form>
<span> <span>
<p>{{ t("getNewEmailTip1") + addressRegex.source }}</p> <p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p>{{ t("getNewEmailTip2") }}</p> <p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p> <p>{{ t("getNewEmailTip3") }}</p>
</span> </span>
<n-button @click="generateName" style="margin-bottom: 10px;"> <n-button v-if="!openSettings.disableCustomAddressName" @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }} {{ t('generateName') }}
</n-button> </n-button>
<n-input-group> <n-input-group>
<n-input-group-label v-if="addressPrefix"> <n-input-group-label v-if="addressPrefix">
{{ addressPrefix }} {{ addressPrefix }}
</n-input-group-label> </n-input-group-label>
<n-input v-model:value="emailName" show-count :minlength="openSettings.minAddressLen" <n-input v-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" /> :maxlength="openSettings.maxAddressLen" />
<n-input v-else :value="t('autoGeneratedName')" disabled />
<n-input-group-label>@</n-input-group-label> <n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false" <n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="domainsOptions" /> :options="domainsOptions" />
+12 -14
View File
@@ -5,7 +5,6 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
import { getRouterPathWithLang } from '../../utils' import { getRouterPathWithLang } from '../../utils'
const { const {
@@ -15,24 +14,24 @@ const router = useRouter()
const message = useMessage() const message = useMessage()
const showLogout = ref(false) const showLogout = ref(false)
const showDelteAccount = ref(false) const showDeleteAccount = ref(false)
const { locale, t } = useI18n({ const { locale, t } = useI18n({
messages: { messages: {
en: { en: {
logout: "Logout", logout: "Logout",
delteAccount: "Delete Account", deleteAccount: "Delete Account",
showAddressCredential: 'Show Address Credential', showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?', logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account", deleteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?", deleteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
}, },
zh: { zh: {
logout: '退出登录', logout: '退出登录',
delteAccount: "删除账户", deleteAccount: "删除账户",
showAddressCredential: '查看邮箱地址凭证', showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?', logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户", deleteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?", deleteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
} }
} }
}); });
@@ -60,15 +59,14 @@ const deleteAccount = async () => {
<template> <template>
<div class="center" v-if="settings.address"> <div class="center" v-if="settings.address">
<n-card :bordered="false" embedded> <n-card :bordered="false" embedded>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong> <n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }} {{ t('showAddressCredential') }}
</n-button> </n-button>
<n-button @click="showLogout = true" secondary block strong> <n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }} {{ t('logout') }}
</n-button> </n-button>
<n-button @click="showDelteAccount = true" type="error" secondary block strong> <n-button @click="showDeleteAccount = true" type="error" secondary block strong>
{{ t('delteAccount') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</n-card> </n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')"> <n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
@@ -79,11 +77,11 @@ const deleteAccount = async () => {
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')"> <n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('delteAccountConfirm') }}</p> <p>{{ t('deleteAccountConfirm') }}</p>
<template #action> <template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error"> <n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('delteAccount') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</template> </template>
</n-modal> </n-modal>
+283
View File
@@ -0,0 +1,283 @@
<script setup>
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import {
ExitToAppFilled,
ContentCopyFilled,
RefreshFilled,
ArrowBackIosNewFilled,
ArrowForwardIosFilled,
SettingsFilled
} from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Login from '../common/Login.vue'
import AccountSettings from './AccountSettings.vue'
import { processItem } from '../../utils/email-parser'
import MailContentRenderer from '../../components/MailContentRenderer.vue'
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
const message = useMessage()
// 邮件数据
const currentPage = ref(1)
const totalCount = ref(0)
const currentMail = ref(null)
const showAccountSettingsCard = ref(false)
const currentAutoRefreshInterval = ref(60)
const timer = ref(null)
const { t } = useI18n({
messages: {
en: {
exitSimpleIndex: 'Exit Simple',
copyAddress: 'Copy',
addressCopied: 'Address copied successfully',
refreshMails: 'Refresh',
noMails: 'No mails found',
prevPage: 'Previous',
nextPage: 'Next',
refreshSuccess: 'Mails refreshed successfully',
mailCount: '{current} / {total} emails',
accountSettings: "Account Settings",
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login',
deleteSuccess: 'Mail deleted successfully',
refreshAfter: 'Refresh After {msg} Seconds',
},
zh: {
exitSimpleIndex: '退出极简',
copyAddress: '复制',
addressCopied: '地址复制成功',
refreshMails: '刷新',
noMails: '暂无邮件',
prevPage: '上一页',
nextPage: '下一页',
refreshSuccess: '邮件刷新成功',
mailCount: '{current} / {total} 封邮件',
accountSettings: "账户设置",
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
deleteSuccess: '邮件删除成功',
refreshAfter: '{msg}秒后刷新',
}
}
})
// 复制地址
const copyAddress = async () => {
try {
await navigator.clipboard.writeText(settings.value.address)
message.success(t('addressCopied'))
} catch (error) {
message.error('复制失败')
}
}
// 获取邮件数据
const fetchMails = async () => {
if (!settings.value.address) return
try {
const { results, count } = await api.fetch(`/api/mails?limit=1&offset=${currentPage.value - 1}`)
totalCount.value = count > 0 ? count : totalCount.value;
const rawMail = results && results.length > 0 ? results[0] : null
currentMail.value = rawMail ? await processItem(rawMail) : null
} catch (error) {
console.error('Failed to fetch mails:', error)
message.error('获取邮件失败')
}
}
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
try {
await api.fetch(`/api/mails/${currentMail.value.id}`, { method: 'DELETE' });
message.success(t('deleteSuccess'));
currentMail.value = null;
await refreshMails();
} catch (error) {
console.error('Failed to delete mail:', error);
message.error('删除邮件失败');
}
}
// 刷新邮件
const refreshMails = async () => {
if (loading.value) return
currentPage.value = 1
showAccountSettingsCard.value = false
currentAutoRefreshInterval.value = 60
await fetchMails()
message.success(t('refreshSuccess'))
}
// 分页控制
const currentPageDisplay = computed(() => currentPage.value)
const totalPages = computed(() => Math.max(1, totalCount.value))
const canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < totalPages.value)
const isFirstPage = computed(() => currentPage.value === 1)
const prevPage = async () => {
if (canGoPrev.value) {
currentPage.value--
}
}
const nextPage = async () => {
if (canGoNext.value) {
currentPage.value++
}
}
// 监听页面变化
watch(currentPage, () => {
fetchMails()
})
onMounted(async () => {
await api.getSettings()
await fetchMails()
// 启动自动刷新
timer.value = setInterval(async () => {
if (!isFirstPage.value) {
currentAutoRefreshInterval.value = 60
return
}
if (--currentAutoRefreshInterval.value <= 0) {
await refreshMails()
}
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<template>
<div class="center">
<div v-if="!settings.address">
<n-card :bordered="false" embedded>
<Login />
</n-card>
</div>
<div v-else>
<n-card :bordered="false" embedded>
<div style="text-align: center; margin-bottom: 16px; font-size: 18px;">
<n-text strong size="large">{{ settings.address }}</n-text>
</div>
<n-flex justify="center">
<n-button @click="refreshMails" :loading="loading" type="primary" tertiary size="small">
<template #icon>
<n-icon>
<RefreshFilled />
</n-icon>
</template>
{{ t('refreshMails') }}
</n-button>
<n-button @click="copyAddress" tertiary size="small">
<template #icon>
<n-icon>
<ContentCopyFilled />
</n-icon>
</template>
{{ t('copyAddress') }}
</n-button>
<n-button @click="useSimpleIndex = false" tertiary size="small">
<template #icon>
<n-icon>
<ExitToAppFilled />
</n-icon>
</template>
{{ t('exitSimpleIndex') }}
</n-button>
<n-button @click="showAccountSettingsCard = true" tertiary size="small">
<template #icon>
<n-icon>
<SettingsFilled />
</n-icon>
</template>
{{ t('accountSettings') }}
</n-button>
</n-flex>
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
<n-text depth="3" size="12">
{{ t('refreshAfter', { msg: Math.max(0, currentAutoRefreshInterval) }) }}
</n-text>
</div>
</n-card>
<!-- 账户设置卡片 -->
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
<AccountSettings />
</n-card>
<n-card v-else :bordered="false" embedded style="text-align: left;">
<div v-if="totalCount > 1">
<n-flex justify="space-between">
<n-button @click="prevPage" :disabled="!canGoPrev" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevPage') }}
</n-button>
<n-text size="small">
{{ t('mailCount', { current: currentPageDisplay, total: totalCount }) }}
</n-text>
<n-button @click="nextPage" :disabled="!canGoNext" text size="small" icon-placement="right">
<template #icon>
<n-icon>
<ArrowForwardIosFilled />
</n-icon>
</template>
{{ t('nextPage') }}
</n-button>
</n-flex>
</div>
<div v-if="!currentMail" class="no-mail">
<n-empty :description="t('noMails')" />
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:onDelete="deleteMail" />
</div>
</div>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.center {
max-width: 800px;
margin: 0 auto;
}
.n-card {
margin-top: 20px;
width: 100%;
}
</style>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp-email-pages", "name": "temp-email-pages",
"version": "1.0.0", "version": "1.0.5",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"wrangler": "^4.20.4" "wrangler": "^4.33.0"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -24,9 +24,9 @@ res = requests.post(
print(res.json()) print(res.json())
``` ```
# 批量创建随机用户名邮箱地址 API 示例 ## 批量创建随机用户名邮箱地址 API 示例
## 通过 admin API 批量新建邮箱地址 ### 通过 admin API 批量新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。 这是一个 `python` 的例子,使用 `requests` 库发送邮件。
+3 -5
View File
@@ -2,7 +2,7 @@
## 创建数据库 ## 创建数据库
打开 cloudflare 控制台,选择 `Workers & Pages` -> `D1` -> `Create Database`,点击创建数据库 打开 cloudflare 控制台,选择 `Storage & Databases` -> `D1 SQL Database` -> `Create Database`,点击创建数据库
![d1](/ui_install/d1.png) ![d1](/ui_install/d1.png)
@@ -10,13 +10,11 @@
## 初始化数据库 ## 初始化数据库
你也可以跳过初始化数据库,在部署完成后,在 admin 页面的 `快速设置` -> `数据库` 中,点击 `初始化数据库` 按钮来初始化数据库
::: warning 注意 ::: warning 注意
下面输入的是 `db/schema.sql` 的内容 你也可以跳过初始化数据库,在部署完成后,在 admin 页面的 `快速设置` -> `数据库` 中,点击 `初始化数据库` 按钮来初始化数据库
::: :::
打开 `Console` 标签页,输入 `db/schema.sql` 的内容,点击 `Execute` 执行 打开 `Console` 标签页,输入仓库中 [db/schema.sql](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/db/schema.sql) 文件的内容,点击 `Execute` 执行
![d1](/ui_install/d1-exec.png) ![d1](/ui_install/d1-exec.png)
+2 -2
View File
@@ -42,11 +42,11 @@ const generate = async () => {
} }
</script> </script>
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application` 1. 点击 `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create pages](/ui_install/worker_home.png) ![create pages](/ui_install/worker_home.png)
2. 选择 `Pages`,选择 `Create using direct upload` 2. 选择 `Pages`,选择 `Use direct upload`
![pages](/ui_install/pages.png) ![pages](/ui_install/pages.png)
+27 -10
View File
@@ -3,15 +3,16 @@
> [!warning] 注意 > [!warning] 注意
> `worker.dev` 域名在中国无法访问,请自定义域名 > `worker.dev` 域名在中国无法访问,请自定义域名
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application` 1. 点击 `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create worker](/ui_install/worker_home.png) ![create worker](/ui_install/worker_home.png)
2. 选择 `Worker`,点击 `Create Worker`, 修改名称然后点击 `Deploy` 2. 选择 `Worker`,点击 `Create Worker`, 修改名称然后点击 `Deploy`
![worker1](/ui_install/worker-1.png) ![worker1](/ui_install/worker-1.png)
![worker2](/ui_install/worker-2.png)
3. 回到 `Overview`,找到刚刚创建的 worker,点击 `Settings` -> `Runtime`, 修改 `Compatibility flags`, 增加 `nodejs_compat`, 兼容日期也需要大于图片中的日期。 3. 回到 `Workers & Pages`,找到刚刚创建的 worker,点击 `Settings` -> `Runtime`, 修改 `Compatibility flags`, 手动输入增加 `nodejs_compat`, 兼容日期也需要大于图片中的日期。
![worker-runtime](/ui_install/worker-runtime.png) ![worker-runtime](/ui_install/worker-runtime.png)
@@ -26,10 +27,11 @@
> >
> 参考: [issues156](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/156#issuecomment-2079453822) > 参考: [issues156](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/156#issuecomment-2079453822)
![worker2](/ui_install/worker-2.png) ![worker3](/ui_install/worker-3.png)
![worker-upload](/ui_install/worker-upload.png) ![worker-upload](/ui_install/worker-upload.png)
6. 点击 `Settings` -> `Variables`, 如图所示添加变量 6. 点击 `Settings` -> `Variables and Secrets`, 如图所示添加变量
![worker-var](/ui_install/worker-var.png) ![worker-var](/ui_install/worker-var.png)
@@ -51,12 +53,16 @@
| `ENABLE_USER_CREATE_EMAIL` | 文本/JSON | 是否允许用户创建邮箱, 不配置则不允许 | `true` | | `ENABLE_USER_CREATE_EMAIL` | 文本/JSON | 是否允许用户创建邮箱, 不配置则不允许 | `true` |
| `ENABLE_USER_DELETE_EMAIL` | 文本/JSON | 是否允许用户删除邮件, 不配置则不允许 | `true` | | `ENABLE_USER_DELETE_EMAIL` | 文本/JSON | 是否允许用户删除邮件, 不配置则不允许 | `true` |
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy` 7. 点击 `Settings` -> `Bindings`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Add Binding`
> [!NOTE] 重要 > [!NOTE] 重要
> 注意此处 `D1 Database` 的绑定名称必须为 `DB` > 注意此处 `D1 Database` 的绑定名称必须为 `DB`
![worker-d1](/ui_install/worker-d1.png) ![worker-bindings](/ui_install/worker-bindings.png)
![worker-d1-1](/ui_install/worker-d1-1.png)
![worker-d1-2](/ui_install/worker-d1-2.png)
8. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。 8. 点击 `Settings` -> `Trggers`, 这里可以添加自己的域名,你也可以使用自动生成的 `*.workers.dev` 的域名。记录下这个域名,后面部署前端会用到。
@@ -67,24 +73,35 @@
![worker3](/ui_install/worker-3.png) ![worker3](/ui_install/worker-3.png)
9. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy` 9. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
> [!NOTE] 重要 > [!NOTE] 重要
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤 > 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
> >
> 注意此处 `KV` 的绑定名称必须为 `KV` > 注意此处 `KV` 的绑定名称必须为 `KV`
点击 `Storage & Databases` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`
![worker-kv](/ui_install/worker-kv.png) ![worker-kv](/ui_install/worker-kv.png)
![worker-kv-bind](/ui_install/worker-kv-bind.png)
![worker-kv-0](/ui_install/worker-kv-0.png)
然后点击 `Settings` -> `Bindings`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 KV,点击 `Add Binding`
![worker-bindings](/ui_install/worker-bindings.png)
![worker-kv-1](/ui_install/worker-kv-1.png)
![worker-kv-2](/ui_install/worker-kv-2.png)
10. Telegram Bot 配置 10. Telegram Bot 配置
> [!NOTE] > [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤 > 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot,然后获取 `token`然后执行下面的命令,`token` 添加到 `Variables` 中, Name: `TELEGRAM_BOT_TOKEN` 请先创建一个 Telegram Bot,然后获取 `token`,将 `token` 添加到 `Variables and Secrets` 中, 变量名称: `TELEGRAM_BOT_TOKEN`
11. 如果你想要使用 admin 页面中的定时任务清理邮件,需要到 `Settings` -> `Triggers` -> `Cron Triggers` 中添加定时任务. 11. 如果你想要使用 admin 页面中的定时任务清理邮件,需要到 `Settings` -> `Trigger Events` -> `Cron Triggers` 中添加定时任务.
> [!NOTE] > [!NOTE]
> 选择 `cron` 表达式,输入 `0 0 * * *`(此表达式表示每天午夜运行),点击 `Add` 增加。请根据您的需求调整此表达式。 > 选择 `cron` 表达式,输入 `0 0 * * *`(此表达式表示每天午夜运行),点击 `Add` 增加。请根据您的需求调整此表达式。
+13 -11
View File
@@ -22,17 +22,19 @@
## 邮箱相关变量 ## 邮箱相关变量
| 变量名 | 类型 | 说明 | 示例 | | 变量名 | 类型 | 说明 | 示例 |
| ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | ------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `PREFIX` | 文本 | 新建 `邮箱名称` 的默认前缀,不需要前缀可不配置 | `tmp` | | `PREFIX` | 文本 | 新建 `邮箱名称` 的默认前缀,不需要前缀可不配置 | `tmp` |
| `MIN_ADDRESS_LEN` | 数字 | `邮箱名称` 的最小长度 | `1` | | `MIN_ADDRESS_LEN` | 数字 | `邮箱名称` 的最小长度 | `1` |
| `MAX_ADDRESS_LEN` | 数字 | `邮箱名称` 的最大长度 | `30` | | `MAX_ADDRESS_LEN` | 数字 | `邮箱名称` 的最大长度 | `30` |
| `ADDRESS_CHECK_REGEX` | 文本 | `邮箱名称` 的正则表达式, 只用于检查 | `^(?!.*admin).*` | | `DISABLE_CUSTOM_ADDRESS_NAME` | 文本/JSON | 禁用自定义邮箱地址名称,如果设置为 true,则用户无法输入自定义邮箱名称,将由后台自动生成 | `true` |
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` | | `ADDRESS_CHECK_REGEX` | 文本 | `邮箱名称` 的正则表达式, 只用于检查 | `^(?!.*admin).*` |
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` | | `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` | | `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` | | `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` | | `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
## 接受邮件相关变量 ## 接受邮件相关变量
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp-mail-docs", "name": "temp-mail-docs",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.5",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.3", "@types/node": "^24.3.0",
"vitepress": "^1.6.3", "vitepress": "^1.6.4",
"wrangler": "^4.20.4" "wrangler": "^4.33.0"
}, },
"scripts": { "scripts": {
"dev": "vitepress dev docs", "dev": "vitepress dev docs",
+449 -431
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.0.0", "version": "1.0.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,26 +11,26 @@
"build": "wrangler deploy --dry-run --outdir dist --minify" "build": "wrangler deploy --dry-run --outdir dist --minify"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250620.0", "@cloudflare/workers-types": "^4.20250826.0",
"@eslint/js": "9.18.0", "@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0", "@simplewebauthn/types": "10.0.0",
"@types/node": "^22.15.32", "@types/node": "^22.18.0",
"eslint": "9.18.0", "eslint": "9.18.0",
"globals": "^15.15.0", "globals": "^15.15.0",
"typescript-eslint": "^8.34.1", "typescript-eslint": "^8.41.0",
"wrangler": "^4.20.4" "wrangler": "^4.33.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.832.0", "@aws-sdk/client-s3": "^3.876.0",
"@aws-sdk/s3-request-presigner": "^3.832.0", "@aws-sdk/s3-request-presigner": "^3.876.0",
"@simplewebauthn/server": "10.0.1", "@simplewebauthn/server": "10.0.1",
"hono": "^4.8.1", "hono": "^4.9.4",
"jsonpath-plus": "^10.3.0", "jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27", "mimetext": "^3.0.27",
"postal-mime": "^2.4.3", "postal-mime": "^2.4.4",
"resend": "^4.6.0", "resend": "^4.8.0",
"telegraf": "4.16.3", "telegraf": "4.16.3",
"worker-mailer": "^1.1.4" "worker-mailer": "^1.1.5"
}, },
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
+14 -4
View File
@@ -1,5 +1,5 @@
diff --git a/lib/core/network/client.js b/lib/core/network/client.js diff --git a/lib/core/network/client.js b/lib/core/network/client.js
index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d698de5ce 100644 index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..ab82c5391596ee2ed8dbe4128f3ba57a8cc998ca 100644
--- a/lib/core/network/client.js --- a/lib/core/network/client.js
+++ b/lib/core/network/client.js +++ b/lib/core/network/client.js
@@ -1,18 +1,18 @@ @@ -1,18 +1,18 @@
@@ -37,7 +37,17 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
const path = __importStar(require("path")); const path = __importStar(require("path"));
const node_fetch_1 = __importDefault(require("node-fetch")); const node_fetch_1 = __importDefault(require("node-fetch"));
const check_1 = require("../helpers/check"); const check_1 = require("../helpers/check");
@@ -61,10 +61,10 @@ const DEFAULT_OPTIONS = { @@ -39,7 +39,8 @@ const multipart_stream_1 = __importDefault(require("./multipart-stream"));
const error_1 = __importDefault(require("./error"));
const url_1 = require("url");
// eslint-disable-next-line @typescript-eslint/no-var-requires
-const debug = require('debug')('telegraf:client');
+// const debug = require('debug')('telegraf:client');
+const debug = (msg) => console.debug(`[telegraf:client] ${msg}`);
const { isStream } = multipart_stream_1.default;
const WEBHOOK_REPLY_METHOD_ALLOWLIST = new Set([
'answerCallbackQuery',
@@ -61,10 +62,10 @@ const DEFAULT_OPTIONS = {
apiRoot: 'https://api.telegram.org', apiRoot: 'https://api.telegram.org',
apiMode: 'bot', apiMode: 'bot',
webhookReply: true, webhookReply: true,
@@ -52,7 +62,7 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
attachmentAgent: undefined, attachmentAgent: undefined,
testEnv: false, testEnv: false,
}; };
@@ -112,9 +112,9 @@ async function buildFormDataConfig(payload, agent) { @@ -112,9 +113,9 @@ async function buildFormDataConfig(payload, agent) {
} }
const boundary = crypto.randomBytes(32).toString('hex'); const boundary = crypto.randomBytes(32).toString('hex');
const formData = new multipart_stream_1.default(boundary); const formData = new multipart_stream_1.default(boundary);
@@ -65,7 +75,7 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
return { return {
method: 'POST', method: 'POST',
compress: true, compress: true,
@@ -205,14 +205,15 @@ async function attachFormMedia(form, media, id, agent) { @@ -205,14 +206,15 @@ async function attachFormMedia(form, media, id, agent) {
if ('source' in media && media.source) { if ('source' in media && media.source) {
let mediaSource = media.source; let mediaSource = media.source;
if (typeof media.source === 'string') { if (typeof media.source === 'string') {
+872 -859
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -14,6 +14,7 @@ import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api' import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail' import { sendMailbyAdmin } from './send_mail'
import db_api from './db_api' import db_api from './db_api'
import { EmailRuleSettings } from '../models'
export const api = new Hono<HonoCustomType>() export const api = new Hono<HonoCustomType>()
@@ -217,13 +218,15 @@ api.get('/admin/account_settings', async (c) => {
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY); const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY); const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : []; const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY); const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
return c.json({ return c.json({
blockList: blockList || [], blockList: blockList || [],
sendBlockList: sendBlockList || [], sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [], verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [], fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [] noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {}
}) })
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -235,7 +238,7 @@ api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */ /** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const { const {
blockList, sendBlockList, noLimitSendAddressList, blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList verifiedAddressList, fromBlockList, emailRuleSettings
} = await c.req.json(); } = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) { if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400) return c.text("Invalid blockList or sendBlockList", 400)
@@ -265,6 +268,10 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY, c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY,
JSON.stringify(noLimitSendAddressList || []) JSON.stringify(noLimitSendAddressList || [])
) )
await saveSetting(
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
JSON.stringify(emailRuleSettings || {})
)
return c.json({ return c.json({
success: true success: true
}) })
+2
View File
@@ -14,6 +14,7 @@ api.get('/open_api/settings', async (c) => {
const auth = c.req.raw.headers.get("x-custom-auth"); const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth); needAuth = !auth || !passwords.includes(auth);
} }
return c.json({ return c.json({
"title": c.env.TITLE, "title": c.env.TITLE,
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT), "announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
@@ -29,6 +30,7 @@ api.get('/open_api/settings', async (c) => {
"adminContact": c.env.ADMIN_CONTACT, "adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL), "enableUserCreateEmail": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL), "disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL), "enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY), "enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT), "enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
+54 -6
View File
@@ -8,6 +8,31 @@ import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g; const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
export const generateRandomName = (c: Context<HonoCustomType>): string => {
// name min length min 1
const minLength = Math.max(
getIntValue(c.env.MIN_ADDRESS_LEN, 1),
1
);
// name max length min 1
const maxLength = Math.max(
getIntValue(c.env.MAX_ADDRESS_LEN, 30),
1
);
// Build full name recursively until minimum length is reached
const buildName = (currentName: string = ""): string => {
return currentName.length >= minLength
? currentName
: buildName(currentName + Math.random().toString(36).substring(2, 15));
};
const fullName = buildName();
// Return truncated to max length
return fullName.substring(0, Math.min(fullName.length, maxLength));
};
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => { const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
let error = null; let error = null;
try { try {
@@ -40,6 +65,23 @@ const getNameRegex = (c: Context<HonoCustomType>): RegExp => {
return DEFAULT_NAME_REGEX; return DEFAULT_NAME_REGEX;
} }
export async function updateAddressUpdatedAt(
c: Context<HonoCustomType>,
address: string | undefined | null
): Promise<void> {
if (!address) {
return;
}
// update address updated_at
try {
await c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address).run();
} catch (e) {
console.warn("Failed to update address updated_at", e);
}
}
export const newAddress = async ( export const newAddress = async (
c: Context<HonoCustomType>, c: Context<HonoCustomType>,
{ {
@@ -59,8 +101,8 @@ export const newAddress = async (
enableCheckNameRegex?: boolean, enableCheckNameRegex?: boolean,
} }
): Promise<{ address: string, jwt: string }> => { ): Promise<{ address: string, jwt: string }> => {
// remove special characters // trim whitespace and remove special characters
name = name.replace(getNameRegex(c), '') name = name.trim().replace(getNameRegex(c), '')
// check name // check name
if (enableCheckNameRegex) { if (enableCheckNameRegex) {
await checkNameBlockList(c, name); await checkNameBlockList(c, name);
@@ -85,15 +127,20 @@ export const newAddress = async (
} }
// create address with prefix // create address with prefix
if (typeof addressPrefix === "string") { if (typeof addressPrefix === "string") {
name = addressPrefix + name; name = addressPrefix.trim() + name;
} else if (enablePrefix) { } else if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name; name = getStringValue(c.env.PREFIX).trim() + name;
} }
// check domain // check domain
const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c); const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c);
// if domain is not set, use the first domain // if domain is not set, select domain based on environment configuration
if (!domain && allowDomains.length > 0) { if (!domain && allowDomains.length > 0) {
domain = allowDomains[0]; const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
if (createAddressDefaultDomainFirst) {
domain = allowDomains[0];
} else {
domain = allowDomains[Math.floor(Math.random() * allowDomains.length)];
}
} }
// check domain is valid // check domain is valid
if (!domain || !allowDomains.includes(domain)) { if (!domain || !allowDomains.includes(domain)) {
@@ -108,6 +155,7 @@ export const newAddress = async (
if (!success) { if (!success) {
throw new Error("Failed to create address") throw new Error("Failed to create address")
} }
await updateAddressUpdatedAt(c, name);
} catch (e) { } catch (e) {
const message = (e as Error).message; const message = (e as Error).message;
if (message && message.includes("UNIQUE")) { if (message && message.includes("UNIQUE")) {
+2 -1
View File
@@ -1,5 +1,5 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v' + '1.0.0', VERSION: 'v' + '1.0.5',
// DB Version // DB Version
DB_VERSION_KEY: 'db_version', DB_VERSION_KEY: 'db_version',
@@ -13,6 +13,7 @@ export const CONSTANTS = {
OAUTH2_SETTINGS_KEY: 'oauth2_settings', OAUTH2_SETTINGS_KEY: 'oauth2_settings',
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list', VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list', NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
EMAIL_RULE_SETTINGS_KEY: 'email_rule_settings',
// KV // KV
TG_KV_PREFIX: "temp-mail-telegram", TG_KV_PREFIX: "temp-mail-telegram",
+32 -3
View File
@@ -1,12 +1,14 @@
import { Context } from "hono"; import { Context } from "hono";
import { getEnvStringList, getJsonObjectValue } from "../utils"; import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
import { sendMailToTelegram } from "../telegram_api"; import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply"; import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list"; import { isBlocked } from "./black_list";
import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common"; import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common";
import { check_if_junk_mail } from "./check_junk"; import { check_if_junk_mail } from "./check_junk";
import { remove_attachment_if_need } from "./check_attachment"; import { remove_attachment_if_need } from "./check_attachment";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) { async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
@@ -32,6 +34,25 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
console.error("check junk mail error", error); console.error("check junk mail error", error);
} }
// check if unknown address mail
try {
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
{ env: env } as Context<HonoCustomType>, CONSTANTS.EMAIL_RULE_SETTINGS_KEY
);
if (emailRuleSettings?.blockReceiveUnknowAddressEmail) {
const db_address_id = await env.DB.prepare(
`SELECT id FROM address where name = ? `
).bind(message.to).first("id");
if (!db_address_id) {
message.setReject("Unknown address");
console.log(`Unknown address mail from ${message.from} to ${message.to}`);
return;
}
}
} catch (error) {
console.error("check unknown address mail error", error);
}
// remove attachment if configured or size > 2MB // remove attachment if configured or size > 2MB
try { try {
await remove_attachment_if_need(env, parsedEmailContext, message.from, message.to, message.rawSize); await remove_attachment_if_need(env, parsedEmailContext, message.from, message.to, message.rawSize);
@@ -70,11 +91,19 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
try { try {
// 遍历 FORWARD_ADDRESS_LIST // 遍历 FORWARD_ADDRESS_LIST
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(env.SUBDOMAIN_FORWARD_ADDRESS_LIST) || []; const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(env.SUBDOMAIN_FORWARD_ADDRESS_LIST) || [];
for (const subdomainForwardAddress of subdomainForwardAddressList) { const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
{ env: env } as Context<HonoCustomType>, CONSTANTS.EMAIL_RULE_SETTINGS_KEY
);
// 合并两个配置, env 里的配置优先级更高
const allSubdomainForwardAddressList = [
...(subdomainForwardAddressList || []),
...(emailRuleSettings?.emailForwardingList || []),
];
for (const subdomainForwardAddress of allSubdomainForwardAddressList) {
// 检查邮件是否匹配 domains // 检查邮件是否匹配 domains
if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) { if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) {
for (const domain of subdomainForwardAddress.domains) { for (const domain of subdomainForwardAddress.domains) {
if (message.to.endsWith(domain)) { if (message.to.endsWith(domain) && subdomainForwardAddress.forward) {
// 转发邮件 // 转发邮件
await message.forward(subdomainForwardAddress.forward); await message.forward(subdomainForwardAddress.forward);
// 支持多邮箱转发收件,不进行截止 // 支持多邮箱转发收件,不进行截止
+8 -25
View File
@@ -2,7 +2,7 @@ import { Context, Hono } from 'hono'
import i18n from '../i18n'; import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue } from '../utils'; import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains } from '../common' import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common'
import { CONSTANTS } from '../constants' import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply' import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings'; import webhook_settings from './webhook_settings';
@@ -20,27 +20,6 @@ api.post('/api/attachment/delete', s3_attachment.deleteKey)
api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl) api.post('/api/attachment/put_url', s3_attachment.getSignedPutUrl)
api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl) api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
export async function updateAddressUpdatedAt(
c: Context<HonoCustomType>,
address: string | undefined | null
): Promise<void> {
if (!address) {
return;
}
// update address updated_at
try {
if (address) {
await c.env.DB.prepare(
`UPDATE address SET updated_at = datetime('now') where name = ?`
).bind(address).run();
}
} catch (e) {
console.warn("Failed to update address updated_at")
}
}
api.get('/api/mails', async (c) => { api.get('/api/mails', async (c) => {
const { address } = c.get("jwtPayload") const { address } = c.get("jwtPayload")
if (!address) { if (!address) {
@@ -144,9 +123,13 @@ api.post('/api/new_address', async (c) => {
} catch (error) { } catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 500) return c.text(msgs.TurnstileCheckFailedMsg, 500)
} }
// if no name, generate random name // Check if custom email names are disabled from environment variable
if (!name) { const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
name = Math.random().toString(36).substring(2, 15);
// if no name or custom names are disabled, generate random name
if (!name || disableCustomAddressName) {
// Generate random name with context-based length configuration
name = generateRandomName(c);
} }
// check name block list // check name block list
try { try {
+5
View File
@@ -145,3 +145,8 @@ export type UserOauth2Settings = {
enableMailAllowList?: boolean | undefined; enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined; mailAllowList?: string[] | undefined;
} }
export type EmailRuleSettings = {
blockReceiveUnknowAddressEmail: boolean;
emailForwardingList: SubdomainForwardAddressList[]
}
+24 -9
View File
@@ -1,8 +1,8 @@
import { Context } from "hono"; import { Context } from "hono";
import { Jwt } from "hono/utils/jwt"; import { Jwt } from "hono/utils/jwt";
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { getIntValue, getJsonSetting } from "../utils"; import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress } from "../common"; import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
export const tgUserNewAddress = async ( export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string c: Context<HonoCustomType>, userId: string, address: string
@@ -15,21 +15,28 @@ export const tgUserNewAddress = async (
throw Error("Rate limit exceeded") throw Error("Rate limit exceeded")
} }
} }
// @ts-ignore // Check if custom address names are disabled
address = address || Math.random().toString(36).substring(2, 15); const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
// Parse address parameter - handle empty or whitespace-only address
const trimmedAddress = address ? address.trim() : "";
const [name, domain] = trimmedAddress.includes("@") ? trimmedAddress.split("@") : [trimmedAddress, null];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || []; const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) { if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限"); throw Error("绑定地址数量已达上限");
} }
// Generate name if disabled or not provided
const finalName = (!name || disableCustomAddressName) ? generateRandomName(c) : name;
// check name block list // check name block list
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY); const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[]; const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) { if (blockList.some((item) => finalName.includes(item))) {
throw Error(`Name[${name}]is blocked`); throw Error(`Name[${finalName}]is blocked`);
} }
const res = await newAddress(c, { const res = await newAddress(c, {
name: name || Math.random().toString(36).substring(2, 15), name: finalName,
domain, domain,
enablePrefix: true enablePrefix: true
}); });
@@ -51,6 +58,14 @@ export const jwtListToAddressData = async (
for (const jwt of jwtList) { for (const jwt of jwtList) {
try { try {
const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256"); const { address, address_id } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
if (!name) {
addressList.push("无效地址");
invalidJwtList.push(jwt);
continue;
}
addressList.push(address as string); addressList.push(address as string);
addressIdMap[address as string] = address_id as number; addressIdMap[address as string] = address_id as number;
} catch (e) { } catch (e) {
@@ -75,7 +90,7 @@ export const bindTelegramAddress = async (
return address as string; return address as string;
} }
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) { if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限"); throw Error("绑定地址数量已达上限, 请先 /cleaninvalidaddress");
} }
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt])); await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
// for mail push to telegram // for mail push to telegram
+5 -5
View File
@@ -18,7 +18,7 @@ const COMMANDS = [
}, },
{ {
command: "new", command: "new",
description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new <name>@<domain>, name [a-z0-9] 有效" description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new, 通过 /new <name>@<domain> 可以指定, name [a-z0-9] 有效, name 为空则随机生成, @<domain> 可选"
}, },
{ {
command: "address", command: "address",
@@ -41,8 +41,8 @@ const COMMANDS = [
description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址" description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址"
}, },
{ {
command: "cleaninvalidadress", command: "cleaninvalidaddress",
description: "清理无效地址, 请输入 /cleaninvalidadress" description: "清理无效地址, 请输入 /cleaninvalidaddress"
}, },
] ]
@@ -159,7 +159,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
} }
try { try {
// @ts-ignore // @ts-ignore
const address = ctx?.message?.text.slice("/unbind".length).trim(); const address = ctx?.message?.text.slice("/delete".length).trim();
if (!address) { if (!address) {
return await ctx.reply("请输入地址"); return await ctx.reply("请输入地址");
} }
@@ -186,7 +186,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
} }
}); });
bot.command("cleaninvalidadress", async (ctx: TgContext) => { bot.command("cleaninvalidaddress", async (ctx: TgContext) => {
const userId = ctx?.message?.from?.id; const userId = ctx?.message?.from?.id;
if (!userId) { if (!userId) {
return await ctx.reply("无法获取用户信息"); return await ctx.reply("无法获取用户信息");
+2
View File
@@ -24,6 +24,8 @@ type Bindings = {
MAX_ADDRESS_LEN: string | number | undefined MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_DOMAINS: string | string[] | undefined DEFAULT_DOMAINS: string | string[] | undefined
DOMAINS: string | string[] | undefined DOMAINS: string | string[] | undefined
DISABLE_CUSTOM_ADDRESS_NAME: string | boolean | undefined
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST: string | boolean | undefined
ADMIN_USER_ROLE: string | undefined ADMIN_USER_ROLE: string | undefined
USER_DEFAULT_ROLE: string | UserRole | undefined USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined USER_ROLES: string | UserRole[] | undefined
+2
View File
@@ -6,6 +6,7 @@ import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { unbindTelegramByAddress } from '../telegram_api/common'; import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n'; import i18n from '../i18n';
import { updateAddressUpdatedAt } from '../common';
const UserBindAddressModule = { const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => { bind: async (c: Context<HonoCustomType>) => {
@@ -237,6 +238,7 @@ const UserBindAddressModule = {
if (!newAddressSuccess) { if (!newAddressSuccess) {
throw new Error("Failed to create address") throw new Error("Failed to create address")
} }
await updateAddressUpdatedAt(c, address);
// find new address id // find new address id
const new_address_id = await c.env.DB.prepare( const new_address_id = await c.env.DB.prepare(
`SELECT id FROM address WHERE name = ?` `SELECT id FROM address WHERE name = ?`
+18 -1
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt' import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'; import i18n from '../i18n';
import { getJsonSetting } from '../utils'; import { getJsonSetting, getStringValue, getUserRoles } from '../utils';
import { UserOauth2Settings } from '../models'; import { UserOauth2Settings } from '../models';
import { CONSTANTS } from '../constants'; import { CONSTANTS } from '../constants';
@@ -110,6 +110,23 @@ export default {
if (!user_id) { if (!user_id) {
return c.text(msgs.UserNotFoundMsg, 400) return c.text(msgs.UserNotFoundMsg, 400)
} }
// process user roles
const defaultRole = getStringValue(c.env.USER_DEFAULT_ROLE);
if (defaultRole) {
const user_roles = getUserRoles(c);
if (!user_roles.find((r) => r.role === defaultRole)) {
return c.text(msgs.InvalidUserDefaultRoleMsg, 500);
}
// update user roles
const { success: success2 } = await c.env.DB.prepare(
`INSERT INTO user_roles (user_id, role_text)`
+ ` VALUES (?, ?)`
+ ` ON CONFLICT(user_id) DO NOTHING`
).bind(user_id, defaultRole).run();
if (!success2) {
return c.text(msgs.FailedUpdateUserDefaultRoleMsg, 500);
}
}
// create jwt // create jwt
const jwt = await Jwt.sign({ const jwt = await Jwt.sign({
user_email: email, user_email: email,
+4
View File
@@ -35,6 +35,8 @@ PREFIX = "tmp"
# (min, max) length of the adderss, if not set, the default is (1, 30) # (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1 # MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30 # MAX_ADDRESS_LEN = 30
# Disable custom email address name, if set true, users cannot input custom email name, will auto generate
# DISABLE_CUSTOM_ADDRESS_NAME = true
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES # IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
# For admin panel # For admin panel
@@ -43,6 +45,8 @@ PREFIX = "tmp"
# DISABLE_ADMIN_PASSWORD_CHECK = false # DISABLE_ADMIN_PASSWORD_CHECK = false
# ADMIN CONTACT, CAN BE ANY STRING # ADMIN CONTACT, CAN BE ANY STRING
# ADMIN_CONTACT = "xx@xx.xxx" # ADMIN_CONTACT = "xx@xx.xxx"
# Create new address with default domain first, if set true, will use first domain from DEFAULT_DOMAINS when no domain specified
# CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = false
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name # For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name