mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
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:
parent
da1ab25d69
commit
80a11ae0ef
7 changed files with 61 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
- 然后重启后端,再触发一次“发送验证码”。
|
||||
|
|
|
|||
|
|
@ -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 分钟,调整只影响之后入箱的项;旧项沿原窗口发送,不会立即重新分组。
|
||||
|
|
|
|||
24
scripts/tests/smtp-compose-render-test.sh
Executable file
24
scripts/tests/smtp-compose-render-test.sh
Executable 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue