mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-10 17:43:31 +08:00
Unify address selection UI (#801)
* feat: unify address selection UI * docs: update changelog for address UI * feat: restore user mailbox tab
This commit is contained in:
35
AGENTS.md
Normal file
35
AGENTS.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure
|
||||
- Backend: `worker/` (Workers API; entry `worker/src/worker.ts`, APIs under `worker/src/*_api/`).
|
||||
- Frontend: `frontend/` (Vue 3 app; routes in `frontend/src/router/`).
|
||||
- Pages middleware: `pages/functions/_middleware.js`.
|
||||
- Mail parser: `mail-parser-wasm/` (Rust WASM).
|
||||
- SMTP/IMAP proxy: `smtp_proxy_server/`.
|
||||
- DB schema/migrations: `db/`.
|
||||
- Docs: `vitepress-docs/`.
|
||||
|
||||
## Build & Dev Commands
|
||||
Run inside each folder:
|
||||
- Frontend: `pnpm dev`, `pnpm build`.
|
||||
- Worker: `pnpm dev`, `pnpm lint`, `pnpm build`.
|
||||
- Pages: `pnpm dev`.
|
||||
- Docs: `pnpm dev` in `vitepress-docs/`.
|
||||
- WASM: `wasm-pack build --release` in `mail-parser-wasm/`.
|
||||
- SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## Coding Style
|
||||
- Follow existing module style. `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep current naming patterns: `*_api/`, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## Testing
|
||||
- No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
|
||||
|
||||
## Commits & PRs
|
||||
- Use Conventional Commits (`feat:`, `fix:`, `docs:`). Recent history includes PR numbers like `(#123)`.
|
||||
- PRs should explain scope and add screenshots for UI changes.
|
||||
|
||||
## Config Tips
|
||||
- Worker settings in `worker/wrangler.toml` (see template for bindings).
|
||||
- Frontend uses `VITE_*` env vars. Don’t commit secrets.
|
||||
@@ -17,6 +17,7 @@
|
||||
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
|
||||
- feat: |地址来源| 新增地址来源追踪功能,记录地址创建来源(Web 记录 IP,Telegram 记录用户 ID,Admin 后台标记)
|
||||
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
|
||||
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
|
||||
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
|
||||
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
|
||||
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
|
||||
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
|
||||
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
|
||||
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
|
||||
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
|
||||
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
|
||||
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
|
||||
|
||||
259
frontend/src/components/AddressSelect.vue
Normal file
259
frontend/src/components/AddressSelect.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { Copy } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small',
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const { toClipboard } = useClipboard()
|
||||
|
||||
const {
|
||||
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
userAddresses: 'User Addresses',
|
||||
localAddresses: 'Local Addresses',
|
||||
address: 'Address',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
zh: {
|
||||
userAddresses: '用户地址',
|
||||
localAddresses: '本地地址',
|
||||
address: '地址',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressOptions = ref([])
|
||||
const addressValue = ref(null)
|
||||
const addressLoading = ref(false)
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", [])
|
||||
const optionValueMap = new Map()
|
||||
|
||||
const formatAddressLabel = (address) => {
|
||||
if (!address) return address;
|
||||
const domain = address.split('@')[1]
|
||||
const domainLabel = openSettings.value.domains.find(
|
||||
d => d.value === domain
|
||||
)?.label;
|
||||
if (!domainLabel) return address;
|
||||
return address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
|
||||
const parseJwtAddress = (curJwt) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return payload.address;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionValue = (key, scope, payload, address) => {
|
||||
if (optionValueMap.has(key)) {
|
||||
const cached = optionValueMap.get(key)
|
||||
cached.scope = scope
|
||||
cached.payload = payload
|
||||
cached.address = address
|
||||
return cached
|
||||
}
|
||||
const value = { key, scope, payload, address }
|
||||
optionValueMap.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const children = localAddressCache.value
|
||||
.map((curJwt) => {
|
||||
const address = parseJwtAddress(curJwt);
|
||||
if (!address) return null;
|
||||
if (excludeAddresses.has(address)) return null;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `local:${curJwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return children;
|
||||
}
|
||||
|
||||
const buildUserOptions = async () => {
|
||||
const children = [];
|
||||
try {
|
||||
const { results } = await api.fetch(`/user_api/bind_address`);
|
||||
for (const row of results || []) {
|
||||
const address = row.address || row.name;
|
||||
if (!address) continue;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `user:${row.id}`;
|
||||
const option = { label, value: getOptionValue(key, 'user', String(row.id), address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
children.push(option);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const buildTelegramOptions = async () => {
|
||||
const children = [];
|
||||
try {
|
||||
const data = await api.fetch(`/telegram/get_bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData
|
||||
})
|
||||
});
|
||||
for (const row of data || []) {
|
||||
if (!row?.address || !row?.jwt) continue;
|
||||
const label = formatAddressLabel(row.address);
|
||||
const key = `tg:${row.jwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'tg', row.jwt, row.address), address: row.address };
|
||||
if (settings.value.address && row.address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
children.push(option);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const refreshAddressOptions = async () => {
|
||||
addressLoading.value = true;
|
||||
addressValue.value = null;
|
||||
try {
|
||||
if (isTelegram.value) {
|
||||
const telegramChildren = await buildTelegramOptions();
|
||||
addressOptions.value = telegramChildren;
|
||||
return;
|
||||
}
|
||||
const groups = [];
|
||||
if (userJwt.value) {
|
||||
const userChildren = await buildUserOptions();
|
||||
if (userChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
|
||||
}
|
||||
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
||||
const localChildren = buildLocalOptions(userAddressSet);
|
||||
if (localChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
|
||||
}
|
||||
} else {
|
||||
const localChildren = buildLocalOptions();
|
||||
if (localChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
|
||||
}
|
||||
}
|
||||
addressOptions.value = groups;
|
||||
} finally {
|
||||
addressLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onAddressChange = async (value) => {
|
||||
if (!value) return;
|
||||
if (value.scope === 'local' || value.scope === 'tg') {
|
||||
jwt.value = value.payload;
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (value.scope === 'user') {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/bind_address_jwt/${value.payload}`);
|
||||
if (!res?.jwt) {
|
||||
message.error("jwt not found");
|
||||
return;
|
||||
}
|
||||
jwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
|
||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="address-row" align="center" justify="center" :wrap="false">
|
||||
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
||||
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
|
||||
class="address-select" />
|
||||
<slot name="actions" />
|
||||
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.address-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address-select {
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
flex: 0 1 420px;
|
||||
}
|
||||
|
||||
.address-copy {
|
||||
margin-left: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +1,51 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Copy, User, ExchangeAlt } from '@vicons/fa'
|
||||
import { User, ExchangeAlt } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
import AddressManagement from '../user/AddressManagement.vue'
|
||||
import TelegramAddress from './TelegramAddress.vue'
|
||||
import LocalAddress from './LocalAddress.vue'
|
||||
import AddressManagement from '../user/AddressManagement.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
import AddressSelect from '../../components/AddressSelect.vue'
|
||||
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, settings, showAddressCredential, userJwt,
|
||||
isTelegram, openSettings, addressPassword
|
||||
isTelegram, addressPassword
|
||||
} = useGlobalState()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
addressManage: 'Address Manage',
|
||||
changeAddress: 'Change Address',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
|
||||
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',
|
||||
addressManage: 'Manage',
|
||||
},
|
||||
zh: {
|
||||
addressManage: '地址管理',
|
||||
changeAddress: '更换地址',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
userLogin: '用户登录',
|
||||
addressManage: '地址管理',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showChangeAddress = ref(false)
|
||||
const showTelegramChangeAddress = ref(false)
|
||||
const showLocalAddress = ref(false)
|
||||
const addressLabel = computed(() => {
|
||||
if (settings.value.address) {
|
||||
const domain = settings.value.address.split('@')[1]
|
||||
const domainLabel = openSettings.value.domains.find(
|
||||
d => d.value === domain
|
||||
)?.label;
|
||||
if (!domainLabel) return settings.value.address;
|
||||
return settings.value.address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
return settings.value.address;
|
||||
})
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
const showAddressManage = ref(false)
|
||||
|
||||
const getUrlWithJwt = () => {
|
||||
return `${window.location.origin}/?jwt=${jwt.value}`
|
||||
@@ -97,29 +67,25 @@ onMounted(async () => {
|
||||
</n-card>
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
<b>{{ addressLabel }}</b>
|
||||
<n-button v-if="isTelegram" style="margin-left: 10px" @click="showTelegramChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button v-else-if="userJwt" style="margin-left: 10px" @click="showChangeAddress = true"
|
||||
size="small" tertiary type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('changeAddress') }}
|
||||
</n-button>
|
||||
<n-button v-else style="margin-left: 10px" @click="showLocalAddress = true" size="small" tertiary
|
||||
type="primary">
|
||||
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
|
||||
</n-button>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
<AddressSelect>
|
||||
<template #actions>
|
||||
<n-button class="address-manage" size="small" tertiary type="primary"
|
||||
@click="showAddressManage = true">
|
||||
<n-icon :component="ExchangeAlt" />
|
||||
{{ t('addressManage') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</AddressSelect>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else-if="isTelegram">
|
||||
<TelegramAddress />
|
||||
</div>
|
||||
<div v-else-if="userJwt" class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 900px; width: 100%;">
|
||||
<AddressManagement />
|
||||
</n-card>
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert v-if="jwt" type="warning" :show-icon="false" :bordered="false" closable>
|
||||
@@ -135,15 +101,6 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showTelegramChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<TelegramAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<AddressManagement />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
|
||||
<LocalAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
@@ -165,6 +122,11 @@ onMounted(async () => {
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')">
|
||||
<TelegramAddress v-if="isTelegram" />
|
||||
<AddressManagement v-else-if="userJwt" />
|
||||
<LocalAddress v-else />
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -186,4 +148,8 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.address-manage {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,7 +48,7 @@ const data = computed(() => {
|
||||
}
|
||||
return localAddressCache.value.map((curJwt: string) => {
|
||||
try {
|
||||
var payload = JSON.parse(
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
|
||||
@@ -17,6 +17,7 @@ import Login from '../common/Login.vue'
|
||||
import AccountSettings from './AccountSettings.vue'
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import MailContentRenderer from '../../components/MailContentRenderer.vue'
|
||||
import AddressSelect from '../../components/AddressSelect.vue'
|
||||
|
||||
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
@@ -171,7 +172,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else>
|
||||
<n-card :bordered="false" embedded>
|
||||
<div style="text-align: center; margin-bottom: 16px; font-size: 18px;">
|
||||
<n-text strong size="large">{{ settings.address }}</n-text>
|
||||
<AddressSelect :showCopy="false" size="small" />
|
||||
</div>
|
||||
<n-flex justify="center">
|
||||
<n-button @click="refreshMails" :loading="loading" type="primary" tertiary size="small">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
|
||||
Reference in New Issue
Block a user