@anthusai/papyrus 5.43.0-next.8 → 5.43.0-next.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/components/monitoring-overview-view.tsx +91 -17
- package/components/monitoring-panel.tsx +4 -2
- package/lib/monitoring-client.ts +14 -1
- package/lib/monitoring-overview.ts +31 -1
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -26,6 +26,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
26
26
|
.returns(a.json())
|
|
27
27
|
.authorization((allow) => [allow.groups(editors)])
|
|
28
28
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
30
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
31
|
+
monitoringDecideEmailSubmission: a
|
|
32
|
+
.mutation()
|
|
33
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
34
|
+
.returns(a.json())
|
|
35
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
36
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
29
37
|
monitoringResolveEmailArticleLink: a
|
|
30
38
|
.mutation()
|
|
31
39
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -28,6 +28,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
28
28
|
.returns(a.json())
|
|
29
29
|
.authorization((allow) => [allow.groups(editors)])
|
|
30
30
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
|
+
// Accept ("release") or Reject ("discard") one held email submission. The function validates the
|
|
32
|
+
// signed-in editor and that the message is routed to this publication; the receiver applies it.
|
|
33
|
+
monitoringDecideEmailSubmission: a
|
|
34
|
+
.mutation()
|
|
35
|
+
.arguments({ receiptId: a.string().required(), decision: a.string().required(), reason: a.string() })
|
|
36
|
+
.returns(a.json())
|
|
37
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
38
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
31
39
|
monitoringResolveEmailArticleLink: a
|
|
32
40
|
.mutation()
|
|
33
41
|
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import Link from "next/link";
|
|
3
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
3
4
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
4
5
|
import {
|
|
5
6
|
confidenceText,
|
|
@@ -12,6 +13,10 @@ import {
|
|
|
12
13
|
beginEmailSubmissionAction,
|
|
13
14
|
endEmailSubmissionAction,
|
|
14
15
|
isCurrentEmailSubmissionAction,
|
|
16
|
+
emailSubmissionAccepted,
|
|
17
|
+
QUARANTINE_LABELS,
|
|
18
|
+
type EmailDecisionResult,
|
|
19
|
+
type EmailQuarantine,
|
|
15
20
|
type MonitoringItem,
|
|
16
21
|
type MonitoringEmailDetails,
|
|
17
22
|
type MonitoringEmailArticleResolution,
|
|
@@ -97,8 +102,9 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
102
|
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
105
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
101
106
|
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
107
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
102
108
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
109
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
110
|
}) {
|
|
@@ -108,8 +114,15 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
108
114
|
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
115
|
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
116
|
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
117
|
+
const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
|
|
118
|
+
const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
|
|
119
|
+
const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
|
|
111
120
|
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
121
|
const receiptKey = receiptId ?? "";
|
|
122
|
+
const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
|
|
123
|
+
const accepted = emailSubmissionAccepted(quarantine);
|
|
124
|
+
const reason = reasonByReceipt[receiptKey] ?? "";
|
|
125
|
+
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
113
126
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
127
|
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
128
|
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
@@ -160,6 +173,34 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
160
173
|
}
|
|
161
174
|
};
|
|
162
175
|
|
|
176
|
+
const decide = async (decision: "accept" | "reject") => {
|
|
177
|
+
if (!receiptId || !onDecide) return;
|
|
178
|
+
const requestReceiptId = receiptId;
|
|
179
|
+
const actionKey = `decide:${requestReceiptId}`;
|
|
180
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
181
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
182
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
183
|
+
try {
|
|
184
|
+
const result = await onDecide(requestReceiptId, decision, reason.trim());
|
|
185
|
+
if ("ok" in result) {
|
|
186
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
|
|
187
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
|
|
188
|
+
? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
|
|
189
|
+
: "Rejected. The message is closed and kept privately until it expires." }));
|
|
190
|
+
} else if ("error" in result && result.quarantine) {
|
|
191
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
|
|
192
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
|
|
193
|
+
} else {
|
|
194
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
|
|
198
|
+
} finally {
|
|
199
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
200
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
163
204
|
const importSelected = async () => {
|
|
164
205
|
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
165
206
|
const requestReceiptId = receiptId;
|
|
@@ -212,6 +253,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
212
253
|
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
213
254
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
214
255
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
256
|
+
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
215
257
|
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
216
258
|
</li>)}
|
|
217
259
|
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
@@ -220,32 +262,60 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
220
262
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
221
263
|
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
222
264
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
265
|
+
{quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
|
|
266
|
+
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
267
|
+
{quarantine.state === "quarantined" ? <>
|
|
268
|
+
<p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
|
|
269
|
+
<label>Why? (optional)
|
|
270
|
+
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
271
|
+
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
272
|
+
</label>
|
|
273
|
+
<button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
|
|
274
|
+
<CheckIcon aria-hidden="true" size={16} /> Accept
|
|
275
|
+
</button>
|
|
276
|
+
<button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
|
|
277
|
+
<XIcon aria-hidden="true" size={16} /> Reject
|
|
278
|
+
</button>
|
|
279
|
+
</> : null}
|
|
280
|
+
{quarantine.decision ? <p data-email-decision-record>
|
|
281
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
|
|
282
|
+
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
283
|
+
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
284
|
+
</p> : null}
|
|
285
|
+
{quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
|
|
286
|
+
{quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
|
|
287
|
+
{notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
|
|
288
|
+
</section> : null}
|
|
223
289
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
224
290
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
225
|
-
{
|
|
291
|
+
{!accepted
|
|
292
|
+
? <span>{link.title || "Source link"} <code>{link.url}</code></span>
|
|
293
|
+
: link.resolutionStatus === "unresolved" && !resolved[index]
|
|
226
294
|
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
227
295
|
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
228
|
-
<label>
|
|
296
|
+
{accepted ? <label>
|
|
229
297
|
<input type="checkbox" checked={selected[index] ?? false}
|
|
230
298
|
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
231
299
|
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
232
300
|
Add as pending Reference
|
|
233
|
-
</label>
|
|
234
|
-
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
301
|
+
</label> : null}
|
|
302
|
+
{accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
235
303
|
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
236
304
|
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
237
305
|
</button>
|
|
238
306
|
) : null}
|
|
239
307
|
</li>)}</ul>
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
308
|
+
{accepted ? <>
|
|
309
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
310
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
311
|
+
onClick={() => void importSelected()}>
|
|
312
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
313
|
+
</button>
|
|
314
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
315
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
316
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
317
|
+
))}</ul> : null}
|
|
318
|
+
</> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
|
|
249
319
|
</section>
|
|
250
320
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
251
321
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -253,8 +323,9 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
253
323
|
);
|
|
254
324
|
}
|
|
255
325
|
|
|
256
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
326
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
257
327
|
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
328
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
258
329
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
259
330
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
260
331
|
}) {
|
|
@@ -267,7 +338,8 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
267
338
|
|
|
268
339
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
269
340
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
270
|
-
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
341
|
+
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
342
|
+
onDecide={demo ? undefined : onDecide} />
|
|
271
343
|
|
|
272
344
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
273
345
|
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
@@ -375,6 +447,7 @@ export function MonitoringOverviewView({
|
|
|
375
447
|
onEmailOpen = () => {},
|
|
376
448
|
onResolveArticleLink,
|
|
377
449
|
onImportCandidates,
|
|
450
|
+
onDecide,
|
|
378
451
|
}: {
|
|
379
452
|
result: MonitoringResult | null;
|
|
380
453
|
canViewAll: boolean;
|
|
@@ -385,6 +458,7 @@ export function MonitoringOverviewView({
|
|
|
385
458
|
onEmailOpen?: (receiptId: string) => void;
|
|
386
459
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
387
460
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
461
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
388
462
|
}) {
|
|
389
463
|
let body: React.ReactNode;
|
|
390
464
|
if (result === null) {
|
|
@@ -401,7 +475,7 @@ export function MonitoringOverviewView({
|
|
|
401
475
|
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
402
476
|
} else {
|
|
403
477
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
404
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
478
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} />;
|
|
405
479
|
}
|
|
406
480
|
const scopeLine = result && "scope" in result
|
|
407
481
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { useSearchParams } from "next/navigation";
|
|
5
5
|
import {
|
|
6
|
+
decideMonitoringEmailSubmission,
|
|
6
7
|
importMonitoringEmailCandidates,
|
|
7
8
|
loadMonitoringEmailSubmission,
|
|
8
9
|
loadMonitoringOverview,
|
|
@@ -39,7 +40,8 @@ function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDe
|
|
|
39
40
|
return <div data-news-desk-admin-panel="monitoring">
|
|
40
41
|
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
41
42
|
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
42
|
-
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
43
|
+
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
44
|
+
onDecide={decideMonitoringEmailSubmission} />}
|
|
43
45
|
</div>;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -86,5 +88,5 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
86
88
|
|
|
87
89
|
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
88
90
|
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
89
|
-
onImportCandidates={importEmailCandidates} />;
|
|
91
|
+
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission} />;
|
|
90
92
|
}
|
package/lib/monitoring-client.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import { emailReferenceBatches } from "./monitoring-overview";
|
|
5
5
|
import type {
|
|
6
|
+
EmailDecisionResult,
|
|
6
7
|
EmailReferenceImportItem,
|
|
7
8
|
EmailReferenceImportOutcome,
|
|
8
9
|
MonitoringEmailArticleResolution,
|
|
@@ -10,7 +11,7 @@ import type {
|
|
|
10
11
|
MonitoringResult,
|
|
11
12
|
} from "./monitoring-overview";
|
|
12
13
|
|
|
13
|
-
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
|
|
14
|
+
type QueryField = "monitoringOverview" | "monitoringEmailSubmission" | "monitoringDecideEmailSubmission" | "monitoringResolveEmailArticleLink" | "intakeEmailSubmissionReferences" | "monitoringQueueEmailReference";
|
|
14
15
|
type GraphQLResult = { data?: Partial<Record<QueryField, unknown>>; errors?: { message?: string; errorType?: string }[] };
|
|
15
16
|
|
|
16
17
|
async function query<T>(field: QueryField, queryText: string, variables: Record<string, string | number>): Promise<T | { error: "forbidden" } | { state: "unavailable" }> {
|
|
@@ -40,6 +41,18 @@ export async function loadMonitoringEmailSubmission(receiptId: string): Promise<
|
|
|
40
41
|
`query MonitoringEmailSubmission($receiptId: String!) { monitoringEmailSubmission(receiptId: $receiptId) }`, { receiptId });
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Accept or Reject one held email submission. "Accept" releases it into the normal intake and
|
|
46
|
+
* "Reject" discards it; the editor and the optional reason are recorded server-side.
|
|
47
|
+
*/
|
|
48
|
+
export async function decideMonitoringEmailSubmission(
|
|
49
|
+
receiptId: string, decision: "accept" | "reject", reason: string,
|
|
50
|
+
): Promise<EmailDecisionResult> {
|
|
51
|
+
return query<EmailDecisionResult>("monitoringDecideEmailSubmission",
|
|
52
|
+
`mutation MonitoringDecideEmailSubmission($receiptId: String!, $decision: String!, $reason: String) { monitoringDecideEmailSubmission(receiptId: $receiptId, decision: $decision, reason: $reason) }`,
|
|
53
|
+
{ receiptId, decision: decision === "accept" ? "release" : "discard", reason });
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
/** Resolve one explicitly selected Beehiiv article redirect; this may register a click with the sender. */
|
|
44
57
|
export async function resolveMonitoringEmailArticleLink(receiptId: string, candidateIndex: number): Promise<MonitoringEmailArticleResolution> {
|
|
45
58
|
return query<MonitoringEmailArticleResolution>("monitoringResolveEmailArticleLink",
|
|
@@ -37,9 +37,39 @@ export type MonitoringEmailSummary = {
|
|
|
37
37
|
receiptId: string; observedAt: string | null; status: string; subject: string | null;
|
|
38
38
|
envelopeSender: string | null; headerFrom: string | null; warnings: string[];
|
|
39
39
|
verdicts: Record<string, string>; linkCount: number; processedCount: number; unresolvedLinkCount: number;
|
|
40
|
-
reviewRequired: boolean; errorCode: string | null;
|
|
40
|
+
reviewRequired: boolean; errorCode: string | null; quarantine: EmailQuarantine | null;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/** Where a held email submission stands. `null` means it never needed review. */
|
|
44
|
+
export type EmailQuarantineState =
|
|
45
|
+
| "quarantined" | "release_requested" | "released" | "discard_requested" | "discarded"
|
|
46
|
+
| "expired" | "decision_failed" | "release_failed";
|
|
47
|
+
export type EmailQuarantine = {
|
|
48
|
+
state: EmailQuarantineState;
|
|
49
|
+
decision: { action: "accept" | "reject"; by: string; at: string | null; reason: string } | null;
|
|
50
|
+
failure: string | null;
|
|
51
|
+
};
|
|
52
|
+
export type EmailDecisionResult =
|
|
53
|
+
| { ok: true; already: boolean; quarantine: EmailQuarantine }
|
|
54
|
+
| { error: "forbidden" | "bad_request" | "already_decided" | "not_quarantined" | "expired"; quarantine?: EmailQuarantine }
|
|
55
|
+
| { state: "unavailable" | "not_configured" };
|
|
56
|
+
|
|
57
|
+
export const QUARANTINE_LABELS: Record<EmailQuarantineState, string> = {
|
|
58
|
+
quarantined: "Held for review",
|
|
59
|
+
release_requested: "Accepted, being queued",
|
|
60
|
+
released: "Accepted",
|
|
61
|
+
discard_requested: "Rejected, being closed",
|
|
62
|
+
discarded: "Rejected",
|
|
63
|
+
expired: "Expired",
|
|
64
|
+
decision_failed: "Decision could not be applied",
|
|
65
|
+
release_failed: "Accepted, retrying",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Links and import are available only once a message is accepted (or never needed review). */
|
|
69
|
+
export function emailSubmissionAccepted(quarantine: EmailQuarantine | null): boolean {
|
|
70
|
+
return quarantine === null || quarantine.state === "released";
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
export type MonitoringEmailDetails = {
|
|
44
74
|
submission: MonitoringEmailSummary & { corpusId: string | null }; safeBodyText: string;
|
|
45
75
|
candidateLinks: { url: string; title: string; resolutionStatus?: "unresolved" }[]; sourceMaterialIsUntrusted: true;
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED