using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; namespace OASystem.API.OAMethodLib.MicrosoftGraphMailbox; public class MicrosoftGraphMailboxService : IMicrosoftGraphMailboxService { private static readonly string[] Scopes = { "Mail.Read", "User.Read", "Mail.Send" }; private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsMonitor _options; private readonly ILogger _logger; private readonly SemaphoreSlim _initLock = new(1, 1); private readonly SemaphoreSlim _tokenLock = new(1, 1); private IPublicClientApplication? _pca; private MsalCacheHelper? _cacheHelper; private const string HttpClientName = "MicrosoftGraph"; public MicrosoftGraphMailboxService( IHttpClientFactory httpClientFactory, IOptionsMonitor options, ILogger logger) { _httpClientFactory = httpClientFactory; _options = options; _logger = logger; } public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) { await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false); await _tokenLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { var app = _pca ?? throw new InvalidOperationException("MSAL 未初始化。"); var accounts = await app.GetAccountsAsync().ConfigureAwait(false); var account = accounts.FirstOrDefault(); try { var result = await app.AcquireTokenSilent(Scopes, account) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); return result.AccessToken; } catch (MsalUiRequiredException) { _logger.LogInformation("Graph 邮箱:需要交互式登录(将打开浏览器),重定向: {Redirect}", _options.CurrentValue.RedirectUri); var result = await app.AcquireTokenInteractive(Scopes) .WithPrompt(Prompt.SelectAccount) .ExecuteAsync(cancellationToken) .ConfigureAwait(false); return result.AccessToken; } } finally { _tokenLock.Release(); } } public async Task GetMeRawJsonAsync(CancellationToken cancellationToken = default) { using var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false); using var response = await client.GetAsync("me", cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) _logger.LogWarning("Graph GET /me 失败: {Status} {Body}", (int)response.StatusCode, body); return body; } public async Task GetInboxMessagesJsonSinceAsync(DateTime startUtc, CancellationToken cancellationToken = default) { var opt = _options.CurrentValue; var startTime = startUtc.ToString("o"); var url = "me/mailFolders/inbox/messages" + "?$select=id,subject,from,receivedDateTime,bodyPreview,conversationId" + $"&$filter=receivedDateTime ge {startTime}" + "&$orderby=receivedDateTime desc" + $"&$top={opt.TopMessages}"; using var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false); using var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) _logger.LogWarning("Graph 拉取收件箱失败: {Status} {Body}", (int)response.StatusCode, body); return body; } public async Task SendMailAsync(string toEmail, string subject, string textBody, CancellationToken cancellationToken = default) { var payload = new GraphSendMailRequest { Message = new GraphSendMailMessage { Subject = subject, Body = new GraphSendMailBody { ContentType = "Text", Content = textBody }, ToRecipients = new List { new() { EmailAddress = new GraphSendMailEmail { Address = toEmail } } } }, SaveToSentItems = true }; var json = System.Text.Json.JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); using var client = await CreateAuthenticatedClientAsync(cancellationToken).ConfigureAwait(false); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await client.PostAsync("me/sendMail", content, cancellationToken).ConfigureAwait(false); var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogError("Graph sendMail 失败: {Status} {Body}", (int)response.StatusCode, responseText); response.EnsureSuccessStatusCode(); } } private async Task CreateAuthenticatedClientAsync(CancellationToken cancellationToken) { var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); var client = _httpClientFactory.CreateClient(HttpClientName); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } private async Task EnsureInitializedAsync(CancellationToken cancellationToken) { if (_pca != null) return; await _initLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_pca != null) return; var opt = _options.CurrentValue; if (string.IsNullOrWhiteSpace(opt.ClientId)) throw new InvalidOperationException("MicrosoftGraphMailbox:ClientId 未配置。"); var cacheDir = string.IsNullOrWhiteSpace(opt.CacheDirectory) ? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OASystem", "MicrosoftGraphMailbox") : opt.CacheDirectory; Directory.CreateDirectory(cacheDir); var storage = new StorageCreationPropertiesBuilder(opt.CacheFileName, cacheDir) .Build(); _cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); _pca = PublicClientApplicationBuilder .Create(opt.ClientId) .WithAuthority($"https://login.microsoftonline.com/{opt.Tenant}") .WithRedirectUri(opt.RedirectUri) .Build(); _cacheHelper.RegisterCache(_pca.UserTokenCache); _logger.LogInformation("Graph 邮箱 MSAL 已初始化,缓存目录: {Dir}", cacheDir); } finally { _initLock.Release(); } } }