整理前后端与测试构建目录并完善配置和安全引导
Some checks are pending
Security Checks / security (push) Waiting to run

统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
This commit is contained in:
smail 2026-09-05 19:19:52 +08:00
parent 28cd9d3406
commit f60d3b0880
603 changed files with 2351 additions and 660 deletions

View File

@ -55,21 +55,21 @@ jobs:
echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
echo "TELEGRAMFILENAME=pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
- name: FE Build
run: cd fe && yarn install --frozen-lockfile && yarn build
run: cd frontend && yarn install --frozen-lockfile && yarn build
- name: BE Build
run: |
cd server
cp -rf ../fe/dist listen/http_server
mkdir -p out/release/plugins
cd backend
BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'"
go build -ldflags "$BUILD_FLAGS" -o ${{ env.FILENAME }} ./cmd/smail
go build -ldflags "$BUILD_FLAGS" -o ${{ env.ADMINFILENAME }} ./cmd/smail-admin
go build -ldflags "-s -w" -o ${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go
go build -ldflags "-s -w" -o ${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go
go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.FILENAME }} ./cmd/smail
go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.ADMINFILENAME }} ./cmd/smail-admin
go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go
go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go
cd ../plugins/pmail_telegram_push
npm ci
npm run build-only
go build -ldflags "-s -w" -o ../../server/${{ env.TELEGRAMFILENAME }} .
cd ../../server
go build -ldflags "-s -w" -o ../../out/release/plugins/${{ env.TELEGRAMFILENAME }} .
cd ../../out/release
cat > release.json <<EOF
{
"version": "${{ env.VERSION }}",
@ -78,27 +78,23 @@ jobs:
"createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
cp ../README.md README.md
cp ../../README.md README.md
ls -alh
- name: Zip
run: |
cd ./server
mkdir plugins
mv ${{ env.WCFILENAME }} plugins/
mv ${{ env.SPAMBLOCKFILENAME }} plugins/
mv ${{ env.TELEGRAMFILENAME }} plugins/
cd ./out/release
zip -r ${{ env.ZIPNAME }}.zip ${{ env.FILENAME }} ${{ env.ADMINFILENAME }} plugins release.json README.md
ls
- name: Upload files to Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ env.ZIPNAME }}
path: server/${{ env.ZIPNAME }}.zip
path: out/release/${{ env.ZIPNAME }}.zip
- name: Upload binaries to release
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: server/${{ env.ZIPNAME }}.zip
file: out/release/${{ env.ZIPNAME }}.zip
tag: ${{ github.ref }}
file_glob: true
@ -113,32 +109,35 @@ jobs:
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version: '1.26.6'
- name: Verify Go dependencies
run: cd server && go mod verify
- name: Run Go tests
run: cd server && go test ./...
- name: Run Go static analysis
run: cd server && go vet ./...
- name: Run Go race tests
run: cd server && go test -race ./...
- name: Run Go security analysis
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10
cd server && gosec ./...
- name: Scan Go vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
cd server && govulncheck ./...
- name: Setup Node.js environment
- name: Setup Node.js for test runner
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
- name: Install Yarn
run: npm install --global yarn@1.22.22
- name: Verify frontend dependencies and build
- name: Prepare frontend and Go embedded assets
run: |
cd fe
npm install --global yarn@1.22.22
cd frontend
yarn install --frozen-lockfile
yarn test
yarn build
- name: Verify Go dependencies
run: cd backend && go mod verify
- name: Run Go tests
run: node test/run.mjs backend
- name: Run Go static analysis
run: cd backend && go vet ./...
- name: Run Go race tests
run: node test/run.mjs backend -race ./...
- name: Run Go security analysis
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10
cd backend && gosec ./...
- name: Scan Go vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
cd backend && govulncheck ./...
- name: Verify frontend dependencies and lint
run: |
cd frontend
yarn audit --groups dependencies
yarn eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore
yarn build

View File

