From c53d7978bb8ce21c9a90c01dfd49a83d6b46beca Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 20:42:46 +0200 Subject: [PATCH] feat(email): introduce IEmailProvider seam + GmailProvider adapter First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP + manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider- neutral contract (search / list-thread / get-message / get-connection) with neutral DTOs, a registry to resolve providers by key, and a GmailProvider that adapts the existing IGmailOAuthService to it. No behaviour change: the seam is registered in DI but not yet consumed. Follow-up slices migrate GmailController's read paths onto IEmailProvider (folding in the N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider. Build clean; backend suite 135/135 green. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi/Program.cs | 4 ++ .../Services/EmailProviders/GmailProvider.cs | 63 +++++++++++++++++ .../Services/EmailProviders/IEmailProvider.cs | 69 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 JobTrackerApi/Services/EmailProviders/GmailProvider.cs create mode 100644 JobTrackerApi/Services/EmailProviders/IEmailProvider.cs diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 21ff958..41f3a6a 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -166,6 +166,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next). +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddIdentityCore(options => { options.User.RequireUniqueEmail = true; diff --git a/JobTrackerApi/Services/EmailProviders/GmailProvider.cs b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs new file mode 100644 index 0000000..4a1c5dc --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Services; + +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Gmail implementation of . Adapts the existing + /// (Gmail REST client) to the provider-neutral contract, + /// mapping Gmail DTOs to the neutral shapes. + /// + public sealed class GmailProvider : IEmailProvider + { + private readonly IGmailOAuthService _gmail; + + public GmailProvider(IGmailOAuthService gmail) + { + _gmail = gmail; + } + + public string ProviderKey => "gmail"; + + public async Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + { + var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); + return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? ""); + } + + public async Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) + { + var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) + { + var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) + { + var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken); + var attachments = detail.Attachments + .Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline)) + .ToList(); + + return new EmailMessageDetail( + detail.Id, + detail.ThreadId, + detail.Subject, + detail.From, + detail.To, + detail.Date, + detail.Snippet, + detail.BodyText, + detail.BodyHtml, + detail.Labels, + attachments); + } + + private static EmailMessageSummary ToSummary(GmailMessageSummary m) + => new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet); + } +} diff --git a/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs new file mode 100644 index 0000000..c7a448e --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs @@ -0,0 +1,69 @@ +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Provider-neutral email operations so job correspondence can be sourced from Gmail, + /// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam. + /// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first + /// implementation (); the controller migration and additional + /// providers land in follow-up slices. + /// + public interface IEmailProvider + { + /// Stable key: "gmail" | "microsoft" | "imap" | "manual". + string ProviderKey { get; } + + /// The user's active connection for this provider, or null if not connected. + Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); + + /// Search the user's mailbox. is provider-specific syntax. + Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + + /// All messages in a thread/conversation. + Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); + + /// Full message content (body + attachments metadata). + Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); + } + + public sealed record EmailConnectionInfo(string ProviderKey, string Address); + + public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); + + public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline); + + public sealed record EmailMessageDetail( + string Id, + string ThreadId, + string Subject, + string From, + string To, + DateTimeOffset? Date, + string Snippet, + string BodyText, + string? BodyHtml, + IReadOnlyList Labels, + IReadOnlyList Attachments); + + /// Resolves a registered by its key. + public interface IEmailProviderRegistry + { + IReadOnlyList All { get; } + IEmailProvider? Get(string? providerKey); + } + + public sealed class EmailProviderRegistry : IEmailProviderRegistry + { + private readonly Dictionary _byKey; + + public EmailProviderRegistry(IEnumerable providers) + { + All = providers.ToList(); + _byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList All { get; } + + public IEmailProvider? Get(string? providerKey) + => !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null; + } +}