Compare commits

..

5 Commits

Author SHA1 Message Date
cesnimda cb2715c323 feat(email): add Correspondence.Provider discriminator
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b4 of the multi-provider email roadmap. The manual/free-text correspondence
entry path already existed (CorrespondenceController.Create) -- this slice
was narrower than the roadmap wording suggests: tag every Correspondence row
with which provider it came from (gmail | manual today; microsoft | imap
once those providers grow an import-into-Correspondence path of their own),
not build a new endpoint.

- Correspondence.Provider (nullable string), reconciled via the existing
  EnsureColumn pattern (SQLite + MySQL).
- Idempotent backfill: rows with an ExternalThreadId (historically only
  ever written by Gmail import) get 'gmail'; everything else gets 'manual'.
- GmailController.ImportSingleMessageAsync now tags Provider = "gmail".
- CorrespondenceController.Create now tags Provider = "manual".
- Both write sites use a fixed literal, not request input -- no injection
  surface introduced. Backfill SQL is static, no interpolation.

148/148 green (147 existing + 1 new CorrespondenceControllerTests; the
GmailController import test gained a Provider assertion in place).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:47:52 +02:00
cesnimda d308f1d5d4 Merge pull request 'feat(email): add ImapProvider (generic IMAP for unsupported providers)' (#12) from feat/imap-provider into main
CI and Deploy / test (push) Successful in 2m8s
CI and Deploy / deploy (push) Successful in 48s
2026-07-11 19:47:31 +02:00
cesnimda a8e2f4dc4a feat(email): add ImapProvider (generic IMAP for unsupported providers)
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).

Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
  than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
  UIDVALIDITY.

Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.

Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.

168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:40:50 +02:00
cesnimda 8edbdceee9 Merge pull request 'ci: retry frontend build once on silent failure' (#15) from ci/retry-frontend-build into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Successful in 22s
2026-07-11 19:40:24 +02:00
cesnimda 4f98195592 ci: retry frontend build once on silent failure
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
npm run build (Terser minify + fork-ts-checker workers) has now died three
distinct ways on this runner in this session: a printed Terser minify error,
an explicit SIGSEGV, and a fully silent kill with zero output between
'Creating an optimized production build...' and the failure line (OOM/SIGSEGV
signature — process killed before it could flush an error). All three are the
same resource-starved-runner class as the npm ci and dotnet-install flakes
already retried elsewhere in this workflow. Retry once, matching that pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:30:15 +02:00
7 changed files with 65 additions and 1 deletions
+6 -1
View File
@@ -70,7 +70,12 @@ jobs:
CI: 'false'
GENERATE_SOURCEMAP: 'false'
NODE_OPTIONS: --max-old-space-size=4096
run: npm run build
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
run: |
npm run build \
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
deploy:
needs: test
@@ -0,0 +1,35 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CorrespondenceControllerTests
{
[Fact]
public async Task Create_tags_manually_entered_correspondence_with_manual_provider()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = new CorrespondenceController(db);
var request = new CorrespondenceController.CreateCorrespondenceRequestV2(
job.Id, "Me", "Called to follow up.", "Follow-up call", "Call", null, "outbound", null, null, null, null, null, null);
var result = await controller.Create(request, CancellationToken.None);
Assert.IsType<Correspondence>(((CreatedAtActionResult)result.Result!).Value);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal("manual", stored.Provider);
}
}
@@ -288,6 +288,7 @@ public sealed class GmailControllerTests
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
Assert.Single(storedMessages);
Assert.Equal("gmail", storedMessages[0].Provider);
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
}
@@ -159,6 +159,7 @@ namespace JobTrackerApi.Controllers
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Provider = "manual",
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
@@ -977,6 +977,7 @@ public sealed class GmailController : ControllerBase
GmailAttachmentId = attachment.ExternalAttachmentId,
Inline = attachment.Inline,
})),
Provider = "gmail",
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -555,6 +555,12 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
// Backfill: historically the only import source was Gmail (rows with an
// ExternalThreadId); everything else was hand-entered. Idempotent — only touches
// rows the app hasn't tagged yet.
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
@@ -709,6 +715,17 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
using (var backfillGmail = conn.CreateCommand())
{
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
backfillGmail.ExecuteNonQuery();
}
using (var backfillManual = conn.CreateCommand())
{
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
backfillManual.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
+4
View File
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
public string? ExternalTo { get; set; }
public string? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
// Provider discriminator: "gmail" | "microsoft" | "imap" | "manual". Set at the write
// site (import controller or the manual-entry endpoint), not inferred from other fields,
// so it stays correct even for hand-entered rows that happen to carry external-looking data.
public string? Provider { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;