@ -26,32 +26,35 @@ jobs:
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
with:
go-version: '1.26.6'
- name: Verify Go dependencies
run: cd server && go mod verify
- name: Run Go tests
run: cd server && go test ./...
- name: Run Go static analysis
run: cd server && go vet ./...
- name: Run Go race tests
run: cd server && go test -race ./...
- name: Run Go security analysis
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10
cd server && gosec ./...
- name: Scan Go vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
cd server && govulncheck ./...
- name: Setup Node.js environment
- name: Setup Node.js for test runner
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
- name: Install Yarn
run: npm install --global yarn@1.22.22
- name: Verify frontend dependencies and build
- name: Prepare frontend and Go embedded assets
run: |
cd fe
npm install --global yarn@1.22.22
cd frontend
yarn install --frozen-lockfile
yarn test
yarn build
- name: Verify Go dependencies
run: cd backend && go mod verify
- name: Run Go tests
run: node test/run.mjs backend
- name: Run Go static analysis
run: cd backend && go vet ./...
- name: Run Go race tests
run: node test/run.mjs backend -race ./...
- name: Run Go security analysis
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.10
cd backend && gosec ./...
- name: Scan Go vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.7.0
cd backend && govulncheck ./...
- name: Verify frontend dependencies and lint
run: |
cd frontend
yarn audit --groups dependencies
yarn eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore
yarn build

70
.gitignore vendored
View File

@ -3,42 +3,60 @@
dist
output
smail.db
server/plugins/*
!server/plugins/
!server/plugins/catalog/
!server/plugins/catalog/*.go
backend/plugins/*
!backend/plugins/
!backend/plugins/catalog/
!backend/plugins/catalog/*.go
config
*_KEY
AURORA_SECRET
.codex-edit-config.py
server/gosec-report
server/gosec-g104-current.json
server/spam_block.exe
server/wechat_push.exe
server/utils/validation/test.tmp
server/.codex-smail-*
server/.tmp-*
backend/gosec-report
backend/gosec-g104-current.json
backend/spam_block.exe
backend/wechat_push.exe
backend/utils/validation/test.tmp
backend/.codex-smail-*
backend/.tmp-*
!server/config/
!server/config/*.go
!backend/config/
backend/config/*
!backend/config/*.go
!backend/config/dkim/
backend/config/dkim/*
!backend/config/dkim/README.md
!backend/config/ssl/
backend/config/ssl/*
!backend/config/ssl/README.md
.codex-cg*.patch
server/config/credential.key
server/smail.exe
server/smail-admin.exe
backend/config/credential.key
backend/smail.exe
backend/smail-admin.exe
.codex-smail-*
# 本地测试输出和临时文件
/out/
/test-log/
/test/archive/
artifacts/
ssl/
/ssl/
.tmp-*
*.log
*.pid
server/smail_*
server/smail-admin_*
server/*.exe
server/config/smail_temp.db
server/config/external-email-*.key
server/config/ssl/*.crt
server/config/ssl/*.key
server/config/ssl/*.csr
server/config/dkim/backup-*/
backend/smail_*
backend/smail-admin_*
backend/*.exe
backend/config/smail_temp.db
backend/config/external-email-*.key
backend/config/ssl/*.crt
backend/config/ssl/*.key
backend/config/ssl/*.csr
backend/config/dkim/backup-*/
# 配置迁移生成的本地分区和原始备份,不进入版本库
backend/config/config.json.legacy
backend/config/smtp.json
backend/config/email.json
backend/config/ssl.json
backend/config/plugins.json
backend/config/migration-backups/

View File

