Compare commits

..
18 Commits
Author SHA1 Message Date
Dream HunterandClaude b337a44e62 feat: add daily request limit and refactor access control (#759)
- Add daily request limit per IP in blacklist settings (1-1,000,000/day)
- Refactor access control logic: merge blacklist and rate limit checks
- Remove RATE_LIMIT_API_DAILY_REQUESTS env var, use database config instead
- Move x-custom-auth check earlier in middleware chain
- Add comprehensive English documentation (31 new guide pages)
- Improve code structure and error handling

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 12:46:30 +08:00
Dream HunterandClaude eaeac8ebec feat: 添加浏览器指纹黑名单功能 (#757)
* feat: 添加浏览器指纹黑名单功能

- 前端集成 @fingerprintjs/fingerprintjs 库自动采集浏览器指纹
- 在所有 API 请求中通过 x-fingerprint header 传递指纹信息
- 将指纹黑名单集成到现有的 IP 黑名单功能中
- 支持精确匹配和正则表达式模式匹配指纹
- 在 App.vue mount 时预初始化指纹,避免首次请求延迟
- 使用 Vue 全局状态缓存指纹,避免重复生成
- 管理后台新增指纹黑名单配置,与 IP/ASN 黑名单统一管理
- 后端在限流 API 请求前检查指纹黑名单,返回 403 阻止访问

技术细节:
- 指纹生成时间:50-300ms(一次性)
- 缓存命中:<1ms
- 请求开销:~20 字节/请求
- 支持最多 1000 条指纹黑名单规则
- 完善的错误处理,失败时不阻塞正常请求

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: 优化浏览器指纹初始化逻辑

- 移除 App.vue 中的预初始化,改为在首次 API 调用时自动初始化
- 移除不必要的 clearFingerprintCache 函数
- 初始化失败时返回特殊值 'ERROR' 而非空字符串
- 失败值会被缓存,避免重复尝试失败

优势:
- 减少页面加载时的初始化开销
- 简化代码,去除不必要的函数
- 更清晰的错误标识

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 15:50:39 +08:00
Dream HunterandClaude 7393519ba4 fix: ASN blacklist not working due to missing asnBlacklist field (#756)
- Fix getIpBlacklistSettings() to include asnBlacklist field in return value
- Add case-insensitive flag support for regex patterns in ASN matching
- Refactor IP blacklist check logic for better code organization

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 23:30:24 +08:00
Dream HunterandClaude 4ddc8e5c96 feat: add ASN organization blacklist for IP filtering (#755)
- Add asnBlacklist field to IpBlacklistSettings (optional)
- Create shared isBlacklisted() function for IP and ASN matching
- Add isAsnBlacklisted() function with case-insensitive matching
- Extend checkIpBlacklist() to also check ASN organizations
- Update admin API to validate and save ASN blacklist
- Add ASN blacklist input to admin UI (below IP blacklist)
- Support text matching and regex for ASN organization names
- ASN data from request.cf.asOrganization (Cloudflare)

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 22:25:27 +08:00
Dream HunterandClaude 8b7ddae4f6 feat: upgrade version to v1.0.7 (#754)
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 RATE_LIMIT_API_DAILY_REQUESTS 配置,用于限制每日 API 请求次数
- fix: |Admin| IP 黑名单检查增加错误处理,提高系统稳定性

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 21:00:44 +08:00
Dream Hunter be36967b80 feat: add IP blacklist feature for rate-limited APIs (#753) 2025-11-03 20:31:32 +08:00
Dream Hunter fac249ed31 feat: add RATE_LIMIT_API_DAILY_REQUESTS (#752) 2025-11-03 02:46:16 +08:00
Dream Hunter bfd7d6811e feat: add RATE_LIMIT_API_DAILY_REQUESTS (#751) 2025-11-03 02:36:38 +08:00
Dream Hunter b5c229b6c4 feat: v1.0.6 (#743) 2025-10-12 18:24:36 +08:00
Dream Hunter 2728e9667b fix: 针对角色配置不同的绑定地址数量上限 (#742) 2025-10-12 18:21:22 +08:00
Dream HunterandClaude 6109ab9e82 feat: add role-based address limit configuration (#741)
- Add RoleAddressConfig component in admin panel
- Implement role_address_config API endpoints (GET/POST)
- Add getMaxAddressCount function with validation chain
- Priority: role config > global settings
- Support editable table with clearable input
- Add extensible RoleConfig type for future fields
- Use context for current user, query DB for target user
- Optimize state management (remove redundant roleConfigsMap)

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:12:36 +08:00
Dream Hunter 09a6cac8fe feat: upgrade dependencies (#740) 2025-10-12 13:47:40 +08:00
Dream HunterandClaude 5f752c94f9 feat: add cleanup for unbound addresses feature (#739)
- Add unboundAddress cleanup type to backend cleanup logic
- Update CleanupSettings model with unbound address fields
- Add scheduled task for automatic unbound address cleanup
- Add UI controls in admin Maintenance panel for manual cleanup
- Add i18n support (en/zh) for unbound address cleanup labels
- Clean addresses not bound to any user created before n days

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 13:46:13 +08:00
Dream HunterandClaude a2f3634c7e feat: add multi-select batch operations for admin account management (#737)
- Add multi-select functionality with native Naive UI selection column
- Implement batch operations: delete, clear inbox, clear sent items
- Create reusable executeBatchOperation function to eliminate code duplication
- Add error recovery mechanism: failed items remain selected for retry
- Add progress modal with real-time percentage display
- Support smart skip logic: skip addresses with no mails/sent items
- Add i18n support for English and Chinese
- Update CHANGELOG.md with new features

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-04 20:04:26 +08:00
Dream HunterandClaude b62a3cbc3e fix: require explicit 'true' value for debug mode in deployment (#735)
- Update GitHub Actions workflow to only enable debug mode when DEBUG_MODE secret is exactly 'true'
- Previously debug mode was enabled for any non-empty value, which could lead to accidental activation
- Change from `[ -n "$debug_mode" ]` to `[ "$debug_mode" = "true" ]` for precise control

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 19:14:46 +08:00
Dream Hunter 8edb75587e fix: code bugs (#734) 2025-09-26 19:05:08 +08:00
Dream Hunter de48661d0d fix: code bugs (#733) 2025-09-26 19:02:57 +08:00
Dream HunterandClaude a905ba5f06 feat: implement address password authentication feature (#731)
* feat: implement address password authentication feature

- Add password field to address table for storing hashed passwords
- Implement address authentication APIs (login, change password)
- Add automatic password generation for new addresses
- Support password login alongside credential login in frontend
- Add password management in account settings and admin panel
- Add ENABLE_ADDRESS_PASSWORD environment variable for feature control
- Update documentation and i18n support
- Enhance security with SHA-256 password hashing

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

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: upgrade dependencies

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 14:52:05 +08:00
86 changed files with 5278 additions and 2052 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
echo "Applied mail-parser-wasm-worker patch"
fi
if [ -n "$debug_mode" ]; then
if [ "$debug_mode" = "true" ]; then
pnpm run deploy
else
output=$(pnpm run deploy 2>&1)
+12 -1
View File
@@ -1,9 +1,20 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG
## main(v1.0.6)
## v1.0.7
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
+1
View File
@@ -40,6 +40,7 @@
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
-**高性能** - Rust WASM 邮件解析,响应速度极快
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
## 📚 部署文档 - 快速开始
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE
address
ADD
password TEXT;
+2
View File
@@ -14,6 +14,7 @@ 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,
password TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -55,6 +56,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 (
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.0.6",
"version": "1.0.7",
"private": true,
"type": "module",
"scripts": {
@@ -20,21 +20,22 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^1.11.20",
"@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.12.2",
"axios": "^1.13.1",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.1",
"postal-mime": "^2.4.4",
"postal-mime": "^2.6.0",
"vooks": "^0.2.12",
"vue": "^3.5.21",
"vue": "^3.5.22",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
@@ -42,13 +43,13 @@
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.3.6",
"vite-plugin-pwa": "^1.0.3",
"vite": "^6.4.1",
"vite-plugin-pwa": "^1.1.0",
"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.37.0"
"wrangler": "^4.45.3"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+930 -913
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -23,7 +23,6 @@ const showAd = computed(() => !isMobile.value && adClient && adSlot);
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
+6
View File
@@ -3,6 +3,7 @@ import { h } from 'vue'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
@@ -20,6 +21,9 @@ const instance = axios.create({
const apiFetch = async (path, options = {}) => {
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
@@ -29,6 +33,7 @@ const apiFetch = async (path, options = {}) => {
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'x-fingerprint': fingerprint,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
@@ -86,6 +91,7 @@ const getOpenSettings = async (message, notification) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;
+5
View File
@@ -36,6 +36,7 @@ export const useGlobalState = createGlobalState(
isS3Enabled: false,
showGithub: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
})
const settings = ref({
fetched: false,
@@ -63,6 +64,7 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const addressPassword = useSessionStorage('addressPassword', '');
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
@@ -109,6 +111,7 @@ export const useGlobalState = createGlobalState(
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
const browserFingerprint = ref('');
return {
isDark,
toggleDark,
@@ -145,6 +148,8 @@ export const useGlobalState = createGlobalState(
userOauth2SessionState,
userOauth2SessionClientID,
useSimpleIndex,
addressPassword,
browserFingerprint,
}
},
)
+30
View File
@@ -0,0 +1,30 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { useGlobalState } from '../store';
const { browserFingerprint } = useGlobalState();
/**
* Get browser fingerprint
* Uses cached value from global state if available to avoid unnecessary computation
* @returns Fingerprint visitor ID, or 'ERROR' if failed
*/
export const getFingerprint = async (): Promise<string> => {
// Return cached fingerprint if available
if (browserFingerprint.value) {
return browserFingerprint.value;
}
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
browserFingerprint.value = result.visitorId;
return browserFingerprint.value;
} catch (error) {
console.error('Failed to get fingerprint:', error);
// Return special error value to prevent blocking requests
const errorValue = 'ERROR';
browserFingerprint.value = errorValue;
return errorValue;
}
};
+12
View File
@@ -14,6 +14,7 @@ import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
@@ -24,6 +25,7 @@ import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
@@ -61,6 +63,7 @@ const { t } = useI18n({
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -70,6 +73,7 @@ const { t } = useI18n({
maintenance: 'Maintenance',
database: 'Database',
workerconfig: 'Worker Config',
ipBlacklistSettings: 'IP Blacklist',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
@@ -88,6 +92,7 @@ const { t } = useI18n({
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -97,6 +102,7 @@ const { t } = useI18n({
maintenance: '维护',
database: '数据库',
workerconfig: 'Worker 配置',
ipBlacklistSettings: 'IP 黑名单',
appearance: '外观',
about: '关于',
ok: '确定',
@@ -157,6 +163,9 @@ onMounted(async () => {
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
<IpBlacklistSettings />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
@@ -173,6 +182,9 @@ onMounted(async () => {
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
+222 -6
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
loading, adminTab,
loading, adminTab, openSettings,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -39,6 +39,19 @@ const { t } = useI18n({
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions',
success: 'Success',
resetPassword: 'Reset Password',
newPassword: 'New Password',
passwordResetSuccess: 'Password reset successfully',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
pleaseSelectAddress: 'Please select address',
selectedItems: 'Selected',
multiDelete: 'Multi Delete',
multiDeleteTip: 'Are you sure to delete selected addresses?',
multiClearInbox: 'Multi Clear Inbox',
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
multiClearSentItems: 'Multi Clear Sent Items',
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
},
zh: {
name: '名称',
@@ -63,6 +76,19 @@ const { t } = useI18n({
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作',
success: '成功',
resetPassword: '重置密码',
newPassword: '新密码',
passwordResetSuccess: '密码重置成功',
selectAll: '全选本页',
unselectAll: '取消全选',
pleaseSelectAddress: '请选择地址',
selectedItems: '已选择',
multiDelete: '批量删除',
multiDeleteTip: '确定要删除选中的邮箱吗?',
multiClearInbox: '批量清空收件箱',
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
multiClearSentItems: '批量清空发件箱',
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
}
}
});
@@ -72,6 +98,18 @@ const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
const showResetPassword = ref(false);
const curResetPasswordAddressId = ref(0);
const newPassword = ref('');
// Multi-action mode state
const checkedRowKeys = ref([]);
const showMultiActionModal = ref(false);
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
const multiActionTitle = ref('');
const selectedCount = computed(() => checkedRowKeys.value.length);
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
const addressQuery = ref("")
@@ -134,6 +172,114 @@ const clearSentItems = async () => {
}
}
const resetPassword = async () => {
try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST',
body: JSON.stringify({
password: newPassword.value
})
});
message.success(t("passwordResetSuccess"));
newPassword.value = '';
showResetPassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
}
// Multi-action mode functions
const multiActionSelectAll = () => {
checkedRowKeys.value = data.value.map(item => item.id);
}
const multiActionUnselectAll = () => {
checkedRowKeys.value = [];
}
//
const executeBatchOperation = async ({
shouldSkip = () => false,
apiCall,
title,
operationName = 'operation'
}) => {
try {
loading.value = true;
const selectedAddresses = data.value.filter((item) =>
checkedRowKeys.value.includes(item.id)
);
if (selectedAddresses.length === 0) {
message.error(t('pleaseSelectAddress'));
return;
}
const failedIds = [];
const totalCount = selectedAddresses.length;
multiActionProgress.value = {
percentage: 0,
tip: `0/${totalCount}`
};
multiActionTitle.value = title;
showMultiActionModal.value = true;
for (const [index, address] of selectedAddresses.entries()) {
try {
if (!shouldSkip(address)) {
await apiCall(address.id);
}
} catch (error) {
console.error(`${operationName} failed for address ${address.id}:`, error);
failedIds.push(address.id);
}
multiActionProgress.value = {
percentage: Math.floor((index + 1) / totalCount * 100),
tip: `${index + 1}/${totalCount}`
};
}
await fetchData();
checkedRowKeys.value = failedIds;
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
const multiActionDeleteAccounts = async () => {
await executeBatchOperation({
apiCall: (id) => api.adminDeleteAddress(id),
title: t('multiDelete') + ' ' + t('success'),
operationName: 'Delete'
});
}
const multiActionClearInbox = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.mail_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
method: 'DELETE'
}),
title: t('multiClearInbox') + ' ' + t('success'),
operationName: 'ClearInbox'
});
}
const multiActionClearSentItems = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.send_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
method: 'DELETE'
}),
title: t('multiClearSentItems') + ' ' + t('success'),
operationName: 'ClearSentItems'
});
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
@@ -148,12 +294,15 @@ const fetchData = async () => {
count.value = addressCount;
}
} catch (error) {
console.log(error)
console.error(error);
message.error(error.message || "error");
}
}
const columns = [
{
type: 'selection'
},
{
title: "ID",
key: "id"
@@ -296,6 +445,19 @@ const columns = [
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curResetPasswordAddressId.value = row.id;
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
show: openSettings.value?.enableAddressPassword
},
{
label: () => h(NButton,
{
@@ -365,13 +527,54 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-input-group>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-input-group style="margin-bottom: 10px;">
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
<n-button @click="multiActionSelectAll" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionUnselectAll" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm @positive-click="multiActionDeleteAccounts">
<template #trigger>
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
</template>
{{ t('multiDeleteTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearInbox">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
</template>
{{ t('multiClearInboxTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearSentItems">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
</template>
{{ t('multiClearSentItemsTip') }}
</n-popconfirm>
<n-tag type="info">
{{ t('selectedItems') }}: {{ selectedCount }}
</n-tag>
</n-space>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -381,8 +584,21 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" embedded />
</div>
<!-- Multi-action progress modal -->
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
<span style="text-align: center">
{{ multiActionProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>
+32 -3
View File
@@ -15,10 +15,13 @@ const { t } = useI18n({
en: {
address: 'Address',
enablePrefix: 'If enable Prefix',
creatNewEmail: 'Get New Email',
creatNewEmail: 'Create New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
linkWithAddressCredential: 'Open to auto login email link',
},
zh: {
address: '地址',
@@ -27,6 +30,9 @@ const { t } = useI18n({
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
}
}
});
@@ -36,6 +42,8 @@ const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const newEmail = async () => {
if (!emailName.value || !emailDomain.value) {
@@ -52,6 +60,8 @@ const newEmail = async () => {
})
})
result.value = res["jwt"];
addressPassword.value = res["password"] || '';
createdAddress.value = res["address"] || '';
message.success(t('successTip'))
showReultModal.value = true
} catch (error) {
@@ -59,6 +69,10 @@ const newEmail = async () => {
}
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
@@ -70,10 +84,25 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card :bordered="false" embedded>
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
@@ -0,0 +1,220 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
title: 'IP Blacklist Settings',
manualInputPrompt: 'Type pattern and press Enter to add',
save: 'Save',
successTip: 'Save Success',
enable_ip_blacklist: 'Enable IP Blacklist',
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
ip_blacklist: 'IP Blacklist Patterns',
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
asn_blacklist: 'ASN Organization Blacklist',
asn_blacklist_placeholder: 'Enter ASN organization (e.g., Google, Amazon)',
fingerprint_blacklist: 'Browser Fingerprint Blacklist',
fingerprint_blacklist_placeholder: 'Enter fingerprint ID (e.g., a1b2c3d4e5f6g7h8)',
tip_ip: 'IP Blacklist: Supports text matching (e.g., "192.168.1") or regex (e.g., "^10\\.0\\.0\\.5$").',
tip_asn: 'ASN Organization: Block by ISP/provider. Case-insensitive text matching or regex.',
tip_fingerprint: 'Browser Fingerprint: Block by browser fingerprint. Supports exact matching or regex patterns.',
tip_daily_limit: 'Daily Limit: Restrict the maximum number of requests per IP address per day (1-1000000).',
tip_scope: 'Applies to: Create Address, Send Mail, External Send Mail API, User Registration, Verify Code',
enable_daily_limit: 'Enable Daily Request Limit',
enable_daily_limit_tip: 'Limit the number of API requests per IP address per day',
daily_request_limit: 'Daily Request Limit',
daily_request_limit_placeholder: 'Enter limit (e.g., 1000)',
},
zh: {
title: 'IP 黑名单设置',
manualInputPrompt: '输入匹配模式后按回车键添加',
save: '保存',
successTip: '保存成功',
enable_ip_blacklist: '启用 IP 黑名单',
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
ip_blacklist: 'IP 黑名单匹配模式',
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$',
asn_blacklist: 'ASN 组织(运营商)黑名单',
asn_blacklist_placeholder: '输入 ASN 组织名称(例如:Google, Amazon',
fingerprint_blacklist: '浏览器指纹黑名单',
fingerprint_blacklist_placeholder: '输入指纹 ID(例如:a1b2c3d4e5f6g7h8',
tip_ip: 'IP 黑名单:支持文本匹配(如 "192.168.1")或正则表达式(如 "^10\\.0\\.0\\.5$")。',
tip_asn: 'ASN 组织:根据运营商/ISP 拉黑。支持不区分大小写的文本匹配或正则表达式。',
tip_fingerprint: '浏览器指纹:根据浏览器指纹拉黑。支持完全匹配或正则表达式。',
tip_daily_limit: '每日限流:限制单个 IP 地址每天最多请求次数(1-1000000)。',
tip_scope: '作用范围:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码验证',
enable_daily_limit: '启用每日请求限流',
enable_daily_limit_tip: '限制每个 IP 地址每天的 API 请求次数',
daily_request_limit: '每日请求次数上限',
daily_request_limit_placeholder: '输入限制次数(例如:1000',
}
}
});
const enabled = ref(false)
const ipBlacklist = ref([])
const asnBlacklist = ref([])
const fingerprintBlacklist = ref([])
const enableDailyLimit = ref(false)
const dailyRequestLimit = ref(1000)
const fetchData = async () => {
try {
loading.value = true
const res = await api.fetch(`/admin/ip_blacklist/settings`)
enabled.value = res.enabled || false
ipBlacklist.value = res.blacklist || []
asnBlacklist.value = res.asnBlacklist || []
fingerprintBlacklist.value = res.fingerprintBlacklist || []
enableDailyLimit.value = res.enableDailyLimit || false
dailyRequestLimit.value = res.dailyRequestLimit || 1000
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
const save = async () => {
try {
loading.value = true
await api.fetch(`/admin/ip_blacklist/settings`, {
method: 'POST',
body: JSON.stringify({
enabled: enabled.value,
blacklist: ipBlacklist.value || [],
asnBlacklist: asnBlacklist.value || [],
fingerprintBlacklist: fingerprintBlacklist.value || [],
enableDailyLimit: enableDailyLimit.value,
dailyRequestLimit: dailyRequestLimit.value
})
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px;">
<template #header-extra>
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</template>
<n-space vertical :size="20">
<n-alert :show-icon="false" :bordered="false" type="info">
<div style="line-height: 1.8;">
<div><strong>{{ t("tip_scope") }}</strong></div>
<div> {{ t("tip_ip") }}</div>
<div> {{ t("tip_asn") }}</div>
<div> {{ t("tip_fingerprint") }}</div>
<div> {{ t("tip_daily_limit") }}</div>
</div>
</n-alert>
<n-form-item-row :label="t('enable_ip_blacklist')">
<n-switch v-model:value="enabled" :round="false" />
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
{{ t('enable_tip') }}
</n-text>
</n-form-item-row>
<n-form-item-row :label="t('ip_blacklist')">
<n-select
v-model:value="ipBlacklist"
filterable
multiple
tag
:placeholder="t('ip_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('asn_blacklist')">
<n-select
v-model:value="asnBlacklist"
filterable
multiple
tag
:placeholder="t('asn_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('fingerprint_blacklist')">
<n-select
v-model:value="fingerprintBlacklist"
filterable
multiple
tag
:placeholder="t('fingerprint_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-divider />
<n-form-item-row :label="t('enable_daily_limit')">
<n-switch v-model:value="enableDailyLimit" :round="false" />
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
{{ t('enable_daily_limit_tip') }}
</n-text>
</n-form-item-row>
<n-form-item-row :label="t('daily_request_limit')">
<n-input-number
v-model:value="dailyRequestLimit"
:min="1"
:max="1000000"
:placeholder="t('daily_request_limit_placeholder')"
:disabled="!enableDailyLimit"
style="width: 100%;"
/>
</n-form-item-row>
</n-space>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>
+16
View File
@@ -17,6 +17,8 @@ const cleanupModel = ref({
cleanAddressDays: 30,
enableInactiveAddressAutoCleanup: false,
cleanInactiveAddressDays: 30,
enableUnboundAddressAutoCleanup: false,
cleanUnboundAddressDays: 30,
})
const { t } = useI18n({
@@ -28,6 +30,7 @@ const { t } = useI18n({
sendBoxLabel: "Cleanup the sendbox before n days",
addressCreateLabel: "Cleanup the address created before n days",
inactiveAddressLabel: "Cleanup the inactive address before n days",
unboundAddressLabel: "Cleanup the unbound address before n days",
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
@@ -41,6 +44,7 @@ const { t } = useI18n({
sendBoxLabel: "清理 n 天前的发件箱",
addressCreateLabel: "清理 n 天前创建的地址",
inactiveAddressLabel: "清理 n 天前的未活跃地址",
unboundAddressLabel: "清理 n 天前的未绑定用户地址",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
cleanupNow: "立即清理",
@@ -161,6 +165,18 @@ onMounted(async () => {
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('unboundAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnboundAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnboundAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('unboundAddress', cleanupModel.cleanUnboundAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
</n-form>
</n-card>
</div>
@@ -0,0 +1,153 @@
<script setup>
import { ref, onMounted, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NInputNumber, NTag, NSpace, NButton } from 'naive-ui';
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
role: 'Role',
maxAddressCount: 'Max Address Count',
save: 'Save',
successTip: 'Success',
noRolesAvailable: 'No roles available in system config',
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
notConfigured: 'Not Configured (Use Global Settings)',
},
zh: {
role: '角色',
maxAddressCount: '最大地址数量',
save: '保存',
successTip: '成功',
noRolesAvailable: '系统配置中没有可用的角色',
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
notConfigured: '未配置(使用全局设置)',
}
}
});
const systemRoles = ref([])
const tableData = ref([])
const fetchUserRoles = async () => {
try {
const results = await api.fetch(`/admin/user_roles`);
systemRoles.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchRoleConfigs = async () => {
try {
const { configs } = await api.fetch(`/admin/role_address_config`);
tableData.value = systemRoles.value.map(roleObj => ({
role: roleObj.role,
max_address_count: configs[roleObj.role]?.maxAddressCount ?? null,
}));
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const saveConfig = async () => {
try {
// convert tableData to object with nested structure
const configs = {};
tableData.value.forEach(row => {
if (row.max_address_count !== null && row.max_address_count !== undefined) {
configs[row.role] = { maxAddressCount: row.max_address_count };
}
});
await api.fetch(`/admin/role_address_config`, {
method: 'POST',
body: JSON.stringify({ configs })
});
message.success(t('successTip'));
await fetchRoleConfigs();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: t('role'),
key: 'role',
width: 200,
render(row) {
return h(NTag, {
type: 'info',
bordered: false
}, {
default: () => row.role
})
}
},
{
title: t('maxAddressCount'),
key: 'max_address_count',
render(row) {
return h(NInputNumber, {
value: row.max_address_count,
min: 0,
max: 999,
clearable: true,
placeholder: t('notConfigured'),
style: 'width: 200px;',
onUpdateValue: (value) => {
row.max_address_count = value;
}
})
}
}
]
onMounted(async () => {
await fetchUserRoles();
await fetchRoleConfigs();
})
</script>
<template>
<div style="margin-top: 10px;">
<n-alert type="info" :bordered="false" style="margin-bottom: 20px;">
{{ t('roleConfigDesc') }}
</n-alert>
<n-alert v-if="systemRoles.length === 0" type="warning" :bordered="false">
{{ t('noRolesAvailable') }}
</n-alert>
<div v-else>
<n-space justify="end" style="margin-bottom: 12px;">
<n-button :loading="loading" @click="saveConfig" type="primary">
{{ t('save') }}
</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="tableData"
:bordered="false"
embedded
/>
</div>
</div>
</template>
<style scoped>
.n-data-table {
min-width: 600px;
}
</style>
+88 -9
View File
@@ -9,7 +9,7 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import { getRouterPathWithLang, hashPassword } from '../../utils'
const props = defineProps({
bindUserAddress: {
@@ -39,7 +39,7 @@ const router = useRouter()
const {
jwt, loading, openSettings,
showAddressCredential, userSettings
showAddressCredential, userSettings, addressPassword
} = useGlobalState()
const tabValue = ref('signin')
@@ -47,8 +47,47 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const loginMethod = ref('credential') // 'credential' or 'password'
const loginAddress = ref('')
const loginPassword = ref('')
// openSettings
const initLoginMethod = () => {
if (openSettings.value?.enableAddressPassword) {
loginMethod.value = 'password';
} else {
loginMethod.value = 'credential';
}
}
const login = async () => {
if (loginMethod.value === 'password') {
// Password login
if (!loginAddress.value || !loginPassword.value) {
message.error(t('emailPasswordRequired'));
return;
}
try {
const res = await api.fetch('/api/address_login', {
method: 'POST',
body: JSON.stringify({
email: loginAddress.value,
password: await hashPassword(loginPassword.value)
})
});
jwt.value = res.jwt;
await api.getSettings();
try {
await props.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
}
return;
}
if (!credential.value) {
message.error(t('credentialInput'));
return;
@@ -85,6 +124,11 @@ const { locale, t } = useI18n({
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',
passwordLogin: 'Password Login',
credentialLogin: 'Credential Login',
email: 'Email',
password: 'Password',
emailPasswordRequired: 'Email and password are required',
},
zh: {
login: '登录',
@@ -102,6 +146,11 @@ const { locale, t } = useI18n({
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
autoGeneratedName: '自动生成名称',
passwordLogin: '密码登录',
credentialLogin: '凭据登录',
email: '邮箱',
password: '密码',
emailPasswordRequired: '邮箱和密码不能为空',
}
}
});
@@ -157,6 +206,7 @@ const newEmail = async () => {
cfToken.value
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
await api.getSettings();
await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true;
@@ -212,6 +262,7 @@ onMounted(async () => {
await api.getOpenSettings(message, notification);
}
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
initLoginMethod();
});
</script>
@@ -223,9 +274,29 @@ onMounted(async () => {
<n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="loginAndBindTag">
<n-form>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<div v-if="loginMethod === 'password'">
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="loginAddress" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="loginPassword" type="password" show-password-on="click" />
</n-form-item-row>
</div>
<div v-else>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
</div>
<div class="switch-login-button">
<n-button v-if="openSettings?.enableAddressPassword"
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
type="info" quaternary size="tiny">
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
</n-button>
</div>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
@@ -244,19 +315,21 @@ onMounted(async () => {
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip1") + addressRegex.source }}</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 v-if="!openSettings.disableCustomAddressName" @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-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" />
<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"
@@ -294,6 +367,12 @@ onMounted(async () => {
margin-top: 10px;
}
.switch-login-button {
display: flex;
justify-content: center;
margin: 10px 0;
}
.n-form {
text-align: left;
}
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils'
import { getRouterPathWithLang } from '../../utils'
const {
@@ -17,6 +18,9 @@ const showLogout = ref(false)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showChangePassword = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
const { locale, t } = useI18n({
messages: {
en: {
@@ -31,6 +35,11 @@ const { locale, t } = useI18n({
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",
changePassword: "Change Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
passwordMismatch: "Passwords do not match",
passwordChanged: "Password changed successfully",
},
zh: {
logout: '退出登录',
@@ -44,6 +53,11 @@ const { locale, t } = useI18n({
clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?",
clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?",
success: "成功",
changePassword: "修改密码",
newPassword: "新密码",
confirmPassword: "确认密码",
passwordMismatch: "密码不匹配",
passwordChanged: "密码修改成功",
}
}
});
@@ -92,6 +106,27 @@ const clearSentItems = async () => {
showClearSentItems.value = false;
}
};
const changePassword = async () => {
if (newPassword.value !== confirmPassword.value) {
message.error(t("passwordMismatch"));
return;
}
try {
await api.fetch(`/api/address_change_password`, {
method: 'POST',
body: JSON.stringify({
new_password: await hashPassword(newPassword.value)
})
});
message.success(t("passwordChanged"));
newPassword.value = '';
confirmPassword.value = '';
showChangePassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
};
</script>
<template>
@@ -100,6 +135,9 @@ const clearSentItems = async () => {
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
{{ t('changePassword') }}
</n-button>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearInbox = true" type="warning" secondary
block strong>
{{ t('clearInbox') }}
@@ -148,6 +186,22 @@ const clearSentItems = async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
<n-form :model="{ newPassword, confirmPassword }">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
</n-form>
<template #action>
<n-button :loading="loading" @click="changePassword" size="small" tertiary type="info">
{{ t('changePassword') }}
</n-button>
</template>
</n-modal>
</div>
</template>
+7 -1
View File
@@ -19,7 +19,7 @@ const router = useRouter()
const {
jwt, settings, showAddressCredential, userJwt,
isTelegram, openSettings
isTelegram, openSettings, addressPassword
} = useGlobalState()
const { locale, t } = useI18n({
@@ -34,6 +34,7 @@ const { locale, t } = useI18n({
addressCredential: 'Mail Address Credential',
linkWithAddressCredential: 'Open to auto login email link',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
userLogin: 'User Login',
},
zh: {
@@ -46,6 +47,7 @@ const { locale, t } = useI18n({
addressCredential: '邮箱地址凭证',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
userLogin: '用户登录',
}
}
@@ -149,6 +151,10 @@ onMounted(async () => {
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ settings.address }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.0.6",
"version": "1.0.7",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.37.0"
"wrangler": "^4.45.3"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+118 -5
View File
@@ -2,13 +2,16 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
export const en = defineConfig({
title: "Temp Mail Doc",
lang: 'zh-Hans',
lang: 'en-US',
description: 'CloudFlare Free sending and receiving of temporary domain name mailboxes',
themeConfig: {
outline: 'deep',
nav: nav(),
sidebar: {
'/en/guide/': { base: '/en/guide/', items: sidebarGuide() },
},
editLink: {
pattern: 'https://github.com/dreamhunter2333/cloudflare_temp_email/edit/main/vitepress-docs/docs/:path',
text: 'Edit this page on GitHub'
@@ -18,6 +21,31 @@ export const en = defineConfig({
message: 'Based on MIT license',
copyright: `Copyright © 2023-${new Date().getFullYear()} Dream Hunter`
},
docFooter: {
prev: 'Previous',
next: 'Next'
},
outline: {
level: 'deep',
label: 'On this page'
},
lastUpdated: {
text: 'Last updated',
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium'
}
},
langMenuLabel: 'Language',
returnToTopLabel: 'Return to top',
sidebarMenuLabel: 'Menu',
darkModeSwitchLabel: 'Theme',
lightModeSwitchTitle: 'Switch to light mode',
darkModeSwitchTitle: 'Switch to dark mode'
}
})
@@ -29,15 +57,16 @@ function nav(): DefaultTheme.NavItem[] {
},
{
text: 'Guide',
link: '/en/cli',
link: '/en/guide/quick-start',
activeMatch: '/en/guide/'
},
{
text: 'Service Status',
link: '/status',
link: '/en/status',
},
{
text: 'Reference',
link: '/reference',
link: '/en/reference',
},
{
text: process.env.TAG_NAME || 'v0.2.2',
@@ -54,3 +83,87 @@ function nav(): DefaultTheme.NavItem[] {
}
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Introduction',
collapsed: false,
items: [
{ text: 'What is Temporary Email', link: 'what-is-temp-mail' },
{ text: 'Star History', link: 'star-history' },
{ text: 'Quick Start', link: 'quick-start' },
]
},
{
text: 'Deploy via Command Line',
collapsed: false,
items: [
{ text: 'CLI Prerequisites', link: 'cli/pre-requisite' },
{ text: 'D1 Database', link: 'cli/d1' },
{ text: 'Cloudflare Workers Backend', link: 'cli/worker' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Cloudflare Pages Frontend', link: 'cli/pages' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
]
},
{
text: 'Deploy via User Interface',
collapsed: false,
items: [
{ text: 'D1 Database', link: 'ui/d1' },
{ text: 'Cloudflare Workers Backend', link: 'ui/worker' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Cloudflare Pages Frontend', link: 'ui/pages' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
]
},
{
text: 'Deploy via Github Actions',
collapsed: true,
items: [
{ text: 'Github Actions Prerequisites', link: 'actions/pre-requisite' },
{ text: 'D1 Database', link: 'actions/d1' },
{ text: 'Github Actions Configuration', link: 'actions/github-action' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
{ text: 'Auto-Update Configuration', link: 'actions/auto-update' },
]
},
{
text: 'General',
collapsed: false,
items: [
{ text: 'Worker Variables', link: 'worker-vars' },
{ text: 'Common Issues', link: 'common-issues' },
]
},
{
text: 'Additional Features',
collapsed: false,
items: [
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Configure Webhook', link: 'feature/webhook' },
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'Feature Overview',
collapsed: false,
items: [
{ text: 'Admin Console', link: 'feature/admin' },
{ text: 'Admin User Management', link: 'feature/admin-user-management' },
]
},
{ text: 'Reference', base: "/en/", link: 'reference' }
]
}
@@ -0,0 +1,10 @@
# How to Configure Auto-Update for GitHub Actions Deployment
::: warning Notice
If you encounter any issues, please report them via `GitHub Issues`. Thank you.
Auto-update will not execute SQL files for the D1 database. When the database schema changes, you need to execute them manually.
:::
1. Open the `Actions` page of the repository, find `Upstream Sync`, and click `enable workflow` to enable the `workflow`
2. If `Upstream Sync` fails, go to the repository homepage and click `Sync` to synchronize manually
3. You can customize the update interval by modifying the `schedule` configuration in `Upstream Sync`, refer to [cron expressions](https://crontab.guru/)
@@ -0,0 +1,17 @@
# Initialize/Update D1 Database
## Create Database
Open the Cloudflare console, select `Workers & Pages` -> `D1` -> `Create Database`, and click to create the database
![d1](/ui_install/d1.png)
After creation, you can see the D1 database in the Cloudflare console and obtain the database `name` and `database ID`
## Initialize Database
After deployment is complete, go to the admin page's `Quick Setup` -> `Database` section and click the `Initialize Database` button to initialize the database
## Update Database Schema
Refer to [Update D1 via Command Line](/en/guide/cli/d1) or [Update D1 via UI](/en/guide/ui/d1)
@@ -0,0 +1,61 @@
# Deploy via GitHub Actions
::: warning Notice
Currently only supports Worker and Pages deployment.
If you encounter any issues, please report them via `GitHub Issues`. Thank you.
The `worker.dev` domain is inaccessible in China, please use a custom domain
:::
## Deployment Steps
### Fork Repository and Enable Actions
- Fork this repository on GitHub
- Open the `Actions` page of the repository
- Find `Deploy Backend` and click `enable workflow` to enable the `workflow`
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `enable workflow` to enable the `workflow`
### Configure Secrets
Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`, and add the following `secrets`:
- Common `secrets`
| Name | Description |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID, [Reference Documentation](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id) |
| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token, [Reference Documentation](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token) |
- Worker backend `secrets`
| Name | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `BACKEND_TOML` | Backend configuration file, [see here](/en/guide/cli/worker.html#modify-wrangler-toml-configuration-file) |
| `DEBUG_MODE` | (Optional) Whether to enable debug mode, set to `true` to enable. By default, worker deployment logs are not output to GitHub Actions page, enabling this will output them |
| `BACKEND_USE_MAIL_WASM_PARSER` | (Optional) Whether to use WASM to parse emails, set to `true` to enable. For features, refer to [Configure Worker to use WASM Email Parser](/en/guide/feature/mail_parser_wasm_worker) |
| `USE_WORKER_ASSETS` | (Optional) Deploy Worker with frontend assets, set to `true` to enable |
- Pages frontend `secrets`
> [!warning] Notice
> If you choose to deploy Worker with frontend assets, these `secrets` are not required
| Name | Description |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FRONTEND_ENV` | Frontend configuration file, please copy the content from `frontend/.env.example`, [and modify according to this guide](/en/guide/cli/pages.html) |
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
| `PAGE_TOML` | (Optional) Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation |
| `TG_FRONTEND_NAME` | (Optional) The project name you created in Cloudflare Pages, same as `FRONTEND_NAME`. Fill this in if you need Telegram Mini App functionality |
### Deploy
- Open the `Actions` page of the repository
- Find `Deploy Backend` and click `Run workflow` to select a branch and deploy manually
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `Run workflow` to select a branch and deploy manually
## How to Configure Auto-Update
1. Open the `Actions` page of the repository, find `Upstream Sync`, and click `enable workflow` to enable the `workflow`
2. If `Upstream Sync` fails, go to the repository homepage and click `Sync` to synchronize manually
@@ -0,0 +1,10 @@
# GitHub Actions Deployment Prerequisites
## GitHub Account
- A GitHub account is required
- A stable network connection
## Fork Repository
- Fork [this repository](https://github.com/dreamhunter2333/cloudflare_temp_email.git) on GitHub
+30
View File
@@ -0,0 +1,30 @@
# Initialize/Update D1 Database
When executing the wrangler login command for the first time, you will be prompted to log in. Follow the prompts to complete the login process.
## Initialize Database
```bash
cd worker
cp wrangler.toml.template wrangler.toml
# Create D1 and execute schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=../db/schema.sql --remote
```
After creation, you can see the D1 database in the Cloudflare console.
![D1](/readme_assets/d1.png)
## Update Database Schema
For `schema` updates, please confirm your previously deployed version.
Check the [Changelog](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md)
Find the `patch` file you need to execute and run it, for example:
```bash
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
```
+51
View File
@@ -0,0 +1,51 @@
# Cloudflare Pages Frontend
> [!warning] Notice
> Choose one of the following methods
## Deploy Worker with Frontend Assets
Refer to [Deploy Worker](/en/guide/cli/worker#deploy-worker-with-frontend-optional)
## Separate Frontend and Backend Deployment
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
```bash
cd frontend
pnpm install
cp .env.example .env.prod
```
Modify the `.env.prod` file.
Change `VITE_API_BASE` to the `worker` `url` created in the previous step. Do not add `/` at the end.
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
```bash
pnpm build --emptyOutDir
# The first deployment will prompt you to create a project, for production branch enter production
pnpm run deploy
```
After deployment, you can see your project in the Cloudflare console. You can configure a custom domain for `pages`.
![pages](/readme_assets/pages.png)
## Forward Backend Requests Through Page Functions
Forwarding requests from page functions to the worker backend can achieve faster response times.
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
```bash
cd frontend
pnpm install
# If you want to enable Cloudflare Zero Trust, you need to use pnpm build:pages:nopwa to disable caching
pnpm build:pages
cd ../pages
pnpm run deploy
```
@@ -0,0 +1,17 @@
# Prerequisites
## Installing wrangler
Install wrangler
```bash
npm install wrangler -g
```
## Clone the Project
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# Switch to the latest tag or the branch you want to deploy, you can also use the main branch directly
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```
+147
View File
@@ -0,0 +1,147 @@
# Cloudflare Worker Backend
> [!warning] Notice
> The `worker.dev` domain is not accessible in China, please use a custom domain
## Initialize Project
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
```
## Create KV Cache
> [!NOTE]
> If you want to enable user registration and need to send email verification, you need to create a `KV` cache. You can skip this step if not needed.
> If you need Telegram Bot, you need to create a `KV` cache. You can skip this step if not needed.
Create KV cache through command line, or create it in the Cloudflare console, then copy the corresponding configuration to the `wrangler.toml` file.
```bash
wrangler kv:namespace create DEV
```
## Modify `wrangler.toml` Configuration File
> [!NOTE] Note
> For more variable configurations, please check [Worker Variables Documentation](/en/guide/worker-vars)
```toml
name = "cloudflare_temp_email"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = [ "nodejs_compat" ]
# If you want to use a custom domain, you need to add routes configuration
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
# If you want to deploy a worker with frontend assets, you need to add assets configuration
# [assets]
# directory = "../frontend/dist/"
# binding = "ASSETS"
# run_worker_first = true
# If you want to use scheduled tasks to clean up emails, uncomment the following and modify the cron expression
# [triggers]
# crons = [ "0 0 * * *" ]
# Send emails through Cloudflare
# send_email = [
# { name = "SEND_MAIL" },
# ]
[vars]
# Email name prefix, can be configured as an empty string or not configured if no prefix is needed
PREFIX = "tmp"
# All domains used for temporary email, supports multiple domains
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
# Secret key for generating JWT, JWT is used for user login and authentication
JWT_SECRET = "xxx"
# Admin console password, if not configured, console access is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# Whether to allow users to create emails, not allowed if not configured
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete emails, not allowed if not configured
ENABLE_USER_DELETE_EMAIL = true
# D1 database name and ID can be viewed in the Cloudflare console
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 database name
database_id = "xxx" # D1 database ID
# KV config for user registration email verification, can be skipped if user registration is not enabled or registration verification is not enabled
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# Rate limit configuration for new address /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
# Bind other workers to process emails, for example, using auth-inbox AI capabilities to parse verification codes or activation links
# [[services]]
# binding = "AUTH_INBOX"
# service = "auth-inbox"
```
## Deploy Worker with Frontend (Optional)
> [!NOTE]
> If you don't need a [worker with frontend], you can skip this step.
> Refer to the frontend deployment documentation later for separate frontend and backend deployment.
Ensure the frontend assets are built to the `frontend/dist` directory.
```bash
cd frontend
pnpm install --no-frozen-lockfile
pnpm build:pages
```
Add the following configuration to the `wrangler.toml` file in the `worker` directory.
```toml
[assets]
directory = "../frontend/dist/"
binding = "ASSETS"
run_worker_first = true
```
## Telegram Bot Configuration
> [!NOTE]
> If you don't need Telegram Bot, you can skip this step.
Please create a Telegram Bot first, then get the `token`, and execute the following command to add the `token` to secrets.
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Deploy
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
```bash
pnpm run deploy
```
After successful deployment, you can see the `worker` `url` in the routes, and the console will also output the `worker` `url`.
![worker](/readme_assets/worker.png)
> [!NOTE]
> Open the `worker` `url`, if it displays `OK`, the deployment is successful.
>
> Open `/health_check`, if it displays `OK`, the deployment is successful.
@@ -0,0 +1,41 @@
# Common Issues
> [!NOTE] Note
> If you don't find a solution here, please search or ask in `Github Issues`, or ask in the Telegram group.
## General
| Issue | Solution |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Sending emails to authenticated forwarding addresses using Cloudflare Workers | Use CF's API for sending, only supports recipient addresses bound to CF, i.e., CF EMAIL forwarding destination addresses |
| Binding multiple domains | Each domain needs to configure email forwarding to worker |
## Worker Related
| Issue | Solution |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `Uncaught Error: No such module "path". imported from "worker.js"` | [Reference](/en/guide/ui/worker) |
| `No such module "node:stream". imported from "worker.js"` | [Reference](/en/guide/ui/worker) |
| `Subdomain cannot send emails` | [Reference](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515) |
| `Failed to send verify code: No balance` | Set unlimited emails in admin console or increase quota on the sending permission page |
| `Github OAuth unable to get email 400 Failed to get user email` | GitHub user needs to set email to public |
| `Cannot read properties of undefined (reading 'map')` | Worker variables not set successfully |
## Pages Related
| Issue | Solution |
| --------------- | --------------------------------------------------------- |
| `network error` | Use incognito mode or clear browser cache and DNS cache |
## Telegram Bot
| Issue | Solution |
| -------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `Telgram Bot failed to get email: 400: Bad Request:BUTTON_URL_INVALID` | tg mini app URL is incorrect, should be the pages URL |
| `Telegram bot bind error: bind adress count reach the limit` | Need to set worker variable `TG_MAX_ADDRESS` |
## Github Actions
| Issue | Solution |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| After Github Action deployment, CF always shows preview branch | Go to CF pages settings to confirm that the frontend branch matches the Github Action frontend deployment branch |
@@ -0,0 +1,84 @@
# Configure Email Sending
::: warning Note
All three methods can be configured simultaneously. When sending emails, it will prioritize using `resend`, if `resend` is not configured, it will use `smtp`.
If a Cloudflare authenticated forwarding email address is configured, CF's internal API will be prioritized for sending emails
:::
## Send Emails Using Resend
Register at `https://resend.com/domains` and add DNS records according to the instructions.
Create an `api key` on the `API KEYS` page.
Then execute the following command to add `RESEND_TOKEN` to secrets:
> [!NOTE]
> If you find this troublesome, you can also put it directly in plain text under `[vars]` in `wrangler.toml`, but this is not recommended
If you deployed through the UI, you can add it under `Variables and Secrets` in the Cloudflare UI interface.
```bash
# Switch to worker directory
cd worker
wrangler secret put RESEND_TOKEN
```
If you have multiple domains with different `api keys`, you can add multiple secrets in `wrangler.toml`, named `RESEND_TOKEN_` + `<UPPERCASE DOMAIN WITH . REPLACED BY _>`, for example:
```bash
wrangler secret put RESEND_TOKEN_XXX_COM
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
```
## Send Emails Using SMTP
The format of `SMTP_CONFIG` is as follows, where key is the domain name and value is the SMTP configuration. For SMTP configuration format details, refer to [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
```json
{
"awsl.uk": {
"host": "smtp.xxx.com",
"port": 465,
"secure": true,
"authType": [
"plain",
"login"
],
"credentials": {
"username": "username",
"password": "password"
}
}
}
```
Then execute the following command to add `SMTP_CONFIG` to secrets:
> [!NOTE]
> If you find this troublesome, you can also put it directly in plain text under `[vars]` in `wrangler.toml`, but this is not recommended
If you deployed through the UI, you can add it under `Variables and Secrets` in the Cloudflare UI interface.
```bash
# Switch to worker directory
cd worker
wrangler secret put SMTP_CONFIG
```
## Send Emails to Authenticated Forwarding Addresses on Cloudflare
Only supported for CLI deployment, add `send_email` configuration in `wrangler.toml`.
The destination email address must be an authenticated email address on Cloudflare, which has significant limitations. If you need to send emails to other addresses, you can use `resend` or `smtp` to send emails.
```toml
# Send emails through Cloudflare
send_email = [
{ name = "SEND_MAIL" },
]
```
Admin console account configuration `Verified address list (can send emails through CF internal API)`
@@ -0,0 +1,9 @@
# Cloudflare Email Routing
1. In the CF console for the corresponding domain under `Email Routing`, configure the `Email DNS records`. If there are multiple domains, you need to configure `Email DNS records` for each domain.
2. Before binding an email address to your Worker, you need to enable email routing and have at least one verified email address (destination address).
3. Configure the `Catch-all address` in the routing rules of each domain's `Email Routing` to send to `worker`.
![email](/readme_assets/email.png)
@@ -0,0 +1,11 @@
# Admin User Management
## User Management Page
![admin-user-management](/feature/admin-user-management.png)
## User Settings
Configure user login and authentication settings here
![admin-user-page](/feature/admin-user-page.png)
@@ -0,0 +1,15 @@
# Admin Console
> [!NOTE]
> You need to configure `ADMIN_PASSWORDS` or `ADMIN_USER_ROLE` to access the admin console
> Admin role configuration: if the user role equals ADMIN_USER_ROLE, they can access the admin console
After deploying the frontend application, click the upper-left logo 5 times or visit the `/admin` path to enter the management console.
You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current user role is `ADMIN_USER_ROLE`, otherwise access to the console will be denied.
![admin](/feature/admin.png)
## If your website is for private access only, you can disable this check
`DISABLE_ADMIN_PASSWORD_CHECK = true`
@@ -0,0 +1,144 @@
# Enhancement via Another Worker
> The core capability of temporary email is email management. Other workers can enhance temporary email functionality, for example, auth-inbox AI can parse verification codes or activation links
> This feature only triggers other workers and executes after webhook
> [!NOTE]
> If you want to use worker enhancement, please create a worker that can be called via RPC in advance, details below
> References:
> - https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/
> - https://developers.cloudflare.com/workers/runtime-apis/rpc/
> - auth-inbox project: https://github.com/TooonyChen/AuthInbox
## Create Another Worker (using auth-inbox AI verification code parsing as an example)
### Transform Worker to Extend WorkerEntrypoint
A simple worker code that acts as a callee providing RPC method calls is as follows (the rpcEmail method is an example)
(Using the already modified project https://github.com/oneisall8955/AuthInbox-fork)
src/index.ts file
```js
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
DB: D1Database;
// ...
}
export default class extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
console.log("Original fetch interface parameter is request,env,ctx");
console.log("After modifying to WorkerEntrypoint style, there's only one parameter request, getting environment variables and context has slight changes");
// Environment variable and context changes see:
// https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#bindings-env
// https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#lifecycle-methods-ctx
const env: Env = this.env;
const ctx: ExecutionContext = this.ctx;
console.log("Subsequent logic remains unchanged");
return new Response('ok', { status: 200 });
}
// Main functionality
async email(message: ForwardableEmailMessage): Promise<void> {
console.log("Original fetch interface parameter is message,env,ctx");
console.log("After modifying to WorkerEntrypoint style, there's only one parameter message, getting environment variables and context is the same as fetch method");
const env: Env = this.env;
const ctx: ExecutionContext = this.ctx;
console.log("After receiving email routing request, subsequent logic remains unchanged");
}
// Expose RPC interface to handle email requests from other workers
async rpcEmail(requestBody: string): Promise<void> {
console.log(`Received request from another worker (temporary email service cloudflare_temp_email), request body: ${requestBody}`);
// requestBody is in JSON format, sent by temporary email service, format as follows
// type RPCEmailMessage = {
// from: string | undefined | null,
// to: string | undefined | null,
// rawEmail: string | undefined | null,
// headers: Map<string, string>,
// }
// ... todo ...
}
}
```
### Deploy Another Worker
After modification, or using auth-inbox as an example, deploy to Cloudflare Worker. See https://github.com/TooonyChen/AuthInbox, or use the already modified project https://github.com/oneisall8955/AuthInbox-fork
## Configure Temporary Email Service to Use Specified Worker Enhancement
## Bind Service
### Configure via wrangler.toml
```toml
[[services]]
binding = "AUTH_INBOX"
service = "auth-inbox"
```
Here `binding = "AUTH_INBOX"` can be customized to any string, `service = "auth-inbox"` is the name of the deployed worker that provides RPC interface calls.
### User Interface Configuration
In Settings - Bindings, add binding, select binding service.
Fill in the variable name with a custom name, can be any string, for example `AUTH_INBOX`.
Select the service created in the previous step for service binding, for example `auth-inbox`.
![another-worker-enhanced-01.png](/feature/another-worker-enhanced-01.png)
![another-worker-enhanced-02.png](/feature/another-worker-enhanced-02.png)
## Environment Variable Configuration
### Configure via wrangler.toml
```toml
ENABLE_ANOTHER_WORKER = true
ANOTHER_WORKER_LIST ="""
[
{
"binding":"AUTH_INBOX",
"method":"rpcEmail",
"keywords":[
"验证码","激活码","激活链接","确认链接","验证邮箱","确认邮件","账号激活","邮件验证","账户确认","安全码","认证码","安全验证","登陆码","确认码","启用账户","激活账户","账号验证","注册确认",
"account","activation","verify","verification","activate","confirmation","email","code","validate","registration","login","code","expire","confirm"
]
}
]
"""
```
Environment variable explanation:
- ENABLE_ANOTHER_WORKER = true: Default is false, set to true to enable other workers to process emails
- ANOTHER_WORKER_LIST is a JSON array, each object contains 3 fields
- binding: *Required, must match the binding = "XXX" specified in the services section*, in the example it's AUTH_INBOX
- method: Optional, default is rpcEmail, refers to which RPC method of this worker to call for processing
- keywords: Keyword array, case-insensitive. Used for filtering, if the *parsed email text* matches these keywords, this worker is triggered and the worker's `method` method is called
### User Interface Configuration
In Settings - Environment Variables, add environment variables
- ENABLE_ANOTHER_WORKER = true
- ANOTHER_WORKER_LIST is the JSON array string mentioned above, no further explanation needed, see above for detailed description
```json
[
{
"binding":"AUTH_INBOX",
"method":"rpcEmail",
"keywords":[
"验证码","激活码","激活链接","确认链接","验证邮箱","确认邮件","账号激活","邮件验证","账户确认","安全码","认证码","安全验证","登陆码","确认码","启用账户","激活账户","账号验证","注册确认",
"account","activation","verify","verification","activate","confirmation","email","code","validate","registration","login","code","expire","confirm"
]
}
]
```
![another-worker-enhanced-03.png](/feature/another-worker-enhanced-03.png)
## Testing
Send an email to the temporary mailbox, observe the worker logs, or check the verification code on the panel provided by auth-inbox
![another-worker-enhanced-04.png](/feature/another-worker-enhanced-04.png)
@@ -0,0 +1,60 @@
# Setting Up SMTP IMAP Proxy Service
::: warning Notice
If you are using `resend`, you can directly use `resend`'s `SMTP` service without needing this service
:::
## Why Do You Need SMTP IMAP Proxy Service
`SMTP` and `IMAP` have a wider range of application scenarios
## How to Set Up SMTP IMAP Proxy Service
### Local Run
```bash
cd smtp_proxy_server/
# Copy configuration file and modify it
# Your worker address, proxy_url=https://temp-email-api.xxx.xxx
# Your SMTP service port, port=8025
cp .env.example .env
python3 -m venv venv
./venv/bin/python3 -m pip install -r requirements.txt
./venv/bin/python3 main.py
```
### Docker Run
```bash
cd smtp_proxy_server/
docker-compose up -d
```
Modify the environment variables in docker-compose.yaml, note to choose the appropriate `tag`
`proxy_url` is the URL address of the `worker`
```yaml
services:
smtp_proxy_server:
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
# build:
# context: .
# dockerfile: dockerfile
container_name: "smtp_proxy_server"
ports:
- "8025:8025"
- "11143:11143"
environment:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025
- imap_port=11143
```
## Using Thunderbird to Login
Download [Thunderbird](https://www.thunderbird.net/en-US/)
For password, enter the `email address credential`
![imap](/feature/imap.png)
@@ -0,0 +1,29 @@
# Adding Google Ads to Your Website
## Command Line Deployment
Modify the `.env.prod` file
Add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values
```txt
VITE_GOOGLE_AD_CLIENT=ca-pub-123456
VITE_GOOGLE_AD_SLOT=123456
```
Then execute the following commands to redeploy pages.
```bash
pnpm build --emptyOutDir
# For first deployment, you'll be prompted to create a project, fill in production for the production branch
pnpm run deploy
```
## GitHub Action Deployment
Modify `FRONTEND_ENV`, add the following two variables, refer to [Google AdSense](https://www.google.com/adsense/start/) for specific values, then redeploy pages.
```txt
VITE_GOOGLE_AD_CLIENT=ca-pub-123456
VITE_GOOGLE_AD_SLOT=123456
```
@@ -0,0 +1,66 @@
# Mail API
## Viewing Emails via Mail API
This is a `python` example using the `requests` library to view emails.
```python
limit = 10
offset = 0
res = requests.get(
f"https://<your-worker-address>/api/mails?limit={limit}&offset={offset}",
headers={
"Authorization": f"Bearer {your-JWT-password}",
# "x-custom-auth": "<your-website-password>", # If custom password is enabled
"Content-Type": "application/json"
}
)
```
## Admin Mail API
Supports `address` filter and `keyword` filter
```python
import requests
url = "https://<your-worker-address>/admin/mails"
querystring = {
"limit":"20",
"offset":"0",
# address and keyword are optional parameters
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
## User Mail API
Supports `address` filter and `keyword` filter
```python
import requests
url = "https://<your-worker-address>/user_api/mails"
querystring = {
"limit":"20",
"offset":"0",
# address and keyword are optional parameters
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
@@ -0,0 +1,82 @@
# mail-parser-wasm-worker
> [!NOTE]
> If you are using webhook forwarding or telegram bot to receive emails, but the email content is garbled or cannot be parsed, and you have higher requirements for parsing, you can use this feature.
## UI Deployment
1. Download [worker-with-wasm-mail-parser.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker-with-wasm-mail-parser.zip)
2. Go back to `Overview`, find the worker you just created, click `Edit Code`, delete the original files, upload `worker.js` and files with `wasm` extension, click `Deploy`
> [!NOTE]
> To upload, first click Explorer in the left menu,
> Right-click in the file list window and find `Upload` in the context menu,
> Please refer to the screenshot below
>
> Reference: [issues156](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/156#issuecomment-2079453822)
![worker2](/ui_install/worker-2.png)
![worker-upload](/ui_install/worker-upload.png)
## CLI Deployment
### Modify Code
```bash
cd worker
pnpm add mail-parser-wasm-worker
```
Edit `worker/src/common.ts`, uncomment this code to use mail-parser-wasm-worker to parse emails
```ts
export const commonParseMail = async (raw_mail: string | undefined | null): Promise<{
sender: string,
subject: string,
text: string,
html: string
} | undefined> => {
if (!raw_mail) {
return undefined;
}
// Uncomment this code to use mail-parser-wasm-worker to parse emails start
// TODO: WASM parse email
try {
const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
const parsedEmail = parse_message_wrapper(raw_mail);
return {
sender: parsedEmail.sender || "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
headers: parsedEmail.headers || [],
html: parsedEmail.body_html || "",
};
} catch (e) {
console.error("Failed use mail-parser-wasm-worker to parse email", e);
}
// Uncomment this code to use mail-parser-wasm-worker to parse emails end
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);
return {
sender: parsedEmail.from ? `${parsedEmail.from.name} <${parsedEmail.from.address}>` : "",
subject: parsedEmail.subject || "",
text: parsedEmail.text || "",
html: parsedEmail.html || "",
};
}
catch (e) {
console.error("Failed use PostalMime to parse email", e);
}
return undefined;
}
```
### Deploy
```bash
cd worker
pnpm run deploy
```
@@ -0,0 +1,92 @@
# Create New Email Address API
## Create Email Address via Admin API
This is a `python` example using the `requests` library to send emails.
```python
res = requests.post(
# Replace xxxx.xxxx with your worker domain
"https://xxxx.xxxx/admin/new_address",
json={
# Enable prefix (True/False)
"enablePrefix": True,
"name": "<email_name>",
"domain": "<email_domain>",
},
headers={
'x-admin-auth': "<your_website_admin_password>",
"Content-Type": "application/json"
}
)
# Returns {"jwt": "<Jwt>"}
print(res.json())
```
## Batch Create Random Username Email Addresses API Example
### Batch Create Email Addresses via Admin API
This is a `python` example using the `requests` library to send emails.
```python
import requests
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
def generate_random_name():
# Generate 5 lowercase letters
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
# Generate 1-3 digits
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
# Generate 1-3 lowercase letters
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
# Combine into final name
return letters1 + numbers + letters2
def fetch_email_data(name):
try:
res = requests.post(
"https://<worker_domain>/admin/new_address",
json={
"enablePrefix": True,
"name": name,
"domain": "<email_domain>",
},
headers={
'x-admin-auth': "<your_website_admin_password>",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
response_data = res.json()
email = response_data.get("address", "no address")
jwt = response_data.get("jwt", "no jwt")
return f"{email}----{jwt}\n"
else:
print(f"Request failed, status code: {res.status_code}")
return None
except requests.RequestException as e:
print(f"Request error: {e}")
return None
def generate_and_save_emails(num_emails):
with ThreadPoolExecutor(max_workers=30) as executor, open('email.txt', 'a') as file:
futures = [executor.submit(fetch_email_data, generate_random_name()) for _ in range(num_emails)]
for future in as_completed(futures):
result = future.result()
if result:
file.write(result)
# Generate 10 emails and append to existing file
generate_and_save_emails(10)
```
@@ -0,0 +1,34 @@
# Configure S3 Attachments
## Configuration
> [!NOTE]
> If you don't need S3 attachments, you can skip this step
Create an R2 bucket in Cloudflare. You can also use other S3 services (please submit an issue if you encounter bugs).
Reference: [Configure Cloudflare R2 cors](https://developers.cloudflare.com/r2/buckets/cors/#add-cors-policies-from-the-dashboard)
Reference: [Cloudflare R2 s3 token](https://developers.cloudflare.com/r2/api/s3/tokens/) to create a token, obtain `ENDPOINT`, `Access Key ID` and `Secret Access Key`, then execute the following commands to add them to secrets
> [!NOTE]
> You can also add `secrets` in the Cloudflare worker UI interface
```bash
cd worker
pnpm wrangler secret put S3_ENDPOINT
pnpm wrangler secret put S3_ACCESS_KEY_ID
pnpm wrangler secret put S3_SECRET_ACCESS_KEY
# Note: Replace bucket with your bucket name
pnpm wrangler secret put S3_BUCKET
```
## Usage
Save attachment
![S3 save](/feature/s3-save.png)
Download attachment
![S3 download](/public/feature/s3-download.png)
@@ -0,0 +1,67 @@
# Send Email API
## Send Email via HTTP API
This is a `python` example using the `requests` library to send emails.
```python
send_body = {
"from_name": "Sender Name",
"to_name": "Recipient Name",
"to_mail": "Recipient Address",
"subject": "Email Subject",
"is_html": False, # Set whether it's HTML based on content
"content": "<Email content: html or text>",
}
res = requests.post(
"http://localhost:8787/api/send_mail",
json=send_body, headers={
"Authorization": f"Bearer {your_JWT_password}",
# "x-custom-auth": "<your_website_password>", # If custom password is enabled
"Content-Type": "application/json"
}
)
# Using body authentication
send_body = {
"token": "<your_JWT_password>",
"from_name": "Sender Name",
"to_name": "Recipient Name",
"to_mail": "Recipient Address",
"subject": "Email Subject",
"is_html": False, # Set whether it's HTML based on content
"content": "<Email content: html or text>",
}
res = requests.post(
"http://localhost:8787/external/api/send_mail",
json=send_body, headers={
"Content-Type": "application/json"
}
)
```
## Send Email via SMTP
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
This is a `python` example using the `smtplib` library to send emails.
`JWT Token Password`: This is the email login password, which can be viewed in the password menu in the UI interface.
```python
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
with smtplib.SMTP('localhost', 8025) as smtp:
smtp.login("jwt", "Enter your JWT token password here")
message = MIMEMultipart()
message['From'] = "Me <me@awsl.uk>"
message['To'] = "Admin <admin@awsl.uk>"
message['Subject'] = "Test Subject"
message.attach(MIMEText("Test Content", 'html'))
smtp.sendmail("me@awsl.uk", "admin@awsl.uk", message.as_string())
```
@@ -0,0 +1,11 @@
# Configure Subdomain Email
::: warning Note
Subdomain emails may not be able to send emails. It is recommended to use main domain emails for sending and subdomain emails only for receiving.
Mail channel is no longer supported. The reference below is limited to the receiving part only.
:::
Reference
- [Configure Subdomain Email](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
@@ -0,0 +1,66 @@
# Configure Telegram Bot
Try it here: [@cf_temp_mail_bot](https://t.me/cf_temp_mail_bot)
::: warning Note
The default `worker.dev` domain certificate for worker is not supported by Telegram. Please use a custom domain when configuring Telegram Bot.
:::
> [!NOTE]
> If you want to use Telegram Bot, please bind `KV` first
>
> If you don't need Telegram Bot, you can skip this step
>
> If you want Telegram to have stronger email parsing capabilities, refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## Telegram Bot Configuration
Please first create a Telegram Bot, obtain the `token`, then execute the following command to add the `token` to secrets
> [!NOTE]
> If you find it troublesome, you can also put it in plain text under `[vars]` in `wrangler.toml`, but this is not recommended
If you deployed via UI, you can add it under `Variables and Secrets` in the Cloudflare UI interface
```bash
# Switch to worker directory
cd worker
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Bot
- Can set whitelist users
- Click `Initialize` to complete the configuration.
- Click `View Status` to check the current configuration status.
![telegram](/feature/telegram.png)
## Mini App
Can be deployed via command line or UI interface
### UI Deployment
For other steps, refer to `Frontend and Backend Separation Deployment` in [UI Deployment](/en/guide/cli/pages)
> [!NOTE]
> Download the zip from here, [telegram-frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/telegram-frontend.zip)
>
> Modify the index-xxx.js file in the zip, where xx is a random string
>
> Search for `https://temp-email-api.xxx.xxx`, replace it with your worker domain, then deploy the new zip file
### Command Line Deployment
```bash
cd frontend
pnpm install
cp .env.example .env.prod
# --project-name can create a separate pages for mini app, you can also share one pages, but may encounter js loading issues
pnpm run deploy:telegram --project-name=<your_project_name>
```
- After deployment, please fill in the web URL in the `Settings` -> `Telegram Mini App` page `Telegram Mini App URL` in the admin backend.
- Please execute `/setmenubutton` in `@BotFather`, then enter your web address to set the `Open App` button in the lower left corner.
- Please execute `/newapp` in `@BotFather` to create a new app and register the mini app.
@@ -0,0 +1,26 @@
# OAuth2 Third-Party Login
> [!WARNING] Note
> Third-party login will automatically register an account using the user's email (emails with the same address will be considered the same account)
>
> This account is the same as a registered account and can also set a password through the forgot password feature
## Register OAuth2 on Third-Party Platforms
### GitHub
- Please first create an OAuth App, then obtain the `Client ID` and `Client Secret`
Reference: [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
### Authentik
- [Authentik OAuth2 Provider](https://docs.goauthentik.io/docs/providers/oauth2/)
## Configure OAuth2 in Admin Backend
![oauth2](/feature/oauth2.png)
## Test User Login Page
![oauth2 login](/feature/oauth2-login.png)
@@ -0,0 +1,44 @@
# Configure Webhook
> [!NOTE]
> If you want to use webhook, please bind `KV` first and configure the `worker` variable `ENABLE_WEBHOOK = true`
>
> If you want webhook to have stronger email parsing capabilities, refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## Prerequisites
You need to set up your own `webhook service` or use a `third-party platform`. This service needs to be able to receive `POST` requests and parse `json` data.
This project uses [songquanpeng/message-pusher](https://github.com/songquanpeng/message-pusher) as an example webhook service.
- You can use the service provided by [msgpusher.com](https://msgpusher.com)
- You can also self-host the `message-pusher` service, refer to [songquanpeng/message-pusher](https://github.com/songquanpeng/message-pusher)
## Admin Configure Global Webhook
![telegram](/feature/admin-mail-webhook.png)
## Admin Allow Email to Use Webhook
![telegram](/feature/admin-webhook-settings.png)
## Configure Webhook for a Specific Email
![telegram](/feature/address-webhook.png)
## Webhook Data Format
To get the url, you need to configure the worker's `FRONTEND_URL` to your frontend address, or you can construct the url yourself using `id` = `${FRONTEND_URL}?mail_id=${id}`
```json
{
"id": "${id}",
"url": "${url}",
"from": "${from}",
"to": "${to}",
"subject": "${subject}",
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
}
```
@@ -0,0 +1,42 @@
# Quick Start
## Before You Begin
You need a `good network environment` and a `Cloudflare account`. Open the [Cloudflare Dashboard](https://dash.cloudflare.com/)
Please choose one of the three deployment methods below:
- [Deploy via Command Line](/en/guide/cli/pre-requisite)
- [Deploy via User Interface](/en/guide/ui/d1)
- [Deploy via Github Actions](/en/guide/actions/pre-requisite)
### You can also refer to detailed tutorials provided by the community
- [【Tutorial】Beginner-Friendly Guide to Building Your Own Cloudflare Temporary Email (Domain Email)](https://linux.do/t/topic/316819/1)
## Upgrade Process
First, confirm your current version, then visit the [Release page](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/) and [CHANGELOG page](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md) to find your current version.
> [!WARNING] Warning
> Pay attention to `Breaking Changes` which require `database SQL execution` or `configuration changes`.
Then review all changes from your current version onwards. Note that `Breaking Changes` require `database SQL execution` or `configuration changes`, while other feature updates can be configured as needed.
Then refer to the documentation below to use `CLI` or `UI` to redeploy the `worker` and `pages` over the previous deployment.
### CLI Deployment
- [Update D1 via Command Line](/en/guide/cli/d1)
- [Deploy Worker via Command Line](/en/guide/cli/worker)
- [Deploy Pages via Command Line](/en/guide/cli/pages)
### UI Deployment
- [Update D1 via User Interface](/en/guide/ui/d1)
- [Deploy Worker via User Interface](/en/guide/ui/worker)
- [Deploy Pages via User Interface](/en/guide/ui/pages)
### Github Actions Deployment
- [How to Configure Auto-Update with Github Actions](/en/guide/actions/auto-update)
@@ -0,0 +1,7 @@
# Star History
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
+31
View File
@@ -0,0 +1,31 @@
# Initialize/Update D1 Database
## Create Database
Open the Cloudflare console, select `Storage & Databases` -> `D1 SQL Database` -> `Create Database`, and click to create the database.
![d1](/ui_install/d1.png)
After creation, we can see the D1 database in the Cloudflare console.
## Initialize Database
::: warning Note
You can also skip initializing the database and after deployment is complete, go to the admin page's `Quick Setup` -> `Database` section and click the `Initialize Database` button to initialize the database.
:::
Open the `Console` tab, enter the content from the [db/schema.sql](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/db/schema.sql) file in the repository, and click `Execute` to run it.
![d1](/ui_install/d1-exec.png)
## Update Database Schema
For `schema` updates, please confirm the version you previously deployed.
Check the [Changelog](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md)
Find the `patch` file that needs to be executed, for example: `db/2024-01-13-patch.sql`
Open the `Console` tab, enter the content of the `patch` file, and click `Execute` to run it.
![d1](/ui_install/d1-exec.png)
+105
View File
@@ -0,0 +1,105 @@
# Cloudflare Pages Frontend
<script setup>
import { ref } from 'vue'
import JSZip from 'jszip';
const domain = ref("")
const downloadUrl = ref("")
const tip = ref("Download")
const generate = async () => {
try {
const response = await fetch("/ui_install/frontend.zip");
const arrayBuffer = await response.arrayBuffer();
var zip = new JSZip();
await zip.loadAsync(arrayBuffer);
let target_content = ""
let target_path = ""
const directory = zip.folder("assets");
if (directory) {
for (const [relativePath, zipEntry] of Object.entries(directory.files)) {
console.log(relativePath);
if (relativePath.startsWith("assets/index-") && relativePath.endsWith(".js")){
let content = await zipEntry.async("string");
content = content.replace("https://temp-email-api.xxx.xxx", domain.value);
target_path = relativePath;
zip.file(relativePath, content);
break;
}
}
}
if (!target_path) {
tip.value = "Generation failed";
downloadUrl.value = '';
}
const blob = await zip.generateAsync({ type: "blob" });
const url = window.URL.createObjectURL(blob);
downloadUrl.value = url;
} catch (error) {
console.error("Error: ", error);
}
}
</script>
1. Click `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create pages](/ui_install/worker_home.png)
2. Select `Pages`, choose `Use direct upload`
![pages](/ui_install/pages.png)
3. Enter the address of the deployed worker. The address should not include a trailing `/`. Click generate, and if successful, a download button will appear. You will get a zip package.
- The worker domain here is the backend API domain. For example, if I deployed at `https://temp-email-api.awsl.uk`, then fill in `https://temp-email-api.awsl.uk`
- If your domain is `https://temp-email-api.xxx.workers.dev`, then fill in `https://temp-email-api.xxx.workers.dev`
> [!warning] Note
> The `worker.dev` domain is not accessible in China, please use a custom domain.
<div :class="$style.container">
<input :class="$style.input" type="text" v-model="domain" placeholder="Please enter address"></input>
<button :class="$style.button" @click="generate">Generate</button>
<a v-if="downloadUrl" :href="downloadUrl" download="frontend.zip">{{ tip }}</a>
</div>
> [!NOTE]
> You can also deploy manually. Download the zip from here: [frontend.zip](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip)
>
> Modify the index-xxx.js file in the archive, where xx is a random string
>
> Search for `https://temp-email-api.xxx.xxx` and replace it with your worker's domain, then deploy the new zip file
4. Select `Pages`, click `Create Pages`, modify the name, upload the downloaded zip package, and then click `Deploy`
![pages1](/ui_install/pages-1.png)
5. Open the `Pages` you just deployed, click `Custom Domain`. Here you can add your own domain, or you can use the automatically generated `*.pages.dev` domain. If you can open the domain, the deployment is successful.
![pages domain](/ui_install/pages-domain.png)
<style module>
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.input {
border: 2px solid deepskyblue;
margin-right: 10px;
width: 75%;
border-radius: 5px;
}
.button {
background-color: deepskyblue;
padding: 5px 10px;
border-radius: 5px;
margin-right: 10px;
}
.button:hover {
background-color: green;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
# Cloudflare Workers Backend
> [!warning] Note
> The `worker.dev` domain is not accessible in China, please use a custom domain.
1. Click `Compute (Workers)` -> `Workers & Pages` -> `Create`
![create worker](/ui_install/worker_home.png)
2. Select `Worker`, click `Create Worker`, modify the name and then click `Deploy`
![worker1](/ui_install/worker-1.png)
![worker2](/ui_install/worker-2.png)
3. Go back to `Workers & Pages`, find the worker you just created, click `Settings` -> `Runtime`, modify `Compatibility flags`, manually add `nodejs_compat`, and the compatibility date also needs to be later than the date shown in the image.
![worker-runtime](/ui_install/worker-runtime.png)
4. Download [worker.js](https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/worker.js)
5. Go back to `Overview`, find the worker you just created, click `Edit Code`, delete the original file, upload `worker.js`, and click `Deploy`
> [!NOTE]
> To upload, first click Explorer in the left menu,
> then right-click in the file list window and find `Upload` in the context menu,
> please refer to the screenshots below
>
> Reference: [issues156](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/156#issuecomment-2079453822)
![worker3](/ui_install/worker-3.png)
![worker-upload](/ui_install/worker-upload.png)
6. Click `Settings` -> `Variables and Secrets`, add variables as shown in the image
![worker-var](/ui_install/worker-var.png)
> [!NOTE] Note
> For more variable configuration, please see [Worker Variables Documentation](/en/guide/worker-vars)
>
> Note that the outermost quotes are not needed for string format variables
>
> For `USER_ROLES`, please configure in this format: `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
Recommended variable list
| Variable Name | Type | Description | Example |
| -------------------------- | ----------- | ---------------------------------------------------------------------- | ------------------------------------ |
| `PREFIX` | Text | Default prefix for new email names, can be omitted if no prefix needed | `tmp` |
| `DOMAINS` | JSON | All domains for temporary email, supports multiple domains | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `JWT_SECRET` | Text/Secret | Secret for generating JWT, JWT is used for login and authentication | `xxx` |
| `ADMIN_PASSWORDS` | JSON | Admin console password, console access not allowed if not configured | `["123", "456"]` |
| `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create emails, not allowed if not configured | `true` |
| `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, not allowed if not configured | `true` |
7. Click `Settings` -> `Bindings`, click `Add Binding`, enter the name as shown, select the D1 database you just created, and click `Add Binding`
> [!NOTE] Important
> Note that the binding name for `D1 Database` here must be `DB`
![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. Click `Settings` -> `Triggers`, here you can add your own domain, or you can use the automatically generated `*.workers.dev` domain. Record this domain, as it will be needed when deploying the frontend later.
> [!NOTE]
> Open the `worker` `url`, if it displays `OK`, the deployment is successful
>
> Open `/health_check`, if it displays `OK`, the deployment is successful
![worker3](/ui_install/worker-3.png)
9. If you want to enable the user registration feature and need to send email verification, you need to create a `KV` cache. You can skip this step if not needed.
> [!NOTE] Important
> If you want to enable the user registration feature and need to send email verification, you need to create a `KV` cache. You can skip this step if not needed.
>
> Note that the binding name for `KV` here must be `KV`
Click `Storage & Databases` -> `KV` -> `Create Namespace`, as shown in the image, click `Create Namespace`
![worker-kv](/ui_install/worker-kv.png)
![worker-kv-0](/ui_install/worker-kv-0.png)
Then click `Settings` -> `Bindings`, click `Add Binding`, enter the name as shown, select the KV you just created, and click `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 Configuration
> [!NOTE]
> If you don't need Telegram Bot, you can skip this step
Please first create a Telegram Bot, then get the `token`, add the `token` to `Variables and Secrets`, variable name: `TELEGRAM_BOT_TOKEN`
11. If you want to use the scheduled task to clean emails in the admin page, you need to add a scheduled task in `Settings` -> `Trigger Events` -> `Cron Triggers`.
> [!NOTE]
> Select `cron` expression, enter `0 0 * * *` (this expression means run daily at midnight), click `Add` to add. Please adjust this expression according to your needs.
@@ -0,0 +1,7 @@
# Introduction to Temporary Email
## What is Temporary Email
A temporary email, also known as disposable email or temporary email address, is a virtual mailbox used for temporarily receiving emails. Unlike regular mailboxes, temporary emails are designed to provide an anonymous and temporary email receiving solution.
Temporary emails are often provided by websites or online service providers. Users can use temporary email addresses when they need to register or receive verification emails, without exposing their real email address. The benefit of this is to protect personal privacy.
+143
View File
@@ -0,0 +1,143 @@
# Worker Variables
> [!NOTE] Note
> For CLI deployment syntax, please refer to `worker/wrangler.toml.template`
## Required Variables
| Variable Name | Type | Description | Example |
| -------------------------- | ----------- | ------------------------------------------------------------ | ------------------------------------ |
| `DOMAINS` | JSON | All domains for temporary email, supports multiple domains | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `JWT_SECRET` | Text/Secret | Secret key for generating JWT, used for login and authentication | `xxx` |
| `ADMIN_PASSWORDS` | JSON | Admin console passwords, console access disabled if not configured | `["123", "456"]` |
| `ENABLE_USER_CREATE_EMAIL` | Text/JSON | Whether to allow users to create mailboxes, disabled if not configured | `true` |
| `ENABLE_USER_DELETE_EMAIL` | Text/JSON | Whether to allow users to delete emails, disabled if not configured | `true` |
## Console Related Variables
| Variable Name | Type | Description | Example |
| ------------------------------ | --------- | -------------------------------------------------- | ---------------- |
| `PASSWORDS` | JSON | Website private passwords, required after configuration | `["123", "456"]` |
| `DISABLE_ADMIN_PASSWORD_CHECK` | Text/JSON | Warning: Admin console without password or user check | `false` |
## Email Related Variables
| Variable Name | Type | Description | Example |
| ------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `PREFIX` | Text | Default prefix for new `email address` names, can be left unconfigured if no prefix needed | `tmp` |
| `MIN_ADDRESS_LEN` | Number | Minimum length of `email address` name | `1` |
| `MAX_ADDRESS_LEN` | Number | Maximum length of `email address` name | `30` |
| `DISABLE_CUSTOM_ADDRESS_NAME` | Text/JSON | Disable custom email address names, if set to true, users cannot enter custom names and they will be auto-generated | `true` |
| `ADDRESS_CHECK_REGEX` | Text | Regular expression for `email address` name, used for validation only | `^(?!.*admin).*` |
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies | `true` |
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` |
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
## Email Reception Related Variables
| Variable Name | Type | Description | Example |
| ------------------------------- | --------- | -------------------------------------------------------------------------------------- | -------------------------- |
| `BLACK_LIST` | Text | Blacklist for filtering senders, comma separated | `gov.cn,edu.cn` |
| `ENABLE_CHECK_JUNK_MAIL` | Text/JSON | Whether to enable junk mail checking, used with the following two lists | `false` |
| `JUNK_MAIL_CHECK_LIST` | JSON | Junk mail check configuration, marked as junk if any item `exists` and `fails` | `["spf", "dkim", "dmarc"]` |
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | Junk mail check configuration, marked as junk if any item `does not exist` or `fails` | `["spf", "dkim", "dmarc"]` |
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
> [!NOTE]
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
>
> If you want stronger email parsing capabilities
>
> Refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## Webhook Related Variables
| Variable Name | Type | Description | Example |
| ---------------- | --------- | -------------------------------------------- | ------------------ |
| `ENABLE_WEBHOOK` | Text/JSON | Whether to enable webhook | `true` |
| `FRONTEND_URL` | Text | Frontend URL, used for sending webhook email URLs | `https://xxxx.xxx` |
> [!NOTE]
> Webhook functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout
>
> If you want stronger email parsing capabilities
>
> Refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## User Related Variables
| Variable Name | Type | Description | Example |
| ------------------------------------- | --------- | ---------------------------------------------------------------------------------------------- | ------- |
| `USER_DEFAULT_ROLE` | Text | Default role for new users, only effective when email verification is enabled | `vip` |
| `ADMIN_USER_ROLE` | Text | Admin role configuration, if user role equals ADMIN_USER_ROLE, user can access admin console | `admin` |
| `USER_ROLES` | JSON | - | See below |
| `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` | Text/JSON | Disable anonymous user mailbox creation, if set to true, users can only create addresses after login | `true` |
| `NO_LIMIT_SEND_ROLE` | Text | Roles that can send unlimited emails, multiple roles separated by comma `vip,admin` | `vip` |
> [!NOTE] USER_ROLES User Role Configuration
>
> - If `domains` is empty, `DEFAULT_DOMAINS` will be used
> - If prefix is null, the default prefix will be used, if prefix is an empty string, no prefix will be used
>
> When deploying through UI, configure `USER_ROLES` in this format: `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]`
>
> When deploying via CLI, refer to `worker/wrangler.toml.template` and configure `USER_ROLES` in this format: `[{ domains = ["awsl.uk", "dreamhunter2333.xyz"], role = "vip", prefix = "vip" }, { domains = ["awsl.uk", "dreamhunter2333.xyz"], role = "admin", prefix = "" }]`
## Web Related Variables
| Variable Name | Type | Description | Example |
| -------------------------- | ----------- | -------------------------------------------------------- | --------------------- |
| `DEFAULT_LANG` | Text | Worker error message default language, zh/en | `zh` |
| `TITLE` | Text | Custom frontend page website title, supports html | `Custom Title` |
| `ANNOUNCEMENT` | Text | Custom frontend page announcement, supports html | `Custom Announcement` |
| `ALWAYS_SHOW_ANNOUNCEMENT` | Text/JSON | Whether to always show announcement (even if unchanged), default `false` | `true` |
| `COPYRIGHT` | Text | Custom frontend footer text, supports html | `Dream Hunter` |
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
## Telegram Bot Related Variables
| Variable Name | Type | Description | Example |
| ---------------- | ------ | ---------------------------------------------------------------------------------------- | ------- |
| `TG_MAX_ADDRESS` | Number | Maximum number of mailboxes that can be bound to telegram bot | `5` |
| `TG_BOT_INFO` | Text | Optional, telegram BOT_INFO, predefined BOT_INFO can reduce webhook latency | `{}` |
> [!NOTE]
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout
>
> If you want stronger email parsing capabilities
>
> Refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## Other Variables
| Variable Name | Type | Description | Example |
| ----------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `ENABLE_ANOTHER_WORKER` | Text/JSON | Whether to enable other workers to process emails | `false` |
| `ANOTHER_WORKER_LIST` | JSON | - Configuration for other workers to process emails, multiple workers can be configured <br/> - Filter by keywords, call the bound worker's method (default method name is rpcEmail)<br/> - keywords are required, otherwise the worker will not be triggered | See below |
> [!NOTE]
> `ANOTHER_WORKER_LIST` configuration example
>
> ```toml
> #ANOTHER_WORKER_LIST ="""
> #[
> # {
> # "binding":"AUTH_INBOX",
> # "method":"rpcEmail",
> # "keywords":[
> # "验证码","激活码","激活链接","确认链接","验证邮箱","确认邮件","账号激活","邮件验证","账户确认","安全码","认证码","安全验证","登陆码","确认码","启用账户","激活账户","账号验证","注册确认",
> # "account","activation","verify","verification","activate","confirmation","email","code","validate","registration","login","code","expire","confirm"
> # ]
> # }
> #]
> #
> ```
+23 -16
View File
@@ -3,22 +3,29 @@
layout: home
hero:
name: "Temporary mailbox document"
tagline: "Build CloudFlare to send and receive free temporary domain name mailboxes"
name: "Temporary Email Docs"
tagline: "Build Free CloudFlare Temporary Domain Email with Send & Receive"
actions:
- theme: brand
text: Try it now
link: https://mail.awsl.uk/en
- theme: alt
text: command line deployment
link: /en/cli
- theme: brand
text: Try it now
link: https://mail.awsl.uk/
- theme: alt
text: CLI Deployment
link: /en/guide/quick-start
- theme: alt
text: Deploy via UI
link: /en/guide/quick-start
- theme: alt
text: Deploy via Github Actions
link: /en/guide/quick-start
features:
- title: Free hosting on CloudFlare, no server required
details: Cloudflare D1 database, Cloudflare Pages frontend, Cloudflare Workers backend, Cloudflare Email Routing
- title: Only domain name required for private deployment
details: Support password login email, access authorization can be used as a private site, support attachment function
- title: Use rust wasm to parse emails
details: Use rust wasm to parse emails, support various RFC standards for emails, support attachments, extremely fast
- title: Support sending emails
details: Support sending txt or html emails through domain name mailboxesSupport DKIM signature
- title: Private deployment with only a domain name, free hosting on CloudFlare, no server required
details: Support password login for mailboxes, user registration, access password for private sites, attachment support.
- title: Email parsing using Rust WASM
details: Parse emails with Rust WASM, support various RFC email standards, support attachments, extremely fast
- title: Telegram Bot and Webhook support
details: Forward emails to Telegram or webhook, Telegram Bot supports mailbox binding, view emails, Telegram Mini App
- title: Send emails (UI/API/SMTP)
details: Send txt or html emails via domain mailboxes, DKIM signature support, send via UI/API/SMTP
---
+6
View File
@@ -0,0 +1,6 @@
# Reference
- https://developers.cloudflare.com/d1/
- https://developers.cloudflare.com/pages/
- https://developers.cloudflare.com/workers/
- https://developers.cloudflare.com/email-routing/
+8
View File
@@ -0,0 +1,8 @@
# Service Status
[Status Link](https://uptime.aks.awsl.icu/status/temp-email)
| Service | Status |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Backend](https://temp-email-api.awsl.uk/) | ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
@@ -46,6 +46,7 @@
| `FRONTEND_ENV` | 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html) |
| `FRONTEND_NAME` | 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建 |
| `FRONTEND_BRANCH` | (可选) pages 部署的分支,可不配置,默认 `production` |
| `PAGE_TOML` | (可选) 使用 page functions 转发后端请求时需要配置,请复制 `pages/wrangler.toml` 的内容,并根据实际情况修改 `service` 字段为你的 worker 后端名称 |
| `TG_FRONTEND_NAME` | (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写 |
### 部署
@@ -51,7 +51,7 @@ def generate_random_name():
def fetch_email_data(name):
try:
res = requests.post(
"https://<worker 域名>",
"https://<worker 域名>/admin/new_address",
json={
"enablePrefix": True,
"name": name,
@@ -35,6 +35,7 @@
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
## 接受邮件相关变量
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "1.0.6",
"version": "1.0.7",
"type": "module",
"devDependencies": {
"@types/node": "^24.4.0",
"@types/node": "^24.10.0",
"vitepress": "^1.6.4",
"wrangler": "^4.37.0"
"wrangler": "^4.45.3"
},
"scripts": {
"dev": "vitepress dev docs",
+425 -416
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.0.6",
"version": "1.0.7",
"private": true,
"type": "module",
"scripts": {
@@ -11,23 +11,23 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250913.0",
"@cloudflare/workers-types": "^4.20251014.0",
"@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^22.18.3",
"@types/node": "^22.19.0",
"eslint": "9.18.0",
"globals": "^15.15.0",
"typescript-eslint": "^8.43.0",
"wrangler": "^4.37.0"
"typescript-eslint": "^8.46.2",
"wrangler": "^4.45.3"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@aws-sdk/s3-request-presigner": "^3.888.0",
"@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.9.7",
"hono": "^4.10.4",
"jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27",
"postal-mime": "^2.4.4",
"postal-mime": "^2.6.0",
"resend": "^4.8.0",
"telegraf": "4.16.3",
"worker-mailer": "^1.1.5"
+638 -632
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models";
import { UserSettings, GeoData, UserInfo, RoleAddressConfig } from "../models";
import { handleListQuery } from '../common'
import UserBindAddressModule from '../user_api/bind_address';
import i18n from '../i18n';
@@ -166,4 +166,14 @@ export default {
results: results,
});
},
getRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
const configs = value || {};
return c.json({ configs });
},
saveRoleAddressConfig: async (c: Context<HonoCustomType>) => {
const { configs } = await c.req.json<{ configs: RoleAddressConfig }>();
await saveSetting(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY, JSON.stringify(configs));
return c.json({ success: true });
},
}
+6
View File
@@ -19,6 +19,7 @@ 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,
password TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -136,6 +137,11 @@ export default {
},
migrate: async (c: Context<HonoCustomType>) => {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version == "v0.0.2") {
// example migration from v0.0.2 to v0.0.3
const query = `ALTER TABLE address ADD password TEXT;`
await c.env.DB.exec(query);
}
if (version != CONSTANTS.DB_VERSION) {
// TODO: Perform migration logic here
// remove all \r and \n characters from the query string
+33 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles } from '../utils'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
@@ -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 ip_blacklist_settings from './ip_blacklist_settings'
import { EmailRuleSettings } from '../models'
export const api = new Hono<HonoCustomType>()
@@ -56,6 +57,7 @@ api.post('/admin/new_address', async (c) => {
checkAllowDomains: false,
enableCheckNameRegex: false,
});
return c.json(res);
} catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
@@ -131,6 +133,30 @@ api.get('/admin/show_password/:id', async (c) => {
})
})
api.post('/admin/address/:id/reset_password', async (c) => {
const { id } = c.req.param();
const { password } = await c.req.json();
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text("Password management is disabled", 403);
}
if (!password) {
return c.text("Password is required", 400);
}
const hashedPassword = await hashPassword(password);
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(hashedPassword, id).run();
if (!success) {
return c.text("Failed to reset password", 500);
}
return c.json({ success: true });
})
// mail api
api.get('/admin/mails', admin_mail_api.getMails);
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
@@ -319,6 +345,8 @@ api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get('/admin/user_roles', async (c) => c.json(getUserRoles(c)))
api.post('/admin/user_roles', admin_user_api.updateUserRoles)
api.get('/admin/role_address_config', admin_user_api.getRoleAddressConfig)
api.post('/admin/role_address_config', admin_user_api.saveRoleAddressConfig)
api.get('/admin/users/bind_address/:user_id', admin_user_api.getBindedAddresses)
api.post('/admin/users/bind_address', admin_user_api.bindAddress)
@@ -345,3 +373,7 @@ api.post("/admin/send_mail", sendMailbyAdmin);
api.get('admin/db_version', db_api.getVersion);
api.post('admin/db_initialize', db_api.initialize);
api.post('admin/db_migration', db_api.migrate);
// IP blacklist settings
api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings);
api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings);
@@ -0,0 +1,115 @@
import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { getJsonSetting, saveSetting } from "../utils";
import { IpBlacklistSettings } from "../ip_blacklist";
/**
* Get IP blacklist settings from database
*/
async function getIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await getJsonSetting<IpBlacklistSettings>(
c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY
);
// Return default settings if not found
return c.json(settings || {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000
});
}
/**
* Save IP blacklist settings to database
*/
async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<IpBlacklistSettings>();
// Validate settings
if (typeof settings.enabled !== 'boolean') {
return c.text("Invalid enabled value", 400);
}
if (!Array.isArray(settings.blacklist)) {
return c.text("Invalid blacklist value", 400);
}
if (!Array.isArray(settings.asnBlacklist)) {
return c.text("Invalid asnBlacklist value", 400);
}
if (!Array.isArray(settings.fingerprintBlacklist)) {
return c.text("Invalid fingerprintBlacklist value", 400);
}
if (typeof settings.enableDailyLimit !== 'boolean') {
return c.text("Invalid enableDailyLimit value", 400);
}
const limit = Number(settings.dailyRequestLimit);
if (isNaN(limit) || limit < 1 || limit > 1000000) {
return c.text("Invalid dailyRequestLimit value (must be between 1 and 1000000)", 400);
}
// Add size limit
const MAX_BLACKLIST_SIZE = 1000;
if (settings.blacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`Blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
}
if (settings.asnBlacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`ASN blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
}
if (settings.fingerprintBlacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`Fingerprint blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
}
// Sanitize patterns (trim and remove empty strings)
// Both regex and plain strings are allowed
const sanitizedBlacklist = settings.blacklist
.map(pattern => pattern.trim())
.filter(pattern => pattern.length > 0);
const sanitizedAsnBlacklist = settings.asnBlacklist
.map(pattern => pattern.trim())
.filter(pattern => pattern.length > 0);
const sanitizedFingerprintBlacklist = settings.fingerprintBlacklist
.map(pattern => pattern.trim())
.filter(pattern => pattern.length > 0);
const sanitizedSettings: IpBlacklistSettings = {
enabled: settings.enabled,
blacklist: sanitizedBlacklist,
asnBlacklist: sanitizedAsnBlacklist,
fingerprintBlacklist: sanitizedFingerprintBlacklist,
enableDailyLimit: settings.enableDailyLimit,
dailyRequestLimit: settings.dailyRequestLimit
};
await saveSetting(
c,
CONSTANTS.IP_BLACKLIST_SETTINGS_KEY,
JSON.stringify(sanitizedSettings)
);
return c.json({ success: true });
}
export default {
getIpBlacklistSettings,
saveIpBlacklistSettings,
}
+2 -1
View File
@@ -40,7 +40,8 @@ api.get('/open_api/settings', async (c) => {
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)
});
})
+44 -2
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList } from './utils';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword } from './utils';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
@@ -82,6 +82,37 @@ export async function updateAddressUpdatedAt(
}
}
export const generateRandomPassword = (): string => {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789";
let password = "";
for (let i = 0; i < 8; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
return password;
}
const generatePasswordForAddress = async (
c: Context<HonoCustomType>,
address: string
): Promise<string | null> => {
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return null;
}
const plainPassword = generateRandomPassword();
const hashedPassword = await hashPassword(plainPassword);
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE name = ?`
).bind(hashedPassword, address).run();
if (!success) {
console.warn("Failed to set generated password for address:", address);
return null;
}
return plainPassword;
}
export const newAddress = async (
c: Context<HonoCustomType>,
{
@@ -100,7 +131,7 @@ export const newAddress = async (
checkAllowDomains?: boolean,
enableCheckNameRegex?: boolean,
}
): Promise<{ address: string, jwt: string }> => {
): Promise<{ address: string, jwt: string, password?: string | null }> => {
// trim whitespace and remove special characters
name = name.trim().replace(getNameRegex(c), '')
// check name
@@ -166,6 +197,10 @@ export const newAddress = async (
const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first<number>("id");
// 如果启用地址密码功能,自动生成密码
const generatedPassword = await generatePasswordForAddress(c, name);
// create jwt
const jwt = await Jwt.sign({
address: name,
@@ -174,6 +209,7 @@ export const newAddress = async (
return {
jwt: jwt,
address: name,
password: generatedPassword,
}
}
@@ -215,6 +251,12 @@ export const cleanup = async (
`created_at < datetime('now', '-${cleanDays} day')`
)
break;
case "unboundAddress":
await batchDeleteAddressWithData(
c,
`id NOT IN (SELECT address_id FROM users_address) AND created_at < datetime('now', '-${cleanDays} day')`
)
break;
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
+4 -2
View File
@@ -1,9 +1,9 @@
export const CONSTANTS = {
VERSION: 'v' + '1.0.6',
VERSION: 'v' + '1.0.7',
// DB Version
DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.2",
DB_VERSION: "v0.0.3",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
@@ -14,6 +14,8 @@ export const CONSTANTS = {
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',
ROLE_ADDRESS_CONFIG_KEY: 'role_address_config',
IP_BLACKLIST_SETTINGS_KEY: 'ip_blacklist_settings',
// KV
TG_KV_PREFIX: "temp-mail-telegram",
+8
View File
@@ -38,6 +38,14 @@ const messages: LocaleMessages = {
Oauth2FailedGetUserInfoMsg: "Failed to get user info from Oauth2 provider",
Oauth2FailedGetAccessTokenMsg: "Failed to get access token from Oauth2 provider",
Oauth2FailedGetUserEmailMsg: "Failed to get user email from Oauth2 provider",
PasswordChangeDisabledMsg: "Password change is disabled",
NewPasswordRequiredMsg: "New password is required",
InvalidAddressTokenMsg: "Invalid address token",
FailedUpdatePasswordMsg: "Failed to update password",
PasswordLoginDisabledMsg: "Password login is disabled",
EmailPasswordRequiredMsg: "Email and password are required",
AddressNotFoundMsg: "Address not found",
}
export default messages;
+8
View File
@@ -36,4 +36,12 @@ export type LocaleMessages = {
Oauth2FailedGetUserInfoMsg: string
Oauth2FailedGetAccessTokenMsg: string
Oauth2FailedGetUserEmailMsg: string
PasswordChangeDisabledMsg: string
NewPasswordRequiredMsg: string
InvalidAddressTokenMsg: string
FailedUpdatePasswordMsg: string
PasswordLoginDisabledMsg: string
EmailPasswordRequiredMsg: string
AddressNotFoundMsg: string
}
+8
View File
@@ -38,6 +38,14 @@ const messages: LocaleMessages = {
Oauth2FailedGetUserInfoMsg: "从 Oauth2 提供商获取用户信息失败",
Oauth2FailedGetAccessTokenMsg: "从 Oauth2 提供商获取访问令牌失败",
Oauth2FailedGetUserEmailMsg: "从 Oauth2 提供商获取用户邮箱失败",
PasswordChangeDisabledMsg: "密码修改已禁用",
NewPasswordRequiredMsg: "新密码不能为空",
InvalidAddressTokenMsg: "无效的地址令牌",
FailedUpdatePasswordMsg: "更新密码失败",
PasswordLoginDisabledMsg: "密码登录已禁用",
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
AddressNotFoundMsg: "邮箱地址不存在",
}
export default messages;
+170
View File
@@ -0,0 +1,170 @@
import { Context } from 'hono';
import { getJsonSetting } from './utils';
import { CONSTANTS } from './constants';
/**
* IP Blacklist Settings stored in database
*/
export type IpBlacklistSettings = {
enabled?: boolean;
blacklist?: string[]; // Array of regex patterns or plain strings
asnBlacklist?: string[]; // Array of ASN organization patterns (e.g., "Google LLC", "Amazon")
fingerprintBlacklist?: string[]; // Array of browser fingerprint patterns
enableDailyLimit?: boolean; // Enable daily request limit per IP
dailyRequestLimit?: number; // Maximum requests per IP per day
}
/**
* Check if a string is a valid regex pattern
* Heuristic: contains regex special characters
*/
function looksLikeRegex(pattern: string): boolean {
// Check if pattern contains common regex metacharacters
// eslint-disable-next-line no-useless-escape
return /[\^$.*+?\[\]{}()|\\]/.test(pattern);
}
/**
* Check if a value matches any blacklist pattern
* Supports both regex patterns and plain string matching
*
* @param value - The value to check (e.g., IP address, ASN organization)
* @param blacklist - Array of patterns (regex or plain strings)
* @param caseSensitive - Whether to use case-sensitive matching for plain strings (default: true for IP, false for ASN)
* @returns true if value is blacklisted, false otherwise
*
* @example
* // IP address matching (case-sensitive):
* isBlacklisted("192.168.1.100", ["192.168.1"], true) // true (substring match)
* isBlacklisted("10.0.0.5", ["^10\\.0\\.0\\.5$"], true) // true (regex match)
*
* // ASN organization matching (case-insensitive):
* isBlacklisted("Google LLC", ["google"], false) // true (case-insensitive)
* isBlacklisted("Amazon.com, Inc.", ["amazon"], false) // true
*/
function isBlacklisted(value: string | null | undefined, blacklist: string[], caseSensitive: boolean = true): boolean {
if (!value || !blacklist || blacklist.length === 0) {
return false;
}
const normalizedValue = value.trim();
return blacklist.some(pattern => {
const normalizedPattern = pattern.trim();
if (!normalizedPattern) {
return false;
}
try {
if (looksLikeRegex(normalizedPattern)) {
// For regex patterns, add 'i' flag if case-insensitive matching is needed
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(normalizedPattern, flags);
return regex.test(normalizedValue);
}
// Plain string mode: substring matching
if (caseSensitive) {
return normalizedValue.includes(normalizedPattern);
}
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
} catch (error) {
console.warn(`Pattern "${normalizedPattern}" failed regex parsing, using plain matching`);
if (caseSensitive) {
return normalizedValue.includes(normalizedPattern);
}
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
}
});
}
/**
* Get IP blacklist settings from database
*
* @param c - Hono context
* @returns IP blacklist settings (may be null or have undefined fields)
*/
export async function getIpBlacklistSettings(
c: Context<HonoCustomType>
): Promise<IpBlacklistSettings | null> {
return await getJsonSetting<IpBlacklistSettings>(
c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY
);
}
/**
* Middleware to check access control (blacklist and rate limiting) for rate-limited endpoints
* Returns 403/429 response if blocked, null if allowed or any error occurs
*
* @param c - Hono context
* @returns Response if blocked, null otherwise (including errors)
*/
export async function checkAccessControl(
c: Context<HonoCustomType>
): Promise<Response | null> {
try {
// Get IP blacklist settings from database
const settings = await getIpBlacklistSettings(c);
if (!settings) {
return null;
}
// Get IP address from CloudFlare header
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
if (!reqIp) {
return null;
}
// Check if blacklist feature is enabled
if (settings.enabled) {
// Check if IP is blacklisted (case-sensitive matching)
if (settings.blacklist && settings.blacklist.length > 0) {
if (isBlacklisted(reqIp, settings.blacklist, true)) {
console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`);
return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403);
}
}
// Check ASN organization blacklist
if (settings.asnBlacklist && settings.asnBlacklist.length > 0) {
const asOrganization = c.req.raw.cf?.asOrganization;
// Check ASN with case-insensitive matching
if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) {
console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`);
return c.text(`Access denied: ASN organization is blacklisted`, 403);
}
}
// Check browser fingerprint blacklist
if (settings.fingerprintBlacklist && settings.fingerprintBlacklist.length > 0) {
const fingerprint = c.req.raw.headers.get("x-fingerprint");
// Check fingerprint with case-sensitive matching
if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) {
console.warn(`Blocked blacklisted fingerprint: ${fingerprint} (IP: ${reqIp}) for path: ${c.req.path}`);
return c.text(`Access denied: Browser fingerprint is blacklisted`, 403);
}
}
}
// Check daily request limit (independent of blacklist feature)
if (settings.enableDailyLimit && settings.dailyRequestLimit && c.env.KV) {
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`;
const dailyLimit = settings.dailyRequestLimit;
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
if (current_count && current_count >= dailyLimit) {
console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`);
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
}
// Increment counter with 24-hour expiration
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
}
return null;
} catch (error) {
// Log error but don't block request
console.error('Error checking IP blacklist and rate limit:', error);
return null;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Context } from 'hono';
import i18n from '../i18n';
import { getBooleanValue, hashPassword } from '../utils';
import { Jwt } from 'hono/utils/jwt';
export default {
// 修改地址密码
changePassword: async (c: Context<HonoCustomType>) => {
const { new_password } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const { address, address_id } = c.get("jwtPayload");
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordChangeDisabledMsg, 403);
}
if (!new_password) {
return c.text(msgs.NewPasswordRequiredMsg, 400);
}
if (!address || !address_id) {
return c.text(msgs.InvalidAddressTokenMsg, 400);
}
// 更新密码
const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(new_password, address_id).run();
if (!success) {
return c.text(msgs.FailedUpdatePasswordMsg, 500);
}
return c.json({ success: true });
},
// 地址密码登录
login: async (c: Context<HonoCustomType>) => {
const { email, password, cf_token } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordLoginDisabledMsg, 403);
}
if (!email || !password) {
return c.text(msgs.EmailPasswordRequiredMsg, 400);
}
// 查找地址
const address = await c.env.DB.prepare(
`SELECT * FROM address WHERE name = ?`
).bind(email).first();
if (!address) {
return c.text(msgs.AddressNotFoundMsg, 404);
}
// 验证密码
if (address.password !== password) {
return c.text(msgs.InvalidEmailOrPasswordMsg, 401);
}
// 创建JWT
const jwt = await Jwt.sign({
address: address.name,
address_id: address.id
}, c.env.JWT_SECRET, "HS256");
return c.json({
jwt: jwt,
address: address.name
});
}
};
+4
View File
@@ -7,6 +7,7 @@ import { CONSTANTS } from '../constants'
import auto_reply from './auto_reply'
import webhook_settings from './webhook_settings';
import s3_attachment from './s3_attachment';
import address_auth from './address_auth';
export const api = new Hono<HonoCustomType>()
@@ -198,3 +199,6 @@ api.delete('/api/clear_sent_items', async (c) => {
success: success
})
})
api.post('/api/address_change_password', address_auth.changePassword)
api.post('/api/address_login', address_auth.login)
+9
View File
@@ -46,6 +46,8 @@ export type CleanupSettings = {
cleanAddressDays: number;
enableInactiveAddressAutoCleanup: boolean | undefined;
cleanInactiveAddressDays: number;
enableUnboundAddressAutoCleanup: boolean | undefined;
cleanUnboundAddressDays: number;
}
export class GeoData {
@@ -152,3 +154,10 @@ export type EmailRuleSettings = {
blockReceiveUnknowAddressEmail: boolean;
emailForwardingList: SubdomainForwardAddressList[]
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here
}
export type RoleAddressConfig = Record<string, RoleConfig>;
+7
View File
@@ -50,4 +50,11 @@ export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any)
autoCleanupSetting.cleanAddressDays
);
}
if (autoCleanupSetting.enableUnboundAddressAutoCleanup) {
await cleanup(
{ env: env, } as Context<HonoCustomType>,
"unboundAddress",
autoCleanupSetting.cleanUnboundAddressDays
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { deleteAddressWithData, newAddress, generateRandomName } from "../common
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
): Promise<{ address: string, jwt: string }> => {
): Promise<{ address: string, jwt: string, password?: string | null }> => {
if (c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
+1
View File
@@ -101,6 +101,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const res = await tgUserNewAddress(c, userId.toString(), address);
return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n`
+ (res.password ? `密码: \`${res.password}\`\n` : '')
+ `凭证: \`${res.jwt}\`\n`,
{
parse_mode: "Markdown"
+1
View File
@@ -40,6 +40,7 @@ type Bindings = {
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null
+29 -7
View File
@@ -1,12 +1,26 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { UserSettings } from "../models";
import { UserSettings, RoleAddressConfig } from "../models";
import { getJsonSetting } from "../utils"
import { CONSTANTS } from "../constants";
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt } from '../common';
import { updateAddressUpdatedAt, commonGetUserRole } from '../common';
const getMaxAddressCount = async (
c: Context<HonoCustomType>,
userRole: string | null | undefined,
settings: UserSettings
): Promise<number> => {
if (!userRole) return settings.maxAddressCount;
const roleConfigs = await getJsonSetting<RoleAddressConfig>(c, CONSTANTS.ROLE_ADDRESS_CONFIG_KEY);
if (!roleConfigs) return settings.maxAddressCount;
const roleMaxCount = roleConfigs[userRole]?.maxAddressCount;
if (typeof roleMaxCount !== 'number') return settings.maxAddressCount;
if (roleMaxCount <= 0) return settings.maxAddressCount;
return roleMaxCount;
};
const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => {
@@ -43,11 +57,15 @@ const UserBindAddressModule = {
// check if binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get user role
const userRole = c.get("userRolePayload");
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRole, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Max address count reached", 400)
}
}
@@ -194,18 +212,22 @@ const UserBindAddressModule = {
// check if target user exists
const target_user_id = await c.env.DB.prepare(
`SELECT id FROM users where user_email = ?`
).bind(target_user_email).first("id");
).bind(target_user_email).first<number>("id");
if (!target_user_id) {
return c.text("Target user not found", 400)
}
// check target user binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value);
if (settings.maxAddressCount > 0) {
// get target user role
const userRoleObj = await commonGetUserRole(c, target_user_id);
// check role-based max address count first, fallback to global settings
const maxAddressCount = await getMaxAddressCount(c, userRoleObj?.role, settings);
if (maxAddressCount > 0) {
const { count } = await c.env.DB.prepare(
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(target_user_id).first<{ count: number }>() || { count: 0 };
if (count >= settings.maxAddressCount) {
if (count >= maxAddressCount) {
return c.text("Target User Max address count reached", 400)
}
}
+7
View File
@@ -296,6 +296,13 @@ export const checkUserPassword = (password: string) => {
return true;
}
export const hashPassword = async (password: string): Promise<string> => {
// use crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}
export default {
getJsonObjectValue,
getSetting,
+24 -10
View File
@@ -14,6 +14,7 @@ import i18n from './i18n';
import { email } from './email';
import { scheduled } from './scheduled';
import { getAdminPasswords, getPasswords, getBooleanValue, getStringArray } from './utils';
import { checkAccessControl } from './ip_blacklist';
const API_PATHS = [
"/api/",
@@ -48,6 +49,17 @@ app.use('/*', async (c, next) => {
const lang = c.req.raw.headers.get("x-lang");
if (lang) { c.set("lang", lang); }
const msgs = i18n.getMessages(lang || c.env.DEFAULT_LANG);
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text(msgs.CustomAuthPasswordMsg, 401)
}
}
// rate limit for specific endpoints
if (
c.req.path.startsWith("/api/new_address")
|| c.req.path.startsWith("/api/send_mail")
@@ -64,6 +76,11 @@ app.use('/*', async (c, next) => {
return c.text(`IP=${reqIp} Rate limit exceeded for ${c.req.path}`, 429)
}
}
// Check access control (blacklist and daily limit)
const accessControlResponse = await checkAccessControl(c);
if (accessControlResponse) {
return accessControlResponse;
}
}
// webhook check
if (
@@ -128,16 +145,6 @@ const checkoutUserRolePayload = async (
// api auth
app.use('/api/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
const lang = c.req.raw.headers.get("x-lang") || c.env.DEFAULT_LANG;
const messages = i18n.getMessages(lang);
return c.text(messages.CustomAuthPasswordMsg, 401)
}
}
if (c.req.path.startsWith("/api/new_address")) {
await checkUserPayload(c);
await next();
@@ -148,6 +155,10 @@ app.use('/api/*', async (c, next) => {
) {
await checkoutUserRolePayload(c);
}
if (c.req.path.startsWith("/api/address_login")) {
await next();
return;
}
try {
return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
@@ -190,6 +201,9 @@ app.use('/user_api/*', async (c, next) => {
console.error(e);
return c.text(msgs.UserTokenExpiredMsg, 401)
}
if (c.req.path.startsWith("/user_api/bind_address")) {
await checkoutUserRolePayload(c);
}
if (c.req.path.startsWith('/user_api/bind_address')
&& c.req.method === 'POST'
) {
+2
View File
@@ -70,6 +70,8 @@ ENABLE_USER_DELETE_EMAIL = true
ENABLE_AUTO_REPLY = false
# Allow webhook
# ENABLE_WEBHOOK = true
# Enable address password feature, if set true, will generate password for new address and support password login and change
# ENABLE_ADDRESS_PASSWORD = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true