fix(notification): preserve default SMTP trust and refresh resend cooldown

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-06 10:19:36 +08:00
parent da1ab25d69
commit 80a11ae0ef
7 changed files with 61 additions and 12 deletions

View file

@ -210,7 +210,9 @@ SPRING_MAIL_PASSWORD=
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
# Optional certificate trust override: leave absent for the JVM trust store.
# Never set an empty value; use an explicit host only when required.
# SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.example.com
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub

View file

@ -159,7 +159,8 @@ services:
SPRING_MAIL_SMTP_AUTH: ${SPRING_MAIL_SMTP_AUTH:-false}
SPRING_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:-}
# Omit the trust override by default: an empty value rejects every SMTP host.
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:
SPRING_MAIL_SMTP_CONNECTION_TIMEOUT_MS: ${SPRING_MAIL_SMTP_CONNECTION_TIMEOUT_MS:-5000}
SPRING_MAIL_SMTP_READ_TIMEOUT_MS: ${SPRING_MAIL_SMTP_READ_TIMEOUT_MS:-10000}
SPRING_MAIL_SMTP_WRITE_TIMEOUT_MS: ${SPRING_MAIL_SMTP_WRITE_TIMEOUT_MS:-10000}

View file

@ -38,7 +38,7 @@
- 当前文档统一按 `465 + SSL` 配置,不再展开 `587 + STARTTLS` 方案。
- 使用 `465` 时配置:`STARTTLS=false`、`SSL_ENABLE=true`。
- 若出现 `PKIX path building failed` / `SSLHandshakeException`,可尝试增加 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=<SMTP_HOST>`(本地联调常用)。
- 生产环境默认不建议配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,仅在证书链异常时临时启用。
- 默认不得配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,以使用 JVM 证书信任库。旧 `.env.release` 若包含 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=` 空行配置,必须删除该配置行后重建后端;空字符串会导致 `Server is not trusted`,不能用它表示“使用默认信任库”。只有经过部署方确认的证书信任例外才填写明确主机名,不得填写 `*`。
- `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` 支持如 `PT5M`、`PT10M`、`PT30M`。
## 1.1 配置方案速查(推荐)
@ -53,7 +53,6 @@ SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
@ -62,7 +61,6 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
本地 `export` 示例写法:
```bash
export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
export SPRING_MAIL_HOST=smtp.mail.example
export SPRING_MAIL_PORT=465
export SPRING_MAIL_USERNAME=mailer@example.com
@ -99,7 +97,6 @@ SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
@ -123,7 +120,6 @@ SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
@ -156,7 +152,6 @@ SPRING_MAIL_PASSWORD=your-smtp-app-password \
SPRING_MAIL_SMTP_AUTH=true \
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true \
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example \
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name \
@ -303,7 +298,6 @@ MANAGEMENT_HEALTH_MAIL_ENABLED=true
- 增加:
```dotenv
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
```
- 然后重启后端,再触发一次“发送验证码”。

View file

@ -34,6 +34,8 @@ SMTP 连接、TLS 和企业邮箱接入方式见 [SMTP 配置指南](19-smtp-pas
3. 确认 SMTP 配置已存在,填写可信站点根地址(子路径部署须包含子路径,例如 `https://skills.example.com/skillhub`),设置摘要窗口后启用。
4. 用测试账号完成验邮,分别验证即时和摘要,再让用户自主订阅。
发送验证码的重发间隔仅限制同一账号申请验证码,不限制业务邮件批量处理。发送失败后仍保留防滥用冷却,页面会重新读取服务端截止时间并显示倒计时。
后台的“SMTP 配置存在”不是连通性测试,也不证明邮件已进入真实邮箱。管理员无法代用户完成收件验证或自动订阅。
普通开关、摘要窗口及站点地址存于 `system_setting` 的 `notification.email` 组。部署初值仅初始化缺失的设置;数据库已存在时,重启或修改初值不会覆盖后台设置。摘要窗口范围 5–1440 分钟,调整只影响之后入箱的项;旧项沿原窗口发送,不会立即重新分组。

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
python3 - "$REPO_ROOT" <<'CHECK'
import json, os, pathlib, subprocess, sys, tempfile
root = pathlib.Path(sys.argv[1])
key = 'SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST'
env = {**os.environ, 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET': 'smtp-contract-non-production-cookie-secret'}
env.pop(key, None)
with tempfile.TemporaryDirectory() as work:
config = pathlib.Path(work) / 'smtp.env'
def render(content):
config.write_text(content)
output = subprocess.check_output(['docker', 'compose', '--env-file', str(config),
'-f', str(root / 'compose.release.yml'), 'config', '--format', 'json'], cwd=root, env=env)
return json.loads(output)['services']['server']['environment']
assert render('').get(key) is None, 'unset trust must not become an empty JavaMail property'
example = (root / '.env.release.example').read_text()
assert not any(line.startswith(key + '=') for line in example.splitlines()), 'default example must omit trust override'
assert render(key + '=smtp.example.test\n')[key] == 'smtp.example.test', 'explicit host remains configurable'
env[key] = 'smtp.shell.example.test'
assert render('')[key] == 'smtp.shell.example.test', 'shell environment remains supported'
print('SMTP Compose default omission and explicit trust configuration passed')
CHECK

View file

@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ contact: vi.fn(), preferences: vi.fn(), requestCode: vi.fn(), verify: vi.fn(), updatePreferences: vi.fn() }))
@ -19,7 +19,7 @@ function setup() {
}
describe('email notification preferences', () => {
beforeEach(() => { session.userId = 'owner-a'; api.contact.mockResolvedValue(contact()); api.preferences.mockResolvedValue([{ category: 'PUBLISH', mode: 'OFF' }]); api.requestCode.mockResolvedValue(contact()); api.verify.mockResolvedValue(contact(true)); api.updatePreferences.mockResolvedValue([{ category: 'PUBLISH', mode: 'DIGEST' }]) })
afterEach(() => { cleanup(); vi.resetAllMocks() })
afterEach(() => { cleanup(); vi.useRealTimers(); vi.resetAllMocks() })
it('lets an unverified recipient request a code and blocks non-OFF subscriptions', async () => {
setup()
await screen.findByText('a***@example.test')
@ -33,6 +33,32 @@ describe('email notification preferences', () => {
fireEvent.click(screen.getByRole('button', { name: 'notificationEmail.verify' }))
await waitFor(() => expect(api.verify.mock.calls[0]?.[0]).toBe('123456'))
})
it('refreshes the server cooldown after a failed send and permits retry when it expires', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] })
api.contact.mockResolvedValueOnce(contact()).mockResolvedValue({
...contact(), resendAvailableAt: new Date(Date.now() + 60000).toISOString(),
})
api.requestCode.mockRejectedValueOnce(new Error('error.notification.email.sendFailed'))
setup()
await screen.findByText('a***@example.test')
fireEvent.click(screen.getByRole('button', { name: 'notificationEmail.sendCode' }))
await screen.findByRole('alert')
const resend = await screen.findByRole('button', { name: 'notificationEmail.resendIn' })
expect(api.contact).toHaveBeenCalledTimes(2)
expect(resend.hasAttribute('disabled')).toBe(true)
expect(screen.queryByText('notificationEmail.codeSent')).toBeNull()
fireEvent.click(resend)
expect(api.requestCode).toHaveBeenCalledTimes(1)
act(() => vi.advanceTimersByTime(61000))
const retry = screen.getByRole('button', { name: 'notificationEmail.sendCode' })
expect(retry.hasAttribute('disabled')).toBe(false)
fireEvent.click(retry)
await screen.findByText('notificationEmail.codeSent')
expect(api.requestCode).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('honors the server resend deadline and explains unavailable platform state', async () => {
api.contact.mockResolvedValue({ ...contact(), reason: 'DISABLED', resendAvailableAt: new Date(Date.now() + 60000).toISOString() })
setup(); await screen.findByText('notificationEmail.unavailable')

View file

@ -10,7 +10,7 @@ export function useNotificationEmail() {
const contact = useQuery({ queryKey: [...scope, 'contact'], queryFn: notificationEmailApi.contact, enabled: !!user })
const preferences = useQuery({ queryKey: [...scope, 'preferences'], queryFn: notificationEmailApi.preferences, enabled: !!user })
const refresh = () => client.invalidateQueries({ queryKey: scope })
const requestCode = useMutation({ mutationFn: notificationEmailApi.requestCode, onSuccess: refresh })
const requestCode = useMutation({ mutationFn: notificationEmailApi.requestCode, onSettled: refresh })
const verify = useMutation({ mutationFn: notificationEmailApi.verify, onSuccess: refresh })
const update = useMutation({ mutationFn: notificationEmailApi.updatePreferences, onSuccess: refresh })
return { contact, preferences, requestCode, verify, update }