Compare commits

...
10 Commits
Author SHA1 Message Date
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
Dream Hunter 6ae90be3bf feat: support github action deploy telegram mini app frontend (#730) 2025-09-22 21:25:15 +08:00
Dream Hunter 5e24817de6 feat: upgrade version (#726) 2025-09-15 10:43:53 +08:00
Dream Hunter 732189482e feat: db schema index update (#725)
* feat: db schema index update

* feat: upgrade dependencies
2025-09-15 10:41:14 +08:00
39 changed files with 2915 additions and 2235 deletions
+9 -2
View File
@@ -33,10 +33,17 @@ jobs:
- name: Deploy Backend for ${{ github.ref_name }} - name: Deploy Backend for ${{ github.ref_name }}
run: | run: |
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }} export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
if [ -n "$use_worker_assets" ]; then if [ -n "$use_worker_assets" ]; then
cd frontend/ cd frontend/
pnpm install --no-frozen-lockfile pnpm install --no-frozen-lockfile
pnpm build:pages if [ -n "$use_worker_assets_with_telegram" ]; then
echo "Building with telegram pages"
pnpm build:telegram:pages
else
echo "Building with normal pages"
pnpm build:pages
fi
cd .. cd ..
fi fi
@@ -53,7 +60,7 @@ jobs:
echo "Applied mail-parser-wasm-worker patch" echo "Applied mail-parser-wasm-worker patch"
fi fi
if [ -n "$debug_mode" ]; then if [ "$debug_mode" = "true" ]; then
pnpm run deploy pnpm run deploy
else else
output=$(pnpm run deploy 2>&1) output=$(pnpm run deploy 2>&1)
+8 -1
View File
@@ -1,7 +1,14 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 --> <!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG # CHANGE LOG
## main(v1.0.5) ## main(v1.0.6)
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
## v1.0.5
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能 - feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名 - feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
+1
View File
@@ -40,6 +40,7 @@
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行 - 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
-**高性能** - Rust WASM 邮件解析,响应速度极快 -**高性能** - Rust WASM 邮件解析,响应速度极快
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便 - 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
## 📚 部署文档 - 快速开始 ## 📚 部署文档 - 快速开始
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE
address
ADD
password TEXT;
+9
View File
@@ -9,15 +9,22 @@ CREATE TABLE IF NOT EXISTS raw_mails (
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address); CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE TABLE IF NOT EXISTS address ( CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE, name TEXT UNIQUE,
password TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name); CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE TABLE IF NOT EXISTS auto_reply_mails ( CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
source_prefix TEXT, source_prefix TEXT,
@@ -50,6 +57,8 @@ CREATE TABLE IF NOT EXISTS sendbox (
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address); 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT, value TEXT,
+9 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,6 +10,7 @@
"build:pages": "vite build -m pages --emptyOutDir", "build:pages": "vite build -m pages --emptyOutDir",
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir", "build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir", "build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir", "build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production", "deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
@@ -24,15 +25,15 @@
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.11.0", "axios": "^1.12.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1", "mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.42.0", "naive-ui": "^2.43.1",
"postal-mime": "^2.4.4", "postal-mime": "^2.5.0",
"vooks": "^0.2.12", "vooks": "^0.2.12",
"vue": "^3.5.21", "vue": "^3.5.22",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.11", "vue-i18n": "^11.1.12",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
@@ -41,13 +42,13 @@
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0", "unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0", "unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5", "vite": "^6.3.6",
"vite-plugin-pwa": "^1.0.3", "vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0", "workbox-build": "^7.3.0",
"workbox-window": "^7.3.0", "workbox-window": "^7.3.0",
"wrangler": "^4.34.0" "wrangler": "^4.42.2"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+921 -914
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -86,6 +86,7 @@ const getOpenSettings = async (message, notification) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "", cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false, enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false, isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
}); });
if (openSettings.value.needAuth) { if (openSettings.value.needAuth) {
showAuth.value = true; showAuth.value = true;
+3
View File
@@ -36,6 +36,7 @@ export const useGlobalState = createGlobalState(
isS3Enabled: false, isS3Enabled: false,
showGithub: true, showGithub: true,
disableAdminPasswordCheck: false, disableAdminPasswordCheck: false,
enableAddressPassword: false,
}) })
const settings = ref({ const settings = ref({
fetched: false, fetched: false,
@@ -63,6 +64,7 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', ''); const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', ''); const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', ''); const jwt = useStorage('jwt', '');
const addressPassword = useSessionStorage('addressPassword', '');
const adminTab = useSessionStorage('adminTab', "account"); const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref(""); const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref(""); const adminSendBoxTabAddress = ref("");
@@ -145,6 +147,7 @@ export const useGlobalState = createGlobalState(
userOauth2SessionState, userOauth2SessionState,
userOauth2SessionClientID, userOauth2SessionClientID,
useSimpleIndex, useSimpleIndex,
addressPassword,
} }
}, },
) )
+222 -6
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, h, onMounted, watch } from 'vue'; import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge } from 'naive-ui' import { NBadge, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material' import { MenuFilled } from '@vicons/material'
const { const {
loading, adminTab, loading, adminTab, openSettings,
adminMailTabAddress, adminSendBoxTabAddress adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
@@ -39,6 +39,19 @@ const { t } = useI18n({
clearSentItemsTip: 'Are you sure to clear sent items for this email?', clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions', actions: 'Actions',
success: 'Success', 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: { zh: {
name: '名称', name: '名称',
@@ -63,6 +76,19 @@ const { t } = useI18n({
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?', clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作', actions: '操作',
success: '成功', 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 curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0); const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = 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("") 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 () => { const fetchData = async () => {
try { try {
addressQuery.value = addressQuery.value.trim() addressQuery.value = addressQuery.value.trim()
@@ -148,12 +294,15 @@ const fetchData = async () => {
count.value = addressCount; count.value = addressCount;
} }
} catch (error) { } catch (error) {
console.log(error) console.error(error);
message.error(error.message || "error"); message.error(error.message || "error");
} }
} }
const columns = [ const columns = [
{
type: 'selection'
},
{ {
title: "ID", title: "ID",
key: "id" key: "id"
@@ -296,6 +445,19 @@ const columns = [
), ),
show: row.send_count > 0 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, label: () => h(NButton,
{ {
@@ -365,13 +527,54 @@ onMounted(async () => {
</n-button> </n-button>
</template> </template>
</n-modal> </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')" <n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" /> @keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary> <n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }} {{ t('query') }}
</n-button> </n-button>
</n-input-group> </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="overflow: auto;">
<div style="display: inline-block;"> <div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" <n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -381,8 +584,21 @@ onMounted(async () => {
</template> </template>
</n-pagination> </n-pagination>
</div> </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> </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> </div>
</template> </template>
+32 -3
View File
@@ -15,10 +15,13 @@ const { t } = useI18n({
en: { en: {
address: 'Address', address: 'Address',
enablePrefix: 'If enable Prefix', enablePrefix: 'If enable Prefix',
creatNewEmail: 'Get New Email', creatNewEmail: 'Create New Email',
fillInAllFields: 'Please fill in all fields', fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created', successTip: 'Success Created',
addressCredential: 'Mail Address Credential', 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: { zh: {
address: '地址', address: '地址',
@@ -27,6 +30,9 @@ const { t } = useI18n({
fillInAllFields: '请填写完整信息', fillInAllFields: '请填写完整信息',
successTip: '创建成功', successTip: '创建成功',
addressCredential: '邮箱地址凭证', addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
} }
} }
}); });
@@ -36,6 +42,8 @@ const emailName = ref("")
const emailDomain = ref("") const emailDomain = ref("")
const showReultModal = ref(false) const showReultModal = ref(false)
const result = ref("") const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const newEmail = async () => { const newEmail = async () => {
if (!emailName.value || !emailDomain.value) { if (!emailName.value || !emailDomain.value) {
@@ -52,6 +60,8 @@ const newEmail = async () => {
}) })
}) })
result.value = res["jwt"]; result.value = res["jwt"];
addressPassword.value = res["password"] || '';
createdAddress.value = res["address"] || '';
message.success(t('successTip')) message.success(t('successTip'))
showReultModal.value = true showReultModal.value = true
} catch (error) { } catch (error) {
@@ -59,6 +69,10 @@ const newEmail = async () => {
} }
} }
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => { onMounted(async () => {
if (openSettings.prefix) { if (openSettings.prefix) {
enablePrefix.value = true enablePrefix.value = true
@@ -70,10 +84,25 @@ onMounted(async () => {
<template> <template>
<div class="center"> <div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')"> <n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p> <span>
<n-card :bordered="false" embedded> <p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b> <b>{{ result }}</b>
</n-card> </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-modal>
<n-card :bordered="false" embedded style="max-width: 600px;"> <n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')"> <n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
+16
View File
@@ -17,6 +17,8 @@ const cleanupModel = ref({
cleanAddressDays: 30, cleanAddressDays: 30,
enableInactiveAddressAutoCleanup: false, enableInactiveAddressAutoCleanup: false,
cleanInactiveAddressDays: 30, cleanInactiveAddressDays: 30,
enableUnboundAddressAutoCleanup: false,
cleanUnboundAddressDays: 30,
}) })
const { t } = useI18n({ const { t } = useI18n({
@@ -28,6 +30,7 @@ const { t } = useI18n({
sendBoxLabel: "Cleanup the sendbox before n days", sendBoxLabel: "Cleanup the sendbox before n days",
addressCreateLabel: "Cleanup the address created before n days", addressCreateLabel: "Cleanup the address created before n days",
inactiveAddressLabel: "Cleanup the inactive address before n days", inactiveAddressLabel: "Cleanup the inactive address before n days",
unboundAddressLabel: "Cleanup the unbound address before n days",
cleanupNow: "Cleanup now", cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup", autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success", cleanupSuccess: "Cleanup success",
@@ -41,6 +44,7 @@ const { t } = useI18n({
sendBoxLabel: "清理 n 天前的发件箱", sendBoxLabel: "清理 n 天前的发件箱",
addressCreateLabel: "清理 n 天前创建的地址", addressCreateLabel: "清理 n 天前创建的地址",
inactiveAddressLabel: "清理 n 天前的未活跃地址", inactiveAddressLabel: "清理 n 天前的未活跃地址",
unboundAddressLabel: "清理 n 天前的未绑定用户地址",
autoCleanup: "自动清理", autoCleanup: "自动清理",
cleanupSuccess: "清理成功", cleanupSuccess: "清理成功",
cleanupNow: "立即清理", cleanupNow: "立即清理",
@@ -161,6 +165,18 @@ onMounted(async () => {
{{ t('cleanupNow') }} {{ t('cleanupNow') }}
</n-button> </n-button>
</n-form-item-row> </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-form>
</n-card> </n-card>
</div> </div>
+88 -9
View File
@@ -9,7 +9,7 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils' import { getRouterPathWithLang, hashPassword } from '../../utils'
const props = defineProps({ const props = defineProps({
bindUserAddress: { bindUserAddress: {
@@ -39,7 +39,7 @@ const router = useRouter()
const { const {
jwt, loading, openSettings, jwt, loading, openSettings,
showAddressCredential, userSettings showAddressCredential, userSettings, addressPassword
} = useGlobalState() } = useGlobalState()
const tabValue = ref('signin') const tabValue = ref('signin')
@@ -47,8 +47,47 @@ const credential = ref('')
const emailName = ref("") const emailName = ref("")
const emailDomain = ref("") const emailDomain = ref("")
const cfToken = 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 () => { 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) { if (!credential.value) {
message.error(t('credentialInput')); message.error(t('credentialInput'));
return; 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', bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user', bindUserAddressError: 'Error when bind email address to user',
autoGeneratedName: 'Auto-generated name', autoGeneratedName: 'Auto-generated name',
passwordLogin: 'Password Login',
credentialLogin: 'Credential Login',
email: 'Email',
password: 'Password',
emailPasswordRequired: 'Email and password are required',
}, },
zh: { zh: {
login: '登录', login: '登录',
@@ -102,6 +146,11 @@ const { locale, t } = useI18n({
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户', bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误', bindUserAddressError: '绑定邮箱地址到用户时错误',
autoGeneratedName: '自动生成名称', autoGeneratedName: '自动生成名称',
passwordLogin: '密码登录',
credentialLogin: '凭据登录',
email: '邮箱',
password: '密码',
emailPasswordRequired: '邮箱和密码不能为空',
} }
} }
}); });
@@ -157,6 +206,7 @@ const newEmail = async () => {
cfToken.value cfToken.value
); );
jwt.value = res["jwt"]; jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
await api.getSettings(); await api.getSettings();
await router.push(getRouterPathWithLang("/", locale.value)); await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true; showAddressCredential.value = true;
@@ -212,6 +262,7 @@ onMounted(async () => {
await api.getOpenSettings(message, notification); await api.getOpenSettings(message, notification);
} }
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : ""; emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
initLoginMethod();
}); });
</script> </script>
@@ -223,9 +274,29 @@ onMounted(async () => {
<n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly"> <n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="loginAndBindTag"> <n-tab-pane name="signin" :tab="loginAndBindTag">
<n-form> <n-form>
<n-form-item-row :label="t('credential')" required> <div v-if="loginMethod === 'password'">
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" /> <n-form-item-row :label="t('email')" required>
</n-form-item-row> <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> <n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon> <template #icon>
<n-icon :component="EmailOutlined" /> <n-icon :component="EmailOutlined" />
@@ -244,19 +315,21 @@ onMounted(async () => {
<n-spin :show="generateNameLoading"> <n-spin :show="generateNameLoading">
<n-form> <n-form>
<span> <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 v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p> <p>{{ t("getNewEmailTip3") }}</p>
</span> </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') }} {{ t('generateName') }}
</n-button> </n-button>
<n-input-group> <n-input-group>
<n-input-group-label v-if="addressPrefix"> <n-input-group-label v-if="addressPrefix">
{{ addressPrefix }} {{ addressPrefix }}
</n-input-group-label> </n-input-group-label>
<n-input v-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count :minlength="openSettings.minAddressLen" <n-input v-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count
:maxlength="openSettings.maxAddressLen" /> :minlength="openSettings.minAddressLen" :maxlength="openSettings.maxAddressLen" />
<n-input v-else :value="t('autoGeneratedName')" disabled /> <n-input v-else :value="t('autoGeneratedName')" disabled />
<n-input-group-label>@</n-input-group-label> <n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false" <n-select v-model:value="emailDomain" :consistent-menu-width="false"
@@ -294,6 +367,12 @@ onMounted(async () => {
margin-top: 10px; margin-top: 10px;
} }
.switch-login-button {
display: flex;
justify-content: center;
margin: 10px 0;
}
.n-form { .n-form {
text-align: left; text-align: left;
} }
@@ -5,6 +5,7 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { hashPassword } from '../../utils'
import { getRouterPathWithLang } from '../../utils' import { getRouterPathWithLang } from '../../utils'
const { const {
@@ -17,6 +18,9 @@ const showLogout = ref(false)
const showDeleteAccount = ref(false) const showDeleteAccount = ref(false)
const showClearInbox = ref(false) const showClearInbox = ref(false)
const showClearSentItems = ref(false) const showClearSentItems = ref(false)
const showChangePassword = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
const { locale, t } = useI18n({ const { locale, t } = useI18n({
messages: { messages: {
en: { en: {
@@ -31,6 +35,11 @@ const { locale, t } = useI18n({
clearInboxConfirm: "Are you sure to clear all emails in your inbox?", clearInboxConfirm: "Are you sure to clear all emails in your inbox?",
clearSentItemsConfirm: "Are you sure to clear all emails in your sent items?", clearSentItemsConfirm: "Are you sure to clear all emails in your sent items?",
success: "Success", success: "Success",
changePassword: "Change Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
passwordMismatch: "Passwords do not match",
passwordChanged: "Password changed successfully",
}, },
zh: { zh: {
logout: '退出登录', logout: '退出登录',
@@ -44,6 +53,11 @@ const { locale, t } = useI18n({
clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?", clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?",
clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?", clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?",
success: "成功", success: "成功",
changePassword: "修改密码",
newPassword: "新密码",
confirmPassword: "确认密码",
passwordMismatch: "密码不匹配",
passwordChanged: "密码修改成功",
} }
} }
}); });
@@ -92,6 +106,27 @@ const clearSentItems = async () => {
showClearSentItems.value = false; 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> </script>
<template> <template>
@@ -100,6 +135,9 @@ const clearSentItems = async () => {
<n-button @click="showAddressCredential = true" type="primary" secondary block strong> <n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }} {{ t('showAddressCredential') }}
</n-button> </n-button>
<n-button 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 <n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearInbox = true" type="warning" secondary
block strong> block strong>
{{ t('clearInbox') }} {{ t('clearInbox') }}
@@ -148,6 +186,22 @@ const clearSentItems = async () => {
</n-button> </n-button>
</template> </template>
</n-modal> </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> </div>
</template> </template>
+7 -1
View File
@@ -19,7 +19,7 @@ const router = useRouter()
const { const {
jwt, settings, showAddressCredential, userJwt, jwt, settings, showAddressCredential, userJwt,
isTelegram, openSettings isTelegram, openSettings, addressPassword
} = useGlobalState() } = useGlobalState()
const { locale, t } = useI18n({ const { locale, t } = useI18n({
@@ -34,6 +34,7 @@ const { locale, t } = useI18n({
addressCredential: 'Mail Address Credential', addressCredential: 'Mail Address Credential',
linkWithAddressCredential: 'Open to auto login email link', 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.', addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
userLogin: 'User Login', userLogin: 'User Login',
}, },
zh: { zh: {
@@ -46,6 +47,7 @@ const { locale, t } = useI18n({
addressCredential: '邮箱地址凭证', addressCredential: '邮箱地址凭证',
linkWithAddressCredential: '打开即可自动登录邮箱的链接', linkWithAddressCredential: '打开即可自动登录邮箱的链接',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。', addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
userLogin: '用户登录', userLogin: '用户登录',
} }
} }
@@ -149,6 +151,10 @@ onMounted(async () => {
<n-card embedded> <n-card embedded>
<b>{{ jwt }}</b> <b>{{ jwt }}</b>
</n-card> </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-card embedded>
<n-collapse> <n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'> <n-collapse-item :title='t("linkWithAddressCredential")'>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp-email-pages", "name": "temp-email-pages",
"version": "1.0.5", "version": "1.0.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"wrangler": "^4.34.0" "wrangler": "^4.42.2"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
@@ -35,6 +35,7 @@
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` | | `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` | | `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` | | `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
## 接受邮件相关变量 ## 接受邮件相关变量
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp-mail-docs", "name": "temp-mail-docs",
"private": true, "private": true,
"version": "1.0.5", "version": "1.0.6",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^24.3.1", "@types/node": "^24.7.2",
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"wrangler": "^4.34.0" "wrangler": "^4.42.2"
}, },
"scripts": { "scripts": {
"dev": "vitepress dev docs", "dev": "vitepress dev docs",
+401 -392
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,23 +11,23 @@
"build": "wrangler deploy --dry-run --outdir dist --minify" "build": "wrangler deploy --dry-run --outdir dist --minify"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250904.0", "@cloudflare/workers-types": "^4.20251011.0",
"@eslint/js": "9.18.0", "@eslint/js": "9.18.0",
"@simplewebauthn/types": "10.0.0", "@simplewebauthn/types": "10.0.0",
"@types/node": "^22.18.1", "@types/node": "^22.18.10",
"eslint": "9.18.0", "eslint": "9.18.0",
"globals": "^15.15.0", "globals": "^15.15.0",
"typescript-eslint": "^8.42.0", "typescript-eslint": "^8.46.0",
"wrangler": "^4.34.0" "wrangler": "^4.42.2"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.879.0", "@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "^3.879.0", "@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "10.0.1", "@simplewebauthn/server": "10.0.1",
"hono": "^4.9.6", "hono": "^4.9.11",
"jsonpath-plus": "^10.3.0", "jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27", "mimetext": "^3.0.27",
"postal-mime": "^2.4.4", "postal-mime": "^2.5.0",
"resend": "^4.8.0", "resend": "^4.8.0",
"telegraf": "4.16.3", "telegraf": "4.16.3",
"worker-mailer": "^1.1.5" "worker-mailer": "^1.1.5"
+889 -877
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -14,15 +14,22 @@ CREATE TABLE IF NOT EXISTS raw_mails (
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address); CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE TABLE IF NOT EXISTS address ( CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE, name TEXT UNIQUE,
password TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name); CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE TABLE IF NOT EXISTS auto_reply_mails ( CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
source_prefix TEXT, source_prefix TEXT,
@@ -54,6 +61,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_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -129,9 +137,20 @@ export default {
}, },
migrate: async (c: Context<HonoCustomType>) => { migrate: async (c: Context<HonoCustomType>) => {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY); 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) { if (version != CONSTANTS.DB_VERSION) {
// TODO: Perform migration logic here // TODO: Perform migration logic here
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n
const query = DB_INIT_QUERIES.replace(/[\r\n]/g, "")
.split(";")
.map((query) => query.trim())
.join(";\n");
await c.env.DB.exec(query);
// Update the version in the settings table // Update the version in the settings table
await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION); await utils.saveSetting(c, CONSTANTS.DB_VERSION_KEY, CONSTANTS.DB_VERSION);
return c.json({ return c.json({
+26 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt' import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n' 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 { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants' import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api' import cleanup_api from './cleanup_api'
@@ -56,6 +56,7 @@ api.post('/admin/new_address', async (c) => {
checkAllowDomains: false, checkAllowDomains: false,
enableCheckNameRegex: false, enableCheckNameRegex: false,
}); });
return c.json(res); return c.json(res);
} catch (e) { } catch (e) {
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400) return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
@@ -131,6 +132,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 // mail api
api.get('/admin/mails', admin_mail_api.getMails); api.get('/admin/mails', admin_mail_api.getMails);
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails); api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
+2 -1
View File
@@ -40,7 +40,8 @@ api.get('/open_api/settings', async (c) => {
"isS3Enabled": isS3Enabled(c), "isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION, "version": CONSTANTS.VERSION,
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB), "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 { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt' 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 { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants'; import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models'; 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 ( export const newAddress = async (
c: Context<HonoCustomType>, c: Context<HonoCustomType>,
{ {
@@ -100,7 +131,7 @@ export const newAddress = async (
checkAllowDomains?: boolean, checkAllowDomains?: boolean,
enableCheckNameRegex?: boolean, enableCheckNameRegex?: boolean,
} }
): Promise<{ address: string, jwt: string }> => { ): Promise<{ address: string, jwt: string, password?: string | null }> => {
// trim whitespace and remove special characters // trim whitespace and remove special characters
name = name.trim().replace(getNameRegex(c), '') name = name.trim().replace(getNameRegex(c), '')
// check name // check name
@@ -166,6 +197,10 @@ export const newAddress = async (
const address_id = await c.env.DB.prepare( const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?` `SELECT id FROM address where name = ?`
).bind(name).first<number>("id"); ).bind(name).first<number>("id");
// 如果启用地址密码功能,自动生成密码
const generatedPassword = await generatePasswordForAddress(c, name);
// create jwt // create jwt
const jwt = await Jwt.sign({ const jwt = await Jwt.sign({
address: name, address: name,
@@ -174,6 +209,7 @@ export const newAddress = async (
return { return {
jwt: jwt, jwt: jwt,
address: name, address: name,
password: generatedPassword,
} }
} }
@@ -215,6 +251,12 @@ export const cleanup = async (
`created_at < datetime('now', '-${cleanDays} day')` `created_at < datetime('now', '-${cleanDays} day')`
) )
break; 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": case "mails":
await c.env.DB.prepare(` await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')` DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
+2 -2
View File
@@ -1,9 +1,9 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v' + '1.0.5', VERSION: 'v' + '1.0.6',
// DB Version // DB Version
DB_VERSION_KEY: 'db_version', DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.1", DB_VERSION: "v0.0.3",
// DB settings // DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list', ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
+8
View File
@@ -38,6 +38,14 @@ const messages: LocaleMessages = {
Oauth2FailedGetUserInfoMsg: "Failed to get user info from Oauth2 provider", Oauth2FailedGetUserInfoMsg: "Failed to get user info from Oauth2 provider",
Oauth2FailedGetAccessTokenMsg: "Failed to get access token from Oauth2 provider", Oauth2FailedGetAccessTokenMsg: "Failed to get access token from Oauth2 provider",
Oauth2FailedGetUserEmailMsg: "Failed to get user email 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; export default messages;
+8
View File
@@ -36,4 +36,12 @@ export type LocaleMessages = {
Oauth2FailedGetUserInfoMsg: string Oauth2FailedGetUserInfoMsg: string
Oauth2FailedGetAccessTokenMsg: string Oauth2FailedGetAccessTokenMsg: string
Oauth2FailedGetUserEmailMsg: 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 提供商获取用户信息失败", Oauth2FailedGetUserInfoMsg: "从 Oauth2 提供商获取用户信息失败",
Oauth2FailedGetAccessTokenMsg: "从 Oauth2 提供商获取访问令牌失败", Oauth2FailedGetAccessTokenMsg: "从 Oauth2 提供商获取访问令牌失败",
Oauth2FailedGetUserEmailMsg: "从 Oauth2 提供商获取用户邮箱失败", Oauth2FailedGetUserEmailMsg: "从 Oauth2 提供商获取用户邮箱失败",
PasswordChangeDisabledMsg: "密码修改已禁用",
NewPasswordRequiredMsg: "新密码不能为空",
InvalidAddressTokenMsg: "无效的地址令牌",
FailedUpdatePasswordMsg: "更新密码失败",
PasswordLoginDisabledMsg: "密码登录已禁用",
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
AddressNotFoundMsg: "邮箱地址不存在",
} }
export default messages; export default messages;
+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 auto_reply from './auto_reply'
import webhook_settings from './webhook_settings'; import webhook_settings from './webhook_settings';
import s3_attachment from './s3_attachment'; import s3_attachment from './s3_attachment';
import address_auth from './address_auth';
export const api = new Hono<HonoCustomType>() export const api = new Hono<HonoCustomType>()
@@ -198,3 +199,6 @@ api.delete('/api/clear_sent_items', async (c) => {
success: success success: success
}) })
}) })
api.post('/api/address_change_password', address_auth.changePassword)
api.post('/api/address_login', address_auth.login)
+2
View File
@@ -46,6 +46,8 @@ export type CleanupSettings = {
cleanAddressDays: number; cleanAddressDays: number;
enableInactiveAddressAutoCleanup: boolean | undefined; enableInactiveAddressAutoCleanup: boolean | undefined;
cleanInactiveAddressDays: number; cleanInactiveAddressDays: number;
enableUnboundAddressAutoCleanup: boolean | undefined;
cleanUnboundAddressDays: number;
} }
export class GeoData { export class GeoData {
+7
View File
@@ -50,4 +50,11 @@ export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any)
autoCleanupSetting.cleanAddressDays 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 ( export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string 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) { if (c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit( const { success } = await c.env.RATE_LIMITER.limit(
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` } { 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); const res = await tgUserNewAddress(c, userId.toString(), address);
return await ctx.reply(`创建地址成功:\n` return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n` + `地址: ${res.address}\n`
+ (res.password ? `密码: \`${res.password}\`\n` : '')
+ `凭证: \`${res.jwt}\`\n`, + `凭证: \`${res.jwt}\`\n`,
{ {
parse_mode: "Markdown" parse_mode: "Markdown"
+1
View File
@@ -40,6 +40,7 @@ type Bindings = {
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null NO_LIMIT_SEND_ROLE: string | undefined | null
+7
View File
@@ -296,6 +296,13 @@ export const checkUserPassword = (password: string) => {
return true; 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 { export default {
getJsonObjectValue, getJsonObjectValue,
getSetting, getSetting,
+4
View File
@@ -148,6 +148,10 @@ app.use('/api/*', async (c, next) => {
) { ) {
await checkoutUserRolePayload(c); await checkoutUserRolePayload(c);
} }
if (c.req.path.startsWith("/api/address_login")) {
await next();
return;
}
try { try {
return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next); return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
+2
View File
@@ -70,6 +70,8 @@ ENABLE_USER_DELETE_EMAIL = true
ENABLE_AUTO_REPLY = false ENABLE_AUTO_REPLY = false
# Allow webhook # Allow webhook
# ENABLE_WEBHOOK = true # 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 # Footer text
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # DISABLE_SHOW_GITHUB = true