用 C# 读取邮箱邮件(net48 + Microsoft Graph)
第一步:先判断走哪条路线,不要直接写代码
先做环境探测,路线选错会白做一整天。
| 用户实际在用的客户端 | 能否走 Outlook COM |
|---|---|
经典 Outlook(OUTLOOK.EXE,Office 2016/2019/365 桌面版) |
✅ 可以,但需已配置账户 |
新版 Outlook for Windows(olk.exe,包 Microsoft.OutlookForWindows_8wekyb3d8bbwe) |
❌ 不行。它基于 Web/Graph,不提供 Outlook.Application COM 自动化接口(微软设计限制) |
| 只开网页版 Outlook | ❌ 不行 |
探测命令
# 1. 看进程里有哪个 Outlook
tasklist | grep -iE "outlook|olk"
# 2. 找经典 Outlook 安装位置
ls "/d/Program Files (x86)/Microsoft Office/Office14/OUTLOOK.EXE" 2>/dev/null
ls "/c/Program Files/Microsoft Office/root/Office16/OUTLOOK.EXE" 2>/dev/null
# 3. 关键:有没有邮件数据库(没有 = 从未配置账户,COM 路线必失败)
find /c /d -maxdepth 6 -iname "*.pst" -o -iname "*.ost" 2>/dev/null | head
ls "$APPDATA/Microsoft/Outlook/" 2>/dev/null
# 4. 判断邮箱服务类型(决定 Graph 还是 IMAP)
nslookup -type=MX 用户域名.cn
nslookup autodiscover.用户域名.cn
判据:
- MX 指向 *.mail.protection.outlook.com 或 autodiscover 指向 *.autodiscover.mira.tm.svc.cloud.microsoft → Exchange Online / M365 → 走 Graph API(最优)。
- 有自建 IMAP(imap.域名 能解析)→ 可走 IMAP,需邮箱 + 授权码。
- 经典 Outlook 已配账户 → 走 COM 最省事(无需 Azure 注册)。
COM 路线的典型死结
GetDefaultFolder(olFolderInbox) 抛 COMException: 无法完成该操作。您没有连接。
= 经典 Outlook 从未配置账户(全盘无 .pst/.ost、%APPDATA%\Microsoft\Outlook 为空)。
无法用代码修复,必须让用户在经典 Outlook 里配好账户,或者换 Graph/IMAP。
> 另一个坑:未配账户时 Outlook 会停在「账户设置向导」窗口,app.Quit() 关不掉向导,进程会残留。用 Stop-Process -Force 清理。
路线 A:Microsoft Graph(推荐,适合 M365 企业邮箱)
A1. net48 工程的编译配方(最关键,照抄)
csc.exe / msbuild.exe 在本机被 LOLBin 黑名单拦截,编译一律走 dotnet build。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType> <!-- 控制台用 Exe -->
<TargetFramework>net48</TargetFramework>
<LangVersion>7.3</LangVersion>
<PlatformTarget>x86</PlatformTarget>
<FrameworkPathOverride>C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8</FrameworkPathOverride>
<AutomaticallyUseReferenceAssemblyPackages>false</AutomaticallyUseReferenceAssemblyPackages>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<NoWarn>MSB3270;MSB3277;NU1701</NoWarn>
<UseWindowsForms>true</UseWindowsForms>
<!-- ★ 下面这行是 0 警告的关键 -->
<AssemblySearchPaths>$(AssemblySearchPaths);$(MSBuildProjectDirectory)\refs</AssemblySearchPaths>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
</PropertyGroup>
<ItemGroup>
<!-- 排除同目录下其他小工程的源码与 obj,否则报 CS0579 特性重复 -->
<Compile Remove="tools\**\*.cs" />
<Compile Remove="tools\**\obj\**" />
<!-- 注意:别把 tools\bin 也排掉,那是工具自己的输出目录 -->
</ItemGroup>
<ItemGroup>
<!-- ★ netstandard 门面程序集:必须指向工程内 refs\ 副本 -->
<Reference Include="netstandard"><HintPath>refs\netstandard.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.IO"><HintPath>refs\System.IO.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Runtime"><HintPath>refs\System.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Runtime.InteropServices.RuntimeInformation"><HintPath>refs\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Security.Cryptography.Algorithms"><HintPath>refs\System.Security.Cryptography.Algorithms.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Security.Cryptography.Encoding"><HintPath>refs\System.Security.Cryptography.Encoding.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Security.Cryptography.Primitives"><HintPath>refs\System.Security.Cryptography.Primitives.dll</HintPath><Private>false</Private></Reference>
<Reference Include="System.Security.Cryptography.X509Certificates"><HintPath>refs\System.Security.Cryptography.X509Certificates.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
<ItemGroup>
<!-- ★ 版本必须锁死。只留这两个,不要手工加 System.Text.Json / System.Memory -->
<PackageReference Include="Microsoft.Graph" Version="4.54.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.61.3" />
</ItemGroup>
</Project>
准备 refs\ 目录(一次性,之后可复制到新工程):
mkdir -p refs
FA="/c/Program Files (x86)/Reference Assemblies/Microsoft/Framework/.NETFramework/v4.8/Facades"
for f in netstandard System.IO System.Runtime System.Runtime.InteropServices.RuntimeInformation \
System.Security.Cryptography.Algorithms System.Security.Cryptography.Encoding \
System.Security.Cryptography.Primitives System.Security.Cryptography.X509Certificates; do
cp "$FA/$f.dll" refs/
done
编译:
dotnet build YourApp.csproj -c Release --disable-build-servers -v q --nologo
验收:0 警告 0 错误。
A2. 为什么需要 refs\(别浪费时间去试别的)
报错是每个 netstandard 引用一条 MSB3245: 无法解析引用。
根因:FrameworkPathOverride 模式下 targets 会把搜索路径重置,只在 v4.8 根目录找程序集,而门面程序集实际在 Facades\ 子目录里。
试过但失败的方案(不要重复踩):
- ❌ AssemblySearchPaths 加 {TargetFrameworkDirectory}\Facades —— 被 targets 重置。
- ❌ HintPath 直接指 $(FrameworkPathOverride)\Facades\*.dll —— 仍是主引用解析路径问题。
- ❌ 手工 <PackageReference Include="System.Text.Json" /> 等 —— 会因代理下载失败,且本来就是多余的传递依赖。
有效方案:门面 dll 复制到工程本地 refs\ + AssemblySearchPaths 追加该目录 + HintPath 指向。见 A1。
A3. MSAL 认证(AuthService.cs 骨架)
using Microsoft.Identity.Client;
private static readonly string[] Scopes = new[]
{
"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
"offline_access"
};
var app = PublicClientApplicationBuilder.Create(clientId)
.WithAuthority("https://login.microsoftonline.com/" + tenantId) // ★ 见下方「租户填什么」
.WithRedirectUri("http://localhost") // ★ 公共客户端本机回环,必须注册进 Azure
.Build();
★ 租户(authority)填什么 —— 填错必报 AADSTS50059
这是最容易踩且最难自查的坑(2026-09 实测确认)。
| 应用注册类型 | authority 必须填 | 填 organizations / common 的后果 |
|---|---|---|
| 仅此组织目录中的帐户(单租户,默认且最常见) | 具体租户域名或租户 ID,如 naga-mori.cn 或 11489fe9-b541-437e-8243-f948d31675ff |
❌ AADSTS50059: No tenant-identifying information found in either the request or implied by any provided credentials |
| 多租户 | organizations 可用 |
正常 |
怎么拿租户 ID(不需要登录,公开端点):
curl -s "https://login.microsoftonline.com/<你的域名>/v2.0/.well-known/openid-configuration" \
| grep -o '"issuer":"[^"]*"'
# issuer: https://login.microsoftonline.com/11489fe9-b541-437e-8243-f948d31675ff/v2.0
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 这就是租户 ID
自查口诀:报 AADSTS50059 = 应用是单租户但你用了多租户 authority。不要去查重定向 URI 或权限,跟它们无关。
> 实战教训:MailTray 的 README 曾让你「租户填 organizations」,对单租户应用是错的,会一直失败。
> organizations 只对多租户应用成立。
// token 缓存落盘,跨次启动免登录
app.UserTokenCache.SetBeforeAccess(a => {
if (File.Exists(CachePath)) a.TokenCache.DeserializeMsalV3(File.ReadAllBytes(CachePath));
});
app.UserTokenCache.SetAfterAccess(a => {
if (a.HasStateChanged) File.WriteAllBytes(CachePath, a.TokenCache.SerializeMsalV3());
});
// 交互式登录
var r = await app.AcquireTokenInteractive(Scopes)
.WithPrompt(Prompt.SelectAccount)
.WithUseEmbeddedWebView(false) // ★ system browser 模式必须显式关闭内嵌 WebView
.ExecuteAsync();
// 静默刷新
var accounts = await app.GetAccountsAsync();
var silent = await app.AcquireTokenSilent(Scopes, accounts.First()).ExecuteAsync();
缓存路径放 `%LOCALAPPDATA%\<AppName>\token_cache.bin`(**不要放 exe 目录**,随目录移动会丢)。
### A4. 调 Graph 拉邮件(Graph **4.x** API)
```csharp
using Microsoft.Graph;
using Microsoft.Graph.Auth; // ❌ 这个包不存在,别 using
// 认证走 DelegateAuthenticationProvider 把 token 塞进 header
var authProvider = new DelegateAuthenticationProvider(request => {
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return Task.CompletedTask;
});
var graph = new GraphServiceClient(authProvider);
var page = await graph.Users[mailbox].MailFolders.Inbox.Messages
.Request()
.Top(top)
.OrderBy("receivedDateTime desc")
.Select("id,subject,from,receivedDateTime,isRead,bodyPreview,hasAttachments,webLink,body")
.GetAsync();
foreach (var m in page) {
string sender = m.From?.EmailAddress?.Name;
// 附件名需二次查询
var atts = await graph.Users[mailbox].Messages[m.Id].Attachments
.Request().Select("name").GetAsync();
}
> ⚠️ 不存在 Microsoft.Graph.Auth 包(旧教程常见误导)。认证统一用 MSAL 或 Azure.Identity。
> ⚠️ 异常属性是 ServiceException.StatusCode,不是 ResponseStatusCode。
> ⚠️ Microsoft.Graph v5+ 的 Kiota 版不支持 .NET Framework,必须锁 4.x。
A5. AADSTS 错误码 → 用户该去哪点哪里
把下表做成 DescribeMsalError(),界面报错时直接告诉用户"去门户哪一步改什么",比让用户查文档强得多。
| 错误码 | 含义 | 修法 |
|---|---|---|
AADSTS50011 |
重定向 URI 不匹配 | 身份验证 → 添加平台「移动和桌面应用程序」→ 勾 http://localhost |
AADSTS65001 / 65004 |
未授予权限 | API 权限 → 点「代表 <租户> 授予管理员同意」 |
AADSTS7000218 |
未识别为公共客户端 | 身份验证 → 高级设置 → 「允许公共客户端流」= 是 |
AADSTS700016 / 500011 |
ClientId 或租户不对 | 核对 ClientId(是否误填了「对象 ID」) |
AADSTS50059 |
单租户应用用了 organizations/common 多租户 authority |
authority 换成具体租户域名或租户 ID(见 A3 下方专节) |
AADSTS50105 / 53003 |
条件访问策略拦截 | 账号被限制在此设备/应用登录,需管理员放行 |
| 浏览器没弹出 | 默认浏览器异常 | 让用户把默认浏览器设为 Edge/Chrome |
A6. Azure 应用注册必备三项(缺任一都会失败)
- 身份验证 → 添加平台 → 移动和桌面应用程序 → 勾
http://localhost(顺手也加https://login.microsoftonline.com/common/oauth2/nativeclient) - 身份验证 → 高级设置 → 允许公共客户端流 = 是
- API 权限 → Microsoft Graph → 委托的权限(不是「应用程序权限」!)→
Mail.Read+User.Read→ 代表 <租户> 授予管理员同意
门户入口:https://entra.microsoft.com(或 portal.azure.com 搜「Microsoft Entra ID」)。
- 账号至少要 应用程序开发者 或 云应用程序管理员 角色。
- 不需要 Azure 订阅、不需要客户端密码(公共客户端不存密码)。
> 企业租户通常关闭了「用户自行同意第三方应用」→ 第 3 步的「授予管理员同意」是必需的,不是可选。
卡在「需要批准」同意页怎么破(2026-09 MailTray 实战)
页面特征:三个权限打勾 + 「输入请求访问此应用的理由」文本框 + 「接受」按钮置灰 + 「此应用是未经认可的吗」
→ 租户开了管理员同意工作流(用户同意被关),用户自己点不了接受。处理顺序:
- 理由框直接填一句提交(会转给租户管理员审批)。理由模板:
「MailTray 为自研桌面托盘小工具,仅用于轮询本人收件箱的新邮件提醒,申请本人邮箱的
Mail.Read/User.Read 委托权限,不涉及组织数据导出,不保存密码。」 - 有管理员角色(全局管理员/应用程序管理员/云应用程序管理员)→ 开一键同意 URL 秒批:
https://login.microsoftonline.com/<租户域名或ID>/adminconsent?client_id=<ClientId> - ROPC(grant_type=password,纯账密换 token)绕不开这次授予:权限从未被同意过照样
AADSTS65001;租户强制 MFA 时直接 AADSTS50076 失败;微软已不推荐。别把它当"免审批方案"。
预期管理:审批 + 登录都只需一次——管理员批一次,用户交互登录一次,MSAL 令牌缓存落盘
(offline_access 刷新令牌随用随续)之后托盘轮询永久静默,不会再弹任何授权页。M365 的
账号密码直连(基础认证 IMAP/POP)微软 2022 年起已全面禁用,任何代码都绕不开 OAuth。
★ 别急着找管理员:先按「权限风险分级」试探一次(2026-09 实测有效)
用户说"卡在需要批准"时,不要立刻判定必须走管理员审批。很多租户的策略是
「允许用户同意低风险权限,只拦中高风险」——此时收窄权限就能自助开通。
实测结论(租户 naga-mori.cn,2026-09):
| Graph 委托权限 | 风险级别 | 该租户实测 |
|---|---|---|
User.Read、Mail.Read、Calendars.Read、Contacts.Read |
低 | ✅ 用户可自行同意,同意页「接受」可点 |
Mail.Send |
中 | ❌ 需管理员同意 |
Mail.ReadWrite 及各类 *.ReadWrite |
中/高 | ❌ 需管理员同意 |
操作顺序(成本从低到高,别一上来就发审批邮件):
- 先只请求低风险只读权限跑一次 device code 登录。通过 → 只读能力立刻可用,业务先转起来。
- 只读被拦或确需写权限 → 才走管理员审批(管理员同意工作流 /
adminconsent链接)。 - 给管理员的申请材料里,只列真正需要的那几项,并按风险分级说明,通过率明显更高。
> 判断技巧:把 scope 拆成"低风险只读"和"中高风险"两批分别试。
> 一次性把 Mail.ReadWrite + Mail.Send 全要上,往往直接被拒,还多耗一轮审批。
adminconsent 只能授予「已配置在应用上」的权限
一键同意链接:
https://login.microsoftonline.com/<租户ID>/adminconsent?client_id=<ClientId>
前置条件:该权限必须已经写在 Azure 应用的「API 权限」列表里。
应用上只配了 Mail.Read,那 adminconsent 也只能批 Mail.Read;代码里再请求 Mail.Send
照样失败。所以顺序永远是 先在应用里加权限 → 再授予同意,反过来白跑。
路线 B:IMAP(有自建 IMAP,或不想做 Azure 注册时)
需要:邮箱地址 + 授权码 / 应用密码(不是登录密码)。
Gmail/QQ/163 都要先在网页端「账户安全」里生成授权码。
.NET Framework 下用 MailKit(注意 MailKit 4.x 起要求 net472+,更老版本要挑)。
比 Graph 简单,但拿不到「已读状态/附件元数据」之外的 Office 365 丰富字段,且 M365 现在基本禁用了基础认证 IMAP —— M365 邮箱优先走 Graph。
无 GUI 会话下验证 WinForm 程序(必用套路)
判定程序是否真崩:看 timeout 退出码
# 后台启动 (./App.exe &) 的进程会随 shell 会话结束被回收,
# 看起来像"闪退",其实不是!
timeout 12 ./App.exe ; echo "exit=$?"
# exit=124 → 被 timeout 杀掉 → 程序一直活着(真崩溃会是别的码)
正式验证用 PowerShell 工具的 Start-Process -PassThru(其子进程不被 shell 会话回收):
$p = Start-Process -FilePath "路径\App.exe" -WorkingDirectory "输出目录" -PassThru
Start-Sleep -Seconds 6
if ($p.HasExited) { "已退出 ExitCode=" + $p.ExitCode }
else { "PID=$($p.Id)" ; "Title=[$($p.MainWindowTitle)]" ; "Handle=$($p.MainWindowHandle)" }
启动日志打点(比猜快得多)
在 Main 里加三处日志:进入 Main → 窗体构造完成 → 进入消息循环。写到 exe 同目录 startup.log。
三行都有 = 界面正常;只有前两行 = 消息循环前崩了;一行都没有 = 启动前就崩。
截窗口图:按 PID 找,不要按标题
❌ FindWindowW(null, "中文标题") —— 中文全角括号经命令行传参编码损坏,永远匹配不到
✅ EnumWindows + GetWindowThreadProcessId 取目标 PID 的顶层窗口,
过滤 IsWindowVisible,取面积最大的那个,再 GetWindowTextW / GetWindowRect / CopyFromScreen
编译成独立 csproj(本机 Add-Type 运行时编译 .NET 代码被安全策略拦截,P/Invoke 探针必须走 dotnet build)。
做成托盘常驻程序(NotifyIcon)的要点
用户要"托盘程序"时,是在 winform 基础上加 NotifyIcon,不是另起炉灶。以下都是实际踩过的。
⚠️ BeginInvoke 时机:必须等句柄创建
Main 里窗体构造完但消息循环尚未进入时,窗口句柄还没创建。此时调
BeginInvoke / Invoke 会抛:
InvalidOperationException: 在创建窗口句柄之前,不能在控件上调用 Invoke 或 BeginInvoke。
要做"显示后立即隐藏/最小化"这类动作,一律挂到 Form.Shown 事件里(那时句柄必定已创建):
// ❌ 错:Main 里直接调
form.BeginInvoke(new Action(() => form.Hide()));
// ✅ 对:挂在 Shown
form.Shown += async (s, e) => {
if (hideOnStart) form.Hide();
await StartupAsync();
};
托盘图标:运行时动态绘制,不依赖 .ico
免去"图标丢了/打包漏了"。用 GDI+ 画好 → GetHicon → Clone() 后再 DestroyIcon(直接返回 Icon.FromHandle 会泄漏句柄):
IntPtr h = bmp.GetHicon();
try {
using (var tmp = Icon.FromHandle(h))
return (Icon)tmp.Clone(); // 必须 Clone,否则 DestroyIcon 后图标失效
} finally {
DestroyIcon(h); // 释放原生句柄
}
图标配色的状态语义(推荐):蓝=正常 / 黄=待登录或未配置 / 灰=检查中 / 红=出错;未读数做成红色数字角标。
NotifyIcon.Text 有 63 字符上限
超长会抛异常(不是截断),必须自己截断:
_icon.Text = s.Length <= 62 ? s : s.Substring(0, 62);
关窗口 ≠ 退出
托盘程序的关闭按钮应当只隐藏。真退出走托盘菜单:
FormClosing += (s, e) => {
if (!RealExitRequested && e.CloseReason == CloseReason.UserClosing) {
e.Cancel = true;
Hide();
}
};
// 托盘菜单「退出」里:form.AllowRealExit(); Application.Exit();
单实例守卫
托盘程序开两份会出现两个图标、状态互相覆盖。用命名 Mutex(Local\ 前缀 = 同一用户会话内有效):
_mutex = new Mutex(true, @"Local\AppName_SingleInstance_XXXX", out bool createdNew);
if (!createdNew) { /* 已有实例,提示后退出 */ }
Mutex 要放在静态字段里活着到进程结束,否则会被 GC 回收。
开机自启:写 HKCU Run,免管理员
using (var key = Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Run", true))
{
key.SetValue("AppName", "\"" + Application.ExecutablePath + "\" --minimized");
}
比创建启动文件夹 .lnk 简单(不依赖 COM)。
首次未配置时不要隐藏窗口
如果默认"启动最小化到托盘",用户首次运行时只看到托盘图标,不知道去哪填 ClientId。
应在 !cfg.IsReady 时强制显示窗口做引导。
⚠️ Windows 11 无法用 P/Invoke 枚举托盘图标
经典路径 Shell_TrayWnd → TrayNotifyWnd → SysPager → ToolbarWindow32
在 Windows 11 的新任务栏里已不存在(SysPager 和 ToolbarWindow32 都返回 0)。
不要浪费时间写这种探针。
验证托盘链路是否正常的正解:写一个独立自验工具,直接跑 NotifyIcon 全链路并写日志:
[1] GDI+ 生成图标 → Icon 转换
[2] NotifyIcon.Visible = true
[3] ShowBalloonTip 调用
[4] 定时动态换图标 + 改文本(多次)
[5] 退出时 Visible = false + Dispose
跑 15–20 秒后退出码 0、日志无 !! 即链路通。
轮询新邮件:增量查询,别每次拉全量
托盘提醒的关键设计。首轮拉全量建立基线,但不弹提醒(否则第一次运行会把整个收件箱当新邮件刷屏);后续轮用时间过滤增量查:
// OData 日期过滤:必须 UTC,且格式不能带毫秒,否则 400
string filter = "receivedDateTime gt " + since.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ");
var page = await graph.Users[mailbox].MailFolders["Inbox"].Messages
.Request().Top(100).OrderBy("receivedDateTime desc")
.Select("id,subject,from,receivedDateTime,isRead,bodyPreview,hasAttachments,webLink")
.Filter(filter) // 只在增量轮加
.GetAsync();
三个要点:
- 用 HashSet<string> 按邮件 Id 去重,避免重复推送。
- 时间点往回多退 2 分钟再查,防时钟偏差/服务器延迟导致边界邮件漏掉。
- Graph 的 Filter 与 OrderBy 组合对某些属性有限制,receivedDateTime 是支持排序的。
> 注意:先 Top().OrderBy().Select() 拿到 request 对象再 .Filter(),或反之皆可,但都要在 GetAsync 之前。
用户给了邮箱密码怎么办
不要把它写进程序或配置。 正确做法:
- 明确告知:M365 / 企业邮箱已禁用基础认证(微软 2022 起),账号密码直连 IMAP/POP 会失败;
- 说明 Graph/OAuth 路线程序不需要也不保存密码(浏览器授权 + 令牌缓存);
- 建议用户改密码 + 开 MFA,因为他已经把密码明文发出去了。
对用户的安全性负责,比"照做"更重要。
WinForm 纯代码构建界面:用 TableLayoutPanel,不要靠 Dock 顺序
Controls.Add 的 Dock 叠加顺序极易错(标签被输入框压住、附件区跑顶部)。别靠调顺序试。
var p = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, Padding = new Padding(8) };
p.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
p.RowStyles.Add(new RowStyle(SizeType.Absolute, 18F)); // 0 标签
p.RowStyles.Add(new RowStyle(SizeType.Absolute, 46F)); // 1 主题(多行)
p.RowStyles.Add(new RowStyle(SizeType.Absolute, 18F)); // 2 标签
p.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); // 3 正文(自适应占满剩余)
p.RowStyles.Add(new RowStyle(SizeType.Absolute, 96F)); // 4 附件列表
p.Controls.Add(lblSubj, 0, 0);
p.Controls.Add(_txtSubject, 0, 1);
用 Absolute 放标签与单行控件,用 Percent 100F 放"吃掉剩余空间"的区域。 一次成型。
交付检查清单
- [ ]
dotnet build -c Release --disable-build-servers→ 0 警告 0 错误 - [ ] 运行时依赖齐全(
Microsoft.Graph*.dll、Microsoft.Identity.Client.dll、System.Text.Json.dll等都在输出目录) - [ ] 冒烟测试:进程存活 +
MainWindowTitle非空 +MainWindowHandle非零 + startup.log 三行齐 - [ ] 托盘程序另加:图标生成 / Visible / 气泡 / 动态更新 / 正常注销 五步自验
- [ ] 截图确认界面完整渲染(无遮挡、无错位)
- [ ] 清理测试残留进程与临时文件
- [ ] 给用户一份 Azure 注册指引(桌面 HTML 或 MD),并在程序里加「打开指引」按钮
- [ ] 用户若给了密码:不写入任何文件,并提示改密码
```