Compare commits

...
36 changed files with 489 additions and 136 deletions
+2
View File
@@ -10,6 +10,7 @@
### Features
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
@@ -21,6 +22,7 @@
### Bug Fixes
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
+2
View File
@@ -10,6 +10,7 @@
### 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: |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
@@ -21,6 +22,7 @@
### 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: |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
+52
View File
@@ -11,6 +11,58 @@ function signToken(payload: Record<string, unknown>) {
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) {
for (const [method, path] of [
['GET', '/api/settings'],
+44
View File
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
headers: { Authorization: `Bearer ${loginBody.jwt}` },
});
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 {
await deleteAddress(request, jwt);
}
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
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
View File
@@ -1,5 +1,6 @@
import { useGlobalState } from '../store'
import { h } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import axios from 'axios'
import i18n from '../i18n'
@@ -9,6 +10,7 @@ import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
import { ErrorCode } from './error-codes'
import { updateLocalAddressCache } from '../utils/local-address-cache'
const API_BASE = APP_CONFIG.API_BASE || "";
const {
@@ -17,6 +19,8 @@ const {
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const localAddressCache = useLocalStorage('LocalAddressCache', []);
const instance = axios.create({
baseURL: API_BASE,
timeout: 30000,
@@ -55,7 +59,7 @@ const apiFetch = async (path, options = {}) => {
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
const adminAuthHeader = safeHeaderValue(adminAuth.value);
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
const authorizationHeader = safeBearerHeader(jwt.value);
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const initialResponse = await instance.request(path, {
@@ -122,6 +126,7 @@ const getOpenSettings = async (message, notification) => {
isS3Enabled: res["isS3Enabled"] || false,
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
@@ -154,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
}
const getSettings = async () => {
let addressToken = jwt.value;
try {
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
return "";
if (!safeHeaderValue(addressToken)) 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 {
settings.value.fetched = true;
if (jwt.value === addressToken) settings.value.fetched = true;
}
}
@@ -98,10 +98,10 @@ const copyText = async (text) => {
<template>
<div class="credential-content">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
</n-alert>
<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 v-if="address" class="credential-field">
<span class="credential-label">{{ t('currentAddress') }}</span>
@@ -112,7 +112,7 @@ const copyText = async (text) => {
</n-button>
</div>
</div>
<div class="credential-field">
<div v-if="!openSettings.addressPasswordLoginOnly" class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
@@ -128,7 +128,7 @@ const copyText = async (text) => {
</div>
</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')">
<template #header-extra>
<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 AddressCredentialContent from './AddressCredentialContent.vue'
import { useGlobalState } from '../store'
const { openSettings } = useGlobalState()
const props = defineProps({
show: {
@@ -34,7 +37,7 @@ const modalShow = computed({
</script>
<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));">
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
</n-modal>
+14 -27
View File
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getCachedAddresses } from '../utils/local-address-cache'
const props = defineProps({
showCopy: {
@@ -28,6 +29,8 @@ const {
} = useGlobalState()
const { t } = useScopedI18n('components.AddressSelect')
const { t: loginT } = useScopedI18n('views.common.Login')
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
const addressOptions = ref([])
const addressValue = ref(null)
@@ -45,21 +48,6 @@ const formatAddressLabel = (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)
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
}
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;
const children = getCachedAddresses(localAddressCache.value)
.map(({ token, address, type }, index) => {
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) {
const isPasswordLogin = type === 'address_password_login';
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
const label = 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;
}
return option;
@@ -207,7 +194,7 @@ onMounted(async () => {
await refreshAddressOptions();
});
watch([userJwt, isTelegram, () => settings.value.address], async () => {
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
await refreshAddressOptions();
});
</script>
+24
View File
@@ -302,6 +302,10 @@ export const MESSAGE_REGISTRY = {
}
},
"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": {
"en": "Address Credential",
"zh": "地址凭证"
@@ -1040,6 +1044,22 @@ export const MESSAGE_REGISTRY = {
}
},
"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": {
"en": "Actions",
"zh": "操作"
@@ -1928,6 +1948,10 @@ export const MESSAGE_REGISTRY = {
}
},
"views.index.LocalAddress": {
"savedMailbox": {
"en": "Saved mailbox {index}",
"zh": "已保存邮箱 {index}"
},
"actions": {
"en": "Actions",
"zh": "操作"
+1
View File
@@ -42,6 +42,7 @@ export const useGlobalState = createGlobalState(
showGithubForUser: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
addressPasswordLoginOnly: false,
enableAgentEmailInfo: false,
enableRedeemCode: false,
redeemCodeUrl: '',
+26
View File
@@ -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];
});
};
+4 -1
View File
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
}
const login = async () => {
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
if (loginMethod.value === 'password') {
// Password login
if (!loginAddress.value || !loginPassword.value) {
@@ -246,6 +247,8 @@ const showNewAddressTab = computed(() => {
return openSettings.value.enableUserCreateEmail;
});
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings(message, notification);
@@ -283,7 +286,7 @@ onMounted(async () => {
v-model:value="loginCfToken" />
<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'"
type="info" quaternary size="tiny">
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
+1 -1
View File
@@ -93,7 +93,7 @@ const changePassword = async () => {
<template>
<div class="center" v-if="settings.address">
<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') }}
</n-button>
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
+16 -40
View File
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useScopedI18n } from '@/i18n/app'
import { NPopconfirm, NButton } from 'naive-ui'
import { getCachedAddresses } from '../../utils/local-address-cache'
import type { CachedAddress } from '../../utils/local-address-cache'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
const { jwt, openSettings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useScopedI18n('views.index.LocalAddress')
const { t: loginT } = useScopedI18n('views.common.Login')
const tabValue = ref('address')
const localAddressCache = useLocalStorage("LocalAddressCache", []);
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
const data = computed(() => {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
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 getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
const isPasswordLogin = type === 'address_password_login';
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
return {
valid: true,
address: payload.address,
jwt: curJwt
address: address
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
: t('savedMailbox', { index: index + 1 }),
jwt: token
}
} catch (e) {
return {
valid: false,
address: `invalid jwt [${curJwt}]`,
jwt: curJwt
}
}
})
}).filter(Boolean)
})
const bindAddress = async () => {
try {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
const bindAddress = () => {
tabValue.value = 'address'
message.success(t('bindAddressSuccess'));
} catch (error) {
message.error((error as Error).message || "error");
}
}
const columns = [
@@ -96,8 +72,8 @@ const columns = [
if (jwt.value === row.jwt) {
return;
}
localAddressCache.value = localAddressCache.value.filter(
(curJwt: string) => curJwt !== row.jwt
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
entry => entry.token !== row.jwt
);
}
},
+71 -5
View File
@@ -6,12 +6,12 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import { getRouterPathWithLang, hashPassword } from '../../utils'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
const { jwt, openSettings, loading } = useGlobalState()
const message = useMessage()
const router = useRouter()
@@ -29,6 +29,43 @@ const targetUserEmail = ref('')
const showAddressCredential = ref(false)
const currentAddressCredential = 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) => {
try {
@@ -161,14 +198,26 @@ const columns = [
key: 'actions',
render(row) {
return h('div', [
h(NButton,
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => showCredential(row)
},
{ 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,
{
onPositiveClick: () => changeMailAddress(row.id)
@@ -208,7 +257,7 @@ const columns = [
},
{ default: () => t('unbindAddress') }
),
default: () => t('unbindAddressTip')
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
}
),
])
@@ -227,6 +276,23 @@ watch([page, pageSize], async () => {
<template>
<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"
:jwt="currentAddressCredential" />
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
@@ -1,5 +1,29 @@
# 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
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` |
| `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` |
| `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` |
| `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"]` |
@@ -1,5 +1,29 @@
# 查看邮件 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":"<64SHA-256>"}
```
后端在同一条 SQL 中检查用户存在及绑定关系,仅更新现有密码和更新时间。成功返回 `{"success":true}`;未登录返回 401,未绑定或功能关闭返回 403,输入错误返回 400。密码重置不撤销已有 JWT,有效 JWT 仍可续期;不新增会话表或撤销状态。
## 通过 邮件 API 查看邮件
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
@@ -50,6 +50,7 @@
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
| `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` |
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
+43 -2
View File
@@ -3,11 +3,34 @@ import { jwt } from 'hono/jwt';
import { Jwt } from 'hono/utils/jwt';
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>,
payload: Record<string, unknown>,
): Promise<JwtPayload | null> => {
): Promise<AddressCredentialPayload | null> => {
const { address, address_id } = payload;
if (typeof address !== 'string' || !address) return null;
if (typeof address_id !== 'number'
@@ -21,6 +44,24 @@ export const validateAddressPayload = async (
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 (
c: Context<HonoCustomType>,
token: string,
+4 -6
View File
@@ -1,5 +1,5 @@
import { Context } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { createAddressToken } from '../address_auth';
import i18n from '../i18n'
import { getBooleanValue } from '../utils'
@@ -134,11 +134,9 @@ const showPassword = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: id
}, c.env.JWT_SECRET, "HS256")
).bind(id).first<string>("name");
if (!name) return c.text(i18n.getMessagesbyContext(c).AddressNotFoundMsg, 404);
const jwt = await createAddressToken(c, name, Number(id));
return c.json({ jwt });
};
+2 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import utils from './utils';
import utils, { isAddressPasswordLoginOnly } from './utils';
import { CONSTANTS } from './constants';
import { isS3Enabled } from './mails_api/s3_attachment';
import { isAnySendMailEnabled } from './common';
@@ -22,6 +22,7 @@ api.get('/open_api/settings', async (c) => {
const imapProxyConfig = smtpImapProxyConfig.imap || {};
return c.json({
"addressPasswordLoginOnly": isAddressPasswordLoginOnly(c),
"title": c.env.TITLE,
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
+2 -5
View File
@@ -1,5 +1,5 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { createAddressToken } from './address_auth';
import { WorkerMailerOptions } from 'worker-mailer';
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);
// create jwt
const jwt = await Jwt.sign({
address: address,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
const jwt = await createAddressToken(c, address, address_id);
return {
jwt: jwt,
address: address,
+1
View File
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
NewPasswordRequiredMsg: "New password is required",
InvalidAddressTokenMsg: "Invalid address token",
FailedUpdatePasswordMsg: "Failed to update password",
CredentialLoginDisabledMsg: "Mailbox password login is required; credential login is disabled",
PasswordLoginDisabledMsg: "Password login is disabled",
EmailPasswordRequiredMsg: "Email and password are required",
AddressNotFoundMsg: "Address not found",
+1
View File
@@ -47,6 +47,7 @@ export type LocaleMessages = {
NewPasswordRequiredMsg: string
InvalidAddressTokenMsg: string
FailedUpdatePasswordMsg: string
CredentialLoginDisabledMsg: string
PasswordLoginDisabledMsg: string
EmailPasswordRequiredMsg: string
AddressNotFoundMsg: string
+1
View File
@@ -49,6 +49,7 @@ const messages: LocaleMessages = {
NewPasswordRequiredMsg: "新密码不能为空",
InvalidAddressTokenMsg: "无效的地址令牌",
FailedUpdatePasswordMsg: "更新密码失败",
CredentialLoginDisabledMsg: "仅允许邮箱密码登录,凭据登录已禁用",
PasswordLoginDisabledMsg: "密码登录已禁用",
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
AddressNotFoundMsg: "邮箱地址不存在",
+4 -7
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono';
import i18n from '../i18n';
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
import { Jwt } from 'hono/utils/jwt';
import utils, { getBooleanValue, checkCfTurnstile } from '../utils';
import { createAddressPasswordLoginToken } from '../address_auth';
export default {
// 修改地址密码
@@ -61,7 +61,7 @@ export default {
// 查找地址
const address = await c.env.DB.prepare(
`SELECT * FROM address WHERE name = ?`
).bind(email).first();
).bind(email).first<{ id: number; name: string; password: string | null }>();
if (!address) {
return c.text(msgs.AddressNotFoundMsg, 404);
@@ -73,10 +73,7 @@ export default {
}
// 创建JWT
const jwt = await Jwt.sign({
address: address.name,
address_id: address.id
}, c.env.JWT_SECRET, "HS256");
const jwt = await createAddressPasswordLoginToken(c, address.name, address.id);
return c.json({
jwt: jwt,
+8 -2
View File
@@ -5,6 +5,7 @@ import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
import { createAddressPasswordLoginToken, ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS } from '../address_auth';
const listMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
@@ -62,14 +63,19 @@ const deleteMail = 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);
const { balance } = await getSendBalanceState(c, address);
return c.json({
address: address,
...payload,
send_balance: balance || 0,
new_address_token: renewedAddressToken,
});
};
+3 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
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 { ErrorCode } from '../error_codes';
@@ -44,8 +44,9 @@ api.post('/open_api/admin_login', async (c) => {
})
api.post('/open_api/credential_login', async (c) => {
const { credential, cf_token } = await c.req.json();
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)) {
try {
await checkCfTurnstile(c, cf_token);
+25 -8
View File
@@ -1,12 +1,27 @@
import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { validateAddressPayload, verifyAddressToken } from '../address_auth';
import { validateAddressIdentity, verifyAddressToken } from '../address_auth';
import { CONSTANTS } from "../constants";
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
import { LocaleMessages } from "../i18n/type";
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 (
c: Context<HonoCustomType>, userId: string, address: string,
msgs: LocaleMessages,
@@ -48,7 +63,8 @@ export const tgUserNewAddress = async (
sourceMeta: `tg:${userId}`
});
// 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());
return res;
}
@@ -65,7 +81,7 @@ export const jwtListToAddressData = async (
const invalidJwtList = [] as string[];
for (const jwt of jwtList) {
try {
const { address, address_id } = await verifyAddressToken(c, jwt);
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
addressList.push(address as string);
addressIdMap[address as string] = address_id as number;
} catch (e) {
@@ -81,7 +97,7 @@ export const bindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, jwt: string,
msgs: LocaleMessages
): 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 { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (address as string in addressIdMap) {
@@ -91,7 +107,8 @@ export const bindTelegramAddress = async (
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
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
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
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') || [];
return Promise.all(jwtList.map(async (jwt) => {
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) {
console.log(`解绑失败: ${(e as Error).message}`);
return { jwt, payload: null };
@@ -125,10 +142,10 @@ export const unbindTelegramAddress = async (
): Promise<boolean> => {
const msgs = i18n.getMessagesbyContext(c);
const bindings = await getTelegramBindings(c, userId);
for (const { payload } of bindings) {
for (const { jwt, payload } of bindings) {
if (payload?.address !== address) continue;
try {
if (!await validateAddressPayload(c, payload)) continue;
await verifyTelegramBindingToken(c, jwt);
} catch (e) {
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
continue;
+4 -4
View File
@@ -1,7 +1,7 @@
import { Context } from "hono";
import { verifyAddressToken } from '../address_auth';
import { createAddressToken } from '../address_auth';
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 { resolveRawEmailRow } from "../gzip";
import { TelegramSettings } from "./settings";
@@ -69,8 +69,8 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
const res = [];
for (const jwt of jwtList) {
try {
const { address } = await verifyAddressToken(c, jwt);
res.push({ address, jwt });
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
res.push({ address, jwt: await createAddressToken(c, address, address_id) });
} catch (e) {
console.error(`failed to verify jwt with error: ${e}`)
continue;
+13 -1
View File
@@ -59,6 +59,7 @@ type Bindings = {
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ADDRESS_PASSWORD_LOGIN_ONLY: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
ENABLE_REDEEM_CODE: string | boolean | undefined
@@ -125,11 +126,22 @@ type Bindings = {
CLEANUP_BATCH_SIZE: string | number | undefined
}
type JwtPayload = {
type AddressCredentialPayload = {
address: string
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 = {
user_email: string
user_id: number
+28 -6
View File
@@ -1,7 +1,7 @@
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 i18n from '../i18n';
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
@@ -23,6 +23,31 @@ export const getBindedAddressById = async (
}
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>) => {
const { user_id } = c.get("userPayload");
const { address_id } = c.get("jwtPayload");
@@ -178,10 +203,7 @@ const UserBindAddressModule = {
if (!name) {
return c.text(msgs.AddressNotBindedMsg, 400)
}
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
const jwt = await createAddressToken(c, name, Number(address_id));
return c.json({
jwt: jwt
})
+1
View File
@@ -38,6 +38,7 @@ api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind);
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/transfer_address', bind_address.transferAddress);
+4
View File
@@ -2,6 +2,10 @@ import { Context } from "hono";
import { UserSettings, RoleAddressConfig } from "./models";
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>(
value: string | any
): T | null => {
+3
View File
@@ -93,6 +93,9 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Enable address password feature, if set true, will generate password for new address and support password login and change
# ENABLE_ADDRESS_PASSWORD = false
# 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
# ENABLE_AGENT_EMAIL_INFO = true
# Show SMTP/IMAP client connection info in the address credential modal