feat: improve address credential connections

This commit is contained in:
Dream Hunter
2026-04-30 15:33:06 +08:00
committed by GitHub
parent 347be5c762
commit 796a5e4ac5
32 changed files with 854 additions and 118 deletions

View File

@@ -10,8 +10,13 @@
### Features ### Features
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
### Bug Fixes ### Bug Fixes
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
### Improvements ### Improvements
## v1.8.0 ## v1.8.0

View File

@@ -10,8 +10,13 @@
### Features ### Features
- feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG`
### Bug Fixes ### Bug Fixes
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
### Improvements ### Improvements
## v1.8.0 ## v1.8.0

View File

@@ -1,4 +1,5 @@
import { APIRequestContext } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test';
import { createHash } from 'crypto';
import WebSocket from 'ws'; import WebSocket from 'ws';
export const WORKER_URL = process.env.WORKER_URL!; export const WORKER_URL = process.env.WORKER_URL!;
@@ -10,6 +11,13 @@ export const FRONTEND_URL = process.env.FRONTEND_URL!;
export const MAILPIT_API = process.env.MAILPIT_API!; export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com'; export const TEST_DOMAIN = 'test.example.com';
/**
* SHA-256 hash matching the frontend hashPassword utility.
*/
export function hashPassword(password: string): string {
return createHash('sha256').update(password).digest('hex');
}
/** /**
* Create a new email address via the worker API. * Create a new email address via the worker API.
* Appends a timestamp suffix to avoid UNIQUE constraint collisions * Appends a timestamp suffix to avoid UNIQUE constraint collisions

View File

@@ -1,15 +1,16 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
test.describe('Address Password Login', () => { test.describe('Address Password Login', () => {
test('set password then login with it', async ({ request }) => { test('set password then login with it', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-login'); const { jwt, address } = await createTestAddress(request, 'pwd-login');
const passwordHash = hashPassword('test-password-123');
try { try {
// Set a password on the address // Set a password on the address
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, { const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` }, headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'test-password-123' }, data: { new_password: passwordHash },
}); });
expect(changePwdRes.ok()).toBe(true); expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json(); const changePwdBody = await changePwdRes.json();
@@ -17,7 +18,7 @@ test.describe('Address Password Login', () => {
// Login with the correct password // Login with the correct password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, { const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'test-password-123' }, data: { email: address, password: passwordHash },
}); });
expect(loginRes.ok()).toBe(true); expect(loginRes.ok()).toBe(true);
const loginBody = await loginRes.json(); const loginBody = await loginRes.json();
@@ -36,12 +37,13 @@ test.describe('Address Password Login', () => {
test('login with wrong password returns 401', async ({ request }) => { test('login with wrong password returns 401', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-wrong'); const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
const passwordHash = hashPassword('correct-password');
try { try {
// Set a password // Set a password
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, { const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` }, headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'correct-password' }, data: { new_password: passwordHash },
}); });
expect(changePwdRes.ok()).toBe(true); expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json(); const changePwdBody = await changePwdRes.json();
@@ -49,11 +51,117 @@ test.describe('Address Password Login', () => {
// Login with wrong password // Login with wrong password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, { const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'wrong-password' }, data: { email: address, password: hashPassword('wrong-password') },
}); });
expect(loginRes.status()).toBe(401); expect(loginRes.status()).toBe(401);
} finally { } finally {
await deleteAddress(request, jwt); await deleteAddress(request, jwt);
} }
}); });
test('admin reset stores frontend-hashed address password', async ({ request }) => {
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-admin-reset');
const plainPassword = `admin-reset-${Date.now()}`;
const passwordHash = hashPassword(plainPassword);
try {
const resetRes = await request.post(`${WORKER_URL}/admin/address/${address_id}/reset_password`, {
data: { password: passwordHash },
});
expect(resetRes.ok()).toBe(true);
await expect(resetRes.json()).resolves.toMatchObject({ success: true });
const plaintextLoginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: plainPassword },
});
expect(plaintextLoginRes.status()).toBe(401);
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: passwordHash },
});
expect(loginRes.ok()).toBe(true);
const loginBody = await loginRes.json();
expect(loginBody.jwt).toBeTruthy();
expect(loginBody.address).toBe(address);
} finally {
await deleteAddress(request, jwt);
}
});
test('admin address list does not expose stored password hash', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-list-hidden');
const passwordHash = hashPassword('list-hidden-password');
try {
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const listRes = await request.get(
`${WORKER_URL}/admin/address?limit=10&offset=0&query=${encodeURIComponent(address)}`
);
expect(listRes.ok()).toBe(true);
const listBody = await listRes.json();
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
expect(listedAddress).toBeTruthy();
expect(listedAddress).not.toHaveProperty('password');
} finally {
await deleteAddress(request, jwt);
}
});
test('user bind address list does not expose stored password hash', async ({ request }) => {
const userEmail = `pwd-bind-hidden-${Date.now()}@test.example.com`;
const userPasswordHash = hashPassword('bind-hidden-user-password');
const { jwt, address } = await createTestAddress(request, 'pwd-bind-hidden');
const addressPasswordHash = hashPassword('bind-hidden-address-password');
try {
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
},
});
expect(enableRes.ok()).toBe(true);
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email: userEmail, password: userPasswordHash },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email: userEmail, password: userPasswordHash },
});
expect(loginRes.ok()).toBe(true);
const { jwt: userJwt } = await loginRes.json();
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: addressPasswordHash },
});
expect(changePwdRes.ok()).toBe(true);
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': userJwt,
},
});
expect(bindRes.ok()).toBe(true);
const listRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
headers: { 'x-user-token': userJwt },
});
expect(listRes.ok()).toBe(true);
const listBody = await listRes.json();
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
expect(listedAddress).toBeTruthy();
expect(listedAddress).not.toHaveProperty('password');
} finally {
await deleteAddress(request, jwt);
}
});
}); });

View File

@@ -1,13 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
import * as crypto from 'crypto';
/**
* SHA-256 hash matching frontend hashPassword utility.
*/
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => { test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
@@ -110,14 +102,14 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
// Set a password // Set a password
await request.post(`${WORKER_URL}/api/address_change_password`, { await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` }, headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'addr-pass-123' }, data: { new_password: hashPassword('addr-pass-123') },
}); });
// Login with cf_token field present but empty // Login with cf_token field present but empty
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, { const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { data: {
email: address, email: address,
password: 'addr-pass-123', password: hashPassword('addr-pass-123'),
cf_token: '' cf_token: ''
}, },
}); });

