diff --git a/.env.release.example b/.env.release.example index 735482d2d..cc2be6a96 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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 diff --git a/compose.release.yml b/compose.release.yml index 3bd1e9f43..4e36fccd6 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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} diff --git a/docs/19-smtp-password-reset-email-setup.md b/docs/19-smtp-password-reset-email-setup.md index eafa17c1a..41f2feaf7 100644 --- a/docs/19-smtp-password-reset-email-setup.md +++ b/docs/19-smtp-password-reset-email-setup.md @@ -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=`(本地联调常用)。 -- 生产环境默认不建议配置 `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 ``` - 然后重启后端,再触发一次“发送验证码”。 diff --git a/docs/notification-email.md b/docs/notification-email.md index 2ff76cfa1..a39ceb5f0 100644 --- a/docs/notification-email.md +++ b/docs/notification-email.md @@ -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 分钟,调整只影响之后入箱的项;旧项沿原窗口发送,不会立即重新分组。 diff --git a/scripts/tests/smtp-compose-render-test.sh b/scripts/tests/smtp-compose-render-test.sh new file mode 100755 index 000000000..90082bf98 --- /dev/null +++ b/scripts/tests/smtp-compose-render-test.sh @@ -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 diff --git a/web/src/features/notification/notification-email-form.test.tsx b/web/src/features/notification/notification-email-form.test.tsx index e54e8ccf5..3ff04b511 100644 --- a/web/src/features/notification/notification-email-form.test.tsx +++ b/web/src/features/notification/notification-email-form.test.tsx @@ -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') diff --git a/web/src/features/notification/use-notification-email.ts b/web/src/features/notification/use-notification-email.ts index f20dfd3fd..628b04978 100644 --- a/web/src/features/notification/use-notification-email.ts +++ b/web/src/features/notification/use-notification-email.ts @@ -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 }