@dxv-systems/helpdesk 0.0.0-stage → 0.1.0
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/README.md +279 -3
- package/dist/autosend.d.ts +43 -0
- package/dist/autosend.js +83 -0
- package/dist/bounce.d.ts +7 -0
- package/dist/bounce.js +41 -0
- package/dist/cleanup.d.ts +7 -0
- package/dist/cleanup.js +39 -0
- package/dist/controls.d.ts +13 -0
- package/dist/controls.js +87 -0
- package/dist/draft-body.d.ts +5 -0
- package/dist/draft-body.js +52 -0
- package/dist/drafting.d.ts +19 -0
- package/dist/drafting.js +311 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +139 -0
- package/dist/inbox/handlers.d.ts +36 -0
- package/dist/inbox/handlers.js +535 -0
- package/dist/inbox/messages.d.ts +35 -0
- package/dist/inbox/messages.js +57 -0
- package/dist/inbox/plain-text.d.ts +1 -0
- package/dist/inbox/plain-text.js +58 -0
- package/dist/inbox/types.d.ts +108 -0
- package/dist/inbox/types.js +2 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +71 -0
- package/dist/ingest.d.ts +25 -0
- package/dist/ingest.js +335 -0
- package/dist/internal.d.ts +8 -0
- package/dist/internal.js +44 -0
- package/dist/noise.d.ts +17 -0
- package/dist/noise.js +77 -0
- package/dist/react.d.ts +57 -0
- package/dist/react.js +431 -0
- package/dist/reply-attachments.d.ts +28 -0
- package/dist/reply-attachments.js +119 -0
- package/dist/sending.d.ts +33 -0
- package/dist/sending.js +291 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -0
- package/dist/states.d.ts +13 -0
- package/dist/states.js +63 -0
- package/dist/subject.d.ts +2 -0
- package/dist/subject.js +30 -0
- package/dist/testing/index.d.ts +99 -0
- package/dist/testing/index.js +489 -0
- package/dist/threading.d.ts +18 -0
- package/dist/threading.js +66 -0
- package/dist/types.d.ts +345 -0
- package/dist/types.js +2 -0
- package/package.json +73 -6
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_REPLY_ATTACHMENTS = exports.MAX_REPLY_ATTACHMENT_BYTES = void 0;
|
|
4
|
+
exports.pendingReplyAttachments = pendingReplyAttachments;
|
|
5
|
+
exports.startReplyUpload = startReplyUpload;
|
|
6
|
+
exports.addReplyAttachment = addReplyAttachment;
|
|
7
|
+
exports.removeReplyAttachment = removeReplyAttachment;
|
|
8
|
+
exports.placeReplyAttachments = placeReplyAttachments;
|
|
9
|
+
exports.markReplyAttachmentsSent = markReplyAttachmentsSent;
|
|
10
|
+
exports.discardPendingReplyAttachments = discardPendingReplyAttachments;
|
|
11
|
+
const errors_1 = require("./errors");
|
|
12
|
+
const internal_1 = require("./internal");
|
|
13
|
+
const states_1 = require("./states");
|
|
14
|
+
exports.MAX_REPLY_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
exports.MAX_REPLY_ATTACHMENTS = 10;
|
|
16
|
+
function attachmentsOf(deps) {
|
|
17
|
+
if (!deps.attachments)
|
|
18
|
+
throw new errors_1.AttachmentsUnavailableError();
|
|
19
|
+
return deps.attachments;
|
|
20
|
+
}
|
|
21
|
+
async function pendingReplyAttachments(deps, threadId) {
|
|
22
|
+
if (!deps.attachments)
|
|
23
|
+
return [];
|
|
24
|
+
return (await deps.attachments.store.listForThread(threadId)).filter((a) => a.sentIntentId === null);
|
|
25
|
+
}
|
|
26
|
+
function assertAttachable(thread) {
|
|
27
|
+
if (!(0, states_1.canTransition)(thread.state, "approve") || thread.reviewReason === "delivery_failed") {
|
|
28
|
+
throw new errors_1.AttachmentsUnavailableError();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function assertWithinLimits(pending, size) {
|
|
32
|
+
if (pending.length >= exports.MAX_REPLY_ATTACHMENTS)
|
|
33
|
+
throw new errors_1.AttachmentLimitError("count");
|
|
34
|
+
const total = pending.reduce((sum, a) => sum + a.size, 0);
|
|
35
|
+
if (size <= 0 || size > exports.MAX_REPLY_ATTACHMENT_BYTES || total + size > exports.MAX_REPLY_ATTACHMENT_BYTES) {
|
|
36
|
+
throw new errors_1.AttachmentLimitError("size");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function startReplyUpload(deps, input) {
|
|
40
|
+
const { files } = attachmentsOf(deps);
|
|
41
|
+
assertAttachable(await (0, internal_1.loadThread)(deps, input.threadId));
|
|
42
|
+
assertWithinLimits(await pendingReplyAttachments(deps, input.threadId), input.size);
|
|
43
|
+
return files.createUpload({ staffId: input.staffId, name: input.name, contentType: input.contentType, size: input.size });
|
|
44
|
+
}
|
|
45
|
+
async function addReplyAttachment(deps, input) {
|
|
46
|
+
const { files, store } = attachmentsOf(deps);
|
|
47
|
+
const thread = await (0, internal_1.loadThread)(deps, input.threadId);
|
|
48
|
+
assertAttachable(thread);
|
|
49
|
+
const file = await files.completeUpload({ staffId: input.staffId, uploadToken: input.uploadToken });
|
|
50
|
+
try {
|
|
51
|
+
assertWithinLimits(await pendingReplyAttachments(deps, thread.id), file.size);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
await files.remove({ fileId: file.fileId, staffId: input.staffId }).catch(() => undefined);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
const attachment = {
|
|
58
|
+
id: (0, internal_1.newId)(deps),
|
|
59
|
+
threadId: thread.id,
|
|
60
|
+
mailbox: thread.mailbox,
|
|
61
|
+
fileId: file.fileId,
|
|
62
|
+
name: file.name,
|
|
63
|
+
contentType: file.contentType,
|
|
64
|
+
size: file.size,
|
|
65
|
+
addedBy: input.staffId,
|
|
66
|
+
createdAt: (0, internal_1.nowIso)(deps),
|
|
67
|
+
placedDraftId: null,
|
|
68
|
+
sentIntentId: null,
|
|
69
|
+
};
|
|
70
|
+
await store.add(attachment);
|
|
71
|
+
return attachment;
|
|
72
|
+
}
|
|
73
|
+
async function removeReplyAttachment(deps, input) {
|
|
74
|
+
const { files, store } = attachmentsOf(deps);
|
|
75
|
+
const own = (await store.listForThread(input.threadId)).find((a) => a.id === input.attachmentId);
|
|
76
|
+
if (!own)
|
|
77
|
+
throw new errors_1.ReplyAttachmentNotFoundError(input.attachmentId);
|
|
78
|
+
const removed = await store.removeUnsent(own.id);
|
|
79
|
+
if (!removed)
|
|
80
|
+
throw new errors_1.ReplyAttachmentNotFoundError(input.attachmentId);
|
|
81
|
+
await files.remove({ fileId: removed.fileId, staffId: removed.addedBy }).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
async function placeReplyAttachments(deps, access, threadId, draftId) {
|
|
84
|
+
const pending = await pendingReplyAttachments(deps, threadId);
|
|
85
|
+
if (pending.length === 0)
|
|
86
|
+
return;
|
|
87
|
+
const { files, store } = attachmentsOf(deps);
|
|
88
|
+
const gateway = access.gateway();
|
|
89
|
+
if (!gateway.addAttachment)
|
|
90
|
+
throw new errors_1.AttachmentsUnavailableError();
|
|
91
|
+
for (const a of pending) {
|
|
92
|
+
if (a.placedDraftId === draftId)
|
|
93
|
+
continue;
|
|
94
|
+
const content = await files.read({ fileId: a.fileId, staffId: a.addedBy });
|
|
95
|
+
await gateway.addAttachment(draftId, { name: a.name, contentType: a.contentType, content });
|
|
96
|
+
await store.update(a.id, { placedDraftId: draftId });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function markReplyAttachmentsSent(deps, threadId, draftId, intentId) {
|
|
100
|
+
if (!deps.attachments || !draftId)
|
|
101
|
+
return;
|
|
102
|
+
for (const a of await pendingReplyAttachments(deps, threadId)) {
|
|
103
|
+
if (a.placedDraftId === draftId)
|
|
104
|
+
await deps.attachments.store.update(a.id, { sentIntentId: intentId });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function discardPendingReplyAttachments(deps, threadId) {
|
|
108
|
+
if (!deps.attachments)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
for (const a of await pendingReplyAttachments(deps, threadId)) {
|
|
112
|
+
const removed = await deps.attachments.store.removeUnsent(a.id);
|
|
113
|
+
if (removed)
|
|
114
|
+
await deps.attachments.files.remove({ fileId: removed.fileId, staffId: removed.addedBy }).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HelpdeskDeps, Paused, SendIntent, Thread, WriteBlocked } from "./types";
|
|
2
|
+
export declare function classifySendError(err: unknown): "not_sent" | "unknown";
|
|
3
|
+
export type SendOutcome = {
|
|
4
|
+
status: "sent";
|
|
5
|
+
thread: Thread;
|
|
6
|
+
} | {
|
|
7
|
+
status: "already_sent";
|
|
8
|
+
} | {
|
|
9
|
+
status: "in_progress";
|
|
10
|
+
} | {
|
|
11
|
+
status: "unknown_outcome";
|
|
12
|
+
intent: SendIntent;
|
|
13
|
+
} | {
|
|
14
|
+
status: "failed_not_sent";
|
|
15
|
+
error: string;
|
|
16
|
+
} | Paused | WriteBlocked;
|
|
17
|
+
export declare function approveAndSend(deps: HelpdeskDeps, input: {
|
|
18
|
+
threadId: string;
|
|
19
|
+
approvedBy: string;
|
|
20
|
+
acceptStale?: boolean;
|
|
21
|
+
}): Promise<SendOutcome>;
|
|
22
|
+
export declare function reconcileUnknownSends(deps: HelpdeskDeps, mailbox: string): Promise<number>;
|
|
23
|
+
export declare function resolveUnknownSend(deps: HelpdeskDeps, input: {
|
|
24
|
+
threadId: string;
|
|
25
|
+
outcome: "sent" | "not_sent";
|
|
26
|
+
}): Promise<SendIntent>;
|
|
27
|
+
export declare function markRepliedExternally(deps: HelpdeskDeps, threadId: string): Promise<Thread | Paused>;
|
|
28
|
+
export declare function dismissThread(deps: HelpdeskDeps, input: {
|
|
29
|
+
threadId: string;
|
|
30
|
+
dismissedBy: string;
|
|
31
|
+
}): Promise<Thread | Paused>;
|
|
32
|
+
export declare function markWaiting(deps: HelpdeskDeps, threadId: string): Promise<Thread>;
|
|
33
|
+
export declare function closeThread(deps: HelpdeskDeps, threadId: string): Promise<Thread>;
|
package/dist/sending.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifySendError = classifySendError;
|
|
4
|
+
exports.approveAndSend = approveAndSend;
|
|
5
|
+
exports.reconcileUnknownSends = reconcileUnknownSends;
|
|
6
|
+
exports.resolveUnknownSend = resolveUnknownSend;
|
|
7
|
+
exports.markRepliedExternally = markRepliedExternally;
|
|
8
|
+
exports.dismissThread = dismissThread;
|
|
9
|
+
exports.markWaiting = markWaiting;
|
|
10
|
+
exports.closeThread = closeThread;
|
|
11
|
+
const cleanup_1 = require("./cleanup");
|
|
12
|
+
const controls_1 = require("./controls");
|
|
13
|
+
const draft_body_1 = require("./draft-body");
|
|
14
|
+
const errors_1 = require("./errors");
|
|
15
|
+
const drafting_1 = require("./drafting");
|
|
16
|
+
const internal_1 = require("./internal");
|
|
17
|
+
const reply_attachments_1 = require("./reply-attachments");
|
|
18
|
+
const states_1 = require("./states");
|
|
19
|
+
const NOT_SENT_STATUSES = new Set([400, 401, 403, 429]);
|
|
20
|
+
function classifySendError(err) {
|
|
21
|
+
const status = err?.status;
|
|
22
|
+
return typeof status === "number" && NOT_SENT_STATUSES.has(status) ? "not_sent" : "unknown";
|
|
23
|
+
}
|
|
24
|
+
const SEND_IN_FLIGHT_MS = 2 * 60 * 1000;
|
|
25
|
+
async function markSent(deps, thread, intent) {
|
|
26
|
+
const fresh = await (0, internal_1.loadThread)(deps, thread.id);
|
|
27
|
+
const sent = await deps.store.transitionSendIntent(intent.id, ["sending", "unknown"], {
|
|
28
|
+
status: "sent",
|
|
29
|
+
updatedAt: (0, internal_1.nowIso)(deps),
|
|
30
|
+
error: null,
|
|
31
|
+
});
|
|
32
|
+
if (!sent)
|
|
33
|
+
return fresh;
|
|
34
|
+
if (fresh.state !== "review")
|
|
35
|
+
return fresh;
|
|
36
|
+
return (0, internal_1.applyEvents)(deps, fresh, ["approve"], { reviewReason: null });
|
|
37
|
+
}
|
|
38
|
+
async function surfaceUnknown(deps, thread, intent) {
|
|
39
|
+
const unknown = (await deps.store.transitionSendIntent(intent.id, ["sending", "unknown"], {
|
|
40
|
+
status: "unknown",
|
|
41
|
+
updatedAt: (0, internal_1.nowIso)(deps),
|
|
42
|
+
})) ?? intent;
|
|
43
|
+
await (0, internal_1.notify)(deps, {
|
|
44
|
+
type: "send_outcome_unknown",
|
|
45
|
+
threadId: thread.id,
|
|
46
|
+
mailbox: thread.mailbox,
|
|
47
|
+
subject: thread.subject,
|
|
48
|
+
draftId: intent.draftId,
|
|
49
|
+
});
|
|
50
|
+
return { status: "unknown_outcome", intent: unknown };
|
|
51
|
+
}
|
|
52
|
+
async function sendingElsewhereOrLost(deps, thread, intent) {
|
|
53
|
+
if (intent.status === "sent")
|
|
54
|
+
return { status: "already_sent" };
|
|
55
|
+
const age = deps.clock.now().getTime() - Date.parse(intent.updatedAt);
|
|
56
|
+
if (intent.status !== "unknown" && (intent.status !== "sending" || age < SEND_IN_FLIGHT_MS)) {
|
|
57
|
+
return { status: "in_progress" };
|
|
58
|
+
}
|
|
59
|
+
return surfaceUnknown(deps, thread, intent);
|
|
60
|
+
}
|
|
61
|
+
async function prepareAppDraft(deps, access, run, intent) {
|
|
62
|
+
const gateway = access.gateway();
|
|
63
|
+
let current = run;
|
|
64
|
+
if (!intent.draftId || run.placedDraftId !== intent.draftId || run.placedBodyHtml === null) {
|
|
65
|
+
const draft = await gateway.createReply(run.inboundMessageId);
|
|
66
|
+
current = await deps.store.updateDraftRun(run.idempotencyKey, {
|
|
67
|
+
placedDraftId: draft.id,
|
|
68
|
+
placedBodyHtml: draft.body.content,
|
|
69
|
+
placementError: null,
|
|
70
|
+
});
|
|
71
|
+
await deps.store.transitionSendIntent(intent.id, ["sending"], { draftId: draft.id });
|
|
72
|
+
}
|
|
73
|
+
const draftId = current.placedDraftId;
|
|
74
|
+
const text = current.commentText ?? current.text ?? "";
|
|
75
|
+
const body = (0, draft_body_1.prependToDraftBody)(current.placedBodyHtml, text);
|
|
76
|
+
await gateway.updateDraftBody(draftId, { contentType: "html", content: body });
|
|
77
|
+
return draftId;
|
|
78
|
+
}
|
|
79
|
+
async function approveAndSend(deps, input) {
|
|
80
|
+
const controls = await (0, controls_1.loadControls)(deps);
|
|
81
|
+
if (controls.paused)
|
|
82
|
+
return (0, controls_1.paused)();
|
|
83
|
+
const thread = await (0, internal_1.loadThread)(deps, input.threadId);
|
|
84
|
+
const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
|
|
85
|
+
if (access.mode === "read_only")
|
|
86
|
+
return { status: "write_blocked", mode: access.mode };
|
|
87
|
+
const inApp = access.mode === "drafts_in_app";
|
|
88
|
+
const run = (0, drafting_1.currentReplyRun)(deps, thread, await deps.store.getLatestDraftRunForThread(thread.id));
|
|
89
|
+
if (!run || (inApp ? run.status !== "succeeded" : !run.placedDraftId)) {
|
|
90
|
+
throw new errors_1.DraftNotReadyError(thread.id, inApp ? "no approved draft text" : "no placed draft");
|
|
91
|
+
}
|
|
92
|
+
const ids = (0, cleanup_1.sendIntentIds)(run);
|
|
93
|
+
for (const id of [ids.perRun, ids.perDraft]) {
|
|
94
|
+
const prior = id ? await deps.store.getSendIntent(id) : null;
|
|
95
|
+
if (prior?.status === "sent")
|
|
96
|
+
return { status: "already_sent" };
|
|
97
|
+
if (prior?.status === "unknown")
|
|
98
|
+
return surfaceUnknown(deps, thread, prior);
|
|
99
|
+
if (prior?.status === "sending")
|
|
100
|
+
return sendingElsewhereOrLost(deps, thread, prior);
|
|
101
|
+
}
|
|
102
|
+
(0, states_1.transition)(thread.state, "approve");
|
|
103
|
+
if ((run.commentText ?? run.text ?? "").trim() === "")
|
|
104
|
+
throw new errors_1.EmptyReplyError(thread.id);
|
|
105
|
+
if (thread.latestInboundId !== run.inboundMessageId && !input.acceptStale) {
|
|
106
|
+
throw new errors_1.StaleDraftError(thread.id);
|
|
107
|
+
}
|
|
108
|
+
const now = (0, internal_1.nowIso)(deps);
|
|
109
|
+
const { intent } = await deps.store.createSendIntentIfAbsent({
|
|
110
|
+
id: inApp ? ids.perRun : ids.perDraft,
|
|
111
|
+
mailbox: thread.mailbox,
|
|
112
|
+
threadId: thread.id,
|
|
113
|
+
draftId: inApp ? null : run.placedDraftId,
|
|
114
|
+
approvedBy: input.approvedBy,
|
|
115
|
+
status: "intended",
|
|
116
|
+
createdAt: now,
|
|
117
|
+
updatedAt: now,
|
|
118
|
+
error: null,
|
|
119
|
+
});
|
|
120
|
+
if (intent.status === "sent")
|
|
121
|
+
return { status: "already_sent" };
|
|
122
|
+
const claimed = await deps.store.transitionSendIntent(intent.id, ["intended", "failed"], {
|
|
123
|
+
status: "sending",
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
error: null,
|
|
126
|
+
});
|
|
127
|
+
if (!claimed) {
|
|
128
|
+
const current = (await deps.store.getSendIntent(intent.id)) ?? intent;
|
|
129
|
+
if (current.status === "sent")
|
|
130
|
+
return { status: "already_sent" };
|
|
131
|
+
return sendingElsewhereOrLost(deps, thread, current);
|
|
132
|
+
}
|
|
133
|
+
if ((await (0, internal_1.loadThread)(deps, thread.id)).state !== "review") {
|
|
134
|
+
await failNotSent(deps, intent.id, new Error("ticket changed before sending"));
|
|
135
|
+
throw new errors_1.StaleThreadError(thread.id);
|
|
136
|
+
}
|
|
137
|
+
let draftId = run.placedDraftId;
|
|
138
|
+
try {
|
|
139
|
+
if (inApp)
|
|
140
|
+
draftId = await prepareAppDraft(deps, access, run, claimed);
|
|
141
|
+
await (0, reply_attachments_1.placeReplyAttachments)(deps, access, thread.id, draftId);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return failNotSent(deps, intent.id, err);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await access.gateway().sendDraft(draftId);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (classifySendError(err) === "unknown") {
|
|
151
|
+
return surfaceUnknown(deps, thread, { ...claimed, draftId });
|
|
152
|
+
}
|
|
153
|
+
return failNotSent(deps, intent.id, err);
|
|
154
|
+
}
|
|
155
|
+
await (0, reply_attachments_1.markReplyAttachmentsSent)(deps, thread.id, draftId, intent.id).catch(() => undefined);
|
|
156
|
+
return { status: "sent", thread: await markSent(deps, thread, claimed) };
|
|
157
|
+
}
|
|
158
|
+
async function failNotSent(deps, intentId, err) {
|
|
159
|
+
await deps.store.transitionSendIntent(intentId, ["sending"], {
|
|
160
|
+
status: "failed",
|
|
161
|
+
updatedAt: (0, internal_1.nowIso)(deps),
|
|
162
|
+
error: (0, internal_1.errorText)(err),
|
|
163
|
+
});
|
|
164
|
+
return { status: "failed_not_sent", error: (0, internal_1.errorText)(err) };
|
|
165
|
+
}
|
|
166
|
+
async function reconcileUnknownSends(deps, mailbox) {
|
|
167
|
+
if (!deps.store.listSendIntentsByStatus)
|
|
168
|
+
return 0;
|
|
169
|
+
const controls = await (0, controls_1.loadControls)(deps);
|
|
170
|
+
if (controls.paused)
|
|
171
|
+
return 0;
|
|
172
|
+
const gateway = (0, controls_1.mailboxAccess)(deps, controls, mailbox).gateway();
|
|
173
|
+
const unknown = await deps.store.listSendIntentsByStatus(mailbox, "unknown");
|
|
174
|
+
let resolved = 0;
|
|
175
|
+
for (const intent of unknown) {
|
|
176
|
+
if (!intent.draftId)
|
|
177
|
+
continue;
|
|
178
|
+
const age = deps.clock.now().getTime() - Date.parse(intent.updatedAt);
|
|
179
|
+
if (age < SEND_IN_FLIGHT_MS)
|
|
180
|
+
continue;
|
|
181
|
+
let draft;
|
|
182
|
+
try {
|
|
183
|
+
draft = await gateway.getMessage(intent.draftId);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
188
|
+
if (draft?.isDraft !== true)
|
|
189
|
+
continue;
|
|
190
|
+
const reset = await deps.store.transitionSendIntent(intent.id, ["unknown"], {
|
|
191
|
+
status: "failed",
|
|
192
|
+
updatedAt: (0, internal_1.nowIso)(deps),
|
|
193
|
+
error: "not sent: the draft was still in Drafts",
|
|
194
|
+
});
|
|
195
|
+
if (reset)
|
|
196
|
+
resolved++;
|
|
197
|
+
}
|
|
198
|
+
return resolved;
|
|
199
|
+
}
|
|
200
|
+
async function resolveUnknownSend(deps, input) {
|
|
201
|
+
const thread = await (0, internal_1.loadThread)(deps, input.threadId);
|
|
202
|
+
const run = await deps.store.getLatestDraftRunForThread(thread.id);
|
|
203
|
+
if (!run)
|
|
204
|
+
throw new errors_1.DraftNotReadyError(thread.id, "no draft run");
|
|
205
|
+
const ids = (0, cleanup_1.sendIntentIds)(run);
|
|
206
|
+
let current = null;
|
|
207
|
+
for (const id of [ids.perRun, ids.perDraft]) {
|
|
208
|
+
const found = id ? await deps.store.getSendIntent(id) : null;
|
|
209
|
+
if (found?.status === "unknown")
|
|
210
|
+
current = found;
|
|
211
|
+
}
|
|
212
|
+
if (!current)
|
|
213
|
+
throw new errors_1.DraftNotReadyError(thread.id, "no unknown send to resolve");
|
|
214
|
+
const id = current.id;
|
|
215
|
+
if (input.outcome === "not_sent") {
|
|
216
|
+
const reset = await deps.store.transitionSendIntent(id, ["unknown"], {
|
|
217
|
+
status: "failed",
|
|
218
|
+
updatedAt: (0, internal_1.nowIso)(deps),
|
|
219
|
+
error: "confirmed not sent by staff",
|
|
220
|
+
});
|
|
221
|
+
return reset ?? current;
|
|
222
|
+
}
|
|
223
|
+
await (0, reply_attachments_1.markReplyAttachmentsSent)(deps, thread.id, current.draftId, id).catch(() => undefined);
|
|
224
|
+
await markSent(deps, thread, current);
|
|
225
|
+
return (await deps.store.getSendIntent(id)) ?? current;
|
|
226
|
+
}
|
|
227
|
+
async function markRepliedExternally(deps, threadId) {
|
|
228
|
+
const controls = await (0, controls_1.loadControls)(deps);
|
|
229
|
+
if (controls.paused)
|
|
230
|
+
return (0, controls_1.paused)();
|
|
231
|
+
const thread = await (0, internal_1.loadThread)(deps, threadId);
|
|
232
|
+
const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
|
|
233
|
+
const updated = await (0, internal_1.applyEvents)(deps, thread, ["approve"], { reviewReason: null });
|
|
234
|
+
await (0, reply_attachments_1.discardPendingReplyAttachments)(deps, thread.id);
|
|
235
|
+
const run = await deps.store.getLatestDraftRunForThread(thread.id).catch(() => null);
|
|
236
|
+
if (run)
|
|
237
|
+
await (0, cleanup_1.discardPlacedDraft)(deps, run, access, thread.id);
|
|
238
|
+
return updated;
|
|
239
|
+
}
|
|
240
|
+
async function dismissThread(deps, input) {
|
|
241
|
+
const controls = await (0, controls_1.loadControls)(deps);
|
|
242
|
+
if (controls.paused)
|
|
243
|
+
return (0, controls_1.paused)();
|
|
244
|
+
const thread = await (0, internal_1.loadThread)(deps, input.threadId);
|
|
245
|
+
const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
|
|
246
|
+
(0, states_1.transition)(thread.state, "dismiss");
|
|
247
|
+
const run = await deps.store.getLatestDraftRunForThread(thread.id);
|
|
248
|
+
for (const id of run ? Object.values((0, cleanup_1.sendIntentIds)(run)) : []) {
|
|
249
|
+
const intent = id ? await deps.store.getSendIntent(id) : null;
|
|
250
|
+
if (intent?.status === "sending") {
|
|
251
|
+
const seen = await sendingElsewhereOrLost(deps, thread, intent);
|
|
252
|
+
throw new errors_1.SendPendingError(thread.id, seen.status === "unknown_outcome" ? "unknown" : "sending");
|
|
253
|
+
}
|
|
254
|
+
if (intent?.status === "unknown")
|
|
255
|
+
throw new errors_1.SendPendingError(thread.id, "unknown");
|
|
256
|
+
}
|
|
257
|
+
const updated = await (0, internal_1.applyEvents)(deps, thread, ["dismiss"], {
|
|
258
|
+
reviewReason: null,
|
|
259
|
+
dismissedBy: input.dismissedBy,
|
|
260
|
+
});
|
|
261
|
+
await stopDrafting(deps, access, thread.id);
|
|
262
|
+
await (0, reply_attachments_1.discardPendingReplyAttachments)(deps, thread.id);
|
|
263
|
+
return updated;
|
|
264
|
+
}
|
|
265
|
+
async function stopDrafting(deps, access, threadId) {
|
|
266
|
+
try {
|
|
267
|
+
let latest = await deps.store.getLatestDraftRunForThread(threadId);
|
|
268
|
+
if (!latest)
|
|
269
|
+
return;
|
|
270
|
+
if (latest.status === "requested" || latest.status === "started") {
|
|
271
|
+
latest = await deps.store.updateDraftRun(latest.idempotencyKey, {
|
|
272
|
+
status: "failed",
|
|
273
|
+
error: "stopped: staff marked the ticket as needing no reply",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const previous = latest.generation > 1
|
|
277
|
+
? await deps.store.getDraftRunGeneration(latest.mailbox, latest.inboundMessageId, latest.generation - 1)
|
|
278
|
+
: null;
|
|
279
|
+
for (const run of previous ? [latest, previous] : [latest]) {
|
|
280
|
+
await (0, cleanup_1.discardPlacedDraft)(deps, run, access, threadId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function markWaiting(deps, threadId) {
|
|
287
|
+
return (0, internal_1.applyEvents)(deps, await (0, internal_1.loadThread)(deps, threadId), ["settle"]);
|
|
288
|
+
}
|
|
289
|
+
async function closeThread(deps, threadId) {
|
|
290
|
+
return (0, internal_1.applyEvents)(deps, await (0, internal_1.loadThread)(deps, threadId), ["close"]);
|
|
291
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createHelpdeskHandlers } from "./inbox/handlers";
|
|
2
|
+
export type { HelpdeskHandlers, HelpdeskHandlersConfig, HelpdeskInboxQueries, InboxThreadQuery, } from "./inbox/handlers";
|
|
3
|
+
export { HELPDESK_BASE_PATH, INBOX_ERROR_MESSAGES } from "./inbox/messages";
|
|
4
|
+
export type { InboxErrorCode } from "./inbox/messages";
|
|
5
|
+
export { htmlToPlainText } from "./inbox/plain-text";
|
|
6
|
+
export type * from "./inbox/types";
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.htmlToPlainText = exports.INBOX_ERROR_MESSAGES = exports.HELPDESK_BASE_PATH = exports.createHelpdeskHandlers = void 0;
|
|
4
|
+
var handlers_1 = require("./inbox/handlers");
|
|
5
|
+
Object.defineProperty(exports, "createHelpdeskHandlers", { enumerable: true, get: function () { return handlers_1.createHelpdeskHandlers; } });
|
|
6
|
+
var messages_1 = require("./inbox/messages");
|
|
7
|
+
Object.defineProperty(exports, "HELPDESK_BASE_PATH", { enumerable: true, get: function () { return messages_1.HELPDESK_BASE_PATH; } });
|
|
8
|
+
Object.defineProperty(exports, "INBOX_ERROR_MESSAGES", { enumerable: true, get: function () { return messages_1.INBOX_ERROR_MESSAGES; } });
|
|
9
|
+
var plain_text_1 = require("./inbox/plain-text");
|
|
10
|
+
Object.defineProperty(exports, "htmlToPlainText", { enumerable: true, get: function () { return plain_text_1.htmlToPlainText; } });
|
package/dist/states.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TicketState } from "./types";
|
|
2
|
+
export declare const TICKET_STATES: readonly TicketState[];
|
|
3
|
+
export type TicketEvent = "ignore" | "draft" | "needs_review" | "auto_send" | "approve" | "redraft" | "settle" | "customer_reply" | "close" | "unignore" | "dismiss" | "bounce";
|
|
4
|
+
export declare const TICKET_EVENTS: readonly TicketEvent[];
|
|
5
|
+
export declare function transition(from: TicketState, event: TicketEvent): TicketState;
|
|
6
|
+
export declare function canTransition(from: TicketState, event: TicketEvent): boolean;
|
|
7
|
+
export type ReplyOutcome = {
|
|
8
|
+
kind: "append";
|
|
9
|
+
next: TicketState;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "new_thread";
|
|
12
|
+
};
|
|
13
|
+
export declare function onCustomerReply(state: TicketState): ReplyOutcome;
|
package/dist/states.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TICKET_EVENTS = exports.TICKET_STATES = void 0;
|
|
4
|
+
exports.transition = transition;
|
|
5
|
+
exports.canTransition = canTransition;
|
|
6
|
+
exports.onCustomerReply = onCustomerReply;
|
|
7
|
+
const errors_1 = require("./errors");
|
|
8
|
+
exports.TICKET_STATES = [
|
|
9
|
+
"new",
|
|
10
|
+
"ignored",
|
|
11
|
+
"drafted",
|
|
12
|
+
"review",
|
|
13
|
+
"sent",
|
|
14
|
+
"waiting",
|
|
15
|
+
"closed",
|
|
16
|
+
];
|
|
17
|
+
exports.TICKET_EVENTS = [
|
|
18
|
+
"ignore",
|
|
19
|
+
"draft",
|
|
20
|
+
"needs_review",
|
|
21
|
+
"auto_send",
|
|
22
|
+
"approve",
|
|
23
|
+
"redraft",
|
|
24
|
+
"settle",
|
|
25
|
+
"customer_reply",
|
|
26
|
+
"close",
|
|
27
|
+
"unignore",
|
|
28
|
+
"dismiss",
|
|
29
|
+
"bounce",
|
|
30
|
+
];
|
|
31
|
+
const TABLE = {
|
|
32
|
+
new: { ignore: "ignored", draft: "drafted", dismiss: "ignored" },
|
|
33
|
+
ignored: { unignore: "new" },
|
|
34
|
+
drafted: { needs_review: "review", auto_send: "sent", dismiss: "ignored" },
|
|
35
|
+
review: { approve: "sent", redraft: "drafted", dismiss: "ignored" },
|
|
36
|
+
sent: { settle: "waiting", bounce: "review" },
|
|
37
|
+
waiting: { customer_reply: "new", close: "closed", bounce: "review" },
|
|
38
|
+
closed: {},
|
|
39
|
+
};
|
|
40
|
+
function transition(from, event) {
|
|
41
|
+
const to = TABLE[from][event];
|
|
42
|
+
if (!to)
|
|
43
|
+
throw new errors_1.InvalidTransitionError(from, event);
|
|
44
|
+
return to;
|
|
45
|
+
}
|
|
46
|
+
function canTransition(from, event) {
|
|
47
|
+
return TABLE[from][event] !== undefined;
|
|
48
|
+
}
|
|
49
|
+
function onCustomerReply(state) {
|
|
50
|
+
switch (state) {
|
|
51
|
+
case "new":
|
|
52
|
+
case "drafted":
|
|
53
|
+
case "review":
|
|
54
|
+
return { kind: "append", next: state };
|
|
55
|
+
case "sent":
|
|
56
|
+
return { kind: "append", next: transition(transition("sent", "settle"), "customer_reply") };
|
|
57
|
+
case "waiting":
|
|
58
|
+
return { kind: "append", next: transition("waiting", "customer_reply") };
|
|
59
|
+
case "ignored":
|
|
60
|
+
case "closed":
|
|
61
|
+
return { kind: "new_thread" };
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/subject.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeSubject = normalizeSubject;
|
|
4
|
+
exports.isForwardSubject = isForwardSubject;
|
|
5
|
+
const GATEWAY_TAG = /^\s*\[(?:extern(?:al)?|external email|ext|caution)[^\]]*\]\s*/i;
|
|
6
|
+
const PREFIX = /^\s*(re|aw|antw|antwoord|sv|vs|fw|fwd|wg|tr|rv|enc|vl|doorgestuurd)\s*(?:\[\d+\]|\(\d+\))?\s*[::]\s*/i;
|
|
7
|
+
const FORWARD_TOKENS = new Set(["fw", "fwd", "wg", "tr", "rv", "enc", "vl", "doorgestuurd"]);
|
|
8
|
+
function peel(subject) {
|
|
9
|
+
let rest = subject.normalize("NFKC");
|
|
10
|
+
const prefixes = [];
|
|
11
|
+
for (let i = 0; i < 20; i++) {
|
|
12
|
+
const tag = GATEWAY_TAG.exec(rest);
|
|
13
|
+
if (tag) {
|
|
14
|
+
rest = rest.slice(tag[0].length);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const p = PREFIX.exec(rest);
|
|
18
|
+
if (!p)
|
|
19
|
+
break;
|
|
20
|
+
prefixes.push((p[1] ?? "").toLowerCase());
|
|
21
|
+
rest = rest.slice(p[0].length);
|
|
22
|
+
}
|
|
23
|
+
return { rest, prefixes };
|
|
24
|
+
}
|
|
25
|
+
function normalizeSubject(subject) {
|
|
26
|
+
return peel(subject ?? "").rest.replace(/\s+/g, " ").trim().toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
function isForwardSubject(subject) {
|
|
29
|
+
return peel(subject ?? "").prefixes.some((p) => FORWARD_TOKENS.has(p));
|
|
30
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { AttachmentKind, Clock, DeltaOptions, DraftRequest, DraftRun, Drafter, HelpdeskConfig, HelpdeskControls, HelpdeskDeps, HelpdeskStore, MailboxGateway, Message, ReplyAttachment, ReplyAttachmentStore, ReplyFiles, SendIntent, StoredMessage, SyncState, Thread } from "../types";
|
|
2
|
+
export interface ManualClock extends Clock {
|
|
3
|
+
advance(ms: number): void;
|
|
4
|
+
set(iso: string): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function createManualClock(startIso?: string): ManualClock;
|
|
7
|
+
export interface MemoryStore extends HelpdeskStore {
|
|
8
|
+
threads: Map<string, Thread>;
|
|
9
|
+
messages: Map<string, StoredMessage>;
|
|
10
|
+
draftRuns: DraftRun[];
|
|
11
|
+
sendIntents: Map<string, SendIntent>;
|
|
12
|
+
syncStates: Map<string, SyncState>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createMemoryStore(): MemoryStore;
|
|
15
|
+
export interface FakeMail {
|
|
16
|
+
id: string;
|
|
17
|
+
from?: string;
|
|
18
|
+
subject?: string;
|
|
19
|
+
conversationId?: string;
|
|
20
|
+
internetMessageId?: string;
|
|
21
|
+
inReplyTo?: string;
|
|
22
|
+
references?: string[];
|
|
23
|
+
replyTo?: string[];
|
|
24
|
+
receivedDateTime?: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
isDraft?: boolean;
|
|
27
|
+
bodyText?: string;
|
|
28
|
+
attachments?: FakeAttachment[];
|
|
29
|
+
}
|
|
30
|
+
export interface FakeAttachment {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
contentType?: string;
|
|
34
|
+
isInline?: boolean;
|
|
35
|
+
kind?: AttachmentKind;
|
|
36
|
+
content?: Uint8Array;
|
|
37
|
+
size?: number;
|
|
38
|
+
}
|
|
39
|
+
export type SendBehaviour = {
|
|
40
|
+
kind: "ok";
|
|
41
|
+
} | {
|
|
42
|
+
kind: "reject";
|
|
43
|
+
error: Error;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "lost_response";
|
|
46
|
+
error: Error;
|
|
47
|
+
};
|
|
48
|
+
export interface FakeMailbox extends MailboxGateway {
|
|
49
|
+
deliver(mail: FakeMail): void;
|
|
50
|
+
touch(id: string): void;
|
|
51
|
+
remove(id: string, reason?: string): void;
|
|
52
|
+
expireLinks(): void;
|
|
53
|
+
sendBehaviour: SendBehaviour;
|
|
54
|
+
sent: string[];
|
|
55
|
+
drafts: Map<string, Message>;
|
|
56
|
+
deleteCalls: string[];
|
|
57
|
+
draftAttachments: Map<string, Array<{
|
|
58
|
+
name: string;
|
|
59
|
+
contentType: string;
|
|
60
|
+
size: number;
|
|
61
|
+
}>>;
|
|
62
|
+
addAttachmentError: Error | null;
|
|
63
|
+
deleteError: Error | null;
|
|
64
|
+
outage: Error | null;
|
|
65
|
+
getMessageCalls: number;
|
|
66
|
+
deltaCalls: DeltaOptions[];
|
|
67
|
+
calls: string[];
|
|
68
|
+
readonly writeCalls: string[];
|
|
69
|
+
}
|
|
70
|
+
export declare function createFakeMailbox(address: string): FakeMailbox;
|
|
71
|
+
export interface FakeDrafter extends Drafter {
|
|
72
|
+
requests: DraftRequest[];
|
|
73
|
+
runs: Map<string, string>;
|
|
74
|
+
failStart: Error | null;
|
|
75
|
+
}
|
|
76
|
+
export declare function createFakeDrafter(): FakeDrafter;
|
|
77
|
+
export interface MemoryReplyAttachments {
|
|
78
|
+
store: ReplyAttachmentStore & {
|
|
79
|
+
rows: Map<string, ReplyAttachment>;
|
|
80
|
+
};
|
|
81
|
+
files: ReplyFiles & {
|
|
82
|
+
upload(uploadToken: string, content: Uint8Array): void;
|
|
83
|
+
stored: Map<string, Uint8Array>;
|
|
84
|
+
removed: string[];
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export declare function createMemoryReplyAttachments(): MemoryReplyAttachments;
|
|
88
|
+
export interface TestWorld {
|
|
89
|
+
deps: HelpdeskDeps;
|
|
90
|
+
store: MemoryStore;
|
|
91
|
+
clock: ManualClock;
|
|
92
|
+
drafter: FakeDrafter;
|
|
93
|
+
mailboxes: Record<string, FakeMailbox>;
|
|
94
|
+
}
|
|
95
|
+
export declare function createTestWorld(options?: {
|
|
96
|
+
mailboxes?: string[];
|
|
97
|
+
config?: Partial<HelpdeskConfig>;
|
|
98
|
+
controls?: HelpdeskControls;
|
|
99
|
+
}): TestWorld;
|