View File

@@ -105,6 +105,8 @@ const getOpenSettings = async (message, notification) => {
enableWebhook: res["enableWebhook"] || false, enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false, isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false, enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "", statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false, enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
}); });

View File

@@ -0,0 +1,322 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
address: {
type: String,
default: '',
},
jwt: {
type: String,
default: '',
},
addressPassword: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:show'])
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const modalShow = computed({
get: () => props.show,
set: (value) => emit('update:show', value),
})
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE || ''
const frontendBaseUrl = computed(() => window.location.origin)
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
const securityLabel = computed(() =>
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
)
const agentConfigJson = computed(() => JSON.stringify({
base: apiBaseUrl.value,
jwt: props.jwt,
site_password: auth.value || '',
}, null, 2))
const agentText = computed(() => [
`${t('currentAddress')}: ${props.address || '-'}`,
`${t('apiBase')}: ${apiBaseUrl.value}`,
`${t('agentSkill')}: ${agentSkillUrl}`,
`${t('agentConfig')}:`,
agentConfigJson.value,
].join('\n'))
const smtpImapText = computed(() => [
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
`${t('security')}: ${securityLabel.value}`,
`${t('username')}: ${props.address || '-'}`,
`${t('password')}: ${props.jwt}`,
].join('\n'))
const copyText = async (text) => {
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
message.success(t('copySuccess'))
return
}
const textarea = document.createElement('textarea')
try {
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
if (document.execCommand('copy')) {
message.success(t('copySuccess'))
return
}
message.error(t('copyFailed'))
} finally {
textarea.parentNode?.removeChild(textarea)
}
} catch (error) {
console.error(error)
message.error(t('copyFailed'))
}
}
</script>
<template>
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
style="width: min(760px, calc(100vw - 32px));">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
<div class="credential-section">
<div class="credential-field" v-if="address">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ address }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field" v-if="addressPassword">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code class="credential-code">{{ addressPassword }}</code>
</div>
</div>
</section>
<n-collapse 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)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
<div class="credential-field">
<span class="credential-label">{{ t('apiBase') }}</span>
<code class="credential-code">{{ apiBaseUrl }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentSkill') }}</span>
<code class="credential-code">
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentConfig') }}</span>
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
</div>
<div class="credential-actions">
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
<div class="credential-grid">
<div class="credential-field">
<span class="credential-label">{{ t('smtpHost') }}</span>
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('smtpPort') }}</span>
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapHost') }}</span>
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapPort') }}</span>
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('security') }}</span>
<code class="credential-code">{{ securityLabel }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('username') }}</span>
<code class="credential-code">{{ address }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('password') }}</span>
<code class="credential-code">{{ jwt }}</code>
</div>
<div class="credential-actions">
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<div class="credential-field">
<code class="credential-code">{{ autoLoginUrl }}</code>
</div>
</div>
</n-collapse-item>
</n-collapse>
</n-modal>
</template>
<style scoped>
.credential-collapse {
margin-top: 14px;
}
.credential-panel {
display: grid;
gap: 12px;
margin-top: 14px;
}
.credential-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.credential-section {
display: grid;
gap: 12px;
text-align: left;
}
.credential-tip {
margin: 0;
color: var(--n-text-color-2);
line-height: 1.6;
}
.credential-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.credential-field {
display: grid;
gap: 6px;
min-width: 0;
}
.credential-label {
color: var(--n-text-color-2);
font-size: 12px;
font-weight: 600;
}
.credential-code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
border-radius: 6px;
padding: 6px 8px;
background: var(--n-color-embedded);
font-size: 12px;
line-height: 1.5;
}
.credential-copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
}
.credential-code-block {
margin: 0;
white-space: pre-wrap;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 640px) {
.credential-grid {
grid-template-columns: 1fr;
}
.credential-copy-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -586,5 +586,32 @@ export const deMessages = {
"views.admin.AccountSettings.tip": "Die folgenden Mehrfachauswahlwerte können manuell eingegeben und mit Enter hinzugefügt werden", "views.admin.AccountSettings.tip": "Die folgenden Mehrfachauswahlwerte können manuell eingegeben und mit Enter hinzugefügt werden",
"components.MailBox.emptyInbox": "Dein Posteingang ist leer", "components.MailBox.emptyInbox": "Dein Posteingang ist leer",
"views.index.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden", "views.index.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden" "views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"components.AddressCredentialModal.addressCredential": "Adresszugangsdaten",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Adresspasswort",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Verwende dieses Postfach in einem AI Agent mit dem Address JWT und den parsed-mail APIs.",
"components.AddressCredentialModal.agentConfig": "Agent-Konfiguration",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "API-Basisadresse",
"components.AddressCredentialModal.autoLoginLink": "Auto-Login-Link",
"components.AddressCredentialModal.copyFailed": "Kopieren fehlgeschlagen",
"components.AddressCredentialModal.copySection": "Kopieren",
"components.AddressCredentialModal.copySuccess": "Kopiert",
"components.AddressCredentialModal.currentAddress": "Aktuelle Adresse",
"components.AddressCredentialModal.docs": "Dokumentation",
"components.AddressCredentialModal.imapHost": "IMAP-Host",
"components.AddressCredentialModal.imapPort": "IMAP-Port",
"components.AddressCredentialModal.password": "Passwort",
"components.AddressCredentialModal.plainOrProxyTls": "Klartext oder Proxy-TLS",
"components.AddressCredentialModal.security": "Sicherheit",
"components.AddressCredentialModal.smtpHost": "SMTP-Host",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Verwende diese Werte in Mail-Clients, nachdem der Administrator den SMTP/IMAP-Proxy konfiguriert hat. Als Passwort kannst du den hier angezeigten Address JWT oder ein vorhandenes Adresspasswort verwenden.",
"components.AddressCredentialModal.smtpPort": "SMTP-Port",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
"components.AddressCredentialModal.username": "Benutzername"
} }

