using Microsoft.AspNetCore.WebUtilities; using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.Graph.Models.ODataErrors; using Microsoft.Kiota.Abstractions.Authentication; using StackExchange.Redis; using System.Text.Json; using System.Text.Json.Serialization; using static OASystem.API.OAMethodLib.Hotmail.HotmailService; namespace OASystem.API.OAMethodLib.Hotmail { public class HotmailService { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _config; private readonly SqlSugarClient _sqlSugar; private const string RedisKeyPrefix = "MailAlchemy:Token:"; public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar) { _httpClientFactory = httpClientFactory; _config = config; _sqlSugar = sqlSugar; } /// /// hotmail 信息验证 /// /// /// public (bool, string) ConfigVerify(HotmailConfig? config) { if (config == null) return (true, "当前用户未配置 hotmail 基础信息。"); if (string.IsNullOrEmpty(config.UserName)) return (true, "当前用户未配置 hotmail 基础信息。"); if (string.IsNullOrEmpty(config.ClientId)) return (true, "当前用户未配置 hotmail 租户标识符 (Guid)。"); if (string.IsNullOrEmpty(config.TenantId)) return (true, "当前用户未配置 hotmail 应用程序的客户端标识。"); if (string.IsNullOrEmpty(config.ClientSecret)) return (true, "当前用户未配置 hotmail 应用程序密钥。"); if (string.IsNullOrEmpty(config.RedirectUri)) return (true, "当前用户未配置 hotmail OAuth2 回调重定向地址。"); return (true, ""); } /// /// Microsoft 鉴权预处理 - 生产增强版 /// public async Task<(int status, string msg)> PrepareAuth(int userId) { // 1. 获取用户信息,支持空合并优化 var userName = await _sqlSugar.Queryable() .Where(x => x.IsDel == 0 && x.Id == userId) .Select(x => x.CnName) .FirstAsync() ?? "未知用户"; var userConfig = await GetUserMailConfig(userId); if (userConfig == null) return (-1, $"[{userName}] Hotmail 基础配置缺失"); if (string.IsNullOrWhiteSpace(userConfig.UserName)) return (-1, $"[{userName}] 未配置邮箱账号"); // 2. 验证状态检查 var redisKey = $"{RedisKeyPrefix}{userConfig.UserName.Trim()}"; var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync(redisKey); // 修正:已通过验证应返回 0 if (!string.IsNullOrWhiteSpace(cachedJson)) return (0, $"{userName} 已通过验证,无需重复操作"); // 3. 授权参数深度净化 var clientId = userConfig.ClientId?.Trim(); var redirectUri = userConfig.RedirectUri?.Trim().Replace("\r", "").Replace("\n", ""); // 彻底剔除换行符 //var redirectUri = "http://localhost:5256/api/microsoft/auth/callback"; if (string.IsNullOrWhiteSpace(clientId)) return (-1, $"[{userName}] 客户端 ID (ClientId) 未配置"); if (string.IsNullOrWhiteSpace(redirectUri)) return (-1, $"[{userName}] 回调地址 (RedirectUri) 未配置"); // 4. 构建授权 URL const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; var queryParams = new Dictionary { { "client_id", clientId }, { "response_type", "code" }, { "redirect_uri", redirectUri }, { "response_mode", "query" }, { "scope", "offline_access Mail.Read Mail.Send User.Read" }, { "state", userId.ToString() } //{ "state", Guid.NewGuid().ToString("N") } }; // QueryHelpers 会处理 URL 编码,确保 RedirectUri 不会被二次破坏 var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams); return (1, authUrl); } /// /// 获取多个账户的合并收件箱 (并行处理) /// public async Task> GetMergedMessagesAsync(List emails, DateTime cstStart, DateTime cstEnd) { var tasks = emails.Select(async email => { try { var client = await GetClientAsync(email); // 转换北京时间为 UTC 字符串 string startFilter = CommonFun.ToGraphUtcString(cstStart); string endFilter = CommonFun.ToGraphUtcString(cstEnd); var response = await client.Me.Messages.GetAsync(q => { q.QueryParameters.Filter = $"receivedDateTime ge {startFilter} and receivedDateTime le {endFilter}"; q.QueryParameters.Select = new[] { "id", "subject", "from", "toRecipients", "body", "receivedDateTime" }; q.QueryParameters.Orderby = new[] { "receivedDateTime desc" }; }); return response?.Value?.Select(m => new MailDto { MessageId = m.Id, Subject = m.Subject, Content = m.Body?.Content, From = m.From?.EmailAddress?.Address, // 关键:将 Graph 返回的 UTC 时间转回北京时间给前端显示 ReceivedTime = TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime,TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")), Source = email }).ToList() ?? Enumerable.Empty(); } catch (Exception ex) { Console.WriteLine($"[Error] Account {email}: {ex.Message}"); return Enumerable.Empty(); } }); var results = await Task.WhenAll(tasks); return results.SelectMany(x => x).OrderByDescending(m => m.ReceivedTime).ToList(); } /// /// 指定账户发送邮件 /// public async Task SendMailAsync(string fromEmail, MailDto mail) { try { var client = await GetClientAsync(fromEmail); var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody { Message = new Message { Subject = mail.Subject, Body = new ItemBody { Content = mail.Content, ContentType = BodyType.Html }, ToRecipients = new List { new Recipient { EmailAddress = new EmailAddress { Address = mail.To } } } } }; // 执行发送 await client.Me.SendMail.PostAsync(requestBody); return new MailSendResult { IsSuccess = true, Message = "邮件发送成功!" }; } catch (ODataError odataError) // 捕获 Graph 特有异常 { // 常见的错误:ErrorInvalidUser, ErrorQuotaExceeded, ErrorMessageSubmissionBlocked var code = odataError.Error?.Code ?? "Unknown"; var msg = odataError.Error?.Message ?? "微软 API 调用异常"; return new MailSendResult { IsSuccess = false, ErrorCode = code, Message = $"发送失败: {msg}" }; } catch (Exception ex) { return new MailSendResult { IsSuccess = false, ErrorCode = "InternalError", Message = $"系统内部错误: {ex.Message}" }; } } /// /// 获取邮箱配置信息 - single /// /// public async Task GetUserMailConfig(int userId) { var allConfigs = await GetUserMailConfigListAsync(); if (allConfigs == null || !allConfigs.Any()) return null; var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId); return userConfig; } /// /// 获取邮箱配置信息 - ALL /// /// public async Task?> GetUserMailConfigListAsync() { var remark = await _sqlSugar.Queryable() .Where(x => x.IsDel == 0 && x.Id == 1555 && x.STid == 137) .Select(x => x.Remark) .FirstAsync(); if (string.IsNullOrWhiteSpace(remark)) return null; try { var allConfigs = JsonConvert.DeserializeObject>(remark); return allConfigs; } catch (Exception) { return null; } } /// /// 获取 Graph 客户端,处理 Token 自动刷新 /// private async Task GetClientAsync(string email) { var cachedJson = await RedisRepository.RedisFactory.CreateRedisRepository().StringGetAsync($"{RedisKeyPrefix}{email}"); if (string.IsNullOrEmpty(cachedJson)) throw new UnauthorizedAccessException($"Account {email} not initialized in Redis."); var token = System.Text.Json.JsonSerializer.Deserialize(cachedJson!)!; // 令牌过期预校验 (提前 5 分钟) if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5)) { token = await RefreshAndSaveTokenAsync(token); } var authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider(token.AccessToken)); return new GraphServiceClient(authProvider); } public async Task RefreshAndSaveTokenAsync(UserToken oldToken) { var httpClient = _httpClientFactory.CreateClient(); var kvp = new Dictionary { { "client_id", _config["AzureAd:ClientId"] }, { "client_secret", _config["AzureAd:ClientSecret"] }, { "grant_type", "refresh_token" }, { "refresh_token", oldToken.RefreshToken }, { "scope", "offline_access Mail.Read Mail.Send" } }; var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(kvp)); if (!response.IsSuccessStatusCode) throw new Exception("Token refresh failed."); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var root = doc.RootElement; var newToken = new UserToken { Email = oldToken.Email, AccessToken = root.GetProperty("access_token").GetString()!, RefreshToken = root.GetProperty("refresh_token").GetString()!, ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32()) }; // 存入 Redis,持久化 90 天(RefreshToken 的典型寿命) await RedisRepository.RedisFactory.CreateRedisRepository().StringSetAsync($"{RedisKeyPrefix}{oldToken.Email}", System.Text.Json.JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90)); return newToken; } /// /// 静态 Token 提供者辅助类 /// public class StaticTokenProvider : IAccessTokenProvider { private readonly string _token; public StaticTokenProvider(string token) => _token = token; public Task GetAuthorizationTokenAsync(Uri uri, Dictionary? context = null, CancellationToken ct = default) => Task.FromResult(_token); public AllowedHostsValidator AllowedHostsValidator { get; } = new(); } #region 数据模型 public class MailSendResult { public bool IsSuccess { get; set; } public string Message { get; set; } = string.Empty; public string? ErrorCode { get; set; } // Microsoft 提供的错误码 public string Source => "Microsoft_Graph_API"; } /// /// Hotmail 邮件服务 OAuth2 配置信息实体 /// public class HotmailConfig { /// /// 用户唯一标识 /// [JsonPropertyName("userId")] public int UserId { get; set; } /// /// 账号用户名 /// [JsonPropertyName("userName")] public string UserName { get; set; } /// /// Azure AD 租户标识符 (Guid) /// [JsonPropertyName("tenantId")] public string TenantId { get; set; } /// /// 注册应用程序的客户端标识 /// [JsonPropertyName("clientId")] public string ClientId { get; set; } /// /// 客户端密钥(敏感数据建议加密存储) /// [JsonPropertyName("clientSecret")] public string ClientSecret { get; set; } /// /// 租户类型(如 common, organizations 或具体域名) /// [JsonPropertyName("tenant")] public string Tenant { get; set; } = "common"; /// /// OAuth2 回调重定向地址 /// [JsonPropertyName("redirectUri")] public string RedirectUri { get; set; } } public class UserToken { public string Email { get; set; } public string AccessToken { get; set; } public string RefreshToken { get; set; } public DateTime ExpiresAt { get; set; } } /// /// 邮件请求对象 /// public class MailDto { /// /// 邮件唯一标识符 (UID/Message-ID) /// [JsonPropertyName("messageId")] public string? MessageId { get; set; } /// /// 邮件主题 /// [JsonPropertyName("subject")] public string? Subject { get; set; } /// /// 发件人地址 (e.g. "sender@example.com") /// [JsonPropertyName("from")] public string? From { get; set; } /// /// 收件人地址 /// [JsonPropertyName("to")] public string? To { get; set; } /// /// 邮件正文内容 (HTML 或纯文本) /// [JsonPropertyName("content")] public string? Content { get; set; } /// /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性 /// [JsonPropertyName("receivedTime")] public DateTimeOffset? ReceivedTime { get; set; } /// /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData") /// [JsonPropertyName("source")] public string? Source { get; set; } = "Hotmail"; } #endregion } }