Compare commits

...

4 Commits

Author SHA1 Message Date
cesnimda 9cb99a7ba7 refactor(gmail): route message import through IEmailProvider
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
ImportSingleMessageAsync now fetches the message + connection through the
provider-neutral seam (Email.GetMessageAsync/GetConnectionAsync), mapping the
neutral ExternalAttachmentId onto CorrespondenceAttachmentMetadata. The
controller's import path no longer touches Gmail directly.

OAuth lifecycle, the rich connection-status DTO, and Gmail candidate ranking
stay on IGmailOAuthService until a second provider (Microsoft/IMAP) forces the
contract shape. 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:18:49 +02:00
cesnimda ad1d0d7b2d Merge pull request 'refactor(gmail): route controller read paths through IEmailProvider' (#7) from feat/gmail-provider-reads into main
CI and Deploy / test (push) Successful in 1m59s
CI and Deploy / deploy (push) Successful in 42s
2026-07-11 13:18:34 +02:00
cesnimda 97900dc05b Merge pull request 'chore: stop tracking .gsd planning tooling' (#6) from chore/untrack-gsd into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Successful in 19s
2026-07-11 13:18:08 +02:00
cesnimda 9febc2b22f refactor(gmail): route controller read paths through IEmailProvider
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
GmailController now resolves the "gmail" provider from IEmailProviderRegistry and
uses the provider-neutral seam for its read paths — message search (SearchAsync)
and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread,
CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth
(connect/callback), connection status and Gmail-specific candidate ranking stay
on IGmailOAuthService until they are generalised.

An optional constructor param keeps direct construction (tests) working via a
fallback single-Gmail registry, so the mocked Gmail service is exercised through
GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes).

This makes the seam a real consumer and sets up MicrosoftGraphProvider /
ImapProvider / a manual free-text provider to slot in next.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:11:10 +02:00
+20 -10
View File
@@ -3,6 +3,7 @@ using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -18,15 +19,24 @@ public sealed class GmailController : ControllerBase
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
private readonly IEmailProviderRegistry _providers;
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
}
// The email provider backing this controller's read paths (search + thread listing),
// via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use
// IGmailOAuthService directly until they are generalised.
private IEmailProvider Email => _providers.Get("gmail")
?? throw new InvalidOperationException("Gmail email provider is not registered.");
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
@@ -383,7 +393,7 @@ public sealed class GmailController : ControllerBase
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
@@ -639,7 +649,7 @@ public sealed class GmailController : ControllerBase
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
// Batch the "already imported?" check with a single query instead of one
// AnyAsync per message (N+1), mirroring RelinkThread below.
@@ -695,7 +705,7 @@ public sealed class GmailController : ControllerBase
}
}
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
@@ -793,7 +803,7 @@ public sealed class GmailController : ControllerBase
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
@@ -897,7 +907,7 @@ public sealed class GmailController : ControllerBase
foreach (var threadId in linkedThreadIds)
{
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctThreadMessages = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.GroupBy(message => message.Id, StringComparer.Ordinal)
@@ -941,9 +951,9 @@ public sealed class GmailController : ControllerBase
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.Address ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
@@ -964,7 +974,7 @@ public sealed class GmailController : ControllerBase
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.GmailAttachmentId,
GmailAttachmentId = attachment.ExternalAttachmentId,
Inline = attachment.Inline,
})),
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,