Initial commit: WpywMail 自建邮件系统:.NET 8 原生 SMTP/IMAP 服务端(DKIM 签名、SPF/DKIM/DMARC 入站校验、SQLite 存储、完整账号体系)、Node 服务端、WinUI 3 客户端与 Web 前端

This commit is contained in:
WpyQwq
2026-09-19 11:19:40 +08:00
commit b8814a7615
84 changed files with 21197 additions and 0 deletions
@@ -0,0 +1,446 @@
<#
账号体系真实验收(在邮件服务器本机执行)
为什么必须跑真机:这套东西的价值全在「真的能注册、真的能登录、验证码邮件真的进得了信箱」,
单元自检(--selftest)只能证明存储层与业务层的逻辑,证明不了 HTTP 链路、IMAP 链路、
以及「验证码邮件是否真的投递到了客户端读得到的地方」。
覆盖:
A 策略接口、B 邀请码、C 本机托管地址注册即开通(含死循环回归)、D 会话/资料/审计、
E 真实 IMAP 993 登录 + 收件箱非空、F 忘记密码→读信取码→重置、G 改密踢其他会话、
H 登录失败锁定(423 + retryAfterSeconds)、I 管理员视角、J 停用后不能登录(并清理测试账号)
用法:
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
退出码 = 失败项数(0 = 全通过)。
#>
param(
[string]$Base = 'http://127.0.0.1:8787',
[string]$ImapHost = '127.0.0.1',
[int]$ImapPort = 993,
[string]$ImapName = 'mail.example.com',
[string]$Report = 'C:\Windows\Temp\wpyw-acct-verify.txt',
[string]$AppSettings = 'C:\Program Files\WpywMail\appsettings.json'
)
$ErrorActionPreference = 'Stop'
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
$script:pass = 0
$script:fail = 0
$script:skip = 0
# 注意:PowerShell 变量名大小写不敏感,这里不能叫 $script:report —— 会和参数 $Report 撞车
$script:logLines = New-Object System.Collections.Generic.List[string]
function Say([string]$line) {
Write-Host $line
$script:logLines.Add($line)
}
function Ok([string]$name, [bool]$cond, [string]$detail = '') {
if ($cond) { $script:pass++; Say ("[PASS] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
else { $script:fail++; Say ("[FAIL] {0}{1}" -f $name, $(if ($detail) { " —— $detail" } else { '' })) }
}
function Skip([string]$name, [string]$why = '') {
$script:skip++
Say ("[SKIP] {0}{1}" -f $name, $(if ($why) { " —— $why" } else { '' }))
}
function Invoke-Api {
param([string]$Method, [string]$Path, $Body = $null, [string]$Token = '')
$headers = @{}
if ($Token) { $headers['Authorization'] = "Bearer $Token" }
$params = @{
Uri = ($Base + $Path); Method = $Method; Headers = $headers
UseBasicParsing = $true; TimeoutSec = 30
}
if ($null -ne $Body) {
# 必须按 UTF-8 字节发,不能直接传字符串:控制台是 GBK,中文会变问号
$params['Body'] = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress -Depth 8))
$params['ContentType'] = 'application/json; charset=utf-8'
}
try {
$r = Invoke-WebRequest @params
$json = $null
try { $json = $r.Content | ConvertFrom-Json } catch { }
return @{ Code = [int]$r.StatusCode; Json = $json; Raw = $r.Content }
} catch {
$resp = $_.Exception.Response
$code = 0
$raw = ''
if ($null -ne $resp) {
try { $code = [int]$resp.StatusCode } catch { }
try {
$reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8)
$raw = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
# ⚠ PowerShell 5.1 的坑:非 2xx 响应体常常已经被它的错误格式化逻辑读掉了,
# 这时 GetResponseStream() 读出来是空的 —— 必须回退到 ErrorDetails.Message。
if (-not $raw) {
try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $raw = $_.ErrorDetails.Message } } catch { }
}
$json = $null
if ($raw) { try { $json = $raw | ConvertFrom-Json } catch { } }
return @{ Code = $code; Json = $json; Raw = $raw }
}
}
function Field($obj, [string]$name, $fallback = $null) {
if ($null -eq $obj) { return $fallback }
$p = $obj.PSObject.Properties[$name]
if ($null -ne $p -and $null -ne $p.Value) { return $p.Value }
return $fallback
}
# /api/login 直接把会话对象摊在顶层({token,expiresAt,user}),/api/register 则包在 session 里。
# 两种形状都得认,否则会静默取到空 token,后面全用着已失效的 token 连锁失败(踩过)。
function Get-Token($json) {
$t = [string](Field (Field $json 'session') 'token' '')
if (-not $t) { $t = [string](Field $json 'token' '') }
return $t
}
function Wait-InboxMessage {
param([string]$Token, [string]$SubjectLike, [int]$Seconds = 40)
for ($i = 0; $i -lt $Seconds; $i++) {
$r = Invoke-Api 'GET' '/api/messages?folder=inbox&limit=20' $null $Token
$list = Field $r.Json 'messages'
if ($r.Code -eq 200 -and $null -ne $list) {
foreach ($m in $list) {
$s = [string](Field $m 'subject' '')
if ($s -like $SubjectLike) { return $m }
}
}
Start-Sleep -Seconds 1
}
return $null
}
function Read-ImapUntil {
param($Reader, [string]$Tag, [int]$MaxLines = 500)
$out = New-Object System.Collections.Generic.List[string]
for ($i = 0; $i -lt $MaxLines; $i++) {
$line = $Reader.ReadLine()
if ($null -eq $line) { break }
$out.Add($line)
if ($line.StartsWith($Tag + ' ')) { break }
}
return $out
}
# 真实 IMAP 客户端:连 993(隐式 TLS)→ LOGIN → SELECT INBOX → UID SEARCH ALL
function Test-ImapLogin {
param([string]$Email, [string]$Password)
$res = @{ Ok = $false; Detail = ''; Count = -1; Select = '' }
$tcp = New-Object Net.Sockets.TcpClient
try {
$tcp.Connect($ImapHost, $ImapPort)
$tcp.ReceiveTimeout = 20000
$cb = [Net.Security.RemoteCertificateValidationCallback] { param($a, $b, $c, $d) return $true }
$ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $cb)
$ssl.AuthenticateAsClient($ImapName)
$reader = New-Object IO.StreamReader($ssl, [Text.Encoding]::UTF8)
$writerEncoding = New-Object Text.UTF8Encoding($false)
$writer = New-Object IO.StreamWriter($ssl, $writerEncoding)
$writer.NewLine = "`r`n"
$writer.AutoFlush = $true
$greeting = $reader.ReadLine()
if ($greeting -notmatch '^\* OK') { $res.Detail = "问候语异常: $greeting"; return $res }
$writer.WriteLine("a1 LOGIN $Email $Password")
$login = Read-ImapUntil $reader 'a1'
$loginText = ($login -join ' | ')
if ($loginText -notmatch 'a1 OK') { $res.Detail = "LOGIN 失败: $loginText"; return $res }
$writer.WriteLine('a2 SELECT INBOX')
$sel = Read-ImapUntil $reader 'a2'
$selText = ($sel -join ' ')
if ($selText -notmatch 'a2 OK') { $res.Detail = "SELECT 失败: $selText"; return $res }
$exists = 0
$m = [regex]::Match($selText, '\*\s+(\d+)\s+EXISTS')
if ($m.Success) { $exists = [int]$m.Groups[1].Value }
$writer.WriteLine('a3 UID SEARCH ALL')
$search = Read-ImapUntil $reader 'a3'
$searchText = ($search -join ' ')
$uidCount = -1
$sm = [regex]::Match($searchText, '\*\s+SEARCH([\d\s]*)')
if ($sm.Success) {
$ids = ($sm.Groups[1].Value -split '\s+' | Where-Object { $_ -ne '' })
$uidCount = $ids.Count
}
$writer.WriteLine('a4 LOGOUT')
[void](Read-ImapUntil $reader 'a4')
$res.Ok = $true
$res.Count = $uidCount
$res.Select = "EXISTS=$exists UIDSEARCH=$uidCount"
return $res
} catch {
$res.Detail = $_.Exception.Message
return $res
} finally {
try { $tcp.Close() } catch { }
}
}
function Set-UserActive {
param([string]$Token, [string]$Email, [bool]$Active)
$path = '/api/admin/users/' + [Uri]::EscapeDataString($Email)
return Invoke-Api 'PATCH' $path @{ active = $Active } $Token
}
# ================================================================ 开始
Say ("WpywMail 账号体系真实验收 {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
Say ("目标 API:{0} IMAP:{1}:{2}" -f $Base, $ImapHost, $ImapPort)
Say ''
# 从服务器自己的配置里取管理员口令与邀请码(不硬编码,避免和部署配置不一致)
$cfg = [System.IO.File]::ReadAllText($AppSettings) | ConvertFrom-Json
$invite = $cfg.Accounts.InviteCode
$adminEmail = $cfg.AdminEmail
$adminPassword = $cfg.AdminPassword
$domain = $cfg.Domain
Say ("配置:域名={0} 邀请码长度={1} 管理员={2}" -f $domain, $invite.Length, $adminEmail)
Say ''
$suffix = (Get-Random -Minimum 100000 -Maximum 999999)
$newEmail = "selftest-$suffix@$domain"
$newPassword = "Selftest-Pass-$suffix"
$resetPassword = "Reset-Pass-$suffix"
$lockEmail = "locktest-$suffix@$domain"
$lockPassword = "Locktest-Pass-$suffix"
$tempAccounts = @($newEmail, $lockEmail)
# 管理员先登录:后面「注册配额用尽」时要靠它兜底建号,最后的管理员用例也复用它
$adminLogin = Invoke-Api 'POST' '/api/login' @{ email = $adminEmail; password = $adminPassword }
$adminToken = Get-Token $adminLogin.Json
Ok 'A0 管理员可以登录' ($adminLogin.Code -eq 200 -and $adminToken.Length -gt 20) ("HTTP " + $adminLogin.Code + " " + [string](Field $adminLogin.Json 'error'))
# ---------------------------------------------------------------- A 策略
$ver = Invoke-Api 'GET' '/api/version'
Ok 'A1 /api/version 可达' ($ver.Code -eq 200) ("HTTP " + $ver.Code)
$pol = Invoke-Api 'GET' '/api/auth/policy'
Ok 'A2 策略接口可达' ($pol.Code -eq 200) ("HTTP " + $pol.Code)
Ok 'A3 注册模式 = invite' ((Field $pol.Json 'registration') -eq 'invite') ([string](Field $pol.Json 'registration'))
Ok 'A4 需要邀请码标记' ((Field $pol.Json 'inviteRequired') -eq $true) ([string](Field $pol.Json 'inviteRequired'))
$allowed = Field $pol.Json 'allowedDomains'
Ok 'A5 允许域名包含本机域' ($allowed -contains $domain) ([string]::Join(',', $allowed))
Ok 'A6 密码最短长度 >= 8' (([int](Field $pol.Json 'minPasswordLength' 0)) -ge 8) ([string](Field $pol.Json 'minPasswordLength'))
$note = [string](Field $pol.Json 'verificationNote' '')
Ok 'A7 策略里说明了「本机托管地址免验证」的原因' ($note.Length -gt 10) $note
# ---------------------------------------------------------------- B/C 注册
$badInvite = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = 'WRONG-CODE'
}
Ok 'B1 邀请码错误被拒(403)' ($badInvite.Code -eq 403) ([string](Field $badInvite.Json 'error'))
$badDomain = Invoke-Api 'POST' '/api/register' @{
email = "nobody-$suffix@example.com"; password = $newPassword; displayName = '外部域'; inviteCode = $invite
}
Ok 'B2 白名单外的域名被拒(400)' ($badDomain.Code -eq 400) ([string](Field $badDomain.Json 'error'))
$weak = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = '12345678'; displayName = '弱密码'; inviteCode = $invite
}
Ok 'B3 弱密码被拒(400)' ($weak.Code -eq 400) ([string](Field $weak.Json 'error'))
$reg = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = $newPassword; displayName = '验收账号'; inviteCode = $invite
}
$session = Field $reg.Json 'session'
$token = [string](Field $session 'token' '')
if ($reg.Code -eq 429) {
# 一小时内重复跑本脚本会撞到 IP 配额(配额按「真的建出的账号数」计,见 AccountService)。
# 这不是缺陷,但要如实标注,并改用管理员接口建号,让后面的 40 多项用例照常跑完。
Skip 'C1/C2/C3 自助注册链路' '本小时该 IP 的注册配额已用尽(重复运行脚本所致);已改用管理员接口建号继续验收'
$boot = Invoke-Api 'POST' '/api/admin/users' @{ email = $newEmail; password = $newPassword; displayName = '验收账号' } $adminToken
Ok 'C1b 配额用尽时管理员接口可以建号' ($boot.Code -eq 201) ("HTTP " + $boot.Code + " " + [string](Field $boot.Json 'error'))
$boot2 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
$token = Get-Token $boot2.Json
Ok 'C1c 兜底建的号可以登录' ($boot2.Code -eq 200 -and $token.Length -gt 20) ("HTTP " + $boot2.Code)
} else {
Ok 'C1 本机域注册成功(201)' ($reg.Code -eq 201) ("HTTP " + $reg.Code + " " + [string](Field $reg.Json 'error'))
Ok 'C2 本机域注册不需要邮箱验证(死循环回归)' ((Field $reg.Json 'verificationRequired') -eq $false) ([string](Field $reg.Json 'verificationRequired'))
Ok 'C3 注册直接返回会话 token' ($token.Length -gt 20) ("token 长度 " + $token.Length)
}
$me = Invoke-Api 'GET' '/api/me' $null $token
Ok 'C4 注册后的 token 可访问 /api/me' ($me.Code -eq 200) ("HTTP " + $me.Code)
Ok 'C5 /api/me 返回的账号一致' (([string](Field (Field $me.Json 'user') 'email' '')) -eq $newEmail) ([string](Field (Field $me.Json 'user') 'email' ''))
$login1 = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
$token2 = Get-Token $login1.Json
Ok 'C6 新账号可以正常登录' ($login1.Code -eq 200 -and $token2.Length -gt 20) ("HTTP " + $login1.Code)
if (-not $token) { $token = $token2 }
# ---------------------------------------------------------------- D 会话 / 资料 / 审计
$prof = Invoke-Api 'PATCH' '/api/account/profile' @{ displayName = "验收账号-$suffix" } $token
Ok 'D1 修改显示名成功' ($prof.Code -eq 200) ([string](Field $prof.Json 'error'))
$me2 = Invoke-Api 'GET' '/api/me' $null $token
Ok 'D2 显示名已生效' (([string](Field (Field $me2.Json 'user') 'displayName' '')) -eq "验收账号-$suffix") ([string](Field (Field $me2.Json 'user') 'displayName' ''))
$sess = Invoke-Api 'GET' '/api/account/sessions' $null $token
$sessList = Field $sess.Json 'sessions'
Ok 'D3 会话列表可读且包含当前会话' ($sess.Code -eq 200 -and $null -ne ($sessList | Where-Object { (Field $_ 'current') -eq $true })) ("共 " + @($sessList).Count + " 个会话")
$audit = Invoke-Api 'GET' '/api/account/audit?limit=50' $null $token
$events = Field $audit.Json 'events'
$reasons = @($events | ForEach-Object { [string](Field $_ 'reason' '') })
Ok 'D4 审计里能看到 register 事件' ($reasons -contains 'register') ([string]::Join(',', $reasons))
Ok 'D5 审计里能看到 login-ok 事件' ($reasons -contains 'login-ok') ''
Ok 'D6 审计里能看到 profile-updated 事件' ($reasons -contains 'profile-updated') ''
# ---------------------------------------------------------------- E 真实 IMAP
$imap = Test-ImapLogin $newEmail $newPassword
Ok 'E1 新账号能用真实 IMAP(993 隐式 TLS) 登录' $imap.Ok ([string]$imap.Detail)
Ok 'E2 IMAP SELECT INBOX 成功' ($imap.Ok -and $imap.Select -ne '') ([string]$imap.Select)
# 自己给自己发一封中文邮件,验证本地投递进了这个新信箱
$send = Invoke-Api 'POST' '/api/send' @{
to = $newEmail; subject = "账号验收邮件 $suffix"; text = "这封邮件用来验证新注册账号的信箱能收信。编号 $suffix"
} $token
Ok 'E3 新账号可以发信(入队 202)' ($send.Code -eq 202) ("HTTP " + $send.Code + " " + [string](Field $send.Json 'error'))
$landed = Wait-InboxMessage $token "账号验收邮件*" 40
$landedId = [string](Field $landed 'id' '')
Ok 'E4 自己发的邮件已投递进收件箱' ($null -ne $landed -and $landedId.Length -gt 0) ([string](Field $landed 'subject' '(未收到)'))
$imap2 = Test-ImapLogin $newEmail $newPassword
Ok 'E5 IMAP 收件箱计数 >= 1' ($imap2.Ok -and $imap2.Count -ge 1) ([string]$imap2.Select)
# ---------------------------------------------------------------- F 忘记密码 → 读信取码 → 重置
$forgot = Invoke-Api 'POST' '/api/auth/forgot' @{ email = $newEmail }
Ok 'F1 申请重置密码返回成功' ($forgot.Code -eq 200) ("HTTP " + $forgot.Code + " " + [string](Field $forgot.Json 'error'))
$resetMail = Wait-InboxMessage $token '*重置密码验证码*' 40
$resetId = [string](Field $resetMail 'id' '')
Ok 'F2 重置验证码邮件已投进收件箱' ($resetId.Length -gt 0) ([string](Field $resetMail 'subject' '(未收到)'))
$code = ''
if ($resetId) {
$detail = Invoke-Api 'GET' ("/api/messages/" + [Uri]::EscapeDataString($resetId)) $null $token
$msg = Field $detail.Json 'message'
$body = [string](Field $msg 'text' '')
if (-not $body) { $body = [string](Field $msg 'html' '') }
# ⚠ 必须锚定「验证码:」这个标签:邮件正文里还写着收件人地址(selftest-123456@…),
# 直接抓第一个 6 位数字会抓到地址里的数字,测试自己就成了假失败源。
$cm = [regex]::Match($body, '验证码[::]\s*(\d{6})')
if (-not $cm.Success) {
$all = [regex]::Matches($body, '\b(\d{6})\b')
if ($all.Count -gt 0) { $cm = $all[$all.Count - 1] }
}
if ($cm.Success) {
if ($cm.Groups.Count -gt 1) { $code = $cm.Groups[1].Value } else { $code = $cm.Value }
}
}
Ok 'F3 能从邮件正文里取出 6 位验证码' ($code.Length -eq 6) ("code=" + $(if ($code) { $code } else { '(空)' }))
$badReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = '000000'; password = $resetPassword }
Ok 'F4 错误验证码被拒(400)' ($badReset.Code -eq 400) ([string](Field $badReset.Json 'error'))
if ($code.Length -eq 6) {
$goodReset = Invoke-Api 'POST' '/api/auth/reset' @{ email = $newEmail; code = $code; password = $resetPassword }
Ok 'F5 正确验证码重置成功' ($goodReset.Code -eq 200) ("HTTP " + $goodReset.Code + " " + [string](Field $goodReset.Json 'error'))
}
$oldLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
Ok 'F6 重置后旧密码失效(401)' ($oldLogin.Code -eq 401) ("HTTP " + $oldLogin.Code)
$newLogin = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
$token3 = Get-Token $newLogin.Json
Ok 'F7 重置后新密码可登录' ($newLogin.Code -eq 200 -and $token3.Length -gt 20) ("HTTP " + $newLogin.Code)
$imap3 = Test-ImapLogin $newEmail $resetPassword
Ok 'F8 新密码同样能用 IMAP 登录' $imap3.Ok ([string]$imap3.Detail)
if ($token3) { $token = $token3 }
# ---------------------------------------------------------------- G 改密踢掉其他会话
$loginExtra = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $resetPassword }
$token4 = Get-Token $loginExtra.Json
$before = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
$changed = Invoke-Api 'POST' '/api/account/password' @{ currentPassword = $resetPassword; password = $newPassword } $token
$after = @(Field (Invoke-Api 'GET' '/api/account/sessions' $null $token).Json 'sessions').Count
Ok 'G1 修改密码成功' ($changed.Code -eq 200) ("HTTP " + $changed.Code + " " + [string](Field $changed.Json 'error'))
Ok 'G2 改密后其他会话被吊销(当前保留)' ($after -lt $before -and $after -ge 1) ("改前 $before → 改后 $after")
if ($token4) {
$stale = Invoke-Api 'GET' '/api/me' $null $token4
Ok 'G3 被踢掉的那个 token 已失效(401)' ($stale.Code -eq 401) ("HTTP " + $stale.Code)
}
# ---------------------------------------------------------------- H 登录失败锁定
$lockReg = Invoke-Api 'POST' '/api/register' @{
email = $lockEmail; password = $lockPassword; displayName = '锁定验收'; inviteCode = $invite
}
if ($lockReg.Code -eq 429) {
Skip 'H1 第二个测试账号自助注册' '本小时注册配额已用尽(重复运行脚本所致);改用管理员接口建号'
$lockReg = Invoke-Api 'POST' '/api/admin/users' @{ email = $lockEmail; password = $lockPassword; displayName = '锁定验收' } $adminToken
}
Ok 'H1 第二个测试账号建号成功' ($lockReg.Code -eq 201) ("HTTP " + $lockReg.Code + " " + [string](Field $lockReg.Json 'error'))
$maxFail = [int](Field $pol.Json 'maxLoginFailures' 8)
$lastCode = 0
for ($i = 1; $i -le ($maxFail + 1); $i++) {
$r = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = "Wrong-Password-$i" }
$lastCode = $r.Code
}
$lockReply = Invoke-Api 'POST' '/api/login' @{ email = $lockEmail; password = $lockPassword }
$retryAfter = Field $lockReply.Json 'retryAfterSeconds'
Ok 'H2 连续失败后返回 423 锁定' ($lockReply.Code -eq 423) ("HTTP " + $lockReply.Code + " " + [string](Field $lockReply.Json 'error'))
Ok 'H3 锁定响应带 retryAfterSeconds' (($null -ne $retryAfter) -and ([int]$retryAfter -gt 0)) ("retryAfterSeconds=" + [string]$retryAfter + " 原始响应: " + $lockReply.Raw)
Ok 'H4 锁定期间即使密码正确也被挡(不泄露密码对错)' ($lockReply.Code -eq 423) ''
# ---------------------------------------------------------------- I 管理员视角
$users = Invoke-Api 'GET' '/api/admin/users' $null $adminToken
$userList = Field $users.Json 'users'
$mine = $userList | Where-Object { ([string](Field $_ 'email' '')) -eq $newEmail }
Ok 'I2 管理员能看到新注册的账号' ($null -ne $mine) ("共 " + @($userList).Count + " 个账号")
Ok 'I3 新账号在管理员视角是启用状态' ($null -ne $mine -and (Field $mine 'active') -eq $true) ([string](Field $mine 'active'))
$adminAudit = Invoke-Api 'GET' '/api/admin/audit?limit=50' $null $adminToken
$adminEvents = Field $adminAudit.Json 'events'
$adminReasons = @($adminEvents | ForEach-Object { [string](Field $_ 'reason' '') })
Ok 'I4 管理员能看到全站审计' ($adminAudit.Code -eq 200 -and $adminEvents.Count -gt 0) ("共 " + @($adminEvents).Count + " 条,含 " + [string]::Join('/', ($adminReasons | Select-Object -Unique -First 6)))
$nonAdmin = Invoke-Api 'GET' '/api/admin/users' $null $token
Ok 'I5 普通账号访问管理接口被拒(403)' ($nonAdmin.Code -eq 403) ("HTTP " + $nonAdmin.Code)
# ---------------------------------------------------------------- J 停用 + 清理
$deact = Set-UserActive $adminToken $lockEmail $false
Ok 'J1 管理员可停用账号' ($deact.Code -eq 200) ("HTTP " + $deact.Code + " " + [string](Field $deact.Json 'error'))
$deact2 = Set-UserActive $adminToken $newEmail $false
Ok 'J2 清理:停用验收账号一' ($deact2.Code -eq 200) ("HTTP " + $deact2.Code)
$disabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = $newPassword }
Ok 'J3 被停用的账号不能登录(401,且不暴露账号状态)' ($disabled.Code -eq 401) ("HTTP " + $disabled.Code + " " + [string](Field $disabled.Json 'error'))
$disabledImap = Test-ImapLogin $newEmail $newPassword
Ok 'J4 被停用的账号不能登录 IMAP' (-not $disabledImap.Ok) ([string]$disabledImap.Detail)
# 停用必须是权威状态:不能靠「重新注册」把封禁翻回来(这是真机验收抓出来的洞)
$revive = Invoke-Api 'POST' '/api/register' @{
email = $newEmail; password = "Revive-Pass-$suffix"; displayName = '尝试复活'; inviteCode = $invite
}
Ok 'J5 被停用的账号不能靠重新注册复活(403)' ($revive.Code -eq 403) ("HTTP " + $revive.Code + " " + [string](Field $revive.Json 'error'))
$stillDisabled = Invoke-Api 'POST' '/api/login' @{ email = $newEmail; password = "Revive-Pass-$suffix" }
Ok 'J6 复活尝试后账号依然进不去(401)' ($stillDisabled.Code -eq 401) ("HTTP " + $stillDisabled.Code)
Say ''
Say ("=== 结果:{0} 项通过,{1} 项失败,{2} 项跳过 ===" -f $script:pass, $script:fail, $script:skip)
Say ''
Say "说明:测试期间创建的两个账号已停用(不是删除,服务器暂无删除账号接口):"
foreach ($a in $tempAccounts) { Say (" - {0}" -f $a) }
try {
$utf8 = New-Object Text.UTF8Encoding($true)
[System.IO.File]::WriteAllLines($Report, $script:logLines, $utf8)
Write-Host ("报告已写入 {0}" -f $Report)
} catch {
Write-Host ("报告写入失败:{0}" -f $_.Exception.Message)
}
exit $script:fail
+93
View File
@@ -0,0 +1,93 @@
<#
.SYNOPSIS
构建 → 自检 → 发布 → 部署到服务器 → 验证,一条命令完成 WpywMail 更新。
.EXAMPLE
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***'
.\deploy.ps1 -Server <SERVER_IP> -RemotePassword '***' -FullCopy # 首次部署或运行时变更时用
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Server,
[string]$RemoteUser = 'Administrator',
[string]$RemotePassword,
[string]$RemotePath = 'C:\Program Files\WpywMail',
[string]$TaskName = 'WpywMail',
[switch]$FullCopy,
[switch]$SkipTests
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$publish = Join-Path (Split-Path -Parent $root) 'work\publish-v2'
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
Step '1) 构建'
Push-Location $root
try {
& dotnet build -c Release -v q --nologo
if ($LASTEXITCODE -ne 0) { throw '构建失败' }
$exe = Join-Path $root 'bin\Release\net8.0\win-x64\WpywMail.Native.exe'
if (-not $SkipTests) {
Step '2) 自检'
& $exe --selftest
if ($LASTEXITCODE -ne 0) { throw '自检未通过,已中止部署' }
}
Step '3) 发布(自包含 win-x64)'
& dotnet publish -c Release -r win-x64 --self-contained true -v q --nologo -o $publish
if ($LASTEXITCODE -ne 0) { throw '发布失败' }
} finally { Pop-Location }
if ($RemotePassword) {
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
} else {
$cred = Get-Credential -UserName "$Server\$RemoteUser" -Message "连接 $Server 的凭据"
}
Step '4) 停服务'
$session = New-PSSession -ComputerName $Server -Credential $cred
try {
Invoke-Command -Session $session -ArgumentList $TaskName -ScriptBlock {
param($task)
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'" |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
}
Step '5) 传输文件'
if ($FullCopy) {
Write-Host ' 全量复制(首次部署或运行时版本变更时使用)'
Copy-Item -Path "$publish\*" -Destination $RemotePath -ToSession $session -Recurse -Force
} else {
Write-Host ' 增量复制(仅程序集,约 400 KB)'
foreach ($f in 'WpywMail.Native.dll', 'WpywMail.Native.exe', 'WpywMail.Native.pdb', 'WpywMail.Native.deps.json') {
Copy-Item -Path (Join-Path $publish $f) -Destination "$RemotePath\" -ToSession $session -Force
}
}
Step '6) 启动并验证'
Invoke-Command -Session $session -ArgumentList $TaskName, $RemotePath -ScriptBlock {
param($task, $path)
# 启动前在服务器上再跑一次自检,确保部署的就是通过测试的二进制
& (Join-Path $path 'WpywMail.Native.exe') --selftest | Select-String -Pattern '失败|=== 结果'
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 7
$p = Get-CimInstance Win32_Process -Filter "Name='WpywMail.Native.exe'"
if (-not $p) { throw '服务未能启动' }
" 运行中 PID: $($p.ProcessId)"
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object { $_.LocalPort -in 25, 587, 8787 } |
ForEach-Object { " 监听 {0}:{1}" -f $_.LocalAddress, $_.LocalPort }
Get-Content 'C:\WpywMailData\service.log' -Tail 4 -Encoding UTF8
}
} finally {
Remove-PSSession $session
}
Write-Host "`n部署完成。回滚:把 $RemotePath 换回 $RemotePath.v1-backup 并重启计划任务 $TaskName。" -ForegroundColor Green
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WpywMail.Native v2 —— 端到端验收测试(在服务器本机执行)
设计原则:
· 只用 Python 标准库(服务器上是 3.8,没有 requests / cryptography);
· 密码从 appsettings.json 读取,绝不硬编码;
· 自带纯 Python 的 RSA/SHA-256 PKCS#1 v1.5 验签器,直接对「将来要发布到 DNS 的公钥」
验证真实投递报文的 DKIM 签名 —— 这一步不依赖服务端私钥,也不依赖外部库;
· 覆盖收信、发信、API、IMAP、DKIM、以及三条安全回归(非开放中继 / 提交需认证 / 明文登录限制)。
用法:
python e2e_acceptance.py # 全部用例
python e2e_acceptance.py --report out.txt
退出码 = 失败用例数(0 表示全部通过)。
"""
import argparse
import base64
import hashlib
import imaplib
import json
import os
import re
import smtplib
import socket
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
from email.message import EmailMessage
from email import utils as email_utils
socket.setdefaulttimeout(20)
EXE = r"C:\Program Files\WpywMail\WpywMail.Native.exe"
CONFIG = r"C:\Program Files\WpywMail\appsettings.json"
PASSED = []
FAILED = []
def check(name, ok, detail=""):
(PASSED if ok else FAILED).append(name)
mark = "PASS" if ok else "FAIL"
line = " [{}] {}".format(mark, name)
if detail and not ok:
line += " <- " + str(detail)[:200]
elif detail:
line += " ({})".format(str(detail)[:120])
print(line)
return ok
def section(title):
print("\n=== {} ===".format(title))
def load_config():
with open(CONFIG, "r", encoding="utf-8-sig") as fh:
return json.load(fh)
# --------------------------------------------------------------------- API 客户端
def api(base, path, method="GET", payload=None, token=None, raw=False):
url = base.rstrip("/") + path
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json; charset=utf-8")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read()
return resp.status, (body if raw else json.loads(body.decode("utf-8")))
except urllib.error.HTTPError as err:
body = err.read()
try:
return err.code, json.loads(body.decode("utf-8"))
except Exception:
return err.code, body
# --------------------------------------------------------------------- 纯 Python DKIM 验签
def der_read(data, offset):
"""极简 DER TLV 解析,返回 (tag, value_bytes, next_offset)。"""
tag = data[offset]
offset += 1
length = data[offset]
offset += 1
if length & 0x80:
count = length & 0x7F
length = int.from_bytes(data[offset:offset + count], "big")
offset += count
return tag, data[offset:offset + length], offset + length
def spki_to_rsa(der):
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
_, spki, _ = der_read(der, 0)
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
if bitstring[0] != 0:
raise ValueError("BIT STRING 未使用位不为 0")
_, rsa_seq, _ = der_read(bitstring[1:], 0)
_, modulus, off2 = der_read(rsa_seq, 0)
_, exponent, _ = der_read(rsa_seq, off2)
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
def rsa_verify_sha256(n, e, signature, message):
try:
size = (n.bit_length() + 7) // 8
m = pow(int.from_bytes(signature, "big"), e, n)
em = m.to_bytes(size, "big")
if em[0] != 0x00 or em[1] != 0x01:
return False, "PKCS#1 padding 前缀不符"
sep = em.index(b"\x00", 2)
digest_info = em[sep + 1:]
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
if digest_info != expected:
return False, "DigestInfo 不匹配"
return True, ""
except Exception as exc:
return False, "{}: {}".format(type(exc).__name__, exc)
def split_message(raw):
sep = raw.find(b"\r\n\r\n")
if sep < 0:
return [], raw
head, body = raw[:sep], raw[sep + 4:]
headers = []
name = None
for line in head.decode("latin-1").split("\r\n"):
if line[:1] in (" ", "\t") and name:
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
else:
idx = line.find(":")
if idx > 0:
name = line[:idx]
headers.append((name, line[idx + 1:]))
return headers, body
def canon_header(name, value):
unfolded = value.replace("\r\n", "").replace("\n", "")
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
def canon_body(body):
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
lines = [re.sub(r"[ \t]+", " ", line).rstrip(" \t") for line in text.split("\n")]
joined = "\r\n".join(lines).rstrip("\r\n")
return (joined + "\r\n").encode("latin-1") if joined else b""
def verify_dkim(raw, n, e):
"""按 RFC 6376 relaxed/relaxed 验证整封邮件的 DKIM 签名。"""
headers, body = split_message(raw)
dkim = [v for k, v in headers if k.lower() == "dkim-signature"]
if not dkim:
return False, "报文没有 DKIM-Signature 头"
tags = {}
for part in dkim[0].split(";"):
if "=" in part:
key, _, value = part.partition("=")
tags[key.strip()] = value.strip()
if tags.get("a") != "rsa-sha256":
return False, "非 rsa-sha256: " + str(tags.get("a"))
if base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode() != tags.get("bh"):
return False, "正文哈希(bh)不匹配"
# RFC 6376 §3.7 第 2 步:先按 h= 顺序哈希各被签名头(每个后面跟一个 CRLF),
# 最后哈希 DKIM-Signature 头本身且不带结尾 CRLF。
signing = ""
for name in tags.get("h", "").split(":"):
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
if hit is None:
return False, "缺少被签名头 " + name
signing += canon_header(name, hit) + "\r\n"
signing += canon_header("DKIM-Signature", dkim[0].replace(tags["b"], "").rstrip())
return rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
# --------------------------------------------------------------------- SMTP 辅助
def smtp_talk(host, port, commands, starttls=False, auth=None, timeout=25):
"""返回每一步的响应码列表,便于断言协议细节。"""
out = []
client = smtplib.SMTP(host, port)
client.ehlo("e2e.local")
out.append(("EHLO", 250, sorted(client.esmtp_features.keys())))
if starttls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
# Python 3.8 的 smtplib.starttls(keyfile, certfile, context):context 必须用关键字传,
# 否则会被当成 keyfile(报 'certfile must be specified')
client.starttls(context=ctx)
client.ehlo("e2e.local")
out.append(("EHLO-after-TLS", 250, sorted(client.esmtp_features.keys())))
if auth:
client.login(auth[0], auth[1])
out.append(("AUTH", 235, ""))
return client, out
def build_chinese_message(sender, recipient, subject, body):
msg = EmailMessage()
msg["From"] = sender
msg["To"] = recipient
msg["Subject"] = subject
msg["Date"] = email_utils.formatdate(localtime=True)
msg["Message-ID"] = email_utils.make_msgid(domain=sender.split("@")[-1])
msg.set_content(body)
return msg
def build_raw_8bit(sender, recipient, subject, body, token):
return (
"From: {} <{}>\r\n"
"To: {}\r\n"
"Subject: {}\r\n"
"Date: {}\r\n"
"Message-ID: <e2e-{}@e2e.local>\r\n"
"MIME-Version: 1.0\r\n"
"Content-Type: text/plain; charset=UTF-8\r\n"
"Content-Transfer-Encoding: 8bit\r\n"
"\r\n"
"{}\r\n"
).format("验收发件人", sender, recipient, subject, email_utils.formatdate(localtime=True), token, body).encode("utf-8")
# --------------------------------------------------------------------- 主流程
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--report", default=r"C:\_probe\e2e_report.txt")
parser.add_argument("--host", default="127.0.0.1")
args = parser.parse_args()
cfg = load_config()
domain = cfg["Domain"]
hostname = cfg["Hostname"]
account = cfg["AdminEmail"]
password = cfg["AdminPassword"]
smtp_port = int(cfg.get("SmtpPort", 25))
submission_port = int(cfg.get("SubmissionPort", 587))
imap_port = int(cfg.get("Imap", {}).get("Port", 143))
api_base = "http://127.0.0.1:{}/".format(
re.search(r":(\d+)", cfg.get("HttpPrefix", "http://127.0.0.1:8787/")).group(1))
stamp = time.strftime("%H%M%S")
host = args.host
print("WpywMail v2 端到端验收 domain={} account={} {}".format(domain, account, stamp))
# ---------- 1. 二进制自检 ----------
section("1) 二进制自检(--check-config / --selftest)")
try:
out = subprocess.run([EXE, "--check-config"], capture_output=True, timeout=60)
check("配置校验通过", out.returncode == 0, out.stderr.decode("utf-8", "replace")[:120])
out = subprocess.run([EXE, "--selftest"], capture_output=True, timeout=180)
text = out.stdout.decode("utf-8", "replace")
match = re.search(r"=+ 结果:(\d+) 项通过,(\d+) 项失败", text)
check("内建自检全部通过", out.returncode == 0 and match and match.group(2) == "0",
match.group(0) if match else text[-160:])
except Exception as exc:
check("二进制自检", False, exc)
# ---------- 2. DKIM 公钥可导出 ----------
section("2) DKIM 公钥与 RSA 参数")
dkim_record = ""
n = e = None
try:
out = subprocess.run([EXE, "--dkim-dns"], capture_output=True, timeout=120)
for line in out.stdout.decode("utf-8", "replace").splitlines():
if line.startswith("VALUE="):
dkim_record = line[len("VALUE="):].strip()
if line.startswith("NAME="):
dkim_name = line[len("NAME="):].strip()
check("--dkim-dns 输出公钥记录", dkim_record.startswith("v=DKIM1;"), dkim_record[:80])
match = re.search(r"p=([A-Za-z0-9+/=]+)", dkim_record)
n, e = spki_to_rsa(base64.b64decode(match.group(1)))
check("公钥可解析为 RSA 参数(2048 位)", n.bit_length() == 2048,
"modulus {} 位".format(n.bit_length()))
print(" 待发布记录: {}.{} = {}...(共 {} 字符)".format(
dkim_name, domain, dkim_record[:48], len(dkim_record)))
except Exception as exc:
check("DKIM 公钥导出", False, exc)
# ---------- 3. SMTP 收信(8bit 裸 UTF-8 中文) ----------
section("3) SMTP 收信(公网收信端口,8bit 裸 UTF-8)")
inbox_token = "E2E-IN-" + stamp
try:
subject = "[验收] 外网中文邮件 " + inbox_token
body = "这是验收测试注入的中文正文。\n标记:{}\n全角标点:你好,世界。()《》——".format(inbox_token)
raw = build_raw_8bit("[email protected]", account, subject, body, inbox_token)
client = smtplib.SMTP(host, smtp_port)
code, caps = client.ehlo("e2e.local")
check("25 端口 EHLO 成功", code == 250, code)
check("25 端口广告 8BITMIME", "8bitmime" in [c.lower() for c in caps.decode().split("\n")[-1:]] or b"8BITMIME" in caps,
caps.decode("utf-8", "replace")[:120])
client.sendmail("[email protected]", [account], raw, mail_options=["BODY=8BITMIME"])
client.quit()
check("8bit 中文邮件投递被接受", True)
except Exception as exc:
check("8bit 中文邮件投递被接受", False, exc)
# ---------- 4. API 登录与收信入库 ----------
section("4) API:登录 / 收件箱 / 中文解码")
token = None
try:
status, obj = api(api_base, "/api/login", "POST", {"email": account, "password": password})
check("登录成功", status == 200 and obj.get("token"), status)
token = obj.get("token")
status, obj = api(api_base, "/api/me", token=token)
check("/api/me 返回统计", status == 200 and "stats" in obj, status)
time.sleep(1.5)
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + inbox_token, token=token)
found = obj.get("messages", []) if status == 200 else []
check("刚投递的中文邮件出现在收件箱", len(found) == 1, "命中 {} 封".format(len(found)))
if found:
check("主题中文正确(无编解码乱码)", inbox_token in found[0]["subject"], found[0]["subject"])
check("正文预览中文正确", "验收测试注入的中文正文" in found[0].get("preview", ""), found[0].get("preview"))
except Exception as exc:
check("API 收件箱检查", False, exc)
# ---------- 5. SMTP 提交(STARTTLS + AUTH) ----------
section("5) SMTP 提交端口:STARTTLS + AUTH + 中文提交")
submit_token = "E2E-OUT-" + stamp
try:
client, steps = smtp_talk(host, submission_port, [], starttls=True)
caps_plain = steps[0][2]
check("587 明文阶段广告 STARTTLS", "starttls" in caps_plain, caps_plain)
caps_tls = steps[1][2] if len(steps) > 1 else []
check("TLS 之后才广告 AUTH", "auth" in caps_tls, caps_tls)
client.login(account, password)
check("AUTH 认证成功(旧版此处为 538 死锁)", True)
msg = build_chinese_message(account, account, "[验收] 587 提交中文邮件 " + submit_token,
"通过 STARTTLS + AUTH 提交的中文正文。\n标记:{}".format(submit_token))
client.send_message(msg)
client.quit()
check("中文邮件经 587 提交成功", True)
except Exception as exc:
check("587 提交链路", False, exc)
# ---------- 6. 队列与本地投递 ----------
section("6) 出站队列与本地投递")
try:
deadline = time.time() + 40
state = "?"
while time.time() < deadline:
status, obj = api(api_base, "/api/queue", token=token)
items = [x for x in obj.get("queue", []) if any(r == account for r in x["recipients"])]
if items and all(x["status"] == "sent" for x in items):
state = "sent"
break
state = items[-1]["status"] if items else "(无任务)"
time.sleep(3)
check("发给本机账号的任务投递完成", state == "sent", "最终状态=" + state)
except Exception as exc:
check("队列投递", False, exc)
# ---------- 7. DKIM 独立验签(对公钥验证真实投递报文) ----------
section("7) DKIM:对将来要发布的公钥验证真实投递报文")
if n and e:
try:
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
msgs = obj.get("messages", []) if status == 200 else []
signed_id = msgs[0]["id"] if msgs else None
if not signed_id:
check("找到带签名的投递副本", False, "收件箱没有找到 587 提交的那封")
else:
status, raw = api(api_base, "/api/messages/{}/raw".format(signed_id), token=token, raw=True)
has_sig = b"DKIM-Signature" in raw
check("投递副本带 DKIM-Signature", has_sig, "{} 字节".format(len(raw)))
if has_sig:
ok, reason = verify_dkim(raw, n, e)
check("DKIM 签名对公钥验证通过(rsa-sha256 / 正文哈希一致)", ok, reason)
except Exception as exc:
check("DKIM 独立验签", False, exc)
else:
check("DKIM 独立验签", False, "公钥不可用")
# ---------- 8. API 其余端点 ----------
section("8) API:附件 / 搜索 / 标记 / 长轮询")
try:
status, version = api(api_base, "/api/watch?since=0", token=token)
check("/api/watch 长轮询返回版本号", status == 200 and "version" in version, status)
attachment = base64.b64encode("中文附件内容".encode("utf-8")).decode()
status, obj = api(api_base, "/api/send", "POST", {
"to": account,
"subject": "[验收] 带附件 " + submit_token,
"text": "正文见附件。",
"attachments": [{"fileName": "验收附件.txt", "contentType": "text/plain", "base64": attachment}],
}, token=token)
check("/api/send 带附件入队(202)", status == 202 and obj.get("queued"), status)
time.sleep(6)
status, obj = api(api_base, "/api/messages?folder=inbox&q=" + submit_token, token=token)
hits = obj.get("messages", []) if status == 200 else []
attach_msg = next((m for m in hits if m.get("hasAttachments")), None)
check("带附件的邮件入库并识别出附件", attach_msg is not None,
"命中 {} 封".format(len(hits)))
if attach_msg:
status, blob = api(api_base, "/api/messages/{}/attachments/0".format(attach_msg["id"]),
token=token, raw=True)
check("附件按原字节下载", status == 200 and blob.decode("utf-8", "replace") == "中文附件内容",
"{} 字节".format(len(blob) if isinstance(blob, bytes) else -1))
if hits:
mid = hits[0]["id"]
status, obj = api(api_base, "/api/messages/{}".format(mid), "PATCH", {"starred": True}, token=token)
check("PATCH 设置星标", status == 200 and obj["message"]["starred"], status)
status, obj = api(api_base, "/api/messages?folder=inbox&starred=1", token=token)
check("按星标筛选命中", status == 200 and any(m["id"] == mid for m in obj.get("messages", [])), status)
except Exception as exc:
check("API 其余端点", False, exc)
# ---------- 9. IMAP ----------
section("9) IMAP:真实客户端流程")
try:
imap = imaplib.IMAP4(host, imap_port)
check("IMAP 欢迎语含 IMAP4rev1", b"IMAP4rev1" in imap.welcome, imap.welcome[:60])
caps = imap.capability()[1][0].decode()
check("广告 STARTTLS", "STARTTLS" in caps, caps[:80])
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
imap.starttls(ctx)
check("STARTTLS 握手成功", True)
imap.login(account, password)
check("IMAP LOGIN 成功", True)
boxes = imap.list()[1]
check("LIST 返回 6 个文件夹", len(boxes) == 6, "{} 个".format(len(boxes)))
typ, data = imap.select("INBOX")
exists = int(data[0])
check("SELECT INBOX 有邮件", exists > 0, "{} 封".format(exists))
typ, data = imap.search(None, "ALL")
ids = data[0].split()
check("SEARCH ALL 返回序号", len(ids) == exists, "{} vs {}".format(len(ids), exists))
typ, data = imap.fetch(ids[-1], "(FLAGS RFC822.SIZE ENVELOPE)")
summary = data[0].decode("latin-1") if data and isinstance(data[0], bytes) else str(data[0])
check("FETCH 摘要含 ENVELOPE 与 RFC822.SIZE", "ENVELOPE" in summary and "RFC822.SIZE" in summary,
summary[:120])
check("RFC822.SIZE 非 0(旧邮件回退实际文件大小)", "RFC822.SIZE 0 " not in summary, summary[:120])
typ, data = imap.fetch(ids[-1], "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])")
head = data[0][1].decode("utf-8", "replace") if isinstance(data[0], tuple) else b"".decode()
check("HEADER.FIELDS 可取回主题", "Subject:" in head, head[:80])
typ, data = imap.store(ids[-1], "+FLAGS", "(\\Seen)")
check("STORE +FLAGS 成功", typ == "OK", typ)
draft = "From: {}\r\nTo: [email protected]\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\n\r\n正文\r\n".format(
account, base64.b64encode("验收草稿".encode("utf-8")).decode())
typ, data = imap.append("Drafts", "(\\Draft)", None, draft.encode("utf-8"))
check("APPEND 中文草稿成功", typ == "OK", typ)
imap.select("Drafts")
typ, data = imap.search(None, "ALL")
check("草稿已入库", len(data[0].split()) > 0, data[0])
imap.logout()
check("IMAP 正常注销", True)
except Exception as exc:
check("IMAP 流程", False, "{}: {}".format(type(exc).__name__, exc))
# ---------- 10. 安全回归 ----------
section("10) 安全回归:非开放中继 / 提交需认证 / 明文登录限制")
try:
client = smtplib.SMTP(host, smtp_port)
client.ehlo("e2e.local")
client.docmd("MAIL FROM:<[email protected]>")
code, resp = client.docmd("RCPT TO:<[email protected]>")
check("25 端口拒绝外域收件人(非开放中继)", code == 550, "{} {}".format(code, resp))
client.quit()
except Exception as exc:
check("非开放中继", False, exc)
try:
client = smtplib.SMTP(host, submission_port)
client.ehlo("e2e.local")
code, resp = client.docmd("MAIL FROM:<[email protected]>")
check("587 未认证即发信被拒(530)", code == 530, "{} {}".format(code, resp))
client.quit()
except Exception as exc:
check("提交需认证", False, exc)
# ---------- 汇总 ----------
section("汇总")
total = len(PASSED) + len(FAILED)
print(" 通过 {} / {},失败 {}".format(len(PASSED), total, len(FAILED)))
if FAILED:
print(" 失败用例:")
for name in FAILED:
print(" - " + name)
try:
with open(args.report, "w", encoding="utf-8") as fh:
fh.write("WpywMail v2 端到端验收报告 {}\n".format(time.strftime("%Y-%m-%d %H:%M:%S")))
fh.write("域: {} 账号: {} 主机: {}\n\n".format(domain, account, hostname))
fh.write("通过 {} / {},失败 {}\n".format(len(PASSED), total, len(FAILED)))
if FAILED:
fh.write("\n失败用例:\n" + "\n".join("- " + x for x in FAILED) + "\n")
fh.write("\n全部用例:\n" + "\n".join("PASS " + x for x in PASSED) + "\n")
print(" 报告已写入: " + args.report)
except Exception as exc:
print(" 报告写入失败: {}".format(exc))
return len(FAILED)
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
<#
.SYNOPSIS
把 WpywMail 需要的 DKIM / DMARC 记录写入 Cloudflare(一条命令补完 DNS)。
.DESCRIPTION
记录值直接由服务端二进制从 DKIM 私钥推导(--dkim-dns),避免手工复制出错。
幂等:已存在的同名记录会被更新而不是重复创建。
.EXAMPLE
# 在本机执行(会通过 WinRM 读取服务器上的公钥)
.\publish-dns.ps1 -Server <SERVER_IP> -Zone wpy.email -ApiToken "<CF Token>"
.NOTES
Token 需要权限:Zone:DNS:Edit(对该 zone)。
也可先只看将要写入的内容而不实际提交:加上 -WhatIfOnly
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Server,
[Parameter(Mandatory)][string]$Zone,
[Parameter(Mandatory)][string]$ApiToken,
[string]$RemoteUser = 'Administrator',
[string]$RemotePassword,
[string]$RemoteExe = 'C:\Program Files\WpywMail\WpywMail.Native.exe',
[string]$DmarcPolicy = 'p=none',
[switch]$WhatIfOnly
)
$ErrorActionPreference = 'Stop'
function Invoke-Cf {
param([string]$Method, [string]$Path, $Body)
$uri = "https://api.cloudflare.com/client/v4$Path"
$headers = @{ Authorization = "Bearer $ApiToken"; 'Content-Type' = 'application/json' }
if ($Body) {
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body ($Body | ConvertTo-Json -Depth 8)
} else {
Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers
}
}
# ---------- 1) 从服务器取回需要写入的记录 ----------
Write-Host '正在从服务器读取 DKIM 公钥…' -ForegroundColor Cyan
if ($RemotePassword) {
$sec = ConvertTo-SecureString $RemotePassword -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential("$Server\$RemoteUser", $sec)
$lines = Invoke-Command -ComputerName $Server -Credential $cred -ScriptBlock {
param($exe) & $exe --dkim-dns
} -ArgumentList $RemoteExe
} else {
$lines = Invoke-Command -ComputerName $Server -ScriptBlock {
param($exe) & $exe --dkim-dns
} -ArgumentList $RemoteExe
}
$map = @{}
foreach ($line in $lines) {
if ($line -match '^([A-Z_]+)=(.*)$') { $map[$Matches[1]] = $Matches[2] }
}
if (-not $map['NAME'] -or -not $map['VALUE']) { throw "未能从服务器取得 DKIM 记录(输出:`n$($lines -join "`n"))" }
$dmarcShortName = $map['DMARC_NAME'] -replace "\.$([regex]::Escape($Zone))$", ''
$dmarcValue = $map['DMARC_VALUE'] -replace 'p=none', $DmarcPolicy
$records = @(
@{ Type = 'TXT'; Name = $map['NAME']; Content = $map['VALUE']; Comment = 'WpywMail DKIM' },
@{ Type = 'TXT'; Name = $dmarcShortName; Content = $dmarcValue; Comment = 'WpywMail DMARC' }
)
Write-Host "`n将写入以下记录(zone=$Zone):" -ForegroundColor Cyan
foreach ($r in $records) {
Write-Host (" {0,-4} {1,-28} {2}" -f $r.Type, $r.Name, ($r.Content.Substring(0, [Math]::Min(80, $r.Content.Length)) + $(if ($r.Content.Length -gt 80) { '…' } else { '' })))
}
if ($WhatIfOnly) { Write-Host "`n-WhatIfOnly:未提交任何更改。" -ForegroundColor Yellow; return }
# ---------- 2) 解析 zone id ----------
$zones = Invoke-Cf -Method GET -Path "/zones?name=$Zone"
if (-not $zones.result -or $zones.result.Count -eq 0) { throw "Cloudflare 中找不到 zone:$Zone(检查 Token 权限与域名拼写)" }
$zoneId = $zones.result[0].id
Write-Host "`nzone id: $zoneId" -ForegroundColor DarkGray
# ---------- 3) 幂等写入 ----------
foreach ($r in $records) {
$fqdn = if ($r.Name) { "$($r.Name).$Zone" } else { $Zone }
$existing = Invoke-Cf -Method GET -Path "/zones/$zoneId/dns_records?type=$($r.Type)&name=$fqdn"
$payload = @{ type = $r.Type; name = $fqdn; content = $r.Content; ttl = 1; comment = $r.Comment }
if ($existing.result.Count -gt 0) {
$id = $existing.result[0].id
$null = Invoke-Cf -Method PUT -Path "/zones/$zoneId/dns_records/$id" -Body $payload
Write-Host " [更新] $fqdn" -ForegroundColor Yellow
} else {
$null = Invoke-Cf -Method POST -Path "/zones/$zoneId/dns_records" -Body $payload
Write-Host " [新建] $fqdn" -ForegroundColor Green
}
}
Write-Host "`n完成。等 1-2 分钟后可用以下命令核验:" -ForegroundColor Cyan
Write-Host " Resolve-DnsName $($map['NAME']).$Zone -Type TXT -Server 1.1.1.1"
Write-Host " Resolve-DnsName _dmarc.$Zone -Type TXT -Server 1.1.1.1"
@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
"""
用「DNS 上已发布的 DKIM 公钥」独立验证真实投递报文的签名。
与 e2e_acceptance.py 内的验签不同:本脚本**不接触私钥**,只吃 DNS TXT 记录的
字面值(v=DKIM1; k=rsa; p=...),因此它证明的是收件方(Gmail/Outlook/QQ)
将会看到的事实:公钥一发布,签名即可被验证通过。
零第三方依赖:手写 DER 解析 SPKI -> (n, e),再用 pow() 做 RSA-SHA256
PKCS#1 v1.5 验签。Python 3.8+ 可跑。
用法:
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml a.eml --eml b.eml
python verify_published_dkim.py --dns-file dns-dkim-published.txt --eml-dir C:\\path\\raw
python verify_published_dkim.py --dns-file ... --eml-dir ... --selector mail --domain wpy.email
--dns-file 内容可以是整条 TXT(v=DKIM1; k=rsa; p=...),也可以是只含 p= 后面
那段 base64 的纯文本;还能容忍 DNS 分段留下的空白/换行。
退出码 = 验签失败的报文数(0 表示全部通过)。
"""
from __future__ import print_function
import argparse
import base64
import hashlib
import os
import re
import sys
SHA256_DIGEST_INFO = bytes.fromhex("3031300d060960864801650304020105000420")
# ------------------------------------------------------------------ DER / RSA
def der_read(data, offset):
"""读一个 TLV,返回 (tag, content, next_offset)。仅支持短/长形式长度。"""
tag = data[offset]
length = data[offset + 1]
offset += 2
if length & 0x80:
count = length & 0x7F
length = int.from_bytes(data[offset:offset + count], "big")
offset += count
return tag, data[offset:offset + length], offset + length
def spki_to_rsa(der):
"""从 SubjectPublicKeyInfo 里取出 (n, e)。"""
_, spki, _ = der_read(der, 0)
_, _, off = der_read(spki, 0) # AlgorithmIdentifier,跳过
_, bitstring, _ = der_read(spki, off) # subjectPublicKey BIT STRING
if bitstring[0] != 0:
raise ValueError("BIT STRING 未使用位不为 0")
_, rsa_seq, _ = der_read(bitstring[1:], 0)
_, modulus, off2 = der_read(rsa_seq, 0)
_, exponent, _ = der_read(rsa_seq, off2)
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
def rsa_verify_sha256(n, e, signature, message):
try:
size = (n.bit_length() + 7) // 8
if len(signature) != size:
return False, "签名长度 %d 与模长 %d 不符" % (len(signature), size)
m = pow(int.from_bytes(signature, "big"), e, n)
em = m.to_bytes(size, "big")
if em[0] != 0x00 or em[1] != 0x01:
return False, "PKCS#1 v1.5 padding 前缀不符"
sep = em.index(b"\x00", 2)
digest_info = em[sep + 1:]
expected = SHA256_DIGEST_INFO + hashlib.sha256(message).digest()
if digest_info != expected:
return False, "DigestInfo 不匹配(摘要或签名输入被改动)"
return True, ""
except Exception as exc: # noqa: BLE001 - 验签失败即失败
return False, "%s: %s" % (type(exc).__name__, exc)
# ------------------------------------------------------------------ DNS TXT 解析
def parse_txt_record(text):
"""从 TXT 字面值里取出 p= 的 base64 并解析成 (n, e)。"""
flat = re.sub(r"\s+", "", text) # DNS 分段/换行一律去掉
m = re.search(r"p=([A-Za-z0-9+/=]+)", flat)
if not m:
raise ValueError("TXT 里找不到 p= 公钥段")
pem_b64 = m.group(1)
try:
der = base64.b64decode(pem_b64, validate=True)
except Exception as exc: # noqa: BLE001
raise ValueError("p= 不是合法 base64: %s" % exc)
return spki_to_rsa(der), pem_b64
def record_tags(text):
flat = re.sub(r"\s+", " ", text).strip()
tags = {}
for part in flat.split(";"):
if "=" in part:
k, _, v = part.partition("=")
tags[k.strip().lower()] = v.strip()
return tags
# ------------------------------------------------------------------ DKIM 验证
def split_message(raw):
sep = raw.find(b"\r\n\r\n")
if sep < 0:
return [], raw
head, body = raw[:sep], raw[sep + 4:]
headers, name = [], None
for line in head.decode("latin-1").split("\r\n"):
if line[:1] in (" ", "\t") and name:
headers[-1] = (name, headers[-1][1] + "\r\n" + line)
else:
idx = line.find(":")
if idx > 0:
name = line[:idx]
headers.append((name, line[idx + 1:]))
return headers, body
def canon_header(name, value):
unfolded = value.replace("\r\n", "").replace("\n", "")
return name.strip().lower() + ":" + re.sub(r"[ \t]+", " ", unfolded).strip()
def canon_body(body):
text = body.decode("latin-1").replace("\r\n", "\n").replace("\r", "\n")
lines = [re.sub(r"[ \t]+", " ", ln).rstrip(" \t") for ln in text.split("\n")]
joined = "\r\n".join(lines).rstrip("\r\n")
return (joined + "\r\n").encode("latin-1") if joined else b""
def verify(raw, n, e):
"""返回 (是否通过, 说明, 详情 dict)。relaxed/relaxed + rsa-sha256。"""
headers, body = split_message(raw)
sigs = [v for k, v in headers if k.lower() == "dkim-signature"]
if not sigs:
return False, "报文里没有 DKIM-Signature 头", {}
tags = {}
for part in sigs[0].split(";"):
if "=" in part:
k, _, v = part.partition("=")
tags[k.strip().lower()] = v.strip()
info = {"d": tags.get("d"), "s": tags.get("s"), "a": tags.get("a"),
"c": tags.get("c"), "h": tags.get("h"), "bh": tags.get("bh"),
"b_len": len(tags.get("b", ""))}
if tags.get("a") != "rsa-sha256":
return False, "非 rsa-sha256(%s)" % tags.get("a"), info
if tags.get("c", "simple/simple") != "relaxed/relaxed":
return False, "非 relaxed/relaxed(%s)" % tags.get("c"), info
bh_calc = base64.b64encode(hashlib.sha256(canon_body(body)).digest()).decode()
info["bh_calc"] = bh_calc
if bh_calc != tags.get("bh"):
return False, "正文哈希 bh 不匹配(正文被改动)", info
signing = ""
for name in tags.get("h", "").split(":"):
if not name.strip():
continue
hit = next((v for k, v in headers if k.lower() == name.strip().lower()), None)
if hit is None:
return False, "被签名的头缺失: %s" % name, info
# RFC 6376 §3.7 第 2 步之 1:每个被签名头后面必须跟一个 CRLF
signing += canon_header(name, hit) + "\r\n"
# 之 2:DKIM-Signature 头本身放在**最后**,且结尾**不带 CRLF**
signing += canon_header("DKIM-Signature", sigs[0].replace(tags["b"], "").rstrip())
ok, why = rsa_verify_sha256(n, e, base64.b64decode(tags["b"]), signing.encode("ascii"))
return ok, why, info
# ------------------------------------------------------------------ 主流程
def collect(paths, eml_dir):
files = list(paths)
if eml_dir:
for name in sorted(os.listdir(eml_dir)):
if name.lower().endswith(".eml"):
files.append(os.path.join(eml_dir, name))
return files
def main():
ap = argparse.ArgumentParser(description="用 DNS 已发布的公钥验证 DKIM")
ap.add_argument("--dns-file", required=True, help="含已发布 TXT 值的文本文件")
ap.add_argument("--eml", action="append", default=[], help="待验报文(可重复)")
ap.add_argument("--eml-dir", help="目录下所有 .eml 都验")
ap.add_argument("--selector", default=None, help="期望的选择器(校验 s=)")
ap.add_argument("--domain", default=None, help="期望的签名域(校验 d=)")
args = ap.parse_args()
raw_txt = open(args.dns_file, "rb").read().decode("utf-8", "replace")
try:
(n, e), pem_b64 = parse_txt_record(raw_txt)
except ValueError as exc:
print("[致命] 无法从 DNS 值解析公钥: %s" % exc)
return 2
tags = record_tags(raw_txt)
print("=" * 68)
print("DNS 已发布 DKIM 公钥")
print("=" * 68)
print(" 记录长度 : %d 字符" % len(re.sub(r"\s+", "", raw_txt)))
print(" v / k : %s / %s" % (tags.get("v"), tags.get("k")))
print(" 公钥位长 : %d bit" % n.bit_length())
print(" 公钥指数 : %d" % e)
print(" p= 长度 : %d 字符 base64" % len(pem_b64))
if tags.get("v") != "DKIM1":
print(" [警告] v 不是 DKIM1")
if n.bit_length() < 1024:
print(" [警告] 密钥短于 1024 bit,多数收件方会直接判失败")
files = collect(args.eml, args.eml_dir)
if not files:
print("\n[致命] 没有指定任何 .eml")
return 2
print("\n" + "=" * 68)
print("对真实报文验签(只用上面这把公钥,不接触私钥)")
print("=" * 68)
failures = 0
for path in files:
name = os.path.basename(path)
data = open(path, "rb").read()
ok, why, info = verify(data, n, e)
print("\n%s (%d 字节)" % (name, len(data)))
print(" d=%s s=%s a=%s c=%s" % (info.get("d"), info.get("s"), info.get("a"), info.get("c")))
print(" 签名字段 b 长度 %s,h=%s" % (info.get("b_len"), info.get("h")))
print(" 正文哈希 bh: %s" % ("一致" if info.get("bh") == info.get("bh_calc") else "不一致"))
if args.domain and info.get("d") != args.domain:
ok, why = False, "d= 与期望域名 %s 不符(实际 %s)" % (args.domain, info.get("d"))
if args.selector and info.get("s") != args.selector:
ok, why = False, "s= 与期望选择器 %s 不符(实际 %s)" % (args.selector, info.get("s"))
if ok:
print(" [通过] RSA-SHA256 签名验证成功 —— 收件方用这条 DNS 记录即可验签")
else:
print(" [失败] %s" % why)
failures += 1
print("\n" + "=" * 68)
print("合计: %d 个报文,通过 %d,失败 %d" % (len(files), len(files) - failures, failures))
print("=" * 68)
return failures
if __name__ == "__main__":
sys.exit(main())