View File

@@ -586,5 +586,32 @@ export const esMessages = {
"views.admin.AccountSettings.tip": "Puedes introducir manualmente los siguientes valores y pulsar Enter para añadirlos", "views.admin.AccountSettings.tip": "Puedes introducir manualmente los siguientes valores y pulsar Enter para añadirlos",
"components.MailBox.emptyInbox": "Tu bandeja de entrada está vacía", "components.MailBox.emptyInbox": "Tu bandeja de entrada está vacía",
"views.index.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo", "views.index.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
"views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo" "views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
"components.AddressCredentialModal.addressCredential": "Credencial de dirección",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Contraseña de la dirección",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Usa este buzón desde un AI Agent con el Address JWT y las APIs parsed-mail.",
"components.AddressCredentialModal.agentConfig": "Configuración del agente",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "Base de la API",
"components.AddressCredentialModal.autoLoginLink": "Enlace de inicio automático",
"components.AddressCredentialModal.copyFailed": "Error al copiar",
"components.AddressCredentialModal.copySection": "Copiar",
"components.AddressCredentialModal.copySuccess": "Copiado",
"components.AddressCredentialModal.currentAddress": "Dirección actual",
"components.AddressCredentialModal.docs": "Documentación",
"components.AddressCredentialModal.imapHost": "Host IMAP",
"components.AddressCredentialModal.imapPort": "Puerto IMAP",
"components.AddressCredentialModal.password": "Contraseña",
"components.AddressCredentialModal.plainOrProxyTls": "Texto plano o TLS del proxy",
"components.AddressCredentialModal.security": "Seguridad",
"components.AddressCredentialModal.smtpHost": "Host SMTP",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Usa estos valores en clientes de correo después de que el administrador configure el proxy SMTP/IMAP. Como contraseña puedes usar el Address JWT mostrado aquí o la contraseña de la dirección si la tienes.",
"components.AddressCredentialModal.smtpPort": "Puerto SMTP",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
"components.AddressCredentialModal.username": "Usuario"
} }

View File

@@ -586,5 +586,32 @@ export const jaMessages = {
"views.admin.AccountSettings.tip": "以下の複数選択項目は手動入力して Enter で追加できます", "views.admin.AccountSettings.tip": "以下の複数選択項目は手動入力して Enter で追加できます",
"components.MailBox.emptyInbox": "受信箱は空です", "components.MailBox.emptyInbox": "受信箱は空です",
"views.index.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します", "views.index.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
"views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します" "views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
"components.AddressCredentialModal.addressCredential": "アドレス認証情報",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "アドレスパスワード",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "AI Agent から Address JWT と parsed-mail API を使ってこのメールボックスを利用できます。",
"components.AddressCredentialModal.agentConfig": "Agent 設定",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "API ベース",
"components.AddressCredentialModal.autoLoginLink": "自動ログインリンク",
"components.AddressCredentialModal.copyFailed": "コピーに失敗しました",
"components.AddressCredentialModal.copySection": "コピー",
"components.AddressCredentialModal.copySuccess": "コピーしました",
"components.AddressCredentialModal.currentAddress": "現在のアドレス",
"components.AddressCredentialModal.docs": "ドキュメント",
"components.AddressCredentialModal.imapHost": "IMAP ホスト",
"components.AddressCredentialModal.imapPort": "IMAP ポート",
"components.AddressCredentialModal.password": "パスワード",
"components.AddressCredentialModal.plainOrProxyTls": "平文またはプロキシ側 TLS",
"components.AddressCredentialModal.security": "セキュリティ",
"components.AddressCredentialModal.smtpHost": "SMTP ホスト",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "管理者が SMTP/IMAP プロキシを設定した後、メールクライアントでこれらの値を使用できます。パスワードにはここに表示される Address JWT、または手元にあるアドレスパスワードを使用できます。",
"components.AddressCredentialModal.smtpPort": "SMTP ポート",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
"components.AddressCredentialModal.username": "ユーザー名"
} }

