Files
jobtrackingapp/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs
cesnimda cacad5cc94
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:

- MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL,
  same shape as GmailConnection: encrypted refresh/access token, sync state).
- MicrosoftGraphOAuthService: auth-code + offline-access flow against
  login.microsoftonline.com, encrypted token storage via IDataProtector,
  message search/thread/detail fetch against Microsoft Graph (conversationId
  stands in for Gmail's threadId), attachment listing.
- MicrosoftGraphProvider implements IEmailProvider — no contract changes;
  the existing seam was already provider-neutral.
- MicrosoftGraphController: connect-url/oauth/callback/status/disconnect,
  mirrors GmailController's OAuth surface exactly (including the popup
  postMessage handshake). Job-matching/review endpoints stay Gmail-only for
  now, per the roadmap — generalising those needs the frontend provider
  picker work, not this slice.
- Registered in DI + IEmailProviderRegistry (multi-registration of
  IEmailProvider, resolved by ProviderKey).
- Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through
  docker-compose.yml + .env.example alongside the existing Google:Gmail* keys.
- Tests: MicrosoftGraphControllerTests (OAuth lifecycle) +
  MicrosoftGraphProviderTests (DTO mapping onto the neutral contract).
  147/147 green (135 existing + 12 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:08:11 +02:00

64 lines
2.7 KiB
C#

using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Outlook / Microsoft 365 implementation of <see cref="IEmailProvider"/>. Adapts
/// <see cref="IMicrosoftGraphOAuthService"/> (Microsoft Graph client) to the provider-neutral
/// contract, mapping Graph DTOs to the neutral shapes.
/// </summary>
public sealed class MicrosoftGraphProvider : IEmailProvider
{
private readonly IMicrosoftGraphOAuthService _graph;
public MicrosoftGraphProvider(IMicrosoftGraphOAuthService graph)
{
_graph = graph;
}
public string ProviderKey => "microsoft";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _graph.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _graph.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _graph.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GraphAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ConversationId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}