@ -9,77 +9,71 @@ BUILD_LDFLAGS = -s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=$(S
build: build_fe build_server spam_block wechat_push telegram_push package
clean:
rm -rf output
@echo "请先审查 test-log 中的清理清单,不自动递归删除文件。"
clean_codex_test_artifacts:
cd server && git clean -f -x -- .codex-smail-*
@echo "测试产物保留在 test-log 中,清理前请先审查。"
build_fe:
cd fe && yarn && yarn build
rm -rf server/listen/http_server/dist
cd server && cp -rf ../fe/dist listen/http_server
cd frontend && yarn && yarn build
build_server:
cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_linux_amd64 ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_linux_arm64 ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_windows_amd64.exe ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_windows_arm64.exe ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_mac_amd64 ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail_mac_arm64 ./cmd/smail
cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_linux_amd64 ./cmd/smail-admin
cd server && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_linux_arm64 ./cmd/smail-admin
cd server && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_windows_amd64.exe ./cmd/smail-admin
cd server && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_windows_arm64.exe ./cmd/smail-admin
cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_mac_amd64 ./cmd/smail-admin
cd server && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o smail-admin_mac_arm64 ./cmd/smail-admin
mkdir -p out/backend
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_amd64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_linux_arm64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_amd64.exe ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_windows_arm64.exe ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_amd64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail_mac_arm64 ./cmd/smail
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_linux_amd64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_linux_arm64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_windows_amd64.exe ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_windows_arm64.exe ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_mac_amd64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_mac_arm64 ./cmd/smail-admin
wechat_push:
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_linux_amd64 wechat_push.go
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o output/wechat_push_linux_arm64 wechat_push.go
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_windows_amd64.exe wechat_push.go
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o output/wechat_push_windows_arm64.exe wechat_push.go
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/wechat_push_mac_amd64 wechat_push.go
cd server/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/wechat_push_mac_arm64 wechat_push.go
mkdir -p out/plugins
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_linux_amd64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_linux_arm64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_windows_amd64.exe wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_windows_arm64.exe wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_mac_amd64 wechat_push.go
cd backend/hooks/wechat_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/wechat_push_mac_arm64 wechat_push.go
spam_block:
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_linux_amd64 spam_block.go
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o output/spam_block_linux_arm64 spam_block.go
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_windows_amd64.exe spam_block.go
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o output/spam_block_windows_arm64.exe spam_block.go
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/spam_block_mac_amd64 spam_block.go
cd server/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/spam_block_mac_arm64 spam_block.go
mkdir -p out/plugins
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_linux_amd64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_linux_arm64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_windows_amd64.exe spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_windows_arm64.exe spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_mac_amd64 spam_block.go
cd backend/hooks/spam_block && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../../out/plugins/spam_block_mac_arm64 spam_block.go
telegram_push:
mkdir -p out/plugins
cd plugins/pmail_telegram_push && npm ci && npm run build
cd plugins/pmail_telegram_push && mkdir -p output && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o output/pmail_telegram_push_linux_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o output/pmail_telegram_push_linux_arm64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o output/pmail_telegram_push_windows_amd64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o output/pmail_telegram_push_windows_arm64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o output/pmail_telegram_push_mac_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o output/pmail_telegram_push_mac_arm64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_linux_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_linux_arm64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_windows_amd64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_windows_arm64.exe .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_mac_amd64 .
cd plugins/pmail_telegram_push && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../../out/plugins/pmail_telegram_push_mac_arm64 .
plugin: spam_block wechat_push telegram_push
package: clean
mkdir output
mkdir output/config
mkdir output/plugins
mv server/smail_linux_amd64 server/smail_linux_arm64 server/smail_windows_amd64.exe server/smail_windows_arm64.exe server/smail_mac_amd64 server/smail_mac_arm64 output/
mv server/smail-admin_linux_amd64 server/smail-admin_linux_arm64 server/smail-admin_windows_amd64.exe server/smail-admin_windows_arm64.exe server/smail-admin_mac_amd64 server/smail-admin_mac_arm64 output/
mkdir -p output/config/dkim && cp server/config/dkim/dkim.public output/config/dkim/ 2>/dev/null || true
cp -r server/config/ssl output/config/
cp -r server/config/config.json output/config/
mv server/hooks/spam_block/output/* output/plugins
mv server/hooks/wechat_push/output/* output/plugins
mv plugins/pmail_telegram_push/output/* output/plugins
cp README.md output/
printf '{\n "version": "%s",\n "gitHash": "%s",\n "protocolVersion": 1,\n "createdAt": "%s"\n}\n' "$(SMAIL_VERSION)" "$(GIT_HASH)" "$(BUILD_TIME)" > output/release.json
package:
mkdir -p out/package/config out/package/plugins
cp out/backend/smail* out/package/
cp out/plugins/*_* out/package/plugins/
cp README.md out/package/
printf '{\n "version": "%s",\n "gitHash": "%s",\n "protocolVersion": 1,\n "createdAt": "%s"\n}\n' "$(SMAIL_VERSION)" "$(GIT_HASH)" "$(BUILD_TIME)" > out/package/release.json
test:
@trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -v -p 1 ./...
node test/run.mjs all
test_mysql:
@trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -args "mysql" -v -p 1 ./...
node test/run.mjs backend -v -p 1 ./... -args mysql
test_postgres:
@trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -args "postgres" -v -p 1 ./...
node test/run.mjs backend -v -p 1 ./... -args postgres

View File

@ -115,9 +115,9 @@ IMAP端口: 993(SSL)
# 插件
[微信推送](server/hooks/wechat_push/README.md)
[微信推送](backend/hooks/wechat_push/README.md)
[垃圾邮件屏蔽](server/hooks/spam_block/README.md)
[垃圾邮件屏蔽](backend/hooks/spam_block/README.md)
# 其他优秀开源插件
@ -131,15 +131,17 @@ IMAP端口: 993(SSL)
# 参与开发
目录约定:独立测试代码和脚本位于 `test/`,测试日志及截图位于 `test-log/`,编译输出位于 `out/`。构建与开发启动方式见 [文件目录整理说明](docs/文件目录整理说明.md),测试入口见 [测试说明](test/README.md)。
## 项目架构
1、前端: vue3+element-plus
前端代码位于 `fe`目录中,运行参考 `fe`目录中的README文件
前端代码位于 `frontend` 目录中,运行参考 `frontend` 目录中的 README 文件。
2、后端: golang + MySQL/SQLite
后端代码进入 `server`文件夹,运行 `main.go`文件
后端代码位于 `backend/`;推荐使用 `deploy/build.ps1` 构建后,通过 `deploy/start-dev.ps1` 启动开发服务。
3、编译项目

View File

@ -160,9 +160,9 @@ PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail
因此,使用 Smail 邮件服务时,请让客户端直接连接 Smail 的 SMTP、IMAP、POP3 服务,并按本节的开关、完整邮箱地址和协议授权码规则配置;不要把 PMail 原版的账号截断或旧密码逻辑复制到 Smail。
# 插件
[微信推送](server/hooks/wechat_push/README.md)
[微信推送](backend/hooks/wechat_push/README.md)
[垃圾邮件屏蔽](server/hooks/spam_block/README.md)
[垃圾邮件屏蔽](backend/hooks/spam_block/README.md)
# 其他优秀开源插件
@ -176,15 +176,17 @@ PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail
# 参与开发
目录约定:独立测试代码和脚本位于 `test/`,测试日志及截图位于 `test-log/`,编译输出位于 `out/`。构建与开发启动方式见 [文件目录整理说明](docs/文件目录整理说明.md),测试入口见 [测试说明](test/README.md)。
## 项目架构
1、前端: vue3+element-plus
前端代码位于 `fe`目录中,运行参考 `fe`目录中的README文件
前端代码位于 `frontend` 目录中,运行参考 `frontend` 目录中的 README 文件。
2、后端: golang + MySQL/SQLite
后端代码进入 `server`文件夹,运行 `main.go`文件
后端代码位于 `backend/`;推荐使用 `deploy/build.ps1` 构建后,通过 `deploy/start-dev.ps1` 启动开发服务。
3、编译项目

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
!backend/config/
!backend/config/*.go

View File

@ -0,0 +1,50 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"git.pcux.cn/xuans/Smail/config"
)
// main 提供部署端显式配置迁移,不在网页或普通启动中自动改写配置。
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string) error {
flags := flag.NewFlagSet("smail-config", flag.ContinueOnError)
root := flags.String("root", "", "Smail 服务根目录,包含 config 子目录")
apply := flags.Bool("apply", false, "确认已停止后端,备份并执行迁移;默认只检查")
if err := flags.Parse(args); err != nil {
return err
}
if *root == "" || flags.NArg() != 0 {
return fmt.Errorf("请指定 --root 服务根目录;迁移前必须停止后端")
}
absolute, err := filepath.Abs(*root)
if err != nil {
return fmt.Errorf("服务根目录无效")
}
options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}
plan, err := config.PlanLegacyMigration(absolute, options)
if err != nil {
// 不输出配置原文、未知字段或底层错误,避免凭据进入终端记录。
return fmt.Errorf("迁移检查未通过:未知字段 %d 项,需人工处理字段 %d 项;未执行迁移", len(plan.UnknownFields), len(plan.ManualFields))
}
if !*apply {
fmt.Println("旧版字段检查通过;尚未写入配置。执行迁移前请停止后端,使用 --apply 继续完整校验和备份迁移。")
return nil
}
result, err := config.MigrateLegacyConfig(absolute, options)
if err != nil {
return fmt.Errorf("配置迁移未完成,请保留原配置和备份,由部署管理员检查;不要手工删除分区文件")
}
fmt.Printf("配置迁移完成,代次:%d;原配置备份:%s。请使用最新版后端启动。\n", result.Generation, result.BackupPath)
return nil
}

View File

@ -172,18 +172,32 @@ func Init() error {
}
loaded := &Config{}
if err = json.Unmarshal(cfgData, loaded); err != nil {
return errors.Wrap(err)
}
applyLegacyLoginPolicy(cfgData, loaded)
migrated, err := unprotectCredentials(loaded)
partitioned, err := isPartitionedConfig(cfgData)
if err != nil {
return errors.Wrap(err)
return err
}
if migrated {
if err = writeProtectedConfigFile(configName, loaded); err != nil {
if partitioned {
if DevelopmentMode {
return fmt.Errorf("分区配置请通过 config.json 启动,不支持 config.dev.json")
}
loaded, err = loadPartitionedCompatibility()
if err != nil {
return err
}
} else {
if err = json.Unmarshal(cfgData, loaded); err != nil {
return errors.Wrap(err)
}
applyLegacyLoginPolicy(cfgData, loaded)
migrated, err := unprotectCredentials(loaded)
if err != nil {
return errors.Wrap(err)
}
if migrated {
if err = writeProtectedConfigFile(configName, loaded); err != nil {
return errors.Wrap(err)
}
}
}
Instance = loaded
Instance.fixPath()
@ -265,6 +279,17 @@ func WriteConfig(cfg *Config) error {
}
func writeProtectedConfigFile(name string, cfg *Config) error {
if data, err := readConfigFile(name); err == nil {
partitioned, parseErr := isPartitionedConfig(data)
if parseErr != nil {
return parseErr
}
if partitioned {
return fmt.Errorf("分区配置不能通过旧版写入接口覆盖,请使用配置管理")
}
} else if !os.IsNotExist(err) {
return err
}
stored, err := protectCredentials(cfg)
if err != nil {
return errors.Wrap(err)
@ -303,6 +328,13 @@ func ReadConfig() (*Config, error) {
log.Errorf("Read Config Error:%s", err.Error())
return nil, errors.Wrap(err)
}
partitioned, err := isPartitionedConfig(cfgData)
if err != nil {
return nil, err
}
if partitioned {
return loadPartitionedCompatibility()
}
if err = json.Unmarshal(cfgData, &configData); err != nil {
log.Error("Read Config Unmarshal Error")
return nil, errors.Wrap(err)

View File

@ -0,0 +1,111 @@
package config
import (
"encoding/json"
"fmt"
"net"
"net/url"
"path/filepath"
"strconv"
"strings"
"git.pcux.cn/xuans/Smail/utils/credential"
"github.com/go-sql-driver/mysql"
)
// isPartitionedConfig 在旧版解码前识别分区根文件,避免静默丢失数据库等字段。
func isPartitionedConfig(data []byte) (bool, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return false, err
}
_, sections := fields["sections"]
_, database := fields["database"]
return sections || database, nil
}
// loadPartitionedCompatibility 仅构造旧业务所需的只读快照,不重写分区配置。
func loadPartitionedCompatibility() (*Config, error) {
runtime, err := NewConfigLoader(ROOT_PATH, ConfigLoaderOptions{
Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: !DevelopmentMode},
}).Load()
if err != nil {
return nil, err
}
vault, err := credential.NewVault(ROOT_PATH)
if err != nil {
return nil, err
}
if err := ValidateRuntimeSecrets(vault, runtime); err != nil {
return nil, err
}
driver, dsn, err := compatibilityDatabase(runtime.Root.Database, vault)
if err != nil {
return nil, err
}
root := runtime.Root
cfg := &Config{
LogLevel: root.LogLevel, Domain: root.Domains.Primary,
Domains: append([]string(nil), root.Domains.Accepted...), WebDomain: root.Domains.Web,
AccessHosts: append([]string(nil), root.Domains.AccessHosts...),
DbType: driver, DbDSN: dsn, SetupState: runtime.SetupState,
IsInit: runtime.SetupState == SetupStateReady,
HttpPort: root.HTTP.Port, HttpsPort: root.HTTP.HTTPSPort,
HttpsEnabled: 2, TLSMode: root.HTTP.TLSMode,
TrustedProxyCIDRs: append([]string(nil), root.HTTP.TrustedProxyCIDRs...),
DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "config", "dkim", "dkim.priv"),
SSLType: SSLTypeUser,
SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel,
LoginPolicy: runtime.Email.Policy.LoginVerificationPolicy,
ExternalRecoveryEnabled: runtime.Email.Policy.ExternalRecoveryEnabled,
SMTPHost: runtime.SMTP.Relay.RelayHost, SMTPPort: runtime.SMTP.Relay.RelayPort,
SMTPUsername: runtime.SMTP.Relay.Username, SMTPSecurity: runtime.SMTP.Relay.Security,
SMTPFrom: runtime.SMTP.Relay.From, SMTPFromDomain: runtime.SMTP.Relay.FromDomain,
VerificationEnabled: runtime.SMTP.Relay.VerificationEnabled,
}
if root.HTTP.HTTPSEnabled {
cfg.HttpsEnabled = 1
}
if runtime.SMTP.Relay.Password.Configured() {
cfg.SMTPPassword, err = OpenStoredSecret(vault, runtime.SMTP.Relay.Password)
if err != nil {
return nil, err
}
}
tls := runtime.SSL.TLS
if tls.Mode == ManagedSSLModeDefault {
cfg.SSLType = SSLTypeAutoHTTP
}
// 迁移器保留的旧证书引用仍走严格的只读文件校验;新托管引用沿用托管解析器。
if strings.HasPrefix(tls.CertificateRef, managedSSLRelativeRoot+"/") {
cfg.SSL = tls
} else {
cfg.SSLPublicKeyPath = resolvePath(tls.CertificateRef)
cfg.SSLPrivateKeyPath = resolvePath(tls.PrivateKeyRef)
}
return cfg, nil
}
func compatibilityDatabase(database DatabaseConfig, vault *credential.Vault) (string, string, error) {
if database.Type == DatabaseTypeSQLite {
return DBTypeSQLite, filepath.Join(ROOT_PATH, "config", database.LocalRef), nil
}
password, err := OpenStoredSecret(vault, database.Password)
if err != nil {
return "", "", err
}
address := net.JoinHostPort(database.Host, strconv.Itoa(int(database.Port)))
switch database.Type {
case DatabaseTypeMySQL:
settings := mysql.NewConfig()
settings.User, settings.Passwd = database.Username, password
settings.Net, settings.Addr, settings.DBName = "tcp", address, database.Name
settings.ParseTime = true
return DBTypeMySQL, settings.FormatDSN(), nil
case DatabaseTypePostgreSQL:
uri := url.URL{Scheme: "postgres", User: url.UserPassword(database.Username, password), Host: address, Path: "/" + database.Name}
return DBTypePostgres, uri.String(), nil
default:
return "", "", fmt.Errorf("不支持的数据库类型")
}
}

View File

@ -0,0 +1,63 @@
package config
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) {
root := t.TempDir()
oldRoot, oldArgs, oldDevelopment := ROOT_PATH, os.Args, DevelopmentMode
oldInstance, oldSnapshot := Instance, CurrentConfig()
ROOT_PATH, os.Args = root, []string{"smail"}
t.Cleanup(func() {
ROOT_PATH, os.Args, DevelopmentMode = oldRoot, oldArgs, oldDevelopment
Instance = oldInstance
PublishConfigSnapshot(oldSnapshot)
})
writeLegacyMigrationFixture(t, root, map[string]any{
"logLevel": "info", "domain": "mail.example.com", "webDomain": "mail.example.com",
"dbType": "sqlite", "dbDSN": "./config/existing.db", "isInit": true,
"sslType": "1", "SSLPublicKeyPath": "./config/ssl/site.crt", "SSLPrivateKeyPath": "./config/ssl/site.key",
"httpPort": 80, "httpsPort": 443, "httpsEnabled": 1,
})
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "config", "config.json")
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := Init(); err != nil {
t.Fatal(err)
}
cfg := CurrentConfig()
if !cfg.IsSetupReady() || cfg.DbType != DBTypeSQLite || cfg.DbDSN != filepath.Join(root, "config", "existing.db") {
t.Fatal("partitioned startup lost initialization or database identity")
}
if cfg.WebDomain != "mail.example.com" || cfg.SSLPublicKeyPath != filepath.Join(root, "config", "ssl", "site.crt") {
t.Fatal("partitioned startup lost domain or certificate path")
}
}
cfg, err := ReadConfig()
if err != nil {
t.Fatal(err)
}
if err := WriteConfig(cfg); err == nil {
t.Fatal("legacy writer must not overwrite partitioned config")
}
after, err := os.ReadFile(path)
if err != nil || !bytes.Equal(before, after) {
t.Fatal("loading or legacy writing changed partitioned root")
}
if err := os.WriteFile(filepath.Join(root, "config", "smtp.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
if err := Init(); err == nil {
t.Fatal("corrupted partition must not silently fall back to legacy startup")
}
}

View File

@ -17,16 +17,21 @@ import (
// ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。
func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
manager, ok := configManagerOrError(w)
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
manager := config.CurrentManager()
if manager == nil {
response.NewSuccessResponse(map[string]any{
"available": false,
"unavailableReason": "当前使用旧版配置,在线配置管理尚未启用。请由部署管理员完成分区配置迁移后重启服务。",
}).FPrint(w)
return
}
status := manager.Status()
response.NewSuccessResponse(map[string]any{
"available": true,
"setupState": status.SetupState,
"activeGeneration": status.ActiveGeneration,
"lastValidAt": status.LastValidAt,
@ -151,7 +156,8 @@ func ListConfigBackups(ctx *appcontext.Context, w http.ResponseWriter, req *http
if !ok {
return
}
if err := authorizeCapability(ctx, access.CapabilityConfigRollback, "", false); err != nil {
// 读取脱敏代次不执行回滚;预览、票据和执行仍要求高风险授权。
if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
@ -330,7 +336,7 @@ func configRequestManager(w http.ResponseWriter, req *http.Request) (*config.Con
func configManagerOrError(w http.ResponseWriter) (*config.ConfigManager, bool) {
manager := config.CurrentManager()
if manager == nil {
writeConfigError(w, errors.New("configuration manager unavailable"))
writeConfigError(w, config.ErrManagerStorage)
return nil, false
}
return manager, true

View File

@ -0,0 +1,76 @@
package controllers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/services/access"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil {
t.Fatal(err)
}
legacy := []byte(`{"domain":"mail.example.com","webDomain":"mail.example.com","dbType":"sqlite","dbDSN":"./config/test.db","isInit":true,"httpPort":80,"httpsPort":443,"httpsEnabled":1}`)
if err := os.WriteFile(filepath.Join(root, "config", "config.json"), legacy, 0600); err != nil {
t.Fatal(err)
}
validation := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}
if _, err := config.MigrateLegacyConfig(root, validation); err != nil {
t.Fatal(err)
}
manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{Validation: validation})
if err != nil {
t.Fatal(err)
}
previous := config.CurrentManager()
config.SetRuntimeManager(manager)
t.Cleanup(func() { config.SetRuntimeManager(previous) })
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
t.Run(string(role), func(t *testing.T) {
ctx := &appcontext.Context{UserID: 1, Role: role, RecentAuth: false}
recorder := httptest.NewRecorder()
ListConfigBackups(ctx, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/config/backups", nil))
if role == access.RoleUser {
if recorder.Code != http.StatusForbidden {
t.Fatalf("普通用户不应读取配置备份:%d", recorder.Code)
}
return
}
var payload struct {
ErrorNo int `json:"errorNo"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if recorder.Code != http.StatusOK || payload.ErrorNo != 0 {
t.Fatalf("管理员读取脱敏备份不应要求近期认证:%d", recorder.Code)
}
})
}
// 列表读取放宽不能改变实际回滚链路的近期认证要求。
for name, handler := range map[string]func(*appcontext.Context, http.ResponseWriter, *http.Request){
"preview": PreviewConfigRollback,
"ticket": IssueConfigRollbackTicket,
"execute": RollbackConfig,
} {
t.Run(name, func(t *testing.T) {
recorder := httptest.NewRecorder()
handler(&appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin}, recorder,
httptest.NewRequest(http.MethodPost, "/api/v1/config/rollback", strings.NewReader(`{"targetGeneration":1}`)))
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "RECENT_AUTH_REQUIRED") {
t.Fatalf("回滚仍应要求近期认证:%d", recorder.Code)
}
})
}
}

View File

@ -99,11 +99,13 @@ func hashedLoginKey(parts ...string) string {
return hex.EncodeToString(hash.Sum(nil))
}
// loginSecurityFields returns the non-sensitive state used by the post-login security guide.
// loginSecurityFields 返回登录后安全引导所需的非敏感状态。
func loginSecurityFields(user *models.User) map[string]any {
fields := map[string]any{
"recoveryCodesConfigured": false,
"showRecoverySetupPrompt": false,
"showSecuritySetupPrompt": user != nil && user.SecuritySetupAcknowledged == 0,
"webAuthnEnabled": webAuthnRuntime() != nil,
}
if user == nil || user.EffectiveRole() == access.RoleSuperAdmin {
return fields
@ -272,11 +274,11 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
func Logout(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if err := session.Instance.Clear(ctx.Context); err != nil {
csrf.Clear(w, req, !config.DevelopmentMode)
if err := session.Instance.Destroy(ctx.Context); err != nil {
log.WithContext(ctx).WithError(err).Error("session cleanup during logout failed")
response.NewErrorResponse(response.ServerError, "退出失败", "").FPrint(w)
return
}
csrf.Clear(w, req, !config.DevelopmentMode)
response.NewSuccessResponse("Success").FPrint(w)
}

Some files were not shown because too many files have changed in this diff Show More