View File

@@ -586,5 +586,32 @@ export const ptBRMessages = {
"views.admin.AccountSettings.tip": "Você pode inserir manualmente os seguintes valores e pressionar Enter para adicioná-los", "views.admin.AccountSettings.tip": "Você pode inserir manualmente os seguintes valores e pressionar Enter para adicioná-los",
"components.MailBox.emptyInbox": "Sua caixa de entrada está vazia", "components.MailBox.emptyInbox": "Sua caixa de entrada está vazia",
"views.index.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail", "views.index.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
"views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail" "views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
"components.AddressCredentialModal.addressCredential": "Credencial do endereço",
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
"components.AddressCredentialModal.addressPassword": "Senha do endereço",
"components.AddressCredentialModal.agentAccess": "AI Agent",
"components.AddressCredentialModal.agentAccessTip": "Use esta caixa de entrada em um AI Agent com o Address JWT e as APIs parsed-mail.",
"components.AddressCredentialModal.agentConfig": "Configuração do Agent",
"components.AddressCredentialModal.agentSkill": "Agent skill",
"components.AddressCredentialModal.apiBase": "Base da API",
"components.AddressCredentialModal.autoLoginLink": "Link de login automático",
"components.AddressCredentialModal.copyFailed": "Falha ao copiar",
"components.AddressCredentialModal.copySection": "Copiar",
"components.AddressCredentialModal.copySuccess": "Copiado",
"components.AddressCredentialModal.currentAddress": "Endereço atual",
"components.AddressCredentialModal.docs": "Documentação",
"components.AddressCredentialModal.imapHost": "Host IMAP",
"components.AddressCredentialModal.imapPort": "Porta IMAP",
"components.AddressCredentialModal.password": "Senha",
"components.AddressCredentialModal.plainOrProxyTls": "Texto puro ou TLS do proxy",
"components.AddressCredentialModal.security": "Segurança",
"components.AddressCredentialModal.smtpHost": "Host SMTP",
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
"components.AddressCredentialModal.smtpImapTip": "Use estes valores em clientes de e-mail depois que o administrador configurar o proxy SMTP/IMAP. Como senha, use o Address JWT mostrado aqui ou a senha do endereço quando você a tiver.",
"components.AddressCredentialModal.smtpPort": "Porta SMTP",
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
"components.AddressCredentialModal.username": "Nome de usuário"
} }

View File

