Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6db3bffb2f | |||
| c0d620f528 | |||
| cb2715c323 | |||
| d308f1d5d4 |
@@ -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();
|
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
|
||||||
Assert.Single(storedMessages);
|
Assert.Single(storedMessages);
|
||||||
|
Assert.Equal("gmail", storedMessages[0].Provider);
|
||||||
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
|
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(),
|
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
|
||||||
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
|
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
|
||||||
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
|
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
|
||||||
|
Provider = "manual",
|
||||||
Content = request.Content,
|
Content = request.Content,
|
||||||
Date = request.Date ?? DateTime.Now,
|
Date = request.Date ?? DateTime.Now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -977,6 +977,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
GmailAttachmentId = attachment.ExternalAttachmentId,
|
GmailAttachmentId = attachment.ExternalAttachmentId,
|
||||||
Inline = attachment.Inline,
|
Inline = attachment.Inline,
|
||||||
})),
|
})),
|
||||||
|
Provider = "gmail",
|
||||||
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||||
Date = messageDate,
|
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", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson 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", "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", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
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", "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", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` 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", "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, "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;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
|
|||||||
public string? ExternalTo { get; set; }
|
public string? ExternalTo { get; set; }
|
||||||
public string? ExternalLabelsJson { get; set; }
|
public string? ExternalLabelsJson { get; set; }
|
||||||
public string? AttachmentMetadataJson { 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 string Content { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.Now;
|
public DateTime Date { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||||
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||||
|
|
||||||
|
import { api, getApiErrorMessage } from "../api";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
||||||
|
|
||||||
|
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
||||||
|
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
||||||
|
// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential
|
||||||
|
// form submitted to POST /api/imap/connect, which verifies the connection before storing it.
|
||||||
|
export default function EmailProviderConnections() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||||
|
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
|
||||||
|
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
|
||||||
|
|
||||||
|
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
|
||||||
|
const [imapConnecting, setImapConnecting] = useState(false);
|
||||||
|
|
||||||
|
const loadGmailStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<GmailStatus>("/gmail/status");
|
||||||
|
setGmailStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
setGmailStatus({ connected: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMicrosoftStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
|
||||||
|
setMicrosoftStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
setMicrosoftStatus({ connected: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadImapStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ImapStatus>("/imap/status");
|
||||||
|
setImapStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
setImapStatus({ connected: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadGmailStatus();
|
||||||
|
void loadMicrosoftStatus();
|
||||||
|
void loadImapStatus();
|
||||||
|
}, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
const data = event.data as { source?: string; status?: string; message?: string };
|
||||||
|
if (data?.source === "jobtracker-gmail-oauth") {
|
||||||
|
if (data.status === "connected") {
|
||||||
|
toast(data.message || "Gmail connected.", "success");
|
||||||
|
void loadGmailStatus();
|
||||||
|
} else {
|
||||||
|
toast(data.message || "Gmail connection failed.", "error");
|
||||||
|
}
|
||||||
|
} else if (data?.source === "jobtracker-microsoft-oauth") {
|
||||||
|
if (data.status === "connected") {
|
||||||
|
toast(data.message || "Outlook connected.", "success");
|
||||||
|
void loadMicrosoftStatus();
|
||||||
|
} else {
|
||||||
|
toast(data.message || "Outlook connection failed.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [loadGmailStatus, loadMicrosoftStatus, toast]);
|
||||||
|
|
||||||
|
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ url: string }>(connectUrlPath);
|
||||||
|
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||||
|
if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error");
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(path);
|
||||||
|
await reload();
|
||||||
|
toast(`${providerLabel} disconnected.`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectImap = async () => {
|
||||||
|
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
|
||||||
|
toast("Host, username, and password are required.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImapConnecting(true);
|
||||||
|
try {
|
||||||
|
await api.post("/imap/connect", imapForm);
|
||||||
|
setImapForm((prev) => ({ ...prev, password: "" }));
|
||||||
|
await loadImapStatus();
|
||||||
|
toast("IMAP account connected.", "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error");
|
||||||
|
} finally {
|
||||||
|
setImapConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Linked email accounts</Typography>
|
||||||
|
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||||
|
Connect a mailbox so recruiter correspondence can be linked to jobs automatically.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<ProviderRow
|
||||||
|
label="Gmail"
|
||||||
|
connected={Boolean(gmailStatus?.connected)}
|
||||||
|
address={gmailStatus?.gmailAddress ?? null}
|
||||||
|
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||||
|
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ProviderRow
|
||||||
|
label="Outlook / Microsoft 365"
|
||||||
|
connected={Boolean(microsoftStatus?.connected)}
|
||||||
|
address={microsoftStatus?.mailAddress ?? null}
|
||||||
|
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||||
|
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<ProviderRow
|
||||||
|
label="Other (IMAP)"
|
||||||
|
connected={Boolean(imapStatus?.connected)}
|
||||||
|
address={imapStatus?.username ?? null}
|
||||||
|
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
|
||||||
|
/>
|
||||||
|
{!imapStatus?.connected && (
|
||||||
|
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="IMAP host"
|
||||||
|
placeholder="imap.example.com"
|
||||||
|
value={imapForm.host}
|
||||||
|
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Port"
|
||||||
|
type="number"
|
||||||
|
value={imapForm.port}
|
||||||
|
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Username"
|
||||||
|
value={imapForm.username}
|
||||||
|
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={imapForm.password}
|
||||||
|
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
|
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
|
||||||
|
label="Use SSL/TLS"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={connectImap}
|
||||||
|
disabled={imapConnecting}
|
||||||
|
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
|
||||||
|
>
|
||||||
|
{imapConnecting ? "Connecting…" : "Connect IMAP account"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderRow({
|
||||||
|
label,
|
||||||
|
connected,
|
||||||
|
address,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
connected: boolean;
|
||||||
|
address: string | null;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
|
||||||
|
{connected ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleIcon fontSize="small" />}
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
label={address || "Connected"}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>Not connected</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{connected ? (
|
||||||
|
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>Disconnect</Button>
|
||||||
|
) : (
|
||||||
|
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>Connect</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { JobTableColumns } from "./JobTable";
|
import { JobTableColumns } from "./JobTable";
|
||||||
import ImportExportJobs from "./ImportExportJobs";
|
import ImportExportJobs from "./ImportExportJobs";
|
||||||
import GoogleAuthCard from "./GoogleAuthCard";
|
import GoogleAuthCard from "./GoogleAuthCard";
|
||||||
|
import EmailProviderConnections from "./EmailProviderConnections";
|
||||||
import RulesSettingsCard from "./RulesSettingsCard";
|
import RulesSettingsCard from "./RulesSettingsCard";
|
||||||
import BackupCard from "./BackupCard";
|
import BackupCard from "./BackupCard";
|
||||||
import QuickCaptureCard from "./QuickCaptureCard";
|
import QuickCaptureCard from "./QuickCaptureCard";
|
||||||
@@ -338,6 +339,9 @@ export default function SettingsView({
|
|||||||
<TabPanel value={tab} index={3}>
|
<TabPanel value={tab} index={3}>
|
||||||
<AuthStatusCard />
|
<AuthStatusCard />
|
||||||
<GoogleAuthCard />
|
<GoogleAuthCard />
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<EmailProviderConnections />
|
||||||
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={4}>
|
<TabPanel value={tab} index={4}>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ToastProvider } from "./toast";
|
||||||
|
import { api } from "./api";
|
||||||
|
import EmailProviderConnections from "./components/EmailProviderConnections";
|
||||||
|
|
||||||
|
jest.mock("./api", () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<EmailProviderConnections />
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EmailProviderConnections", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => {
|
||||||
|
mockedApi.get.mockImplementation((path: string) => {
|
||||||
|
if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } });
|
||||||
|
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
|
||||||
|
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
|
||||||
|
return Promise.reject(new Error("unexpected path"));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(await screen.findByText("me@gmail.test")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("IMAP host")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits IMAP connect form and reloads status on success", async () => {
|
||||||
|
mockedApi.get.mockImplementation((path: string) => {
|
||||||
|
if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } });
|
||||||
|
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
|
||||||
|
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
|
||||||
|
return Promise.reject(new Error("unexpected path"));
|
||||||
|
});
|
||||||
|
mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } });
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
await screen.findByLabelText("IMAP host");
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test");
|
||||||
|
await userEvent.type(screen.getByLabelText("Username"), "user@example.test");
|
||||||
|
await userEvent.type(screen.getByLabelText("Password"), "secret");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({
|
||||||
|
host: "imap.example.test",
|
||||||
|
username: "user@example.test",
|
||||||
|
password: "secret",
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -384,6 +384,35 @@ export interface GmailStatus {
|
|||||||
lastSyncError?: string | null;
|
lastSyncError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MicrosoftGraphStatus {
|
||||||
|
connected: boolean;
|
||||||
|
mailAddress?: string | null;
|
||||||
|
connectedAt?: string;
|
||||||
|
lastSyncedAt?: string;
|
||||||
|
lastSyncAttemptedAt?: string;
|
||||||
|
lastSyncSucceededAt?: string;
|
||||||
|
lastSyncMode?: string | null;
|
||||||
|
lastSyncSource?: string | null;
|
||||||
|
lastSyncStatus?: string | null;
|
||||||
|
lastSyncError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImapStatus {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
useSsl?: boolean | null;
|
||||||
|
username?: string | null;
|
||||||
|
connectedAt?: string;
|
||||||
|
lastSyncedAt?: string;
|
||||||
|
lastSyncAttemptedAt?: string;
|
||||||
|
lastSyncSucceededAt?: string;
|
||||||
|
lastSyncMode?: string | null;
|
||||||
|
lastSyncSource?: string | null;
|
||||||
|
lastSyncStatus?: string | null;
|
||||||
|
lastSyncError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GmailManualSyncResult {
|
export interface GmailManualSyncResult {
|
||||||
queriesRun: number;
|
queriesRun: number;
|
||||||
candidateThreadCount: number;
|
candidateThreadCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user