e9937accd8
Gmail and Graph request explicit send consent and classify provider rejection separately from uncertain transport failure. IMAP remains read-only; no send API is exposed.
576 lines
28 KiB
C#
576 lines
28 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using JobTrackerApi.Services.EmailProviders;
|
|
|
|
namespace JobTrackerApi.Services;
|
|
|
|
public interface IMicrosoftGraphOAuthService
|
|
{
|
|
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
|
|
string? ConsumeState(string state);
|
|
Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
|
|
Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
|
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
|
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
|
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
|
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
|
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
|
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
|
public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline);
|
|
public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<MicrosoftGraphMessageAttachment> Attachments);
|
|
public sealed record MicrosoftGraphSendRequest(string To, string Subject, string BodyText);
|
|
public sealed record MicrosoftGraphSendResult();
|
|
|
|
internal sealed class MicrosoftGraphTokenResponse
|
|
{
|
|
public string? access_token { get; set; }
|
|
public int expires_in { get; set; }
|
|
public string? refresh_token { get; set; }
|
|
public string? scope { get; set; }
|
|
public string? token_type { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors <see cref="GmailOAuthService"/>'s shape
|
|
/// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers
|
|
/// stay structurally interchangeable behind <see cref="EmailProviders.IEmailProvider"/>.
|
|
/// </summary>
|
|
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
|
{
|
|
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
|
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope}";
|
|
private readonly IConfiguration _cfg;
|
|
private readonly JobTrackerContext _db;
|
|
private readonly IDataProtector _protector;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
public MicrosoftGraphOAuthService(
|
|
IConfiguration cfg,
|
|
JobTrackerContext db,
|
|
IDataProtectionProvider protectionProvider,
|
|
IHttpClientFactory httpClientFactory,
|
|
IMemoryCache cache)
|
|
{
|
|
_cfg = cfg;
|
|
_db = db;
|
|
_protector = protectionProvider.CreateProtector("microsoft-graph-oauth-tokens-v1");
|
|
_httpClientFactory = httpClientFactory;
|
|
_cache = cache;
|
|
}
|
|
|
|
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
|
|
{
|
|
var clientId = GetRequiredClientId();
|
|
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
|
|
.Replace("+", "-")
|
|
.Replace("/", "_")
|
|
.TrimEnd('=');
|
|
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
|
|
|
|
var query = new Dictionary<string, string?>
|
|
{
|
|
["client_id"] = clientId,
|
|
["redirect_uri"] = redirectUri,
|
|
["response_type"] = "code",
|
|
["response_mode"] = "query",
|
|
["scope"] = Scope,
|
|
["state"] = state,
|
|
};
|
|
|
|
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
|
|
return $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/authorize?{encoded}";
|
|
}
|
|
|
|
public async Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
|
|
{
|
|
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
|
|
var accessToken = tokens.access_token?.Trim();
|
|
var refreshToken = tokens.refresh_token?.Trim();
|
|
if (string.IsNullOrWhiteSpace(accessToken))
|
|
throw new InvalidOperationException("Microsoft did not return an access token.");
|
|
if (string.IsNullOrWhiteSpace(refreshToken))
|
|
throw new InvalidOperationException("Microsoft did not return a refresh token. Reconnect Outlook and ensure offline_access consent is granted.");
|
|
|
|
var mailAddress = await GetProfileAsync(accessToken, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(mailAddress))
|
|
throw new InvalidOperationException("Microsoft did not return a mail address.");
|
|
|
|
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
existing = new MicrosoftGraphConnection
|
|
{
|
|
OwnerUserId = ownerUserId,
|
|
};
|
|
_db.MicrosoftGraphConnections.Add(existing);
|
|
}
|
|
|
|
existing.MailAddress = mailAddress.Trim();
|
|
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
|
|
existing.EncryptedAccessToken = _protector.Protect(accessToken);
|
|
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
|
|
existing.Scope = tokens.scope?.Trim() ?? Scope;
|
|
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
|
existing.LastSyncStatus = "connected";
|
|
existing.LastSyncSource = "oauth-callback";
|
|
existing.LastSyncMode = "connect";
|
|
existing.LastSyncError = null;
|
|
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
|
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return new MicrosoftGraphOAuthExchangeResult(existing.MailAddress);
|
|
}
|
|
|
|
public string? ConsumeState(string state)
|
|
{
|
|
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
_cache.Remove(GetStateCacheKey(state));
|
|
return ownerUserId;
|
|
}
|
|
|
|
public Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
|
{
|
|
return _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
}
|
|
|
|
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
|
|
{
|
|
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (existing is null) return;
|
|
_db.MicrosoftGraphConnections.Remove(existing);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
|
{
|
|
maxResults = Math.Clamp(maxResults, 1, 25);
|
|
try
|
|
{
|
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
|
|
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
|
|
var url = $"https://graph.microsoft.com/v1.0/me/messages?$top={maxResults}&$select={select}&$orderby=receivedDateTime desc";
|
|
if (!string.IsNullOrWhiteSpace(query))
|
|
{
|
|
client.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
|
|
url += $"&$search={Uri.EscapeDataString(EscapeSearchQuery(query.Trim()))}";
|
|
}
|
|
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
var results = ReadMessageSummaries(doc.RootElement);
|
|
|
|
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
|
return results;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(conversationId))
|
|
{
|
|
return Array.Empty<MicrosoftGraphMessageSummary>();
|
|
}
|
|
|
|
try
|
|
{
|
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
|
|
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
|
|
var filter = $"conversationId eq '{conversationId.Trim().Replace("'", "''")}'";
|
|
var url = $"https://graph.microsoft.com/v1.0/me/messages?$filter={Uri.EscapeDataString(filter)}&$select={select}&$orderby=receivedDateTime asc";
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
var results = ReadMessageSummaries(doc.RootElement);
|
|
|
|
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", true, null, cancellationToken);
|
|
return results;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", false, ex.Message, cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
|
|
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,categories,hasAttachments";
|
|
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}?$select={select}";
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
var root = doc.RootElement;
|
|
|
|
var conversationId = root.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "";
|
|
var snippet = root.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : "";
|
|
var categories = root.TryGetProperty("categories", out var categoriesEl) && categoriesEl.ValueKind == JsonValueKind.Array
|
|
? categoriesEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
|
: new List<string>();
|
|
|
|
string bodyText = "";
|
|
string? bodyHtml = null;
|
|
if (root.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object)
|
|
{
|
|
var contentType = bodyEl.TryGetProperty("contentType", out var ctEl) ? ctEl.GetString() ?? "" : "";
|
|
var content = bodyEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
|
|
if (string.Equals(contentType, "html", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
bodyHtml = content;
|
|
bodyText = StripHtml(content);
|
|
}
|
|
else
|
|
{
|
|
bodyText = content;
|
|
}
|
|
}
|
|
|
|
var attachments = root.TryGetProperty("hasAttachments", out var hasAttEl) && hasAttEl.ValueKind == JsonValueKind.True
|
|
? await ListAttachmentsAsync(client, messageId, cancellationToken)
|
|
: Array.Empty<MicrosoftGraphMessageAttachment>();
|
|
|
|
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", true, null, cancellationToken);
|
|
return new MicrosoftGraphMessageDetail(
|
|
messageId,
|
|
conversationId,
|
|
ReadSubject(root),
|
|
ReadRecipientAddress(root, "from"),
|
|
ReadRecipientListAddresses(root, "toRecipients"),
|
|
ReadReceivedDate(root),
|
|
snippet,
|
|
bodyText.Trim(),
|
|
bodyHtml,
|
|
categories,
|
|
attachments);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", false, ex.Message, cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var connection = await _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (connection is null || !HasSendScope(connection.Scope))
|
|
throw new EmailProviderDeliveryException("reauthorization_required", false, "Reconnect Outlook and approve send access before sending.");
|
|
ValidateSendRequest(request.To, request.Subject, request.BodyText);
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
message = new
|
|
{
|
|
subject = request.Subject.Trim(),
|
|
body = new { contentType = "Text", content = request.BodyText },
|
|
toRecipients = new[] { new { emailAddress = new { address = request.To.Trim() } } },
|
|
},
|
|
saveToSentItems = true,
|
|
});
|
|
|
|
try
|
|
{
|
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
using var response = await client.PostAsync(
|
|
"https://graph.microsoft.com/v1.0/me/sendMail",
|
|
new StringContent(payload, System.Text.Encoding.UTF8, "application/json"),
|
|
cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var category = response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden
|
|
? "reauthorization_required"
|
|
: "provider_rejected";
|
|
throw new EmailProviderDeliveryException(category, false, "Microsoft Graph rejected the send request.");
|
|
}
|
|
|
|
return new MicrosoftGraphSendResult();
|
|
}
|
|
catch (EmailProviderDeliveryException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
|
{
|
|
throw new EmailProviderDeliveryException("transport_interrupted", true, "Outlook delivery status is uncertain.", ex);
|
|
}
|
|
}
|
|
|
|
public static bool HasSendScope(string? scope)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scope)) return false;
|
|
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(value =>
|
|
string.Equals(value, SendScope, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(value, "Mail.Send", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
|
if (string.IsNullOrWhiteSpace(subject)) throw new ArgumentException("Subject is required.", nameof(subject));
|
|
if (string.IsNullOrWhiteSpace(bodyText)) throw new ArgumentException("Body is required.", nameof(bodyText));
|
|
if (to.Length > 320 || subject.Length > 998 || bodyText.Length > 200_000) throw new ArgumentException("Email content exceeds the supported limit.");
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<MicrosoftGraphMessageAttachment>> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken)
|
|
{
|
|
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
|
|
using var response = await client.GetAsync(url, cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return Array.Empty<MicrosoftGraphMessageAttachment>();
|
|
}
|
|
|
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
if (!doc.RootElement.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return Array.Empty<MicrosoftGraphMessageAttachment>();
|
|
}
|
|
|
|
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageAttachment(
|
|
item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
|
|
item.TryGetProperty("contentType", out var typeEl) ? typeEl.GetString() : null,
|
|
item.TryGetProperty("size", out var sizeEl) && sizeEl.TryGetInt64(out var size) ? size : null,
|
|
item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null,
|
|
item.TryGetProperty("isInline", out var inlineEl) && inlineEl.ValueKind == JsonValueKind.True
|
|
)).ToList();
|
|
}
|
|
|
|
private static List<MicrosoftGraphMessageSummary> ReadMessageSummaries(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return new List<MicrosoftGraphMessageSummary>();
|
|
}
|
|
|
|
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageSummary(
|
|
item.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "",
|
|
item.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "",
|
|
ReadSubject(item),
|
|
ReadRecipientAddress(item, "from"),
|
|
ReadRecipientListAddresses(item, "toRecipients"),
|
|
ReadReceivedDate(item),
|
|
item.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : ""
|
|
)).Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToList();
|
|
}
|
|
|
|
private static string ReadSubject(JsonElement item)
|
|
=> item.TryGetProperty("subject", out var subjectEl) ? subjectEl.GetString() ?? "" : "";
|
|
|
|
private static DateTimeOffset? ReadReceivedDate(JsonElement item)
|
|
=> item.TryGetProperty("receivedDateTime", out var dateEl) && dateEl.ValueKind == JsonValueKind.String
|
|
&& DateTimeOffset.TryParse(dateEl.GetString(), out var parsed) ? parsed : null;
|
|
|
|
private static string ReadRecipientAddress(JsonElement item, string property)
|
|
{
|
|
if (!item.TryGetProperty(property, out var recipientEl) || recipientEl.ValueKind != JsonValueKind.Object) return "";
|
|
if (!recipientEl.TryGetProperty("emailAddress", out var addressEl) || addressEl.ValueKind != JsonValueKind.Object) return "";
|
|
return addressEl.TryGetProperty("address", out var addrEl) ? addrEl.GetString() ?? "" : "";
|
|
}
|
|
|
|
private static string ReadRecipientListAddresses(JsonElement item, string property)
|
|
{
|
|
if (!item.TryGetProperty(property, out var listEl) || listEl.ValueKind != JsonValueKind.Array) return "";
|
|
var addresses = listEl.EnumerateArray()
|
|
.Where(r => r.TryGetProperty("emailAddress", out _))
|
|
.Select(r => r.GetProperty("emailAddress").TryGetProperty("address", out var a) ? a.GetString() : null)
|
|
.Where(a => !string.IsNullOrWhiteSpace(a));
|
|
return string.Join(", ", addresses);
|
|
}
|
|
|
|
private static string EscapeSearchQuery(string query) => "\"" + query.Replace("\"", "'") + "\"";
|
|
|
|
private static string StripHtml(string html)
|
|
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
|
|
|
|
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
|
|
{
|
|
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (connection is null)
|
|
throw new InvalidOperationException("Outlook is not connected for this account.");
|
|
|
|
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
|
|
connection.AccessTokenExpiresAt is { } expiresAt &&
|
|
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
|
{
|
|
try
|
|
{
|
|
return _protector.Unprotect(connection.EncryptedAccessToken);
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
connection.EncryptedAccessToken = null;
|
|
connection.AccessTokenExpiresAt = null;
|
|
}
|
|
}
|
|
|
|
string refreshToken;
|
|
try
|
|
{
|
|
refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
|
|
}
|
|
catch (CryptographicException)
|
|
{
|
|
throw new InvalidOperationException("Your stored Outlook connection can no longer be decrypted after a server key change. Disconnect Outlook and connect it again.");
|
|
}
|
|
|
|
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
|
|
var accessToken = refreshed.access_token?.Trim();
|
|
if (string.IsNullOrWhiteSpace(accessToken))
|
|
throw new InvalidOperationException("Failed to refresh Outlook access token.");
|
|
|
|
connection.EncryptedAccessToken = _protector.Protect(accessToken);
|
|
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
|
|
if (!string.IsNullOrWhiteSpace(refreshed.scope))
|
|
{
|
|
connection.Scope = refreshed.scope.Trim();
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(refreshed.refresh_token))
|
|
{
|
|
connection.EncryptedRefreshToken = _protector.Protect(refreshed.refresh_token.Trim());
|
|
}
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return accessToken;
|
|
}
|
|
|
|
private async Task<MicrosoftGraphTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
|
|
{
|
|
var client = _httpClientFactory.CreateClient();
|
|
using var response = await client.PostAsync(
|
|
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
|
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["code"] = code,
|
|
["client_id"] = GetRequiredClientId(),
|
|
["client_secret"] = GetRequiredClientSecret(),
|
|
["redirect_uri"] = redirectUri,
|
|
["grant_type"] = "authorization_code",
|
|
["scope"] = Scope,
|
|
}),
|
|
cancellationToken);
|
|
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new InvalidOperationException($"Microsoft token exchange failed: {payload}");
|
|
}
|
|
|
|
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
|
|
?? throw new InvalidOperationException("Unable to parse Microsoft token response.");
|
|
}
|
|
|
|
private async Task<MicrosoftGraphTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
|
|
{
|
|
var client = _httpClientFactory.CreateClient();
|
|
using var response = await client.PostAsync(
|
|
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
|
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["refresh_token"] = refreshToken,
|
|
["client_id"] = GetRequiredClientId(),
|
|
["client_secret"] = GetRequiredClientSecret(),
|
|
["grant_type"] = "refresh_token",
|
|
["scope"] = Scope,
|
|
}),
|
|
cancellationToken);
|
|
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new InvalidOperationException($"Microsoft token refresh failed: {payload}");
|
|
}
|
|
|
|
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
|
|
?? throw new InvalidOperationException("Unable to parse Microsoft refresh response.");
|
|
}
|
|
|
|
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
|
|
{
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
using var response = await client.GetAsync("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", cancellationToken);
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw new InvalidOperationException($"Failed to read Microsoft profile: {payload}");
|
|
}
|
|
|
|
using var doc = JsonDocument.Parse(payload);
|
|
var mail = doc.RootElement.TryGetProperty("mail", out var mailEl) ? mailEl.GetString() : null;
|
|
if (!string.IsNullOrWhiteSpace(mail)) return mail;
|
|
return doc.RootElement.TryGetProperty("userPrincipalName", out var upnEl) ? upnEl.GetString() ?? "" : "";
|
|
}
|
|
|
|
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
|
|
{
|
|
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
|
if (connection is null) return;
|
|
|
|
connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
|
connection.LastSyncMode = mode;
|
|
connection.LastSyncSource = source;
|
|
connection.LastSyncStatus = succeeded ? "ok" : "error";
|
|
connection.LastSyncError = succeeded ? null : error;
|
|
if (succeeded)
|
|
{
|
|
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
|
connection.LastSyncSucceededAt = connection.LastSyncedAt;
|
|
}
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private string GetTenant() => (_cfg["Microsoft:TenantId"] ?? "common").Trim();
|
|
|
|
private string GetRequiredClientId()
|
|
{
|
|
var value = (_cfg["Microsoft:ClientId"] ?? "").Trim();
|
|
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientId is not configured.");
|
|
}
|
|
|
|
private string GetRequiredClientSecret()
|
|
{
|
|
var value = (_cfg["Microsoft:ClientSecret"] ?? "").Trim();
|
|
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientSecret is not configured.");
|
|
}
|
|
|
|
private static string GetStateCacheKey(string state) => $"microsoft-oauth-state:{state}";
|
|
}
|