feat(mail): add governed POP3 legacy import
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ function read(relativePath) {
|
||||
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
|
||||
const mailbox = read("../src/features/mail/MailboxPage.tsx");
|
||||
const bounces = read("../src/features/mail/MailBouncePage.tsx");
|
||||
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
|
||||
const moduleSource = read("../src/module.ts");
|
||||
const styles = read("../src/styles/mail-profiles.css");
|
||||
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
|
||||
@@ -40,8 +41,18 @@ assert.match(bounces, /topicId: "mail\.bounce-processing"/);
|
||||
assert.match(bounces, /<ConfirmDialog[\s\S]*confirmLabel="Remove watcher"[\s\S]*tone="danger"/);
|
||||
assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/);
|
||||
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
for (const sharedComponent of ["PageLayout", "PageActionBar", "SelectionList", "DataGrid", "Dialog", "ConfirmDialog", "ToggleSwitch", "StatusBadge"]) {
|
||||
assert.match(legacyImport, new RegExp(`\\b${sharedComponent}\\b`));
|
||||
}
|
||||
assert.match(legacyImport, /archetype="collection"/);
|
||||
assert.match(legacyImport, /variant="collection"[\s\S]*refreshable[\s\S]*reloadAction=/);
|
||||
assert.match(legacyImport, /topicId: "mail\.workflow\.legacy-pop3-import"/);
|
||||
assert.match(legacyImport, /<ConfirmDialog[\s\S]*tone="danger"[\s\S]*onConfirm=\{\(\) => void runImport\(\)\}/);
|
||||
assert.match(legacyImport, /legacy_import_enabled: false[\s\S]*is_active: false[\s\S]*createMailServerCredential[\s\S]*updateMailServerEndpoint/);
|
||||
assert.match(legacyImport, /expected_transport_revision: preview\.transport_revision/);
|
||||
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}`, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
assert.match(moduleSource, /"mail\.profiles"/);
|
||||
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
|
||||
assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
|
||||
+129
-1
@@ -296,7 +296,7 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
|
||||
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
|
||||
|
||||
export type MailServerEndpointPayload = {
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "pop3";
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
inherit_to_lower_scopes?: boolean | null;
|
||||
@@ -304,6 +304,77 @@ export type MailServerEndpointPayload = {
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerConfig = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string;
|
||||
timeout_seconds?: number;
|
||||
max_message_bytes?: number;
|
||||
max_batch_bytes?: number;
|
||||
preview_body_lines?: number;
|
||||
legacy_import_enabled?: boolean;
|
||||
allow_delete_after_import?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerEndpoint = Omit<MailServerEndpoint, "protocol" | "config"> & {
|
||||
protocol: "pop3";
|
||||
config: MailPop3ServerConfig;
|
||||
};
|
||||
|
||||
export type MailPop3MessagePreview = {
|
||||
message_number: number;
|
||||
uidl: string;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes: number;
|
||||
body_preview?: string | null;
|
||||
already_imported: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3PreviewResponse = {
|
||||
profile_id: string;
|
||||
server_id: string;
|
||||
transport_revision: string;
|
||||
host: string;
|
||||
port: number;
|
||||
security: string;
|
||||
message_count: number;
|
||||
mailbox_size_bytes: number;
|
||||
delete_after_import_allowed: boolean;
|
||||
messages: MailPop3MessagePreview[];
|
||||
};
|
||||
|
||||
export type MailPop3ImportRecord = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
pop3_server_id: string;
|
||||
transport_revision: string;
|
||||
provider_uidl: string;
|
||||
message_id?: string | null;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
body_preview?: string | null;
|
||||
size_bytes: number;
|
||||
raw_sha256: string;
|
||||
status: string;
|
||||
imported_at: string;
|
||||
deletion_requested: boolean;
|
||||
deletion_status: string;
|
||||
deletion_attempted_at?: string | null;
|
||||
deletion_error?: string | null;
|
||||
};
|
||||
|
||||
export type MailPop3ImportResponse = {
|
||||
imports: MailPop3ImportRecord[];
|
||||
duplicate_uidls: string[];
|
||||
deletion_status: string;
|
||||
};
|
||||
|
||||
export type MailCredentialCreatePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
@@ -503,6 +574,63 @@ export async function testMailProfileImap(
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
credentialId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-pop3`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: { server_id: string; credential_id?: string | null; limit?: number }
|
||||
): Promise<MailPop3PreviewResponse> {
|
||||
return apiPostJson<MailPop3PreviewResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/preview`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: {
|
||||
server_id: string;
|
||||
credential_id?: string | null;
|
||||
expected_transport_revision: string;
|
||||
uidls: string[];
|
||||
delete_after_import?: boolean;
|
||||
}
|
||||
): Promise<MailPop3ImportResponse> {
|
||||
return apiPostJson<MailPop3ImportResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/import`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMailPop3Imports(
|
||||
settings: ApiSettings,
|
||||
profileId?: string | null,
|
||||
limit = 100
|
||||
): Promise<MailPop3ImportRecord[]> {
|
||||
const response = await apiFetch<{ imports: MailPop3ImportRecord[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/mail/pop3/imports", { profile_id: profileId, limit })
|
||||
);
|
||||
return response.imports;
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Pencil, Plus, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
ContentGrid,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
FormGrid,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
adminErrorMessage,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useGuardedNavigate,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createMailServerCredential,
|
||||
createMailServerEndpoint,
|
||||
importMailProfilePop3,
|
||||
listMailPop3Imports,
|
||||
listMailServerProfiles,
|
||||
previewMailProfilePop3,
|
||||
testMailProfilePop3,
|
||||
updateMailServerCredential,
|
||||
updateMailServerEndpoint,
|
||||
type MailCredentialEnvelope,
|
||||
type MailPop3ImportRecord,
|
||||
type MailPop3MessagePreview,
|
||||
type MailPop3PreviewResponse,
|
||||
type MailPop3ServerEndpoint,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
|
||||
const DOCUMENTATION = {
|
||||
topicId: "mail.workflow.legacy-pop3-import",
|
||||
documentationType: "admin"
|
||||
} as const;
|
||||
|
||||
type Source = {
|
||||
profile: MailServerProfile;
|
||||
server: MailPop3ServerEndpoint;
|
||||
};
|
||||
|
||||
type SourceDraft = {
|
||||
profileId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
security: "tls" | "starttls" | "plain";
|
||||
timeoutSeconds: string;
|
||||
maxMessageMiB: string;
|
||||
maxBatchMiB: string;
|
||||
previewBodyLines: string;
|
||||
username: string;
|
||||
password: string;
|
||||
enabled: boolean;
|
||||
allowDeleteAfterImport: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: SourceDraft = {
|
||||
profileId: "",
|
||||
name: "Legacy POP3 source",
|
||||
host: "",
|
||||
port: "995",
|
||||
security: "tls",
|
||||
timeoutSeconds: "30",
|
||||
maxMessageMiB: "25",
|
||||
maxBatchMiB: "100",
|
||||
previewBodyLines: "20",
|
||||
username: "",
|
||||
password: "",
|
||||
enabled: true,
|
||||
allowDeleteAfterImport: false
|
||||
};
|
||||
|
||||
export default function MailLegacyImportPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [imports, setImports] = useState<MailPop3ImportRecord[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||
const [preview, setPreview] = useState<MailPop3PreviewResponse | null>(null);
|
||||
const [selectedUidls, setSelectedUidls] = useState<string[]>([]);
|
||||
const [deleteAfterImport, setDeleteAfterImport] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
|
||||
const [editingSourceId, setEditingSourceId] = useState<string | null>(null);
|
||||
const [sourceDraft, setSourceDraft] = useState<SourceDraft>(EMPTY_DRAFT);
|
||||
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
|
||||
|
||||
const canImport = hasScope(auth, "mail:pop3:import");
|
||||
const canDelete = hasScope(auth, "mail:pop3:delete");
|
||||
const canManage = hasScope(auth, "mail:pop3:manage");
|
||||
const canManageSecrets = hasScope(auth, "mail:secret:manage");
|
||||
const sources = useMemo(() => pop3Sources(profiles), [profiles]);
|
||||
const selectedSource = sources.find((item) => item.server.id === selectedSourceId) ?? sources[0] ?? null;
|
||||
const selectedCredential = selectedSource ? defaultCredential(selectedSource.server) : null;
|
||||
const configurableProfiles = profiles.filter((profile) => profileCanBeConfigured(auth, profile));
|
||||
const sourceCanBeConfigured = selectedSource ? profileCanBeConfigured(auth, selectedSource.profile) : false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextProfiles, nextImports] = await Promise.all([
|
||||
listMailServerProfiles(settings, canManage),
|
||||
canImport ? listMailPop3Imports(settings, null, 200) : Promise.resolve([])
|
||||
]);
|
||||
const nextSources = pop3Sources(nextProfiles);
|
||||
setProfiles(nextProfiles);
|
||||
setImports(nextImports);
|
||||
setSelectedSourceId((current) =>
|
||||
nextSources.some((item) => item.server.id === current)
|
||||
? current
|
||||
: nextSources[0]?.server.id ?? ""
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, canImport]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(null);
|
||||
setSelectedUidls([]);
|
||||
setDeleteAfterImport(false);
|
||||
}, [selectedSourceId]);
|
||||
|
||||
async function runConnectionTest() {
|
||||
if (!selectedSource) return;
|
||||
setBusy("test");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await testMailProfilePop3(
|
||||
settings,
|
||||
selectedSource.profile.id,
|
||||
selectedSource.server.id,
|
||||
selectedCredential?.id
|
||||
);
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
setSuccess(
|
||||
`POP3 authentication succeeded. The mailbox currently reports ${String(result.details.message_count ?? 0)} message(s).`
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
if (!selectedSource || !canImport) return;
|
||||
setBusy("preview");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const next = await previewMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
limit: 100
|
||||
});
|
||||
setPreview(next);
|
||||
setSelectedUidls((current) =>
|
||||
current.filter((uidl) => next.messages.some((item) => item.uidl === uidl && !item.already_imported))
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!selectedSource || !preview || selectedUidls.length === 0) return;
|
||||
setDeleteConfirmationOpen(false);
|
||||
setBusy("import");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await importMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
expected_transport_revision: preview.transport_revision,
|
||||
uidls: selectedUidls,
|
||||
delete_after_import: deleteAfterImport
|
||||
});
|
||||
setSuccess(
|
||||
`${result.imports.length} message(s) imported; ${result.duplicate_uidls.length} duplicate(s) skipped. Source deletion: ${result.deletion_status.replaceAll("_", " ")}.`
|
||||
);
|
||||
setSelectedUidls([]);
|
||||
const [nextPreview, nextImports] = await Promise.all([
|
||||
previewMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
limit: 100
|
||||
}),
|
||||
listMailPop3Imports(settings, null, 200)
|
||||
]);
|
||||
setPreview(nextPreview);
|
||||
setImports(nextImports);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function openSourceDialog(source: Source | null) {
|
||||
const credential = source ? defaultCredential(source.server) : null;
|
||||
setEditingSourceId(source?.server.id ?? null);
|
||||
setSourceDraft(source ? sourceDraftFromSource(source, credential) : {
|
||||
...EMPTY_DRAFT,
|
||||
profileId: selectedSource?.profile.id ?? configurableProfiles[0]?.id ?? ""
|
||||
});
|
||||
setSourceDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
const profile = profiles.find((item) => item.id === sourceDraft.profileId);
|
||||
if (!profile) return;
|
||||
const existing = sources.find((item) => item.server.id === editingSourceId) ?? null;
|
||||
const existingCredential = existing ? defaultCredential(existing.server) : null;
|
||||
setBusy("source");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
if (existing) {
|
||||
if (canManageSecrets && (sourceDraft.password || !existingCredential)) {
|
||||
if (existingCredential) {
|
||||
await updateMailServerCredential(
|
||||
settings,
|
||||
existing.profile.id,
|
||||
existing.server.id,
|
||||
existingCredential.id,
|
||||
{
|
||||
name: `${sourceDraft.name.trim()} credential`,
|
||||
username: sourceDraft.username.trim(),
|
||||
...(sourceDraft.password ? { password: sourceDraft.password } : {})
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await createMailServerCredential(
|
||||
settings,
|
||||
existing.profile.id,
|
||||
existing.server.id,
|
||||
credentialPayload(sourceDraft, existing.server.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
await updateMailServerEndpoint(settings, existing.profile.id, existing.server.id, {
|
||||
name: sourceDraft.name.trim(),
|
||||
config: sourceConfig(sourceDraft),
|
||||
is_active: true
|
||||
});
|
||||
setSuccess("Legacy POP3 source updated.");
|
||||
} else {
|
||||
const disabledServer = await createMailServerEndpoint(settings, profile.id, {
|
||||
protocol: "pop3",
|
||||
name: sourceDraft.name.trim(),
|
||||
config: {
|
||||
...sourceConfig(sourceDraft),
|
||||
legacy_import_enabled: false,
|
||||
allow_delete_after_import: false
|
||||
},
|
||||
is_default: false,
|
||||
is_active: false
|
||||
});
|
||||
await createMailServerCredential(
|
||||
settings,
|
||||
profile.id,
|
||||
disabledServer.id,
|
||||
credentialPayload(sourceDraft, disabledServer.id)
|
||||
);
|
||||
await updateMailServerEndpoint(settings, profile.id, disabledServer.id, {
|
||||
config: sourceConfig(sourceDraft),
|
||||
is_active: true
|
||||
});
|
||||
setSelectedSourceId(disabledServer.id);
|
||||
setSuccess("Legacy POP3 source created. It was enabled only after its encrypted credential was stored.");
|
||||
}
|
||||
setSourceDialogOpen(false);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
const sourceBlocker = sourceSaveBlocker({
|
||||
draft: sourceDraft,
|
||||
existingCredential: editingSourceId ? defaultCredential(sources.find((item) => item.server.id === editingSourceId)?.server) : null,
|
||||
canManageSecrets
|
||||
});
|
||||
const importBlocker = !selectedSource
|
||||
? "Select an enabled legacy POP3 source."
|
||||
: !preview
|
||||
? "Refresh the live preview before importing."
|
||||
: selectedUidls.length === 0
|
||||
? "Select at least one message that has not already been imported."
|
||||
: deleteAfterImport && (!canDelete || !preview.delete_after_import_allowed)
|
||||
? "Source deletion needs both the destructive permission and an endpoint policy that allows it."
|
||||
: "";
|
||||
|
||||
const previewColumns: DataGridColumn<MailPop3MessagePreview>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: "Select",
|
||||
width: 82,
|
||||
render: (item) => <input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${item.subject || item.uidl}`}
|
||||
checked={selectedUidls.includes(item.uidl)}
|
||||
disabled={Boolean(busy) || item.already_imported}
|
||||
onChange={() => setSelectedUidls((current) =>
|
||||
current.includes(item.uidl)
|
||||
? current.filter((uidl) => uidl !== item.uidl)
|
||||
: [...current, item.uidl]
|
||||
)} />
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
header: "Message",
|
||||
width: "minmax(240px, 1.3fr)",
|
||||
filterable: true,
|
||||
value: (item) => `${item.subject || ""} ${item.from_header || ""}`,
|
||||
render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span>
|
||||
},
|
||||
{ id: "date", header: "Provider date", width: "minmax(170px, .8fr)", value: (item) => item.date || "", render: (item) => item.date || "Unknown" },
|
||||
{ id: "size", header: "Size", width: 105, value: (item) => item.size_bytes, render: (item) => formatBytes(item.size_bytes) },
|
||||
{
|
||||
id: "state",
|
||||
header: "State",
|
||||
width: 130,
|
||||
value: (item) => item.already_imported ? "imported" : "available",
|
||||
render: (item) => <StatusBadge status={item.already_imported ? "inactive" : "success"} label={item.already_imported ? "imported" : "available"} />
|
||||
}
|
||||
];
|
||||
|
||||
const importColumns: DataGridColumn<MailPop3ImportRecord>[] = [
|
||||
{ id: "imported", header: "Imported", width: "minmax(170px, .8fr)", value: (item) => item.imported_at, render: (item) => formatDateTime(item.imported_at) },
|
||||
{ id: "subject", header: "Message", width: "minmax(240px, 1.2fr)", filterable: true, value: (item) => `${item.subject || ""} ${item.from_header || ""}`, render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span> },
|
||||
{ id: "review", header: "Review state", width: 140, value: (item) => item.status, render: (item) => <StatusBadge status="warning" label={item.status.replaceAll("_", " ")} /> },
|
||||
{ id: "deletion", header: "Source deletion", width: 165, value: (item) => item.deletion_status, render: (item) => <StatusBadge status={deletionTone(item.deletion_status)} label={item.deletion_status.replaceAll("_", " ")} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
title="Legacy POP3 import"
|
||||
description="Migrate bounded messages into encrypted local review records. POP3 is disabled by default and is not recommended for ongoing mailbox access."
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), disabled: Boolean(busy), disabledReason: busy ? "Wait for the current POP3 action to finish." : undefined }}
|
||||
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={DOCUMENTATION} />}
|
||||
createAction={canManage ? <Button variant="primary" onClick={() => openSourceDialog(null)} disabled={Boolean(busy) || configurableProfiles.length === 0} disabledReason={configurableProfiles.length === 0 ? "Create or gain write access to a Mail profile first." : undefined}><Plus size={16} aria-hidden="true" /> Add legacy source</Button> : undefined}
|
||||
/>}
|
||||
loading={loading}
|
||||
loadingLabel="Loading legacy POP3 sources"
|
||||
error={error}
|
||||
success={success}
|
||||
documentationType="admin"
|
||||
>
|
||||
<ContentGrid columns={2} collapseAt="workspace" align="stretch">
|
||||
<Card title="Legacy sources">
|
||||
{sources.length === 0 ? <ActionBlockerHint reason={{
|
||||
summary: "No POP3 legacy source is configured.",
|
||||
details: "Mail never derives or enables POP3 from an SMTP or IMAP profile.",
|
||||
requiredAction: canManage ? "Add a dedicated legacy source to an existing Mail profile." : "Ask a Mail profile administrator to configure and explicitly enable a source.",
|
||||
actor: "Mail profile administrator",
|
||||
target: "Legacy POP3 import"
|
||||
}} documentation={DOCUMENTATION} /> : <SelectionList label="POP3 legacy sources" variant="navigation">
|
||||
{sources.map((source) => <SelectionListItem key={source.server.id} selected={source.server.id === selectedSource?.server.id} onClick={() => setSelectedSourceId(source.server.id)}>
|
||||
<SelectionListItemContent
|
||||
title={source.server.name}
|
||||
description={`${source.profile.name} · ${source.server.config.host || "Host missing"}`}
|
||||
leading={<ShieldCheck size={18} />}
|
||||
/>
|
||||
</SelectionListItem>)}
|
||||
</SelectionList>}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedSource?.server.name || "Selected source"}
|
||||
actions={selectedSource && canManage && sourceCanBeConfigured ? <TableActionGroup actions={[{
|
||||
id: "edit",
|
||||
label: "Configure source",
|
||||
icon: <Pencil aria-hidden="true" />,
|
||||
disabled: Boolean(busy),
|
||||
disabledReason: busy ? "Wait for the current POP3 action to finish." : "",
|
||||
onClick: () => openSourceDialog(selectedSource)
|
||||
}]} /> : undefined}
|
||||
>
|
||||
{selectedSource ? <FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label="Profile"><span>{selectedSource.profile.name}</span></FormField>
|
||||
<FormField label="Policy"><StatusBadge status={selectedSource.server.config.legacy_import_enabled ? "success" : "inactive"} label={selectedSource.server.config.legacy_import_enabled ? "explicitly enabled" : "disabled"} /></FormField>
|
||||
<FormField label="Transport"><span>{selectedSource.server.config.security || "tls"} · {String(selectedSource.server.config.port || 995)}</span></FormField>
|
||||
<FormField label="Credential"><span>{selectedCredential ? String(selectedCredential.public_data?.username || selectedCredential.name) : "No credential"}</span></FormField>
|
||||
<Button onClick={() => void runConnectionTest()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Test connection</Button>
|
||||
{canImport ? <Button variant="primary" onClick={() => void refreshPreview()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Refresh live preview</Button> : null}
|
||||
</FormGrid> : <p className="muted">Select or configure a legacy source.</p>}
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
|
||||
<Card title="Live provider preview">
|
||||
{preview ? <>
|
||||
<p className="muted">Provider reports {preview.message_count} message(s), {formatBytes(preview.mailbox_size_bytes)} total. Preview and ordinary import do not delete source messages.</p>
|
||||
<DataGrid id="mail-pop3-preview" rows={preview.messages} columns={previewColumns} getRowKey={(item) => item.uidl} emptyText="The legacy mailbox contains no messages." />
|
||||
<FormGrid columns={2} collapseAt="standard" spacing="block">
|
||||
<ToggleSwitch checked={deleteAfterImport} disabled={!canDelete || !preview.delete_after_import_allowed || Boolean(busy)} onChange={setDeleteAfterImport} label="Delete newly imported messages at the source" help="Destructive and separately governed. The local encrypted import is committed first." />
|
||||
<Button variant={deleteAfterImport ? "danger" : "primary"} onClick={() => deleteAfterImport ? setDeleteConfirmationOpen(true) : void runImport()} disabled={Boolean(busy) || Boolean(importBlocker)} disabledReason={importBlocker || (busy ? "Wait for the current POP3 action to finish." : undefined)}>Import {selectedUidls.length || "selected"} message(s)</Button>
|
||||
</FormGrid>
|
||||
</> : <p className="muted">Refresh a source to obtain a bounded, non-destructive preview.</p>}
|
||||
</Card>
|
||||
|
||||
<Card title="Governed local imports">
|
||||
<DataGrid id="mail-pop3-imports" rows={imports} columns={importColumns} getRowKey={(item) => item.id} emptyText="No legacy messages have been imported." />
|
||||
</Card>
|
||||
</PageLayout>
|
||||
|
||||
<Dialog
|
||||
open={sourceDialogOpen}
|
||||
title={editingSourceId ? "Configure legacy POP3 source" : "Add legacy POP3 source"}
|
||||
onClose={() => !busy && setSourceDialogOpen(false)}
|
||||
footer={<><Button onClick={() => setSourceDialogOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button variant="primary" onClick={() => void saveSource()} disabled={Boolean(busy) || Boolean(sourceBlocker)} disabledReason={sourceBlocker || (busy ? "Wait for the source to be saved." : undefined)}>Save source</Button></>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label="Mail profile" help="The profile supplies scope and lifecycle ownership for this dedicated source.">
|
||||
<select value={sourceDraft.profileId} disabled={Boolean(editingSourceId) || Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, profileId: event.target.value }))}>
|
||||
<option value="">Select a profile</option>
|
||||
{configurableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profile.scope_type})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Source name"><input value={sourceDraft.name} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /></FormField>
|
||||
<FormField label="POP3 host"><input value={sourceDraft.host} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /></FormField>
|
||||
<FormField label="Port"><input type="number" min={1} max={65535} value={sourceDraft.port} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, port: event.target.value }))} /></FormField>
|
||||
<FormField label="Transport security">
|
||||
<select value={sourceDraft.security} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, security: event.target.value as SourceDraft["security"], port: event.target.value === "tls" ? "995" : "110" }))}>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="plain">Plain (deployment policy may deny)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Timeout (seconds)"><input type="number" min={1} max={300} value={sourceDraft.timeoutSeconds} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, timeoutSeconds: event.target.value }))} /></FormField>
|
||||
<FormField label="Maximum message size (MiB)"><input type="number" min={1} max={50} value={sourceDraft.maxMessageMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxMessageMiB: event.target.value }))} /></FormField>
|
||||
<FormField label="Maximum batch size (MiB)"><input type="number" min={1} max={500} value={sourceDraft.maxBatchMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxBatchMiB: event.target.value }))} /></FormField>
|
||||
<FormField label="Preview body lines"><input type="number" min={0} max={100} value={sourceDraft.previewBodyLines} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, previewBodyLines: event.target.value }))} /></FormField>
|
||||
<FormField label="Username"><input value={sourceDraft.username} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, username: event.target.value }))} autoComplete="username" /></FormField>
|
||||
<FormField label={editingSourceId ? "New password (optional)" : "Password"}><input type="password" value={sourceDraft.password} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /></FormField>
|
||||
<ToggleSwitch checked={sourceDraft.enabled} disabled={Boolean(busy)} onChange={(enabled) => setSourceDraft((draft) => ({ ...draft, enabled, allowDeleteAfterImport: enabled ? draft.allowDeleteAfterImport : false }))} label="Explicitly enable legacy import" help="Off is the product default." />
|
||||
<ToggleSwitch checked={sourceDraft.allowDeleteAfterImport} disabled={Boolean(busy) || !sourceDraft.enabled} onChange={(allowDeleteAfterImport) => setSourceDraft((draft) => ({ ...draft, allowDeleteAfterImport }))} label="Permit delete-after-import requests" help="Operators still need a separate destructive permission and must choose deletion per batch." />
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmationOpen}
|
||||
title="Import and delete source messages"
|
||||
message={`Mail will first commit and audit ${selectedUidls.length} encrypted local import(s), then ask the POP3 server to delete only those newly imported messages. Provider deletion cannot be undone and may require reconciliation if its outcome is unknown.`}
|
||||
confirmLabel="Import, then delete source"
|
||||
tone="danger"
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteConfirmationOpen(false)}
|
||||
onConfirm={() => void runImport()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pop3Sources(profiles: MailServerProfile[]): Source[] {
|
||||
return profiles.flatMap((profile) =>
|
||||
((profile.servers ?? []) as unknown as MailPop3ServerEndpoint[])
|
||||
.filter((server) => server.protocol === "pop3")
|
||||
.map((server) => ({ profile, server }))
|
||||
);
|
||||
}
|
||||
|
||||
function defaultCredential(server: MailPop3ServerEndpoint | undefined): MailCredentialEnvelope | null {
|
||||
if (!server) return null;
|
||||
return server.credentials.find((credential) => credential.is_default)
|
||||
?? server.credentials.find((credential) => credential.is_active)
|
||||
?? server.credentials[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function profileCanBeConfigured(auth: AuthInfo, profile: MailServerProfile): boolean {
|
||||
if (profile.scope_type === "system") return hasScope(auth, "system:settings:write");
|
||||
if (hasScope(auth, "mail:profile:write")) return true;
|
||||
return profile.scope_type === "user"
|
||||
&& profile.scope_id === auth.user.id
|
||||
&& hasScope(auth, "mail:profile:write_own");
|
||||
}
|
||||
|
||||
function sourceDraftFromSource(source: Source, credential: MailCredentialEnvelope | null): SourceDraft {
|
||||
const config = source.server.config;
|
||||
return {
|
||||
profileId: source.profile.id,
|
||||
name: source.server.name,
|
||||
host: String(config.host ?? ""),
|
||||
port: String(config.port ?? (config.security === "tls" ? 995 : 110)),
|
||||
security: config.security === "starttls" || config.security === "plain" ? config.security : "tls",
|
||||
timeoutSeconds: String(config.timeout_seconds ?? 30),
|
||||
maxMessageMiB: String(Math.max(1, Math.round(Number(config.max_message_bytes ?? 25 * 1024 * 1024) / 1024 / 1024))),
|
||||
maxBatchMiB: String(Math.max(1, Math.round(Number(config.max_batch_bytes ?? 100 * 1024 * 1024) / 1024 / 1024))),
|
||||
previewBodyLines: String(config.preview_body_lines ?? 20),
|
||||
username: String(credential?.public_data?.username ?? ""),
|
||||
password: "",
|
||||
enabled: Boolean(config.legacy_import_enabled),
|
||||
allowDeleteAfterImport: Boolean(config.allow_delete_after_import)
|
||||
};
|
||||
}
|
||||
|
||||
function sourceConfig(draft: SourceDraft): Record<string, unknown> {
|
||||
return {
|
||||
host: draft.host.trim(),
|
||||
port: Number(draft.port),
|
||||
security: draft.security,
|
||||
timeout_seconds: Number(draft.timeoutSeconds),
|
||||
max_message_bytes: Number(draft.maxMessageMiB) * 1024 * 1024,
|
||||
max_batch_bytes: Number(draft.maxBatchMiB) * 1024 * 1024,
|
||||
preview_body_lines: Number(draft.previewBodyLines),
|
||||
legacy_import_enabled: draft.enabled,
|
||||
allow_delete_after_import: draft.allowDeleteAfterImport
|
||||
};
|
||||
}
|
||||
|
||||
function credentialPayload(draft: SourceDraft, serverId: string) {
|
||||
return {
|
||||
name: `${draft.name.trim()} credential`,
|
||||
credential_kind: "username_password",
|
||||
username: draft.username.trim(),
|
||||
password: draft.password,
|
||||
allowed_modules: ["mail"],
|
||||
allowed_server_refs: [`mail:${serverId}`],
|
||||
is_default: true
|
||||
};
|
||||
}
|
||||
|
||||
function sourceSaveBlocker({
|
||||
draft,
|
||||
existingCredential,
|
||||
canManageSecrets
|
||||
}: {
|
||||
draft: SourceDraft;
|
||||
existingCredential: MailCredentialEnvelope | null;
|
||||
canManageSecrets: boolean;
|
||||
}): string {
|
||||
if (!draft.profileId) return "Select a Mail profile.";
|
||||
if (!draft.name.trim()) return "Enter a source name.";
|
||||
if (!draft.host.trim()) return "Enter the POP3 host.";
|
||||
if (!boundedInteger(draft.port, 1, 65535)) return "Enter a valid POP3 port.";
|
||||
if (!boundedInteger(draft.timeoutSeconds, 1, 300)) return "Enter a timeout from 1 to 300 seconds.";
|
||||
if (!boundedInteger(draft.maxMessageMiB, 1, 50)) return "Enter a message limit from 1 to 50 MiB.";
|
||||
if (!boundedInteger(draft.maxBatchMiB, 1, 500)) return "Enter a batch limit from 1 to 500 MiB.";
|
||||
if (Number(draft.maxBatchMiB) < Number(draft.maxMessageMiB)) return "The batch limit cannot be lower than the per-message limit.";
|
||||
if (!boundedInteger(draft.previewBodyLines, 0, 100)) return "Enter 0 to 100 preview body lines.";
|
||||
if (draft.allowDeleteAfterImport && !draft.enabled) return "Enable legacy import before permitting source deletion.";
|
||||
if (!existingCredential && !canManageSecrets) return "Managing the encrypted POP3 credential requires Mail secret authority.";
|
||||
if (!existingCredential && !draft.username.trim()) return "Enter the POP3 username.";
|
||||
if (!existingCredential && !draft.password) return "Enter the POP3 password.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function boundedInteger(value: string, minimum: number, maximum: number): boolean {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum;
|
||||
}
|
||||
|
||||
function deletionTone(status: string): "success" | "warning" | "error" | "inactive" {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed" || status === "outcome_unknown") return "error";
|
||||
if (status === "pending") return "warning";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
+8
-2
@@ -9,8 +9,10 @@ import "./styles/mail-profiles.css";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
|
||||
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
|
||||
const mailboxRead = ["mail:mailbox:read"];
|
||||
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
|
||||
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
@@ -39,10 +41,14 @@ export const mailModule: PlatformWebModule = {
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||
],
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
navItems: [
|
||||
{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 },
|
||||
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
|
||||
],
|
||||
routes: [
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) },
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) }],
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) },
|
||||
{ path: "/mail/legacy-import", anyOf: legacyImportAccess, order: 52, render: ({ settings, auth }) => createElement(MailLegacyImportPage, { settings, auth }) }],
|
||||
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||
|
||||
Reference in New Issue
Block a user