mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-12 02:46:28 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8baa92dfbe | ||
|
|
288634c774 |
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
|
||||||
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||||
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||||
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
|
||||||
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
|
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
|
||||||
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
||||||
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes
|
||||||
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
||||||
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
||||||
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs
|
||||||
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code
|
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code
|
||||||
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
|
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
|
||||||
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
|
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
|
||||||
|
|||||||
@@ -11,6 +11,58 @@ function signToken(payload: Record<string, unknown>) {
|
|||||||
return `${header}.${body}.${signature}`;
|
return `${header}.${body}.${signature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'password-renewal');
|
||||||
|
const day = 24 * 60 * 60;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const identity = { address: mailbox.address, address_id: mailbox.address_id };
|
||||||
|
try {
|
||||||
|
for (const remainingDays of [8, 6]) {
|
||||||
|
const token = signToken({
|
||||||
|
...identity, type: 'address_password_login',
|
||||||
|
iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day,
|
||||||
|
});
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const settings = await response.json();
|
||||||
|
expect(settings.type).toBe('address_password_login');
|
||||||
|
if (remainingDays > 7) {
|
||||||
|
expect(settings.new_address_token).toBeNull();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(settings.new_address_token).toEqual(expect.any(String));
|
||||||
|
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${settings.new_address_token}` },
|
||||||
|
});
|
||||||
|
expect(renewed.ok()).toBe(true);
|
||||||
|
const renewedSettings = await renewed.json();
|
||||||
|
expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null });
|
||||||
|
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day);
|
||||||
|
}
|
||||||
|
const legacy = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
expect(legacy.ok()).toBe(true);
|
||||||
|
expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null });
|
||||||
|
for (const claims of [
|
||||||
|
{ type: 'telegram_binding' },
|
||||||
|
{ type: 'unknown' },
|
||||||
|
{ type: 'address_password_login' },
|
||||||
|
{ type: 'address_password_login', iat: now - 30 * day, exp: now - 1 },
|
||||||
|
{ type: 'address_password_login', iat: now, exp: now + 31 * day },
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` },
|
||||||
|
});
|
||||||
|
expect(response.status(), JSON.stringify(claims)).toBe(401);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function expectRejected(request: APIRequestContext, jwt: string) {
|
async function expectRejected(request: APIRequestContext, jwt: string) {
|
||||||
for (const [method, path] of [
|
for (const [method, path] of [
|
||||||
['GET', '/api/settings'],
|
['GET', '/api/settings'],
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
|
|||||||
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
||||||
});
|
});
|
||||||
expect(settingsRes.ok()).toBe(true);
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
const settings = await settingsRes.json();
|
||||||
|
expect(settings.type).toBe('address_password_login');
|
||||||
|
expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60);
|
||||||
|
expect(settings.new_address_token).toBeNull();
|
||||||
} finally {
|
} finally {
|
||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
}
|
}
|
||||||
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
|
|||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('users can reset a mailbox password only while it is bound to them', async ({ request }) => {
|
||||||
|
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset');
|
||||||
|
const email = `pwd-reset-user-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('password-reset-user');
|
||||||
|
const newPassword = hashPassword('replacement-mailbox-password');
|
||||||
|
try {
|
||||||
|
const enable = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: { enable: true, enableMailVerify: false },
|
||||||
|
});
|
||||||
|
expect(enable.ok()).toBe(true);
|
||||||
|
const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } });
|
||||||
|
expect(register.ok()).toBe(true);
|
||||||
|
const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } });
|
||||||
|
expect(login.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await login.json();
|
||||||
|
const headers = { 'x-user-token': userJwt };
|
||||||
|
const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, {
|
||||||
|
headers, data: { new_password: value },
|
||||||
|
});
|
||||||
|
expect((await reset()).status()).toBe(403);
|
||||||
|
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(bind.ok()).toBe(true);
|
||||||
|
expect((await reset('plaintext')).status()).toBe(400);
|
||||||
|
expect((await reset()).ok()).toBe(true);
|
||||||
|
const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
|
data: { email: address, password: newPassword },
|
||||||
|
});
|
||||||
|
expect(mailboxLogin.ok()).toBe(true);
|
||||||
|
const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, {
|
||||||
|
headers, data: { address_id },
|
||||||
|
});
|
||||||
|
expect(unbind.ok()).toBe(true);
|
||||||
|
expect((await reset()).status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-10
@@ -1,5 +1,6 @@
|
|||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
@@ -9,6 +10,7 @@ import { sanitizeHtml } from '../utils/sanitize-html'
|
|||||||
import { APP_CONFIG } from '../config'
|
import { APP_CONFIG } from '../config'
|
||||||
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
||||||
import { ErrorCode } from './error-codes'
|
import { ErrorCode } from './error-codes'
|
||||||
|
import { updateLocalAddressCache } from '../utils/local-address-cache'
|
||||||
|
|
||||||
const API_BASE = APP_CONFIG.API_BASE || "";
|
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||||
const {
|
const {
|
||||||
@@ -17,6 +19,8 @@ const {
|
|||||||
showAuth, adminAuth, showAdminAuth, userJwt
|
showAuth, adminAuth, showAdminAuth, userJwt
|
||||||
} = useGlobalState();
|
} = useGlobalState();
|
||||||
|
|
||||||
|
const localAddressCache = useLocalStorage('LocalAddressCache', []);
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: API_BASE,
|
baseURL: API_BASE,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
@@ -55,7 +59,7 @@ const apiFetch = async (path, options = {}) => {
|
|||||||
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
||||||
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
||||||
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
||||||
const authorizationHeader = safeBearerHeader(jwt.value);
|
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
|
||||||
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||||
|
|
||||||
const initialResponse = await instance.request(path, {
|
const initialResponse = await instance.request(path, {
|
||||||
@@ -122,6 +126,7 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
isS3Enabled: res["isS3Enabled"] || false,
|
isS3Enabled: res["isS3Enabled"] || false,
|
||||||
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
||||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||||
|
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
|
||||||
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
||||||
enableRedeemCode: res["enableRedeemCode"] || false,
|
enableRedeemCode: res["enableRedeemCode"] || false,
|
||||||
redeemCodeUrl: res["redeemCodeUrl"] || "",
|
redeemCodeUrl: res["redeemCodeUrl"] || "",
|
||||||
@@ -154,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSettings = async () => {
|
const getSettings = async () => {
|
||||||
|
let addressToken = jwt.value;
|
||||||
try {
|
try {
|
||||||
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
|
if (!safeHeaderValue(addressToken)) return;
|
||||||
return "";
|
const res = await apiFetch('/api/settings', { addressJwt: addressToken });
|
||||||
|
if (jwt.value !== addressToken) return;
|
||||||
|
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res);
|
||||||
|
settings.value = res;
|
||||||
|
const renewedAddressToken = res.new_address_token;
|
||||||
|
if (!renewedAddressToken) return;
|
||||||
|
try {
|
||||||
|
const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken });
|
||||||
|
if (jwt.value !== addressToken) return;
|
||||||
|
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings);
|
||||||
|
addressToken = renewedAddressToken;
|
||||||
|
jwt.value = renewedAddressToken;
|
||||||
|
settings.value = renewedSettings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to renew mailbox JWT', error);
|
||||||
}
|
}
|
||||||
const res = await apiFetch("/api/settings");;
|
|
||||||
settings.value = {
|
|
||||||
address: res["address"],
|
|
||||||
auto_reply: res["auto_reply"],
|
|
||||||
send_balance: res["send_balance"],
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
settings.value.fetched = true;
|
if (jwt.value === addressToken) settings.value.fetched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ const copyText = async (text) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="credential-content">
|
<div class="credential-content">
|
||||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||||
{{ t('tip') }}
|
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<section class="credential-panel">
|
<section class="credential-panel">
|
||||||
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'addressCredential') }}</h3>
|
||||||
<div class="credential-section">
|
<div class="credential-section">
|
||||||
<div v-if="address" class="credential-field">
|
<div v-if="address" class="credential-field">
|
||||||
<span class="credential-label">{{ t('currentAddress') }}</span>
|
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||||
@@ -112,7 +112,7 @@ const copyText = async (text) => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="credential-field">
|
<div v-if="!openSettings.addressPasswordLoginOnly" class="credential-field">
|
||||||
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||||
<div class="credential-copy-row">
|
<div class="credential-copy-row">
|
||||||
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
|
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
|
||||||
@@ -128,7 +128,7 @@ const copyText = async (text) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<n-collapse accordion class="credential-collapse">
|
<n-collapse v-if="!openSettings.addressPasswordLoginOnly" accordion class="credential-collapse">
|
||||||
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { computed } from 'vue'
|
|||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
import AddressCredentialContent from './AddressCredentialContent.vue'
|
import AddressCredentialContent from './AddressCredentialContent.vue'
|
||||||
|
import { useGlobalState } from '../store'
|
||||||
|
|
||||||
|
const { openSettings } = useGlobalState()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -34,7 +37,7 @@ const modalShow = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
|
||||||
style="width: min(760px, calc(100vw - 32px));">
|
style="width: min(760px, calc(100vw - 32px));">
|
||||||
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
|
|||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { getCachedAddresses } from '../utils/local-address-cache'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showCopy: {
|
showCopy: {
|
||||||
@@ -28,6 +29,8 @@ const {
|
|||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('components.AddressSelect')
|
const { t } = useScopedI18n('components.AddressSelect')
|
||||||
|
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||||
|
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
|
||||||
|
|
||||||
const addressOptions = ref([])
|
const addressOptions = ref([])
|
||||||
const addressValue = ref(null)
|
const addressValue = ref(null)
|
||||||
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
|
|||||||
return address.replace('@' + domain, `@${domainLabel}`);
|
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) => {
|
const getOptionValue = (key, scope, payload, address) => {
|
||||||
if (optionValueMap.has(key)) {
|
if (optionValueMap.has(key)) {
|
||||||
const cached = optionValueMap.get(key)
|
const cached = optionValueMap.get(key)
|
||||||
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
const children = getCachedAddresses(localAddressCache.value)
|
||||||
localAddressCache.value.push(jwt.value)
|
.map(({ token, address, type }, index) => {
|
||||||
}
|
|
||||||
const children = localAddressCache.value
|
|
||||||
.map((curJwt) => {
|
|
||||||
const address = parseJwtAddress(curJwt);
|
|
||||||
if (!address) return null;
|
|
||||||
if (excludeAddresses.has(address)) return null;
|
if (excludeAddresses.has(address)) return null;
|
||||||
const label = formatAddressLabel(address);
|
const isPasswordLogin = type === 'address_password_login';
|
||||||
const key = `local:${curJwt}`;
|
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
const label = address
|
||||||
if (settings.value.address && address === settings.value.address) {
|
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||||
|
: localAddressT('savedMailbox', { index: index + 1 });
|
||||||
|
const key = `local:${token}`;
|
||||||
|
const option = { label, value: getOptionValue(key, 'local', token, address), address };
|
||||||
|
if (token === jwt.value) {
|
||||||
addressValue.value = option.value;
|
addressValue.value = option.value;
|
||||||
}
|
}
|
||||||
return option;
|
return option;
|
||||||
@@ -207,7 +194,7 @@ onMounted(async () => {
|
|||||||
await refreshAddressOptions();
|
await refreshAddressOptions();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
|
||||||
await refreshAddressOptions();
|
await refreshAddressOptions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components.AddressCredentialModal": {
|
"components.AddressCredentialModal": {
|
||||||
|
"passwordOnlyTip": {
|
||||||
|
"en": "Save your mailbox password. Credential and login-link access are disabled. Bound mailboxes can also be opened from the user center.",
|
||||||
|
"zh": "请保存邮箱密码。凭据及链接登录已禁用;已绑定邮箱仍可从用户中心进入。"
|
||||||
|
},
|
||||||
"addressCredential": {
|
"addressCredential": {
|
||||||
"en": "Address Credential",
|
"en": "Address Credential",
|
||||||
"zh": "地址凭证"
|
"zh": "地址凭证"
|
||||||
@@ -1040,6 +1044,22 @@ export const MESSAGE_REGISTRY = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"views.user.AddressManagement": {
|
"views.user.AddressManagement": {
|
||||||
|
"resetPassword": {
|
||||||
|
"en": "Reset Password",
|
||||||
|
"zh": "重置密码"
|
||||||
|
},
|
||||||
|
"resetPasswordTip": {
|
||||||
|
"en": "Set a new password for this bound mailbox without its old password.",
|
||||||
|
"zh": "为已绑定邮箱设置新密码,无需提供原邮箱密码。"
|
||||||
|
},
|
||||||
|
"newPasswordRequired": {
|
||||||
|
"en": "Enter a new password.",
|
||||||
|
"zh": "请输入新密码。"
|
||||||
|
},
|
||||||
|
"unbindPasswordTip": {
|
||||||
|
"en": "Save the mailbox password before unlinking so you can log in again.",
|
||||||
|
"zh": "解绑前请保存邮箱密码,以便之后重新登录。"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"en": "Actions",
|
"en": "Actions",
|
||||||
"zh": "操作"
|
"zh": "操作"
|
||||||
@@ -1928,6 +1948,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"views.index.LocalAddress": {
|
"views.index.LocalAddress": {
|
||||||
|
"savedMailbox": {
|
||||||
|
"en": "Saved mailbox {index}",
|
||||||
|
"zh": "已保存邮箱 {index}"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"en": "Actions",
|
"en": "Actions",
|
||||||
"zh": "操作"
|
"zh": "操作"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const useGlobalState = createGlobalState(
|
|||||||
showGithubForUser: true,
|
showGithubForUser: true,
|
||||||
disableAdminPasswordCheck: false,
|
disableAdminPasswordCheck: false,
|
||||||
enableAddressPassword: false,
|
enableAddressPassword: false,
|
||||||
|
addressPasswordLoginOnly: false,
|
||||||
enableAgentEmailInfo: false,
|
enableAgentEmailInfo: false,
|
||||||
enableRedeemCode: false,
|
enableRedeemCode: false,
|
||||||
redeemCodeUrl: '',
|
redeemCodeUrl: '',
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type CachedAddress = {
|
||||||
|
token: string;
|
||||||
|
address?: string;
|
||||||
|
type?: 'address_password_login' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
|
||||||
|
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
|
||||||
|
.filter(entry => typeof entry?.token === 'string' && entry.token);
|
||||||
|
|
||||||
|
export const updateLocalAddressCache = (
|
||||||
|
cache: (string | CachedAddress)[], token: string,
|
||||||
|
{ address, type }: { address: string; type?: 'address_password_login' },
|
||||||
|
) => {
|
||||||
|
const entries = getCachedAddresses(cache);
|
||||||
|
const loginType = type ?? null;
|
||||||
|
const existing = entries.find(entry => entry.token === token
|
||||||
|
|| (entry.address === address && entry.type === loginType));
|
||||||
|
const updated = { token, address, type: loginType };
|
||||||
|
if (!existing) return [...entries, updated];
|
||||||
|
return entries.flatMap(entry => {
|
||||||
|
if (entry === existing) return [updated];
|
||||||
|
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
|
||||||
|
return [entry];
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
|
||||||
if (loginMethod.value === 'password') {
|
if (loginMethod.value === 'password') {
|
||||||
// Password login
|
// Password login
|
||||||
if (!loginAddress.value || !loginPassword.value) {
|
if (!loginAddress.value || !loginPassword.value) {
|
||||||
@@ -246,6 +247,8 @@ const showNewAddressTab = computed(() => {
|
|||||||
return openSettings.value.enableUserCreateEmail;
|
return openSettings.value.enableUserCreateEmail;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||||
await api.getOpenSettings(message, notification);
|
await api.getOpenSettings(message, notification);
|
||||||
@@ -283,7 +286,7 @@ onMounted(async () => {
|
|||||||
v-model:value="loginCfToken" />
|
v-model:value="loginCfToken" />
|
||||||
|
|
||||||
<div class="switch-login-button">
|
<div class="switch-login-button">
|
||||||
<n-button v-if="openSettings?.enableAddressPassword"
|
<n-button v-if="openSettings?.enableAddressPassword && !openSettings.addressPasswordLoginOnly"
|
||||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||||
type="info" quaternary size="tiny">
|
type="info" quaternary size="tiny">
|
||||||
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const changePassword = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="center" v-if="settings.address">
|
<div class="center" v-if="settings.address">
|
||||||
<n-card :bordered="false" embedded class="account-card">
|
<n-card :bordered="false" embedded class="account-card">
|
||||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
<n-button v-if="!openSettings.addressPasswordLoginOnly" @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>
|
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
||||||
|
|||||||
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
|
|||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { NPopconfirm, NButton } from 'naive-ui'
|
import { NPopconfirm, NButton } from 'naive-ui'
|
||||||
|
import { getCachedAddresses } from '../../utils/local-address-cache'
|
||||||
|
import type { CachedAddress } from '../../utils/local-address-cache'
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
const { jwt } = useGlobalState()
|
const { jwt, openSettings } = useGlobalState()
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.index.LocalAddress')
|
const { t } = useScopedI18n('views.index.LocalAddress')
|
||||||
|
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||||
|
|
||||||
const tabValue = ref('address')
|
const tabValue = ref('address')
|
||||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
// @ts-ignore
|
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
|
||||||
if (!localAddressCache.value.includes(jwt.value)) {
|
const isPasswordLogin = type === 'address_password_login';
|
||||||
// @ts-ignore
|
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||||
localAddressCache.value.push(jwt.value)
|
|
||||||
}
|
|
||||||
return localAddressCache.value.map((curJwt: string) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(
|
|
||||||
decodeURIComponent(
|
|
||||||
atob(curJwt.split(".")[1]
|
|
||||||
.replace(/-/g, "+").replace(/_/g, "/")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
address: address
|
||||||
address: payload.address,
|
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||||
jwt: curJwt
|
: t('savedMailbox', { index: index + 1 }),
|
||||||
|
jwt: token
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}).filter(Boolean)
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
address: `invalid jwt [${curJwt}]`,
|
|
||||||
jwt: curJwt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const bindAddress = async () => {
|
const bindAddress = () => {
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
if (!localAddressCache.value.includes(jwt.value)) {
|
|
||||||
// @ts-ignore
|
|
||||||
localAddressCache.value.push(jwt.value)
|
|
||||||
}
|
|
||||||
tabValue.value = 'address'
|
tabValue.value = 'address'
|
||||||
message.success(t('bindAddressSuccess'));
|
message.success(t('bindAddressSuccess'));
|
||||||
} catch (error) {
|
|
||||||
message.error((error as Error).message || "error");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -96,8 +72,8 @@ const columns = [
|
|||||||
if (jwt.value === row.jwt) {
|
if (jwt.value === row.jwt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localAddressCache.value = localAddressCache.value.filter(
|
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
|
||||||
(curJwt: string) => curJwt !== row.jwt
|
entry => entry.token !== row.jwt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
|||||||
|
|
||||||
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'
|
||||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||||
|
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
const { jwt } = useGlobalState()
|
const { jwt, openSettings, loading } = useGlobalState()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -29,6 +29,43 @@ const targetUserEmail = ref('')
|
|||||||
const showAddressCredential = ref(false)
|
const showAddressCredential = ref(false)
|
||||||
const currentAddressCredential = ref('')
|
const currentAddressCredential = ref('')
|
||||||
const credentialAddress = ref('')
|
const credentialAddress = ref('')
|
||||||
|
const passwordResetAddress = ref(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const isResettingPassword = ref(false)
|
||||||
|
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
|
||||||
|
|
||||||
|
const clearPasswordResetForm = () => {
|
||||||
|
passwordResetAddress.value = null;
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBoundAddressPassword = async () => {
|
||||||
|
if (!passwordResetAddress.value || isResettingPassword.value) return;
|
||||||
|
if (!newPassword.value) {
|
||||||
|
message.error(t('newPasswordRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
message.error(accountSettingsT('passwordMismatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isResettingPassword.value = true;
|
||||||
|
try {
|
||||||
|
await api.fetch(`/user_api/address/${passwordResetAddress.value.id}/reset_password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: await hashPassword(newPassword.value) }),
|
||||||
|
});
|
||||||
|
message.success(accountSettingsT('passwordChanged'));
|
||||||
|
clearPasswordResetForm();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error');
|
||||||
|
} finally {
|
||||||
|
isResettingPassword.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const showCredential = async (row) => {
|
const showCredential = async (row) => {
|
||||||
try {
|
try {
|
||||||
@@ -161,14 +198,26 @@ const columns = [
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h(NButton,
|
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
|
||||||
{
|
{
|
||||||
tertiary: true,
|
tertiary: true,
|
||||||
type: "primary",
|
type: "primary",
|
||||||
onClick: () => showCredential(row)
|
onClick: () => showCredential(row)
|
||||||
},
|
},
|
||||||
{ default: () => credentialT('addressCredential') }
|
{ default: () => credentialT('addressCredential') }
|
||||||
),
|
) : null,
|
||||||
|
openSettings.value.enableAddressPassword ? h(NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
type: 'warning',
|
||||||
|
onClick: () => {
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
passwordResetAddress.value = row;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => t('resetPassword') }
|
||||||
|
) : null,
|
||||||
h(NPopconfirm,
|
h(NPopconfirm,
|
||||||
{
|
{
|
||||||
onPositiveClick: () => changeMailAddress(row.id)
|
onPositiveClick: () => changeMailAddress(row.id)
|
||||||
@@ -208,7 +257,7 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{ default: () => t('unbindAddress') }
|
{ default: () => t('unbindAddress') }
|
||||||
),
|
),
|
||||||
default: () => t('unbindAddressTip')
|
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -227,6 +276,23 @@ watch([page, pageSize], async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<n-modal :show="!!passwordResetAddress" @update:show="show => { if (!show && !isResettingPassword) clearPasswordResetForm() }"
|
||||||
|
preset="dialog" :title="t('resetPassword')" :mask-closable="!isResettingPassword" :closable="!isResettingPassword">
|
||||||
|
<p>{{ passwordResetAddress?.name }}</p>
|
||||||
|
<p>{{ t('resetPasswordTip') }}</p>
|
||||||
|
<n-form @submit.prevent="resetBoundAddressPassword">
|
||||||
|
<n-form-item :label="accountSettingsT('newPassword')">
|
||||||
|
<n-input v-model:value="newPassword" type="password" show-password-on="click" :disabled="isResettingPassword" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="accountSettingsT('confirmPassword')">
|
||||||
|
<n-input v-model:value="confirmPassword" type="password" show-password-on="click"
|
||||||
|
:disabled="isResettingPassword" @keyup.enter="resetBoundAddressPassword" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #action>
|
||||||
|
<n-button type="warning" :loading="isResettingPassword" @click="resetBoundAddressPassword">{{ t('resetPassword') }}</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||||
:jwt="currentAddressCredential" />
|
:jwt="currentAddressCredential" />
|
||||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# Mail API
|
# Mail API
|
||||||
|
|
||||||
|
## Mailbox password login
|
||||||
|
|
||||||
|
`ADDRESS_PASSWORD_LOGIN_ONLY` defaults to `false` and only takes effect with `ENABLE_ADDRESS_PASSWORD=true`. It rejects legacy credentials for login and API access, and hides credential displays and automatic login links. Legacy credential links also fail API authentication. Existing mailboxes without passwords need a bound user or administrator to set one; no database migration is needed.
|
||||||
|
|
||||||
|
- Password login issues a 30-day JWT with `type: "address_password_login"`, `address`, `address_id`, `iat`, and `exp`. Mailbox APIs retain `Authorization: Bearer <jwt>` and use middleware for authentication.
|
||||||
|
- `GET /api/settings` returns this login information, `send_balance`, and `new_address_token`. A valid JWT with less than 7 days remaining receives a new 30-day token; otherwise the field is `null`. Expired JWTs require login again, and legacy credentials cannot obtain new tokens.
|
||||||
|
- When loading settings, the frontend validates the new token with another settings request before replacing the current token. Ordinary requests do not refresh tokens. External clients should also save `new_address_token`; the switch rejects legacy credentials used directly by SMTP/IMAP and Agent clients.
|
||||||
|
- The local cache uses server-returned mailbox information without decoding JWTs and retains both login methods independently. Historical token-only entries display “Saved mailbox” until selected and validated. Password-only login hides identified legacy entries without deleting them.
|
||||||
|
- Mailbox creation and authorized access through user accounts, administrators, and Telegram issue mailbox JWTs according to the switch. Telegram KV stores separate permanent `telegram_binding` tokens, which mailbox APIs reject. Expiration is ignored only for historical stored bindings accessed after Telegram identity verification; tokens submitted for new bindings must pass mailbox authentication.
|
||||||
|
|
||||||
|
### Reset a bound mailbox password
|
||||||
|
|
||||||
|
With `ENABLE_ADDRESS_PASSWORD` enabled, the user center offers password reset without the previous password:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /user_api/address/:address_id/reset_password
|
||||||
|
x-user-token: <user JWT>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"new_password":"<64-character lowercase SHA-256 hex digest of the new password>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
A single SQL statement checks that the user exists and owns the binding, updating only the existing password and update time. Success returns `{"success":true}`; missing authentication returns 401, an unbound mailbox or disabled feature returns 403, and invalid input returns 400. Resetting a password does not revoke existing JWTs; valid JWTs can still renew. No session table or revocation state is added.
|
||||||
|
|
||||||
## Viewing Emails via Mail API
|
## Viewing Emails via Mail API
|
||||||
|
|
||||||
This is a `python` example using the `requests` library to view emails.
|
This is a `python` example using the `requests` library to view emails.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted.
|
|||||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
||||||
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` |
|
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `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` |
|
| `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` |
|
||||||
|
| `ADDRESS_PASSWORD_LOGIN_ONLY` | Text/JSON | Default `false`; only effective with mailbox passwords enabled. Rejects legacy credentials for login and API access, using renewable mailbox login JWTs. See [mailbox password login](./feature/mail-api#mailbox-password-login). | `true` |
|
||||||
| `ENABLE_AGENT_EMAIL_INFO` | Text/JSON | Whether to show AI Agent access info in the frontend "Address Credentials & Connection Methods" dialog (Address JWT, parsed-mail APIs, skill link) | `true` |
|
| `ENABLE_AGENT_EMAIL_INFO` | Text/JSON | Whether to show AI Agent access info in the frontend "Address Credentials & Connection Methods" dialog (Address JWT, parsed-mail APIs, skill link) | `true` |
|
||||||
| `SMTP_IMAP_PROXY_CONFIG` | JSON | Show SMTP/IMAP proxy connection info in the frontend "Address Credentials & Connection Methods" dialog; display-only, does not start the proxy service, which must be deployed separately | See example below |
|
| `SMTP_IMAP_PROXY_CONFIG` | JSON | Show SMTP/IMAP proxy connection info in the frontend "Address Credentials & Connection Methods" dialog; display-only, does not start the proxy service, which must be deployed separately | See example below |
|
||||||
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
|
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# 查看邮件 API
|
# 查看邮件 API
|
||||||
|
|
||||||
|
## 邮箱密码登录
|
||||||
|
|
||||||
|
`ADDRESS_PASSWORD_LOGIN_ONLY` 默认 `false`,仅在 `ENABLE_ADDRESS_PASSWORD=true` 时生效。启用后,后端拒绝旧凭据登录及 API 访问,前端隐藏凭据和自动登录链接;旧凭据登录链接也无法通过 API 鉴权。历史无密码邮箱需由绑定用户或管理员设置密码,无需数据库迁移。
|
||||||
|
|
||||||
|
- 密码登录返回 `type: "address_password_login"`、`address`、`address_id`、`iat`、`exp` 的 JWT,有效期 30 天。邮箱 API 仍使用 `Authorization: Bearer <jwt>`,由中间件统一鉴权。
|
||||||
|
- `GET /api/settings` 返回上述登录信息、`send_balance` 和 `new_address_token`;有效 JWT 剩余不足 7 天时返回新签发的 30 天 token,否则为 `null`。已过期 JWT 必须重新登录,旧凭据不能换取新 token。
|
||||||
|
- 网页加载设置时使用新 token 再次请求 `settings`,验证成功后替换当前 token,普通请求不额外刷新。外部客户端也应保存 `new_address_token`;SMTP/IMAP、Agent 使用旧凭据直接调用 API 同样受开关限制。
|
||||||
|
- 本地缓存使用后端返回的邮箱信息,不解码 JWT;两种登录方式独立保留。历史 token 缓存先显示“已保存邮箱”,选中并验证后补全名称。仅密码登录时隐藏已识别的旧凭据入口,保留缓存。
|
||||||
|
- 创建邮箱及从用户中心、管理员、Telegram 打开有权访问的邮箱时,按开关签发邮箱 JWT。Telegram KV 单独保存永久的 `telegram_binding` token,邮箱 API 拒绝该类型;仅对已验证 Telegram 身份后读取的历史绑定忽略过期时间,新绑定提交的 token 仍须通过邮箱鉴权。
|
||||||
|
|
||||||
|
### 重置绑定邮箱密码
|
||||||
|
|
||||||
|
启用 `ENABLE_ADDRESS_PASSWORD` 后,用户中心提供“重置密码”,不需要原密码:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /user_api/address/:address_id/reset_password
|
||||||
|
x-user-token: <用户JWT>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"new_password":"<新密码的64位小写SHA-256十六进制值>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端在同一条 SQL 中检查用户存在及绑定关系,仅更新现有密码和更新时间。成功返回 `{"success":true}`;未登录返回 401,未绑定或功能关闭返回 403,输入错误返回 400。密码重置不撤销已有 JWT,有效 JWT 仍可续期;不新增会话表或撤销状态。
|
||||||
|
|
||||||
## 通过 邮件 API 查看邮件
|
## 通过 邮件 API 查看邮件
|
||||||
|
|
||||||
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
|
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
||||||
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
|
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
|
||||||
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
|
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
|
||||||
|
| `ADDRESS_PASSWORD_LOGIN_ONLY` | 文本/JSON | 默认 `false`,仅在启用邮箱密码时生效;禁用旧凭据登录及 API 访问,使用可自动续期的邮箱登录 JWT。见[邮箱密码登录](./feature/mail-api#邮箱密码登录) | `true` |
|
||||||
| `ENABLE_AGENT_EMAIL_INFO` | 文本/JSON | 是否在前端“地址凭证与连接方式”弹窗中展示 AI Agent 接入信息(Address JWT、parsed-mail API、skill 链接) | `true` |
|
| `ENABLE_AGENT_EMAIL_INFO` | 文本/JSON | 是否在前端“地址凭证与连接方式”弹窗中展示 AI Agent 接入信息(Address JWT、parsed-mail API、skill 链接) | `true` |
|
||||||
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
|
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
|
||||||
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
|
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
|
||||||
|
|||||||
@@ -3,11 +3,34 @@ import { jwt } from 'hono/jwt';
|
|||||||
import { Jwt } from 'hono/utils/jwt';
|
import { Jwt } from 'hono/utils/jwt';
|
||||||
|
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
import { isAddressPasswordLoginOnly } from './utils';
|
||||||
|
|
||||||
export const validateAddressPayload = async (
|
const ADDRESS_PASSWORD_LOGIN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
export const ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export const createAddressPasswordLoginToken = (
|
||||||
|
c: Context<HonoCustomType>, address: string, addressId: number,
|
||||||
|
) => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: AddressPasswordLoginPayload = {
|
||||||
|
address, address_id: addressId, type: 'address_password_login',
|
||||||
|
iat: now, exp: now + ADDRESS_PASSWORD_LOGIN_TTL_SECONDS,
|
||||||
|
};
|
||||||
|
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAddressToken = (
|
||||||
|
c: Context<HonoCustomType>, address: string, addressId: number,
|
||||||
|
) => {
|
||||||
|
if (isAddressPasswordLoginOnly(c)) return createAddressPasswordLoginToken(c, address, addressId);
|
||||||
|
const payload: AddressCredentialPayload = { address, address_id: addressId };
|
||||||
|
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateAddressIdentity = async (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): Promise<JwtPayload | null> => {
|
): Promise<AddressCredentialPayload | null> => {
|
||||||
const { address, address_id } = payload;
|
const { address, address_id } = payload;
|
||||||
if (typeof address !== 'string' || !address) return null;
|
if (typeof address !== 'string' || !address) return null;
|
||||||
if (typeof address_id !== 'number'
|
if (typeof address_id !== 'number'
|
||||||
@@ -21,6 +44,24 @@ export const validateAddressPayload = async (
|
|||||||
return exists ? { address, address_id: addressId } : null;
|
return exists ? { address, address_id: addressId } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const validateAddressPayload = async (
|
||||||
|
c: Context<HonoCustomType>,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<JwtPayload | null> => {
|
||||||
|
if (payload.type !== undefined && payload.type !== 'address_password_login') return null;
|
||||||
|
if (isAddressPasswordLoginOnly(c) && payload.type !== 'address_password_login') return null;
|
||||||
|
const identity = await validateAddressIdentity(c, payload);
|
||||||
|
if (!identity) return null;
|
||||||
|
if (payload.type !== 'address_password_login') return identity;
|
||||||
|
const { iat, exp } = payload;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (typeof iat !== 'number' || !Number.isSafeInteger(iat) || iat > now
|
||||||
|
|| typeof exp !== 'number' || !Number.isSafeInteger(exp) || exp <= now
|
||||||
|
|| exp <= iat || exp - iat > ADDRESS_PASSWORD_LOGIN_TTL_SECONDS
|
||||||
|
) return null;
|
||||||
|
return { ...identity, type: 'address_password_login', iat, exp };
|
||||||
|
};
|
||||||
|
|
||||||
export const verifyAddressToken = async (
|
export const verifyAddressToken = async (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
token: string,
|
token: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Context } from 'hono'
|
import { Context } from 'hono'
|
||||||
import { Jwt } from 'hono/utils/jwt'
|
import { createAddressToken } from '../address_auth';
|
||||||
|
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { getBooleanValue } from '../utils'
|
import { getBooleanValue } from '../utils'
|
||||||
@@ -134,11 +134,9 @@ const showPassword = async (c: Context<HonoCustomType>) => {
|
|||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const name = await c.env.DB.prepare(
|
const name = await c.env.DB.prepare(
|
||||||
`SELECT name FROM address WHERE id = ? `
|
`SELECT name FROM address WHERE id = ? `
|
||||||
).bind(id).first("name");
|
).bind(id).first<string>("name");
|
||||||
const jwt = await Jwt.sign({
|
if (!name) return c.text(i18n.getMessagesbyContext(c).AddressNotFoundMsg, 404);
|
||||||
address: name,
|
const jwt = await createAddressToken(c, name, Number(id));
|
||||||
address_id: id
|
|
||||||
}, c.env.JWT_SECRET, "HS256")
|
|
||||||
return c.json({ jwt });
|
return c.json({ jwt });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import utils from './utils';
|
import utils, { isAddressPasswordLoginOnly } from './utils';
|
||||||
import { CONSTANTS } from './constants';
|
import { CONSTANTS } from './constants';
|
||||||
import { isS3Enabled } from './mails_api/s3_attachment';
|
import { isS3Enabled } from './mails_api/s3_attachment';
|
||||||
import { isAnySendMailEnabled } from './common';
|
import { isAnySendMailEnabled } from './common';
|
||||||
@@ -22,6 +22,7 @@ api.get('/open_api/settings', async (c) => {
|
|||||||
const imapProxyConfig = smtpImapProxyConfig.imap || {};
|
const imapProxyConfig = smtpImapProxyConfig.imap || {};
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
"addressPasswordLoginOnly": isAddressPasswordLoginOnly(c),
|
||||||
"title": c.env.TITLE,
|
"title": c.env.TITLE,
|
||||||
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
|
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
|
||||||
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
|
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { Jwt } from 'hono/utils/jwt'
|
import { createAddressToken } from './address_auth';
|
||||||
import { WorkerMailerOptions } from 'worker-mailer';
|
import { WorkerMailerOptions } from 'worker-mailer';
|
||||||
|
|
||||||
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils';
|
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils';
|
||||||
@@ -452,10 +452,7 @@ export const newAddress = async (
|
|||||||
const generatedPassword = await generatePasswordForAddress(c, address);
|
const generatedPassword = await generatePasswordForAddress(c, address);
|
||||||
|
|
||||||
// create jwt
|
// create jwt
|
||||||
const jwt = await Jwt.sign({
|
const jwt = await createAddressToken(c, address, address_id);
|
||||||
address: address,
|
|
||||||
address_id: address_id
|
|
||||||
}, c.env.JWT_SECRET, "HS256")
|
|
||||||
return {
|
return {
|
||||||
jwt: jwt,
|
jwt: jwt,
|
||||||
address: address,
|
address: address,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
|
|||||||
NewPasswordRequiredMsg: "New password is required",
|
NewPasswordRequiredMsg: "New password is required",
|
||||||
InvalidAddressTokenMsg: "Invalid address token",
|
InvalidAddressTokenMsg: "Invalid address token",
|
||||||
FailedUpdatePasswordMsg: "Failed to update password",
|
FailedUpdatePasswordMsg: "Failed to update password",
|
||||||
|
CredentialLoginDisabledMsg: "Mailbox password login is required; credential login is disabled",
|
||||||
PasswordLoginDisabledMsg: "Password login is disabled",
|
PasswordLoginDisabledMsg: "Password login is disabled",
|
||||||
EmailPasswordRequiredMsg: "Email and password are required",
|
EmailPasswordRequiredMsg: "Email and password are required",
|
||||||
AddressNotFoundMsg: "Address not found",
|
AddressNotFoundMsg: "Address not found",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type LocaleMessages = {
|
|||||||
NewPasswordRequiredMsg: string
|
NewPasswordRequiredMsg: string
|
||||||
InvalidAddressTokenMsg: string
|
InvalidAddressTokenMsg: string
|
||||||
FailedUpdatePasswordMsg: string
|
FailedUpdatePasswordMsg: string
|
||||||
|
CredentialLoginDisabledMsg: string
|
||||||
PasswordLoginDisabledMsg: string
|
PasswordLoginDisabledMsg: string
|
||||||
EmailPasswordRequiredMsg: string
|
EmailPasswordRequiredMsg: string
|
||||||
AddressNotFoundMsg: string
|
AddressNotFoundMsg: string
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
|
|||||||
NewPasswordRequiredMsg: "新密码不能为空",
|
NewPasswordRequiredMsg: "新密码不能为空",
|
||||||
InvalidAddressTokenMsg: "无效的地址令牌",
|
InvalidAddressTokenMsg: "无效的地址令牌",
|
||||||
FailedUpdatePasswordMsg: "更新密码失败",
|
FailedUpdatePasswordMsg: "更新密码失败",
|
||||||
|
CredentialLoginDisabledMsg: "仅允许邮箱密码登录,凭据登录已禁用",
|
||||||
PasswordLoginDisabledMsg: "密码登录已禁用",
|
PasswordLoginDisabledMsg: "密码登录已禁用",
|
||||||
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
|
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
|
||||||
AddressNotFoundMsg: "邮箱地址不存在",
|
AddressNotFoundMsg: "邮箱地址不存在",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
|
import utils, { getBooleanValue, checkCfTurnstile } from '../utils';
|
||||||
import { Jwt } from 'hono/utils/jwt';
|
import { createAddressPasswordLoginToken } from '../address_auth';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// 修改地址密码
|
// 修改地址密码
|
||||||
@@ -61,7 +61,7 @@ export default {
|
|||||||
// 查找地址
|
// 查找地址
|
||||||
const address = await c.env.DB.prepare(
|
const address = await c.env.DB.prepare(
|
||||||
`SELECT * FROM address WHERE name = ?`
|
`SELECT * FROM address WHERE name = ?`
|
||||||
).bind(email).first();
|
).bind(email).first<{ id: number; name: string; password: string | null }>();
|
||||||
|
|
||||||
if (!address) {
|
if (!address) {
|
||||||
return c.text(msgs.AddressNotFoundMsg, 404);
|
return c.text(msgs.AddressNotFoundMsg, 404);
|
||||||
@@ -73,10 +73,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建JWT
|
// 创建JWT
|
||||||
const jwt = await Jwt.sign({
|
const jwt = await createAddressPasswordLoginToken(c, address.name, address.id);
|
||||||
address: address.name,
|
|
||||||
address_id: address.id
|
|
||||||
}, c.env.JWT_SECRET, "HS256");
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
jwt: jwt,
|
jwt: jwt,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getBooleanValue } from '../utils';
|
|||||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||||
import { resolveRawEmailRow } from '../gzip'
|
import { resolveRawEmailRow } from '../gzip'
|
||||||
import { getSendBalanceState } from './send_balance';
|
import { getSendBalanceState } from './send_balance';
|
||||||
|
import { createAddressPasswordLoginToken, ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS } from '../address_auth';
|
||||||
|
|
||||||
const listMails = async (c: Context<HonoCustomType>) => {
|
const listMails = async (c: Context<HonoCustomType>) => {
|
||||||
const { address } = c.get("jwtPayload")
|
const { address } = c.get("jwtPayload")
|
||||||
@@ -62,14 +63,19 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||||
const { address } = c.get("jwtPayload")
|
const payload = c.get("jwtPayload");
|
||||||
|
const { address } = payload;
|
||||||
|
const renewedAddressToken = payload.type === 'address_password_login'
|
||||||
|
&& payload.exp < Math.floor(Date.now() / 1000) + ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS
|
||||||
|
? await createAddressPasswordLoginToken(c, address, payload.address_id) : null;
|
||||||
|
|
||||||
updateAddressUpdatedAt(c, address);
|
updateAddressUpdatedAt(c, address);
|
||||||
|
|
||||||
const { balance } = await getSendBalanceState(c, address);
|
const { balance } = await getSendBalanceState(c, address);
|
||||||
return c.json({
|
return c.json({
|
||||||
address: address,
|
...payload,
|
||||||
send_balance: balance || 0,
|
send_balance: balance || 0,
|
||||||
|
new_address_token: renewedAddressToken,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { verifyAddressToken } from '../address_auth';
|
import { verifyAddressToken } from '../address_auth';
|
||||||
|
|
||||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword, isAddressPasswordLoginOnly } from '../utils';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { ErrorCode } from '../error_codes';
|
import { ErrorCode } from '../error_codes';
|
||||||
|
|
||||||
@@ -44,8 +44,9 @@ api.post('/open_api/admin_login', async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.post('/open_api/credential_login', async (c) => {
|
api.post('/open_api/credential_login', async (c) => {
|
||||||
const { credential, cf_token } = await c.req.json();
|
|
||||||
const msgs = i18n.getMessagesbyContext(c);
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
|
if (isAddressPasswordLoginOnly(c)) return c.text(msgs.CredentialLoginDisabledMsg, 403);
|
||||||
|
const { credential, cf_token } = await c.req.json();
|
||||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||||
try {
|
try {
|
||||||
await checkCfTurnstile(c, cf_token);
|
await checkCfTurnstile(c, cf_token);
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Jwt } from "hono/utils/jwt";
|
import { Jwt } from "hono/utils/jwt";
|
||||||
import { validateAddressPayload, verifyAddressToken } from '../address_auth';
|
import { validateAddressIdentity, verifyAddressToken } from '../address_auth';
|
||||||
import { CONSTANTS } from "../constants";
|
import { CONSTANTS } from "../constants";
|
||||||
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
|
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
|
||||||
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
|
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
|
||||||
import { LocaleMessages } from "../i18n/type";
|
import { LocaleMessages } from "../i18n/type";
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
const createTelegramBindingToken = (c: Context<HonoCustomType>, address: string, addressId: number) =>
|
||||||
|
Jwt.sign({ type: 'telegram_binding', address, address_id: addressId }, c.env.JWT_SECRET, 'HS256');
|
||||||
|
|
||||||
|
// Only for tokens read from the authenticated Telegram user's stored bindings.
|
||||||
|
export const verifyTelegramBindingToken = async (c: Context<HonoCustomType>, token: string) => {
|
||||||
|
const payload = await Jwt.verify(token, c.env.JWT_SECRET, { alg: 'HS256', exp: false });
|
||||||
|
if (payload.type !== undefined && payload.type !== 'telegram_binding'
|
||||||
|
&& payload.type !== 'address_password_login') {
|
||||||
|
throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
|
||||||
|
}
|
||||||
|
const identity = await validateAddressIdentity(c, payload);
|
||||||
|
if (!identity) throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
|
||||||
|
return identity;
|
||||||
|
};
|
||||||
|
|
||||||
export const tgUserNewAddress = async (
|
export const tgUserNewAddress = async (
|
||||||
c: Context<HonoCustomType>, userId: string, address: string,
|
c: Context<HonoCustomType>, userId: string, address: string,
|
||||||
msgs: LocaleMessages,
|
msgs: LocaleMessages,
|
||||||
@@ -48,7 +63,8 @@ export const tgUserNewAddress = async (
|
|||||||
sourceMeta: `tg:${userId}`
|
sourceMeta: `tg:${userId}`
|
||||||
});
|
});
|
||||||
// for mail push to telegram
|
// for mail push to telegram
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
|
const bindingToken = await createTelegramBindingToken(c, res.address, res.address_id);
|
||||||
|
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
|
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -65,7 +81,7 @@ export const jwtListToAddressData = async (
|
|||||||
const invalidJwtList = [] as string[];
|
const invalidJwtList = [] as string[];
|
||||||
for (const jwt of jwtList) {
|
for (const jwt of jwtList) {
|
||||||
try {
|
try {
|
||||||
const { address, address_id } = await verifyAddressToken(c, jwt);
|
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
|
||||||
addressList.push(address as string);
|
addressList.push(address as string);
|
||||||
addressIdMap[address as string] = address_id as number;
|
addressIdMap[address as string] = address_id as number;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -81,7 +97,7 @@ export const bindTelegramAddress = async (
|
|||||||
c: Context<HonoCustomType>, userId: string, jwt: string,
|
c: Context<HonoCustomType>, userId: string, jwt: string,
|
||||||
msgs: LocaleMessages
|
msgs: LocaleMessages
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const { address } = await verifyAddressToken(c, jwt);
|
const { address, address_id } = await verifyAddressToken(c, jwt);
|
||||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||||
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
|
||||||
if (address as string in addressIdMap) {
|
if (address as string in addressIdMap) {
|
||||||
@@ -91,7 +107,8 @@ export const bindTelegramAddress = async (
|
|||||||
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
|
||||||
throw Error(msgs.TgMaxAddressReachedCleanMsg);
|
throw Error(msgs.TgMaxAddressReachedCleanMsg);
|
||||||
}
|
}
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
|
const bindingToken = await createTelegramBindingToken(c, address, address_id);
|
||||||
|
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
|
||||||
// for mail push to telegram
|
// for mail push to telegram
|
||||||
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
|
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
|
||||||
return address as string;
|
return address as string;
|
||||||
@@ -101,7 +118,7 @@ const getTelegramBindings = async (c: Context<HonoCustomType>, userId: string) =
|
|||||||
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
|
||||||
return Promise.all(jwtList.map(async (jwt) => {
|
return Promise.all(jwtList.map(async (jwt) => {
|
||||||
try {
|
try {
|
||||||
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256") };
|
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, { alg: 'HS256', exp: false }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`解绑失败: ${(e as Error).message}`);
|
console.log(`解绑失败: ${(e as Error).message}`);
|
||||||
return { jwt, payload: null };
|
return { jwt, payload: null };
|
||||||
@@ -125,10 +142,10 @@ export const unbindTelegramAddress = async (
|
|||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const msgs = i18n.getMessagesbyContext(c);
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
const bindings = await getTelegramBindings(c, userId);
|
const bindings = await getTelegramBindings(c, userId);
|
||||||
for (const { payload } of bindings) {
|
for (const { jwt, payload } of bindings) {
|
||||||
if (payload?.address !== address) continue;
|
if (payload?.address !== address) continue;
|
||||||
try {
|
try {
|
||||||
if (!await validateAddressPayload(c, payload)) continue;
|
await verifyTelegramBindingToken(c, jwt);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
|
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { verifyAddressToken } from '../address_auth';
|
import { createAddressToken } from '../address_auth';
|
||||||
import { CONSTANTS } from "../constants";
|
import { CONSTANTS } from "../constants";
|
||||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, verifyTelegramBindingToken } from "./common";
|
||||||
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
||||||
import { resolveRawEmailRow } from "../gzip";
|
import { resolveRawEmailRow } from "../gzip";
|
||||||
import { TelegramSettings } from "./settings";
|
import { TelegramSettings } from "./settings";
|
||||||
@@ -69,8 +69,8 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
|
|||||||
const res = [];
|
const res = [];
|
||||||
for (const jwt of jwtList) {
|
for (const jwt of jwtList) {
|
||||||
try {
|
try {
|
||||||
const { address } = await verifyAddressToken(c, jwt);
|
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
|
||||||
res.push({ address, jwt });
|
res.push({ address, jwt: await createAddressToken(c, address, address_id) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`failed to verify jwt with error: ${e}`)
|
console.error(`failed to verify jwt with error: ${e}`)
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Vendored
+13
-1
@@ -59,6 +59,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
|
||||||
|
ADDRESS_PASSWORD_LOGIN_ONLY: string | boolean | undefined
|
||||||
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
|
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
|
||||||
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
|
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
|
||||||
ENABLE_REDEEM_CODE: string | boolean | undefined
|
ENABLE_REDEEM_CODE: string | boolean | undefined
|
||||||
@@ -125,11 +126,22 @@ type Bindings = {
|
|||||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type JwtPayload = {
|
type AddressCredentialPayload = {
|
||||||
address: string
|
address: string
|
||||||
address_id: number
|
address_id: number
|
||||||
|
type?: never
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AddressPasswordLoginPayload = {
|
||||||
|
address: string
|
||||||
|
address_id: number
|
||||||
|
type: 'address_password_login'
|
||||||
|
iat: number
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type JwtPayload = AddressCredentialPayload | AddressPasswordLoginPayload
|
||||||
|
|
||||||
type UserPayload = {
|
type UserPayload = {
|
||||||
user_email: string
|
user_email: string
|
||||||
user_id: number
|
user_id: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { Jwt } from 'hono/utils/jwt'
|
import { createAddressToken } from '../address_auth';
|
||||||
|
|
||||||
import { isAddressCountLimitReached } from "../utils"
|
import { getBooleanValue, isAddressCountLimitReached } from "../utils"
|
||||||
import { unbindTelegramByAddress } from '../telegram_api/common';
|
import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
||||||
@@ -23,6 +23,31 @@ export const getBindedAddressById = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UserBindAddressModule = {
|
const UserBindAddressModule = {
|
||||||
|
resetPassword: async (c: Context<HonoCustomType>) => {
|
||||||
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
|
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||||
|
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||||
|
}
|
||||||
|
const addressId = Number(c.req.param('address_id'));
|
||||||
|
const userId = c.get('userPayload')?.user_id;
|
||||||
|
if (!Number.isSafeInteger(addressId) || addressId <= 0 || !userId) {
|
||||||
|
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400);
|
||||||
|
}
|
||||||
|
const body = await c.req.json<{ new_password?: unknown }>().catch(() => null);
|
||||||
|
if (typeof body?.new_password !== 'string' || !/^[a-f0-9]{64}$/.test(body.new_password)) {
|
||||||
|
return c.text(msgs.InvalidInputMsg, 400);
|
||||||
|
}
|
||||||
|
const result = await c.env.DB.prepare(
|
||||||
|
`UPDATE address SET password = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND EXISTS (
|
||||||
|
SELECT 1 FROM users_address ua JOIN users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.address_id = address.id AND ua.user_id = ?
|
||||||
|
)`
|
||||||
|
).bind(body.new_password, addressId, userId).run();
|
||||||
|
if (!result.success) return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||||
|
if (result.meta.changes !== 1) return c.text(msgs.AddressNotBindedMsg, 403);
|
||||||
|
return c.json({ success: true });
|
||||||
|
},
|
||||||
bind: async (c: Context<HonoCustomType>) => {
|
bind: async (c: Context<HonoCustomType>) => {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
const { address_id } = c.get("jwtPayload");
|
const { address_id } = c.get("jwtPayload");
|
||||||
@@ -178,10 +203,7 @@ const UserBindAddressModule = {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
return c.text(msgs.AddressNotBindedMsg, 400)
|
return c.text(msgs.AddressNotBindedMsg, 400)
|
||||||
}
|
}
|
||||||
const jwt = await Jwt.sign({
|
const jwt = await createAddressToken(c, name, Number(address_id));
|
||||||
address: name,
|
|
||||||
address_id: address_id
|
|
||||||
}, c.env.JWT_SECRET, "HS256")
|
|
||||||
return c.json({
|
return c.json({
|
||||||
jwt: jwt
|
jwt: jwt
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
|
|||||||
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
||||||
api.post('/user_api/bind_address', bind_address.bind);
|
api.post('/user_api/bind_address', bind_address.bind);
|
||||||
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
|
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
|
||||||
|
api.post('/user_api/address/:address_id/reset_password', bind_address.resetPassword);
|
||||||
api.post('/user_api/unbind_address', bind_address.unbind);
|
api.post('/user_api/unbind_address', bind_address.unbind);
|
||||||
api.post('/user_api/transfer_address', bind_address.transferAddress);
|
api.post('/user_api/transfer_address', bind_address.transferAddress);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { Context } from "hono";
|
|||||||
import { UserSettings, RoleAddressConfig } from "./models";
|
import { UserSettings, RoleAddressConfig } from "./models";
|
||||||
import { CONSTANTS } from "./constants";
|
import { CONSTANTS } from "./constants";
|
||||||
|
|
||||||
|
export const isAddressPasswordLoginOnly = (c: Context<HonoCustomType>): boolean =>
|
||||||
|
getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)
|
||||||
|
&& getBooleanValue(c.env.ADDRESS_PASSWORD_LOGIN_ONLY);
|
||||||
|
|
||||||
export const getJsonObjectValue = <T = any>(
|
export const getJsonObjectValue = <T = any>(
|
||||||
value: string | any
|
value: string | any
|
||||||
): T | null => {
|
): T | null => {
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ ENABLE_AUTO_REPLY = false
|
|||||||
# 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 feature, if set true, will generate password for new address and support password login and change
|
||||||
# ENABLE_ADDRESS_PASSWORD = false
|
# ENABLE_ADDRESS_PASSWORD = false
|
||||||
|
# Only allow password login to mailboxes (requires ENABLE_ADDRESS_PASSWORD).
|
||||||
|
# Rejects legacy mailbox credentials, including direct API access.
|
||||||
|
# ADDRESS_PASSWORD_LOGIN_ONLY = false
|
||||||
# Show AI Agent mailbox connection info in the address credential modal
|
# Show AI Agent mailbox connection info in the address credential modal
|
||||||
# ENABLE_AGENT_EMAIL_INFO = true
|
# ENABLE_AGENT_EMAIL_INFO = true
|
||||||
# Show SMTP/IMAP client connection info in the address credential modal
|
# Show SMTP/IMAP client connection info in the address credential modal
|
||||||
|
|||||||
Reference in New Issue
Block a user