@@ -281,6 +281,116 @@ export const MESSAGE_REGISTRY = {
"zh": "用户地址" "zh": "用户地址"
} }
}, },
"components.AddressCredentialModal": {
"addressCredential": {
"en": "Address Credential",
"zh": "地址凭证"
},
"addressCredentialLabel": {
"en": "Address JWT",
"zh": "Address JWT"
},
"addressPassword": {
"en": "Address Password",
"zh": "地址密码"
},
"agentAccess": {
"en": "AI Agent",
"zh": "AI Agent"
},
"agentAccessTip": {
"en": "Use this mailbox from an AI agent with the Address JWT and parsed-mail APIs.",
"zh": "AI Agent 可使用 Address JWT 和 parsed-mail API 读取这个邮箱。"
},
"agentConfig": {
"en": "Agent config",
"zh": "Agent 配置"
},
"agentSkill": {
"en": "Agent skill",
"zh": "Agent skill"
},
"apiBase": {
"en": "API Base",
"zh": "API 地址"
},
"autoLoginLink": {
"en": "Auto-login link",
"zh": "自动登录链接"
},
"copyFailed": {
"en": "Copy failed",
"zh": "复制失败"
},
"copySection": {
"en": "Copy",
"zh": "复制"
},
"copySuccess": {
"en": "Copied",
"zh": "已复制"
},
"currentAddress": {
"en": "Current address",
"zh": "当前邮箱"
},
"docs": {
"en": "Docs",
"zh": "文档"
},
"imapHost": {
"en": "IMAP host",
"zh": "IMAP 主机"
},
"imapPort": {
"en": "IMAP port",
"zh": "IMAP 端口"
},
"password": {
"en": "Password",
"zh": "密码"
},
"plainOrProxyTls": {
"en": "Plain or proxy TLS",
"zh": "明文或代理层 TLS"
},
"security": {
"en": "Security",
"zh": "安全"
},
"smtpHost": {
"en": "SMTP host",
"zh": "SMTP 主机"
},
"smtpImapAccess": {
"en": "SMTP / IMAP",
"zh": "SMTP / IMAP"
},
"smtpImapTip": {
"en": "Use these values in mail clients after the administrator configures the SMTP/IMAP proxy. The password can be the Address JWT shown here, or the address password when you have it.",
"zh": "管理员配置 SMTP/IMAP 代理后,可在邮件客户端中使用这些信息。密码可使用这里展示的 Address JWT也可使用你持有的地址密码。"
},
"smtpPort": {
"en": "SMTP port",
"zh": "SMTP 端口"
},
"starttls": {
"en": "STARTTLS",
"zh": "STARTTLS"
},
"tip": {
"en": "Use these credentials only with clients and agents you trust.",
"zh": "请只在可信的客户端和 Agent 中使用这些凭证。"
},
"title": {
"en": "Address Credentials & Connection Methods",
"zh": "地址凭证与连接方式"
},
"username": {
"en": "Username",
"zh": "用户名"
}
},
"views.user.UserMailBox": { "views.user.UserMailBox": {
"addressQueryTip": { "addressQueryTip": {
"en": "Leave blank to query all addresses", "en": "Leave blank to query all addresses",
@@ -817,8 +927,8 @@ export const MESSAGE_REGISTRY = {
"zh": "密码不匹配" "zh": "密码不匹配"
}, },
"showAddressCredential": { "showAddressCredential": {
"en": "Show Address Credential", "en": "Credentials & Connection Methods",
"zh": "查看邮箱地址凭证" "zh": "地址凭证与连接方式"
}, },
"success": { "success": {
"en": "Success", "en": "Success",

View File

@@ -61,8 +61,20 @@ router.beforeEach((to, from, next) => {
preferredLocale.value = getPreferredLocale('', getBrowserLocales()) preferredLocale.value = getPreferredLocale('', getBrowserLocales())
} }
if (to.query.jwt) { if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
jwt.value = to.query.jwt const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
if (typeof jwtQuery === 'string') {
jwt.value = jwtQuery
}
const query = { ...to.query }
delete query.jwt
next({
path: to.path,
query,
hash: to.hash,
replace: true,
})
return
} }
if (routeLocale) { if (routeLocale) {

View File

@@ -40,6 +40,19 @@ export const useGlobalState = createGlobalState(
showGithub: true, showGithub: true,
disableAdminPasswordCheck: false, disableAdminPasswordCheck: false,
enableAddressPassword: false, enableAddressPassword: false,
enableAgentEmailInfo: false,
smtpImapProxyConfig: {
smtp: {
host: '',
port: 8025,
starttls: false,
},
imap: {
host: '',
port: 11143,
starttls: false,
},
},
statusUrl: '', statusUrl: '',
enableGlobalTurnstileCheck: false, enableGlobalTurnstileCheck: false,
}) })

View File

@@ -110,7 +110,7 @@ onMounted(async () => {
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false" <n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')"> preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p> <p>{{ t('accessTip') }}</p>
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" /> <n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" @keyup.enter="authFunc" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" /> <Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action> <template #action>
<n-button @click="authFunc" type="primary" :loading="loading"> <n-button @click="authFunc" type="primary" :loading="loading">

View File

@@ -314,7 +314,7 @@ onMounted(async () => {
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog" <n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')"> :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p> <p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="password" show-password-on="click" /> <n-input v-model:value="auth" type="password" show-password-on="click" @keyup.enter="authFunc" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" /> <Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action> <template #action>
<n-button :loading="loading" @click="authFunc" type="primary"> <n-button :loading="loading" @click="authFunc" type="primary">

View File

@@ -5,8 +5,10 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { hashPassword } from '../../utils'
import { NButton, NMenu } from 'naive-ui'; import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material' import { MenuFilled } from '@vicons/material'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const { const {
loading, adminTab, openSettings, loading, adminTab, openSettings,
@@ -18,6 +20,7 @@ const { t } = useScopedI18n('views.admin.Account')
const showEmailCredential = ref(false) const showEmailCredential = ref(false)
const curEmailCredential = ref("") const curEmailCredential = ref("")
const curEmailAddress = ref("")
const curDeleteAddressId = ref(0); const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0); const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0); const curClearSentItemsAddressId = ref(0);
@@ -46,14 +49,16 @@ const showDeleteAccount = ref(false)
const showClearInbox = ref(false) const showClearInbox = ref(false)
const showClearSentItems = ref(false) const showClearSentItems = ref(false)
const showCredential = async (id) => { const showCredential = async (row) => {
try { try {
curEmailCredential.value = await api.adminShowAddressCredential(id) curEmailAddress.value = row.name
curEmailCredential.value = await api.adminShowAddressCredential(row.id)
showEmailCredential.value = true showEmailCredential.value = true
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
showEmailCredential.value = false showEmailCredential.value = false
curEmailCredential.value = "" curEmailCredential.value = ""
curEmailAddress.value = ""
} }
} }
@@ -98,11 +103,16 @@ const clearSentItems = async () => {
} }
const resetPassword = async () => { const resetPassword = async () => {
const normalizedPassword = newPassword.value.trim()
if (!normalizedPassword) {
message.error(t("newPassword"));
return;
}
try { try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, { await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
password: newPassword.value password: await hashPassword(normalizedPassword)
}) })
}); });
message.success(t("passwordResetSuccess")); message.success(t("passwordResetSuccess"));
@@ -365,7 +375,7 @@ const columns = computed(() => [
label: () => h(NButton, label: () => h(NButton,
{ {
text: true, text: true,
onClick: () => showCredential(row.id) onClick: () => showCredential(row)
}, },
{ default: () => t('showCredential') } { default: () => t('showCredential') }
), ),
@@ -467,19 +477,8 @@ onMounted(async () => {
<template> <template>
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog"> <AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
<template #header> :jwt="curEmailCredential" />
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card :bordered="false" embedded>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')"> <n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p> <p>{{ t('deleteTip') }}</p>
<template #action> <template #action>
@@ -507,7 +506,8 @@ onMounted(async () => {
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')"> <n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')"> <n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" /> <n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="resetPassword" />
</n-form-item> </n-form-item>
<template #action> <template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info"> <n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">

View File

@@ -4,6 +4,7 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const { const {
loading, openSettings, loading, openSettings,
@@ -59,10 +60,6 @@ const newEmail = async () => {
} }
} }
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => { onMounted(async () => {
if (openSettings.prefix) { if (openSettings.prefix) {
enablePrefix.value = true enablePrefix.value = true
@@ -73,27 +70,8 @@ onMounted(async () => {
<template> <template>
<div class="center"> <div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')"> <AddressCredentialModal v-model:show="showReultModal" :address="createdAddress" :jwt="result"
<span> :address-password="addressPassword" />
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;"> <n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')"> <n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-switch v-model:value="enablePrefix" :round="false" /> <n-switch v-model:value="enablePrefix" :round="false" />

View File

@@ -304,7 +304,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" /> <n-input v-model:value="user.email" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" /> <n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="createUser" />
</n-form-item-row> </n-form-item-row>
</n-form> </n-form>
<template #action> <template #action>
@@ -315,7 +316,8 @@ onMounted(async () => {
</n-modal> </n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')"> <n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" /> <n-input v-model:value="newResetPassword" type="password" show-password-on="click"
@keyup.enter="resetPassword" />
</n-form-item-row> </n-form-item-row>
<template #action> <template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary"> <n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">

View File

@@ -260,7 +260,8 @@ onMounted(async () => {
<n-input v-model:value="loginAddress" /> <n-input v-model:value="loginAddress" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="loginPassword" type="password" show-password-on="click" /> <n-input v-model:value="loginPassword" type="password" show-password-on="click"
@keyup.enter="login" />
</n-form-item-row> </n-form-item-row>
</div> </div>

View File

@@ -92,7 +92,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> <n-card :bordered="false" embedded class="account-card">
<n-button @click="showAddressCredential = true" type="primary" secondary block strong> <n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }} {{ t('showAddressCredential') }}
</n-button> </n-button>
@@ -110,11 +110,13 @@ const changePassword = async () => {
<n-button @click="showLogout = true" secondary block strong> <n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }} {{ t('logout') }}
</n-button> </n-button>
<n-divider v-if="openSettings.enableUserDeleteEmail" />
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary <n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong> block strong>
{{ t('deleteAccount') }} {{ t('deleteAccount') }}
</n-button> </n-button>
</n-card> </n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')"> <n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p> <p>{{ t('logoutConfirm') }}</p>
<template #action> <template #action>
@@ -151,10 +153,12 @@ const changePassword = async () => {
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')"> <n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
<n-form :model="{ newPassword, confirmPassword }"> <n-form :model="{ newPassword, confirmPassword }">
<n-form-item :label="t('newPassword')"> <n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" /> <n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="changePassword" />
</n-form-item> </n-form-item>
<n-form-item :label="t('confirmPassword')"> <n-form-item :label="t('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" /> <n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="changePassword" />
</n-form-item> </n-form-item>
</n-form> </n-form>
<template #action> <template #action>
@@ -172,13 +176,13 @@ const changePassword = async () => {
justify-content: center; justify-content: center;
} }
.account-card {
.n-card {
max-width: 800px; max-width: 800px;
text-align: left; text-align: left;
} }
.n-button { .n-button {
margin-top: 10px; margin-top: 14px;
} }
</style> </style>

View File

@@ -12,6 +12,7 @@ import LocalAddress from './LocalAddress.vue'
import AddressManagement from '../user/AddressManagement.vue' import AddressManagement from '../user/AddressManagement.vue'
import { getRouterPathWithLang } from '../../utils' import { getRouterPathWithLang } from '../../utils'
import AddressSelect from '../../components/AddressSelect.vue' import AddressSelect from '../../components/AddressSelect.vue'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const router = useRouter() const router = useRouter()
@@ -24,10 +25,6 @@ const { locale, t } = useScopedI18n('views.index.AddressBar')
const showAddressManage = ref(false) const showAddressManage = ref(false)
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${jwt.value}`
}
const onUserLogin = async () => { const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value)) await router.push(getRouterPathWithLang("/user", locale.value))
} }
@@ -78,27 +75,8 @@ onMounted(async () => {
</n-button> </n-button>
</n-card> </n-card>
</div> </div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')"> <AddressCredentialModal v-model:show="showAddressCredential" :address="settings.address" :jwt="jwt"
<span> :address-password="addressPassword" />
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ settings.address }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')" <n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')"
style="width: 720px;"> style="width: 720px;">
<TelegramAddress v-if="isTelegram" /> <TelegramAddress v-if="isTelegram" />

View File

@@ -187,7 +187,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" /> <n-input v-model:value="user.email" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" /> <n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailLogin" />
</n-form-item-row> </n-form-item-row>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" /> <Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
<n-button @click="emailLogin" type="primary" block secondary strong> <n-button @click="emailLogin" type="primary" block secondary strong>
@@ -218,7 +219,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" /> <n-input v-model:value="user.email" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" /> <n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row> </n-form-item-row>
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" /> <Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required> <n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
@@ -244,7 +246,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" /> <n-input v-model:value="user.email" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('password')" required> <n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" /> <n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row> </n-form-item-row>
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" /> <Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
<n-form-item-row :label="t('verifyCode')" required> <n-form-item-row :label="t('verifyCode')" required>

View File

@@ -43,6 +43,8 @@
| `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` |
| `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"]` | | `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"]` |
> [!NOTE] > [!NOTE]
@@ -66,6 +68,17 @@
> >
> `SEND_MAIL_DOMAINS` only affects the `SEND_MAIL` binding fallback path and > `SEND_MAIL_DOMAINS` only affects the `SEND_MAIL` binding fallback path and
> `/admin/send_mail_by_binding`. It does not affect Resend, SMTP, or `verifiedAddressList`. > `/admin/send_mail_by_binding`. It does not affect Resend, SMTP, or `verifiedAddressList`.
>
> `SMTP_IMAP_PROXY_CONFIG` example:
>
> ```json
> {
> "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
> "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
> }
> ```
>
> SMTP and IMAP can use different hostnames, which is useful for reverse proxies or separate port mappings.
## Email Reception Related Variables ## Email Reception Related Variables

View File

@@ -43,6 +43,8 @@
| `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` |
| `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"]` | | `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
> [!NOTE] > [!NOTE]
@@ -62,6 +64,17 @@
> >
> `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`。 > `SEND_MAIL_DOMAINS` 只影响 `SEND_MAIL` binding 的兜底发信路径和 `/admin/send_mail_by_binding`。
> 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。 > 它不影响 Resend、SMTP、`verifiedAddressList` 等其他发信通道。
>
> `SMTP_IMAP_PROXY_CONFIG` 示例:
>
> ```json
> {
> "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
> "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
> }
> ```
>
> SMTP 与 IMAP 可以使用不同主机名,便于反向代理或不同端口映射。
## 接受邮件相关变量 ## 接受邮件相关变量

View File

@@ -2,7 +2,7 @@ import { Context } from 'hono'
import { Jwt } from 'hono/utils/jwt' import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n' import i18n from '../i18n'
import { getBooleanValue, hashPassword } from '../utils' import { getBooleanValue } from '../utils'
import { newAddress, handleListQuery } from '../common' import { newAddress, handleListQuery } from '../common'
const listAddresses = async (c: Context<HonoCustomType>) => { const listAddresses = async (c: Context<HonoCustomType>) => {
@@ -32,7 +32,7 @@ const listAddresses = async (c: Context<HonoCustomType>) => {
+ ` FROM address a` + ` FROM address a`
+ ` where ${whereClause}`, + ` where ${whereClause}`,
`SELECT count(*) as count FROM address where ${whereClause}`, `SELECT count(*) as count FROM address where ${whereClause}`,
[param], limit, offset, orderBy [param], limit, offset, orderBy, ['password']
); );
} }
return await handleListQuery(c, return await handleListQuery(c,
@@ -41,7 +41,7 @@ const listAddresses = async (c: Context<HonoCustomType>) => {
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count` + ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`, + ` FROM address a`,
`SELECT count(*) as count FROM address`, `SELECT count(*) as count FROM address`,
[], limit, offset, orderBy [], limit, offset, orderBy, ['password']
); );
}; };
@@ -137,16 +137,16 @@ const resetPassword = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c); const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param(); const { id } = c.req.param();
const { password } = await c.req.json(); const { password } = await c.req.json();
// NOTE: Keep the admin API field as password, but the value is a frontend SHA-256 hash.
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) { if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordChangeDisabledMsg, 403); return c.text(msgs.PasswordChangeDisabledMsg, 403);
} }
if (!password) { if (!password) {
return c.text(msgs.NewPasswordRequiredMsg, 400); return c.text(msgs.NewPasswordRequiredMsg, 400);
} }
const hashedPassword = await hashPassword(password);
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?` `UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(hashedPassword, id).run(); ).bind(password, id).run();
if (!success) { if (!success) {
return c.text(msgs.FailedUpdatePasswordMsg, 500); return c.text(msgs.FailedUpdatePasswordMsg, 500);
} }

View File

@@ -15,6 +15,11 @@ api.get('/open_api/settings', async (c) => {
const auth = c.req.raw.headers.get("x-custom-auth"); const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth); needAuth = !auth || !passwords.includes(auth);
} }
const smtpImapProxyConfig = utils.getJsonObjectValue<SmtpImapProxyConfig>(
c.env.SMTP_IMAP_PROXY_CONFIG
) || {};
const smtpProxyConfig = smtpImapProxyConfig.smtp || {};
const imapProxyConfig = smtpImapProxyConfig.imap || {};
return c.json({ return c.json({
"title": c.env.TITLE, "title": c.env.TITLE,
@@ -45,6 +50,19 @@ api.get('/open_api/settings', async (c) => {
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB), "showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK), "disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD), "enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
"enableAgentEmailInfo": utils.getBooleanValue(c.env.ENABLE_AGENT_EMAIL_INFO),
"smtpImapProxyConfig": {
"smtp": {
"host": utils.getStringValue(smtpProxyConfig.host),
"port": utils.getIntValue(smtpProxyConfig.port, 8025),
"starttls": utils.getBooleanValue(smtpProxyConfig.starttls),
},
"imap": {
"host": utils.getStringValue(imapProxyConfig.host),
"port": utils.getIntValue(imapProxyConfig.port, 11143),
"starttls": utils.getBooleanValue(imapProxyConfig.starttls),
},
},
"statusUrl": utils.getStringValue(c.env.STATUS_URL), "statusUrl": utils.getStringValue(c.env.STATUS_URL),
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c) "enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
}); });

View File

@@ -611,7 +611,8 @@ export const handleListQuery = async (
limit: string | number | undefined | null, limit: string | number | undefined | null,
offset: string | number | undefined | null, offset: string | number | undefined | null,
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */ /** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
orderBy?: string orderBy?: string,
hiddenFields: string[] = []
): Promise<Response> => { ): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c); const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") { if (typeof limit === "string") {
@@ -634,7 +635,23 @@ export const handleListQuery = async (
const count = offset == 0 ? await c.env.DB.prepare( const count = offset == 0 ? await c.env.DB.prepare(
countQuery countQuery
).bind(...params).first("count") : 0; ).bind(...params).first("count") : 0;
return c.json({ results, count }); if (hiddenFields.length === 0) {
return c.json({ results, count });
}
const filteredResults = results.map((row) => hideObjectFields(row, hiddenFields));
return c.json({ results: filteredResults, count });
}
export const hideObjectFields = <T extends Record<string, unknown>>(
row: T,
fields: string[]
): T => {
const filteredRow = { ...row };
for (const field of fields) {
delete filteredRow[field];
}
return filteredRow;
} }
/** /**

View File

@@ -23,7 +23,7 @@ export default {
return c.text(msgs.InvalidAddressTokenMsg, 400); return c.text(msgs.InvalidAddressTokenMsg, 400);
} }
// 更新密码 // NOTE: new_password is the frontend SHA-256 hash, stored directly in address.password.
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?` `UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
).bind(new_password, address_id).run(); ).bind(new_password, address_id).run();
@@ -67,7 +67,7 @@ export default {
return c.text(msgs.AddressNotFoundMsg, 404); return c.text(msgs.AddressNotFoundMsg, 404);
} }
// 验证密码 // NOTE: password is the frontend SHA-256 hash, compared directly with address.password.
if (address.password !== password) { if (address.password !== password) {
return c.text(msgs.InvalidEmailOrPasswordMsg, 401); return c.text(msgs.InvalidEmailOrPasswordMsg, 401);
} }

15
worker/src/types.d.ts vendored
View File

@@ -4,6 +4,19 @@ type UserRole = {
prefix: string | undefined | null prefix: string | undefined | null
} }
type SmtpImapProxyConfig = {
smtp?: {
host?: string
port?: number | string
starttls?: boolean | string
}
imap?: {
host?: string
port?: number | string
starttls?: boolean | string
}
}
type Bindings = { type Bindings = {
// bindings // bindings
DB: D1Database DB: D1Database
@@ -45,6 +58,8 @@ type Bindings = {
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
SMTP_IMAP_PROXY_CONFIG: string | SmtpImapProxyConfig | undefined
ENABLE_INDEX_ABOUT: string | boolean | undefined ENABLE_INDEX_ABOUT: string | boolean | undefined
DEFAULT_SEND_BALANCE: number | string | undefined DEFAULT_SEND_BALANCE: number | string | undefined
NO_LIMIT_SEND_ROLE: string | undefined | null NO_LIMIT_SEND_ROLE: string | undefined | null

View File

@@ -4,7 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
import { isAddressCountLimitReached } from "../utils" import { 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 } from '../common'; import { updateAddressUpdatedAt, commonGetUserRole, hideObjectFields } from '../common';
const UserBindAddressModule = { const UserBindAddressModule = {
bind: async (c: Context<HonoCustomType>) => { bind: async (c: Context<HonoCustomType>) => {
@@ -140,7 +140,7 @@ const UserBindAddressModule = {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}>(); }>();
return results || []; return (results || []).map((row) => hideObjectFields(row, ['password']));
}, },
getBindedAddressJwt: async (c: Context<HonoCustomType>) => { getBindedAddressJwt: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c); const msgs = i18n.getMessagesbyContext(c);

View File

@@ -81,6 +81,15 @@ 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
# 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
# SMTP_IMAP_PROXY_CONFIG = """
# {
# "smtp": { "host": "smtp.example.com", "port": 8025, "starttls": true },
# "imap": { "host": "imap.example.com", "port": 11143, "starttls": true }
# }
# """
# Footer text # Footer text
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # DISABLE_SHOW_GITHUB = true