Compare commits

...
15 Commits
Author SHA1 Message Date
Dream Hunter 5e24817de6 feat: upgrade version (#726) 2025-09-15 10:43:53 +08:00
Dream Hunter 732189482e feat: db schema index update (#725)
* feat: db schema index update

* feat: upgrade dependencies
2025-09-15 10:41:14 +08:00
Dream HunterandClaude 2bbde15f53 feat: add clear inbox and sent items functionality (#720)
- Add clear inbox/sent items APIs for users and admins
- Implement ENABLE_USER_DELETE_EMAIL permission checks
- Fix multilingual support for success messages
- Update Vue to 3.5.21 and Wrangler to 4.34.0
- Add UI components for clearing email data in account settings

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 20:43:28 +08:00
Dream HunterandClaude 37cf0776b5 feat: enhance webhook security with configurable allow list (#719)
- Add enableAllowList flag to webhook settings for flexible access control
- Update frontend UI with toggle switch and improved user experience
- Maintain backward compatibility with default allow-all behavior
- Add input validation hints and better form controls across admin panels

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 17:24:30 +08:00
Dream HunterandClaude 3fbace871c feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_… (#717)
* feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 21:04:42 +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
76 changed files with 3520 additions and 2625 deletions
+1
View File
@@ -1,3 +1,4 @@
.DS_Store
dist/
test/
.vscode/
+28 -1
View File
@@ -1,7 +1,34 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main(v1.0.1)
## main(v1.0.6)
- feat: |DB| update db schema add index
## v1.0.5
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
## 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 不生效的问题
+2 -2
View File
@@ -1,5 +1,5 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# 🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
@@ -81,7 +81,7 @@
<details open>
<summary>📖 目录(点击收缩/展开)</summary>
- [🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#-cloudflare-临时邮箱---免费搭建临时邮件服务)
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
- [📝 更新日志](#-更新日志)
- [🎯 在线体验](#-在线体验)
+7
View File
@@ -9,6 +9,8 @@ CREATE TABLE IF NOT EXISTS raw_mails (
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
@@ -18,6 +20,10 @@ CREATE TABLE IF NOT EXISTS address (
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -49,6 +55,7 @@ CREATE TABLE IF NOT EXISTS sendbox (
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.0.1",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {
@@ -24,15 +24,15 @@
"@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.10.0",
"axios": "^1.12.2",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.42.0",
"naive-ui": "^2.43.1",
"postal-mime": "^2.4.4",
"vooks": "^0.2.12",
"vue": "^3.5.17",
"vue": "^3.5.21",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.10",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
},
"devDependencies": {
@@ -41,13 +41,13 @@
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1",
"vite": "^6.3.6",
"vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^4.25.0"
"wrangler": "^4.37.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+969 -982
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"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
+80 -150
View File
@@ -1,13 +1,13 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled, ForwardFilled } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import MailContentRenderer from "./MailContentRenderer.vue";
const message = useMessage()
const isMobile = useIsMobile()
@@ -51,8 +51,8 @@ const props = defineProps({
})
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate, autoRefresh, configAutoRefreshInterval,
useIframeShowMail, sendMailModel, preferShowTextMail
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const data = ref([])
@@ -62,10 +62,49 @@ const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const canGoPrevMail = computed(() => {
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 showTextMail = ref(preferShowTextMail.value)
const multiActionMode = ref(false)
const showMultiActionDownload = ref(false)
@@ -94,6 +133,8 @@ const { t } = useI18n({
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
},
zh: {
success: '成功',
@@ -114,6 +155,8 @@ const { t } = useI18n({
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
}
}
});
@@ -185,10 +228,6 @@ const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
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;
}
const attachmentLoding = ref(false)
const saveToS3Proxy = async (filename, blob) => {
attachmentLoding.value = true
try {
await props.saveToS3(curMail.value.id, filename, blob);
} finally {
attachmentLoding.value = false
}
await props.saveToS3(curMail.value.id, filename, blob);
}
const multiActionModeClick = (enableMulti) => {
@@ -413,59 +446,31 @@ onBeforeUnmount(() => {
</div>
</template>
<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"
style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</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;" />
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
@@ -517,89 +522,14 @@ onBeforeUnmount(() => {
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</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)">
<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;" />
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
</n-drawer-content>
</n-drawer>
</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-tag type="info">
{{ 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>
+1
View File
@@ -22,6 +22,7 @@ export const useGlobalState = createGlobalState(
adminContact: '',
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableAutoReply: false,
enableIndexAbout: false,
+15 -1
View File
@@ -5,6 +5,8 @@ import { useRoute } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { useIsMobile } from '../utils/composables'
import { FullscreenExitOutlined } from '@vicons/material'
import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue';
@@ -15,12 +17,12 @@ import Appearance from './common/Appearance.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
import SimpleIndex from './index/SimpleIndex.vue';
const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
const message = useMessage()
const route = useRoute()
const isMobile = useIsMobile()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
@@ -42,6 +44,7 @@ const { t } = useI18n({
saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings',
query: 'Query',
enterSimpleMode: 'Simple Mode',
},
zh: {
mailbox: '收件箱',
@@ -55,6 +58,7 @@ const { t } = useI18n({
saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置',
query: '查询',
enterSimpleMode: '极简模式',
}
}
});
@@ -133,6 +137,16 @@ onMounted(() => {
<div v-else>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<template #prefix v-if="!isMobile">
<n-button @click="useSimpleIndex = true" tertiary size="small">
<template #icon>
<n-icon>
<FullscreenExitOutlined />
</n-icon>
</template>
{{ t('enterSimpleMode') }}
</n-button>
</template>
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
+86 -2
View File
@@ -33,7 +33,12 @@ const { t } = useI18n({
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
actions: 'Actions'
clearInbox: 'Clear Inbox',
clearSentItems: 'Clear Sent Items',
clearInboxTip: 'Are you sure to clear inbox for this email?',
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions',
success: 'Success',
},
zh: {
name: '名称',
@@ -52,7 +57,12 @@ const { t } = useI18n({
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
clearInbox: '清空收件箱',
clearSentItems: '清空发件箱',
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作',
success: '成功',
}
}
});
@@ -60,6 +70,8 @@ const { t } = useI18n({
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
const addressQuery = ref("")
@@ -68,6 +80,8 @@ const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showCredential = async (id) => {
try {
@@ -83,7 +97,7 @@ const showCredential = async (id) => {
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success("success");
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
@@ -92,6 +106,34 @@ const deleteEmail = async () => {
}
}
const clearInbox = async () => {
try {
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false
}
}
const clearSentItems = async () => {
try {
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false
}
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
@@ -228,6 +270,32 @@ const columns = [
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearInboxAddressId.value = row.id;
showClearInbox.value = true;
}
},
{ default: () => t('clearInbox') }
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearSentItemsAddressId.value = row.id;
showClearSentItems.value = true;
}
},
{ default: () => t('clearSentItems') }
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
@@ -281,6 +349,22 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
+187 -12
View File
@@ -1,17 +1,19 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const { loading, openSettings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
tip: 'You can manually input the following multiple select input and enter',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
@@ -20,9 +22,24 @@ const { t } = useI18n({
noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
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: {
tip: '您可以手动输入以下多选输入框, 回车增加',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
successTip: '保存成功',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
@@ -31,6 +48,20 @@ const { t } = useI18n({
noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
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 +71,90 @@ const sendAddressBlockList = ref([])
const noLimitSendAddressList = ref([])
const verifiedAddressList = 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 () => {
try {
@@ -49,6 +164,10 @@ const fetchData = async () => {
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
noLimitSendAddressList.value = res.noLimitSendAddressList || []
emailRuleSettings.value = res.emailRuleSettings || {
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
}
} catch (error) {
message.error(error.message || "error");
}
@@ -64,6 +183,7 @@ const save = async () => {
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
})
})
message.success(t('successTip'))
@@ -81,33 +201,88 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" style="margin-bottom: 10px;">
{{ t("tip") }}
<n-alert :show-icon="false" :bordered="false" type="warning" style="margin-bottom: 10px;">
<span>{{ t("tip") }}</span>
</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-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('send_address_block_list')">
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('noLimitSendAddressList')">
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
:placeholder="t('noLimitSendAddressList')" />
:placeholder="t('noLimitSendAddressList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('verified_address_list')">
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')" />
:placeholder="t('verified_address_list')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<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')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('block_receive_unknow_address_email')">
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
</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-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-card>
</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>
<style scoped>
+1 -1
View File
@@ -77,7 +77,7 @@ onMounted(async () => {
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-checkbox v-model:checked="enablePrefix" />
<n-switch v-model:value="enablePrefix" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>
+44 -23
View File
@@ -15,25 +15,29 @@ const { t } = useI18n({
init: 'Init',
successTip: 'Success',
status: 'Check Status',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input Chat ID)',
enable: 'Enable',
telegramAllowList: 'Telegram Allow List(Manually input telegram user ID)',
telegramAllowList: 'Telegram Allow List(Manually input telegram Chat ID)',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
miniAppUrl: 'Telegram Mini App URL',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
globalMailPushList: 'Global Mail Push List',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram Chat ID)',
globalMailPushList: 'Global Mail Push Chat ID List',
globalMailPushListTip: 'Support chat_id of private chat/group/channel. You can send a message to your bot, then visit this link to see chat_id, https://api.telegram.org/bot<Replace with your BOT TOKEN>/getUpdates',
},
zh: {
init: '初始化',
successTip: '成功',
status: '查看状态',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID, 回车增加)',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入 Chat ID, 回车增加)',
enable: '启用',
telegramAllowList: 'Telegram 白名单(手动输入用户 ID, 回车增加)',
telegramAllowList: 'Telegram 白名单(手动输入 Chat ID, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID, 回车增加)',
globalMailPushList: '全局邮件推送用户列表',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram Chat ID, 回车增加)',
globalMailPushList: '全局邮件推送 Chat ID 列表',
globalMailPushListTip: '支持对话/群组/频道的 Chat ID, 您可以发送一条消息给您的机器人,然后访问此链接来查看 chat_id, https://api.telegram.org/bot<这里替换成您的 BOT TOKEN>/getUpdates',
}
}
});
@@ -113,6 +117,17 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="fetchStatus" secondary>
{{ t('status') }}
</n-button>
<n-button @click="init" type="primary">
{{ t('init') }}
</n-button>
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-card :bordered="false" embedded>
<n-form-item-row :label="t('enableTelegramAllowList')">
<n-input-group>
@@ -120,31 +135,41 @@ onMounted(async () => {
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
:placeholder="t('telegramAllowList')" />
:placeholder="t('telegramAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
</n-form-item-row>
<br />
<n-form-item-row :label="t('enableGlobalMailPush')">
<n-input-group>
<n-checkbox v-model:checked="settings.enableGlobalMailPush" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.globalMailPushList" filterable multiple tag
style="width: 80%;" :placeholder="t('globalMailPushList')" />
style="width: 80%;" :placeholder="t('globalMailPushList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
<template #feedback>
<n-text depth="3">
{{ t('globalMailPushListTip') }}
</n-text>
</template>
</n-form-item-row>
<br />
<n-form-item-row :label="t('miniAppUrl')">
<n-input v-model:value="settings.miniAppUrl"></n-input>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-button @click="init" type="primary" block>
{{ t('init') }}
</n-button>
<n-button @click="fetchStatus" secondary block>
{{ t('status') }}
</n-button>
<pre v-if="status.fetched">{{ JSON.stringify(status, null, 2) }}</pre>
</n-card>
</div>
@@ -157,8 +182,4 @@ onMounted(async () => {
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>
@@ -21,6 +21,7 @@ const { t } = useI18n({
successTip: 'Save Success',
enable: 'Enable',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
manualInputPrompt: 'Type and press Enter to add',
mailAllowList: 'Mail Address Allow List',
addOauth2: 'Add Oauth2',
name: 'Name',
@@ -33,6 +34,7 @@ const { t } = useI18n({
successTip: '保存成功',
enable: '启用',
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
mailAllowList: '邮件地址白名单',
addOauth2: '添加 Oauth2',
name: '名称',
@@ -184,7 +186,7 @@ onMounted(async () => {
</template>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" type="warning" closable style="margin-bottom: 10px;">
<n-alert :show-icon="false" :bordered="false" type="warning" closable style="margin-bottom: 10px;">
{{ t("tip") }}
</n-alert>
<n-flex justify="end">
@@ -246,7 +248,13 @@ onMounted(async () => {
</n-checkbox>
<n-select v-model:value="item.mailAllowList" v-if="item.enableMailAllowList" filterable
multiple tag style="width: 80%;" :options="mailAllowOptions"
:placeholder="t('mailAllowList')" />
:placeholder="t('mailAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
</n-form-item-row>
</n-form>
+15 -5
View File
@@ -18,6 +18,7 @@ const { t } = useI18n({
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
verifyMailSender: 'Verify Mail Sender',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
manualInputPrompt: 'Type and press Enter to add',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
},
@@ -29,6 +30,7 @@ const { t } = useI18n({
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
verifyMailSender: '验证邮件发送地址',
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
}
@@ -83,9 +85,14 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-flex justify="end">
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />
<n-switch v-model:value="userSettings.enable" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailVerify')">
<n-input-group>
@@ -103,7 +110,13 @@ onMounted(async () => {
</n-checkbox>
<n-select v-model:value="userSettings.mailAllowList" v-if="userSettings.enableMailAllowList"
filterable multiple tag style="width: 80%;" :options="mailAllowOptions"
:placeholder="t('mailAllowList')" />
:placeholder="t('mailAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('maxAddressCount')">
@@ -112,9 +125,6 @@ onMounted(async () => {
:placeholder="t('maxAddressCount')" />
</n-input-group>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>
+25 -8
View File
@@ -13,13 +13,17 @@ const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook and enter)',
enableAllowList: 'Enable Allow List (Restrict webhook access to specific users)',
webhookAllowList: 'Webhook Allow List(Enter the mail address that is allowed to use webhook and enter)',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
notEnabled: 'Webhook is not enabled',
},
zh: {
successTip: '成功',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址, 回车增加)',
enableAllowList: '启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的邮箱地址, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
notEnabled: 'Webhook 未开启',
}
@@ -27,14 +31,16 @@ const { t } = useI18n({
});
class WebhookSettings {
enableAllowList: boolean;
allowList: string[];
constructor(allowList: string[]) {
constructor(enableAllowList: boolean, allowList: string[]) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
}
}
const webhookSettings = ref(new WebhookSettings([]))
const webhookSettings = ref(new WebhookSettings(false, []))
const webhookEnabled = ref(false)
const errorInfo = ref('')
@@ -68,13 +74,24 @@ onMounted(async () => {
<template>
<div class="center">
<n-card v-if="webhookEnabled" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enableAllowList')">
<n-switch v-model:value="webhookSettings.enableAllowList" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />
:placeholder="t('webhookAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" :description="errorInfo" />
</div>
+1 -1
View File
@@ -26,7 +26,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px; overflow: auto;">
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<pre>{{ JSON.stringify(settings, null, 2) }}</pre>
</n-card>
</div>
+10 -5
View File
@@ -84,6 +84,7 @@ const { locale, t } = useI18n({
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',
bindUserAddressError: 'Error when bind email address to user',
autoGeneratedName: 'Auto-generated name',
},
zh: {
login: '登录',
@@ -100,6 +101,7 @@ const { locale, t } = useI18n({
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
autoGeneratedName: '自动生成名称',
}
}
});
@@ -147,8 +149,10 @@ const generateName = async () => {
const newEmail = async () => {
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(
emailName.value,
nameToSend,
emailDomain.value,
cfToken.value
);
@@ -240,19 +244,20 @@ onMounted(async () => {
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
<n-button v-if="!openSettings.disableCustomAddressName" @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="addressPrefix">
{{ addressPrefix }}
</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" />
<n-input v-else :value="t('autoGeneratedName')" disabled />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="domainsOptions" />
+65 -2
View File
@@ -8,13 +8,15 @@ import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const {
jwt, settings, showAddressCredential, loading
jwt, settings, showAddressCredential, loading, openSettings
} = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const { locale, t } = useI18n({
messages: {
en: {
@@ -24,6 +26,11 @@ const { locale, t } = useI18n({
logoutConfirm: 'Are you sure to logout?',
deleteAccount: "Delete Account",
deleteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
clearInbox: "Clear Inbox",
clearSentItems: "Clear Sent Items",
clearInboxConfirm: "Are you sure to clear all emails in your inbox?",
clearSentItemsConfirm: "Are you sure to clear all emails in your sent items?",
success: "Success",
},
zh: {
logout: '退出登录',
@@ -32,6 +39,11 @@ const { locale, t } = useI18n({
logoutConfirm: '确定要退出登录吗?',
deleteAccount: "删除账户",
deleteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
clearInbox: "清空收件箱",
clearSentItems: "清空发件箱",
clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?",
clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?",
success: "成功",
}
}
});
@@ -54,6 +66,32 @@ const deleteAccount = async () => {
message.error(error.message || "error");
}
};
const clearInbox = async () => {
try {
await api.fetch(`/api/clear_inbox`, {
method: 'DELETE'
});
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false;
}
};
const clearSentItems = async () => {
try {
await api.fetch(`/api/clear_sent_items`, {
method: 'DELETE'
});
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false;
}
};
</script>
<template>
@@ -62,10 +100,19 @@ const deleteAccount = async () => {
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearInbox = true" type="warning" secondary
block strong>
{{ t('clearInbox') }}
</n-button>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearSentItems = true" type="warning"
secondary block strong>
{{ t('clearSentItems') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-button @click="showDeleteAccount = true" type="error" secondary block strong>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong>
{{ t('deleteAccount') }}
</n-button>
</n-card>
@@ -85,6 +132,22 @@ const deleteAccount = async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="warning">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="warning">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
</div>
</template>
+53 -22
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import {
@@ -16,18 +16,18 @@ import { api } from '../../api'
import Login from '../common/Login.vue'
import AccountSettings from './AccountSettings.vue'
import { processItem } from '../../utils/email-parser'
import { utcToLocalDate } from '../../utils'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import MailContentRenderer from '../../components/MailContentRenderer.vue'
const { jwt, settings, useSimpleIndex, useUTCDate, showAddressCredential } = useGlobalState()
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
const message = useMessage()
// 邮件数据
const currentPage = ref(1)
const totalCount = ref(0)
const loading = ref(false)
const currentMail = ref(null)
const showAccountSettingsCard = ref(false)
const currentAutoRefreshInterval = ref(60)
const timer = ref(null)
const { t } = useI18n({
messages: {
@@ -44,6 +44,8 @@ const { t } = useI18n({
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: '退出极简',
@@ -57,7 +59,9 @@ const { t } = useI18n({
mailCount: '{current} / {total} 封邮件',
accountSettings: "账户设置",
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。'
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
deleteSuccess: '邮件删除成功',
refreshAfter: '{msg}秒后刷新',
}
}
})
@@ -86,9 +90,26 @@ const fetchMails = async () => {
}
}
// 删除邮件
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'))
}
@@ -98,6 +119,7 @@ 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) {
@@ -119,6 +141,22 @@ watch(currentPage, () => {
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>
@@ -169,6 +207,11 @@ onMounted(async () => {
{{ 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>
<!-- 账户设置卡片 -->
@@ -177,7 +220,7 @@ onMounted(async () => {
<AccountSettings />
</n-card>
<n-card :bordered="false" embedded style="text-align: left;">
<n-card v-else :bordered="false" embedded style="text-align: left;">
<div v-if="totalCount > 1">
<n-flex justify="space-between">
@@ -208,22 +251,10 @@ onMounted(async () => {
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<n-space>
<n-tag type="info">
ID: {{ currentMail.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(currentMail.created_at, useUTCDate.value) }}
</n-tag>
<n-tag type="info">
FROM: {{ currentMail.source }}
</n-tag>
</n-space>
<div style="margin-top: 16px;">
<ShadowHtmlComponent v-if="currentMail.message" :htmlContent="currentMail.message" />
<pre v-else>{{ currentMail.text }}</pre>
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:onDelete="deleteMail" />
</div>
</div>
</n-card>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.0.1",
"version": "1.0.6",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.25.0"
"wrangler": "^4.37.0"
},
"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())
```
# 批量创建随机用户名邮箱地址 API 示例
## 批量创建随机用户名邮箱地址 API 示例
## 通过 admin API 批量新建邮箱地址
### 通过 admin API 批量新建邮箱地址
这是一个 `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)
@@ -10,13 +10,11 @@
## 初始化数据库
你也可以跳过初始化数据库,在部署完成后,在 admin 页面的 `快速设置` -> `数据库` 中,点击 `初始化数据库` 按钮来初始化数据库
::: 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)
+2 -2
View File
@@ -42,11 +42,11 @@ const generate = async () => {
}
</script>
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application`
1. 点击 `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create pages](/ui_install/worker_home.png)
2. 选择 `Pages`,选择 `Create using direct upload`
2. 选择 `Pages`,选择 `Use direct upload`
![pages](/ui_install/pages.png)
+27 -10
View File
@@ -3,15 +3,16 @@
> [!warning] 注意
> `worker.dev` 域名在中国无法访问,请自定义域名
1. 点击 `Workers & Pages` -> `Overview` -> `Create Application`
1. 点击 `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create worker](/ui_install/worker_home.png)
2. 选择 `Worker`,点击 `Create Worker`, 修改名称然后点击 `Deploy`
![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)
@@ -26,10 +27,11 @@
>
> 参考: [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)
6. 点击 `Settings` -> `Variables`, 如图所示添加变量
6. 点击 `Settings` -> `Variables and Secrets`, 如图所示添加变量
![worker-var](/ui_install/worker-var.png)
@@ -51,12 +53,16 @@
| `ENABLE_USER_CREATE_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] 重要
> 注意此处 `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` 的域名。记录下这个域名,后面部署前端会用到。
@@ -67,24 +73,35 @@
![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] 重要
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
>
> 注意此处 `KV` 的绑定名称必须为 `KV`
点击 `Storage & Databases` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`
![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 配置
> [!NOTE]
> 如果不需要 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]
> 选择 `cron` 表达式,输入 `0 0 * * *`(此表达式表示每天午夜运行),点击 `Add` 增加。请根据您的需求调整此表达式。
+13 -11
View File
@@ -22,17 +22,19 @@
## 邮箱相关变量
| 变量名 | 类型 | 说明 | 示例 |
| ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `PREFIX` | 文本 | 新建 `邮箱名称` 的默认前缀,不需要前缀可不配置 | `tmp` |
| `MIN_ADDRESS_LEN` | 数字 | `邮箱名称` 的最小长度 | `1` |
| `MAX_ADDRESS_LEN` | 数字 | `邮箱名称` 的最大长度 | `30` |
| `ADDRESS_CHECK_REGEX` | 文本 | `邮箱名称` 的正则表达式, 只用于检查 | `^(?!.*admin).*` |
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
| 变量名 | 类型 | 说明 | 示例 |
| ------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `PREFIX` | 文本 | 新建 `邮箱名称` 的默认前缀,不需要前缀可不配置 | `tmp` |
| `MIN_ADDRESS_LEN` | 数字 | `邮箱名称` 的最小长度 | `1` |
| `MAX_ADDRESS_LEN` | 数字 | `邮箱名称` 的最大长度 | `30` |
| `DISABLE_CUSTOM_ADDRESS_NAME` | 文本/JSON | 禁用自定义邮箱地址名称,如果设置为 true,则用户无法输入自定义邮箱名称,将由后台自动生成 | `true` |
| `ADDRESS_CHECK_REGEX` | 文本 | `邮箱名称` 的正则表达式, 只用于检查 | `^(?!.*admin).*` |
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
| `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",
"private": true,
"version": "1.0.1",
"version": "1.0.6",
"type": "module",
"devDependencies": {
"@types/node": "^24.0.15",
"vitepress": "^1.6.3",
"wrangler": "^4.25.0"
"@types/node": "^24.4.0",
"vitepress": "^1.6.4",
"wrangler": "^4.37.0"
},
"scripts": {
"dev": "vitepress dev docs",
+405 -384
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.0.1",
"version": "1.0.6",
"private": true,
"type": "module",
"scripts": {
@@ -11,26 +11,26 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250719.0",
"@cloudflare/workers-types": "^4.20250913.0",
"@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^22.16.5",
"@types/node": "^22.18.3",
"eslint": "9.18.0",
"globals": "^15.15.0",
"typescript-eslint": "^8.37.0",
"wrangler": "^4.25.0"
"typescript-eslint": "^8.43.0",
"wrangler": "^4.37.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.848.0",
"@aws-sdk/s3-request-presigner": "^3.848.0",
"@aws-sdk/client-s3": "^3.888.0",
"@aws-sdk/s3-request-presigner": "^3.888.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.8.5",
"hono": "^4.9.7",
"jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27",
"postal-mime": "^2.4.4",
"resend": "^4.7.0",
"resend": "^4.8.0",
"telegraf": "4.16.3",
"worker-mailer": "^1.1.4"
"worker-mailer": "^1.1.5"
},
"pnpm": {
"patchedDependencies": {
+14 -4
View File
@@ -1,5 +1,5 @@
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
+++ b/lib/core/network/client.js
@@ -1,18 +1,18 @@
@@ -37,7 +37,17 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
const path = __importStar(require("path"));
const node_fetch_1 = __importDefault(require("node-fetch"));
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',
apiMode: 'bot',
webhookReply: true,
@@ -52,7 +62,7 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
attachmentAgent: undefined,
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 formData = new multipart_stream_1.default(boundary);
@@ -65,7 +75,7 @@ index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d
return {
method: 'POST',
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) {
let mediaSource = media.source;
if (typeof media.source === 'string') {
+916 -911
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS raw_mails (
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
@@ -23,6 +25,10 @@ CREATE TABLE IF NOT EXISTS address (
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -54,6 +60,7 @@ CREATE TABLE IF NOT EXISTS sendbox (
);
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -131,7 +138,13 @@ export default {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version != CONSTANTS.DB_VERSION) {
// TODO: Perform migration logic here
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n
const query = DB_INIT_QUERIES.replace(/[\r\n]/g, "")
.split(";")
.map((query) => query.trim())
.join(";\n");
await c.env.DB.exec(query);
// Update the version in the settings table
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
return c.json({
+37 -2
View File
@@ -14,6 +14,7 @@ import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail'
import db_api from './db_api'
import { EmailRuleSettings } from '../models'
export const api = new Hono<HonoCustomType>()
@@ -88,6 +89,34 @@ api.delete('/admin/delete_address/:id', async (c) => {
})
})
api.delete('/admin/clear_inbox/:id', async (c) => {
const { id } = c.req.param();
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to clear inbox", 500)
}
return c.json({
success: mailSuccess
})
})
api.delete('/admin/clear_sent_items/:id', async (c) => {
const { id } = c.req.param();
const { success: sendboxSuccess } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!sendboxSuccess) {
return c.text("Failed to clear sent items", 500)
}
return c.json({
success: sendboxSuccess
})
})
api.get('/admin/show_password/:id', async (c) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
@@ -217,13 +246,15 @@ api.get('/admin/account_settings', async (c) => {
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_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 emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || []
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {}
})
} catch (error) {
console.error(error);
@@ -235,7 +266,7 @@ api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList
verifiedAddressList, fromBlockList, emailRuleSettings
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400)
@@ -265,6 +296,10 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY,
JSON.stringify(noLimitSendAddressList || [])
)
await saveSetting(
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
JSON.stringify(emailRuleSettings || {})
)
return c.json({
success: true
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { AdminWebhookSettings } from "../models";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
return c.json(settings || new AdminWebhookSettings([]));
return c.json(settings || new AdminWebhookSettings(false, []));
}
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
+2
View File
@@ -14,6 +14,7 @@ api.get('/open_api/settings', async (c) => {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth);
}
return c.json({
"title": c.env.TITLE,
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
@@ -29,6 +30,7 @@ api.get('/open_api/settings', async (c) => {
"adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": utils.getBooleanValue(c.env.ENABLE_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),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
+38 -10
View File
@@ -8,6 +8,31 @@ import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
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) => {
let error = null;
try {
@@ -76,8 +101,8 @@ export const newAddress = async (
enableCheckNameRegex?: boolean,
}
): Promise<{ address: string, jwt: string }> => {
// remove special characters
name = name.replace(getNameRegex(c), '')
// trim whitespace and remove special characters
name = name.trim().replace(getNameRegex(c), '')
// check name
if (enableCheckNameRegex) {
await checkNameBlockList(c, name);
@@ -102,15 +127,20 @@ export const newAddress = async (
}
// create address with prefix
if (typeof addressPrefix === "string") {
name = addressPrefix + name;
name = addressPrefix.trim() + name;
} else if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name;
name = getStringValue(c.env.PREFIX).trim() + name;
}
// check domain
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) {
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
if (!domain || !allowDomains.includes(domain)) {
@@ -238,9 +268,7 @@ const batchDeleteAddressWithData = async (
return true;
}
/**
* TODO: need senbox delete?
*/
export const deleteAddressWithData = async (
c: Context<HonoCustomType>,
address: string | undefined | null,
@@ -450,7 +478,7 @@ export async function triggerWebhook(
// user mail webhook
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.allowList.includes(address)) {
if (!adminSettings?.enableAllowList || adminSettings?.allowList.includes(address)) {
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
);
+3 -2
View File
@@ -1,9 +1,9 @@
export const CONSTANTS = {
VERSION: 'v' + '1.0.1',
VERSION: 'v' + '1.0.6',
// DB Version
DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.1",
DB_VERSION: "v0.0.2",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
@@ -13,6 +13,7 @@ export const CONSTANTS = {
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
EMAIL_RULE_SETTINGS_KEY: 'email_rule_settings',
// KV
TG_KV_PREFIX: "temp-mail-telegram",
+32 -3
View File
@@ -1,12 +1,14 @@
import { Context } from "hono";
import { getEnvStringList, getJsonObjectValue } from "../utils";
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common";
import { check_if_junk_mail } from "./check_junk";
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) {
@@ -32,6 +34,25 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
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
try {
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 {
// 遍历 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
if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) {
for (const domain of subdomainForwardAddress.domains) {
if (message.to.endsWith(domain)) {
if (message.to.endsWith(domain) && subdomainForwardAddress.forward) {
// 转发邮件
await message.forward(subdomainForwardAddress.forward);
// 支持多邮箱转发收件,不进行截止
+44 -4
View File
@@ -2,7 +2,7 @@ import { Context, Hono } from 'hono'
import i18n from '../i18n';
import { getBooleanValue, getJsonSetting, checkCfTurnstile, getStringValue, getSplitStringListValue } from '../utils';
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt } from '../common'
import { newAddress, handleListQuery, deleteAddressWithData, getAddressPrefix, getAllowDomains, updateAddressUpdatedAt, generateRandomName } from '../common'
import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings';
@@ -123,9 +123,13 @@ api.post('/api/new_address', async (c) => {
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 500)
}
// if no name, generate random name
if (!name) {
name = Math.random().toString(36).substring(2, 15);
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// 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
try {
@@ -158,3 +162,39 @@ api.delete('/api/delete_address', async (c) => {
success: success
})
})
api.delete('/api/clear_inbox', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear inbox", 500)
}
return c.json({
success: success
})
})
api.delete('/api/clear_sent_items', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const { success } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear sent items", 500)
}
return c.json({
success: success
})
})
+2 -2
View File
@@ -7,7 +7,7 @@ import { commonParseMail, sendWebhook } from "../common";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
}
const settings = await c.env.KV.get<WebhookSettings>(
@@ -20,7 +20,7 @@ async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response>
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (!adminSettings?.allowList.includes(address)) {
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
}
const settings = await c.req.json<WebhookSettings>();
+8 -1
View File
@@ -14,9 +14,11 @@ export type Passkey = {
};
export class AdminWebhookSettings {
enableAllowList: boolean;
allowList: string[];
constructor(allowList: string[]) {
constructor(enableAllowList: boolean, allowList: string[]) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
}
}
@@ -145,3 +147,8 @@ export type UserOauth2Settings = {
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}
export type EmailRuleSettings = {
blockReceiveUnknowAddressEmail: boolean;
emailForwardingList: SubdomainForwardAddressList[]
}
+24 -9
View File
@@ -1,8 +1,8 @@
import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { CONSTANTS } from "../constants";
import { getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress } from "../common";
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
@@ -15,21 +15,28 @@ export const tgUserNewAddress = async (
throw Error("Rate limit exceeded")
}
}
// @ts-ignore
address = address || Math.random().toString(36).substring(2, 15);
const [name, domain] = address.includes("@") ? address.split("@") : [address, null];
// Check if custom address names are disabled
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
// 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') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限");
}
// Generate name if disabled or not provided
const finalName = (!name || disableCustomAddressName) ? generateRandomName(c) : name;
// check name block list
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
const blockList = (value || []) as string[];
if (blockList.some((item) => name.includes(item))) {
throw Error(`Name[${name}]is blocked`);
if (blockList.some((item) => finalName.includes(item))) {
throw Error(`Name[${finalName}]is blocked`);
}
const res = await newAddress(c, {
name: name || Math.random().toString(36).substring(2, 15),
name: finalName,
domain,
enablePrefix: true
});
@@ -51,6 +58,14 @@ export const jwtListToAddressData = async (
for (const jwt of jwtList) {
try {
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);
addressIdMap[address as string] = address_id as number;
} catch (e) {
@@ -75,7 +90,7 @@ export const bindTelegramAddress = async (
return address as string;
}
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]));
// for mail push to telegram
+5 -5
View File
@@ -18,7 +18,7 @@ const COMMANDS = [
},
{
command: "new",
description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new <name>@<domain>, name [a-z0-9] 有效"
description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new, 通过 /new <name>@<domain> 可以指定, name [a-z0-9] 有效, name 为空则随机生成, @<domain> 可选"
},
{
command: "address",
@@ -41,8 +41,8 @@ const COMMANDS = [
description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址"
},
{
command: "cleaninvalidadress",
description: "清理无效地址, 请输入 /cleaninvalidadress"
command: "cleaninvalidaddress",
description: "清理无效地址, 请输入 /cleaninvalidaddress"
},
]
@@ -159,7 +159,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
}
try {
// @ts-ignore
const address = ctx?.message?.text.slice("/unbind".length).trim();
const address = ctx?.message?.text.slice("/delete".length).trim();
if (!address) {
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;
if (!userId) {
return await ctx.reply("无法获取用户信息");
+2
View File
@@ -24,6 +24,8 @@ type Bindings = {
MAX_ADDRESS_LEN: string | number | undefined
DEFAULT_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
USER_DEFAULT_ROLE: string | UserRole | undefined
USER_ROLES: string | UserRole[] | undefined
+15 -13
View File
@@ -110,20 +110,22 @@ export default {
if (!user_id) {
return c.text(msgs.UserNotFoundMsg, 400)
}
// process user roles
const defaultRole = getStringValue(c.env.USER_DEFAULT_ROLE);
if (!defaultRole) return c.json({ success: true })
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);
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
const jwt = await Jwt.sign({
+4
View File
@@ -35,6 +35,8 @@ PREFIX = "tmp"
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
# 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
# PASSWORDS = ["123", "456"]
# For admin panel
@@ -43,6 +45,8 @@ PREFIX = "tmp"
# DISABLE_ADMIN_PASSWORD_CHECK = false
# ADMIN CONTACT, CAN BE ANY STRING
# 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
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
# For chinese domain name, you can use DOMAIN_LABELS to show chinese domain name