@dxv-systems/helpdesk 0.1.0 → 0.3.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/dist/bounce.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { HelpdeskStore, Thread } from "./types";
2
2
  export declare function bouncedReplyRefs(bodyText: string): string[];
3
+ export interface BounceReport {
4
+ recipient: string | null;
5
+ statusCode: string | null;
6
+ diagnostic: string;
7
+ }
8
+ export declare function readBounceReport(text: string): BounceReport;
3
9
  export declare function matchBounce(store: HelpdeskStore, input: {
4
10
  mailbox: string;
5
11
  refs: string[];
package/dist/bounce.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.bouncedReplyRefs = bouncedReplyRefs;
4
+ exports.readBounceReport = readBounceReport;
4
5
  exports.matchBounce = matchBounce;
5
6
  const threading_1 = require("./threading");
6
7
  const QUOTED_HEADER = /^(?:in-reply-to|references)[ \t]*:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/gim;
@@ -15,6 +16,43 @@ function bouncedReplyRefs(bodyText) {
15
16
  }
16
17
  return [...ids];
17
18
  }
19
+ const MAX_DIAGNOSTIC_CHARS = 2000;
20
+ const ADDRESS = String.raw `<?([^\s<>;:()"',]+@[^\s<>;:()"',]+)>?`;
21
+ const RECIPIENT_PATTERNS = [
22
+ new RegExp(String.raw `^(?:final|original)-recipient:[ \t]*(?:rfc822;)?[ \t]*${ADDRESS}[ \t]*$`, "im"),
23
+ new RegExp(String.raw `^recipient:[ \t]*${ADDRESS}[ \t]*$`, "im"),
24
+ new RegExp(String.raw `(?:your message to|je bericht aan|uw bericht aan)[ \t]+${ADDRESS}(?=[\s.,]|$)`, "i"),
25
+ new RegExp(String.raw `(?:delivery has failed to these recipients or groups|bezorging is mislukt voor deze ontvangers of groepen):\s*${ADDRESS}(?=[\s.,]|$)`, "i"),
26
+ ];
27
+ const CODE_PATTERNS = [/\b[245]\d\d[ -]#?([245]\.\d{1,3}\.\d{1,3})\b/, /(?:status:|#)[ \t]*([245]\.\d{1,3}\.\d{1,3})\b/i, /\b([45]\.\d{1,3}\.\d{1,3})\b/];
28
+ const DIAGNOSTIC_START = /(?:diagnostic information for administrators|diagnostische gegevens voor beheerders)\s*:/i;
29
+ const DIAGNOSTIC_END = /(?:original message headers|oorspronkelijke berichtkoppen)\s*:/i;
30
+ function readBounceReport(text) {
31
+ const body = text.replace(/\r\n?/g, "\n");
32
+ let recipient = null;
33
+ for (const re of RECIPIENT_PATTERNS) {
34
+ const found = re.exec(body)?.[1];
35
+ if (found) {
36
+ recipient = found.replace(/[.]+$/, "").toLowerCase();
37
+ break;
38
+ }
39
+ }
40
+ let statusCode = null;
41
+ for (const re of CODE_PATTERNS) {
42
+ const found = re.exec(body)?.[1];
43
+ if (found) {
44
+ statusCode = found;
45
+ break;
46
+ }
47
+ }
48
+ const start = DIAGNOSTIC_START.exec(body);
49
+ const rest = start ? body.slice(start.index + start[0].length) : body;
50
+ const end = DIAGNOSTIC_END.exec(rest);
51
+ let diagnostic = (end ? rest.slice(0, end.index) : rest).replace(/\n{3,}/g, "\n\n").trim();
52
+ if (diagnostic.length > MAX_DIAGNOSTIC_CHARS)
53
+ diagnostic = `${diagnostic.slice(0, MAX_DIAGNOSTIC_CHARS).trimEnd()}…`;
54
+ return { recipient, statusCode, diagnostic };
55
+ }
18
56
  const awaitsDelivery = (t) => t.state === "sent" || t.state === "waiting";
19
57
  async function matchBounce(store, input) {
20
58
  const { mailbox, refs, conversationId } = input;
package/dist/controls.js CHANGED
@@ -10,8 +10,7 @@ const errors_1 = require("./errors");
10
10
  exports.DEFAULT_WRITE_MODE = "read_only";
11
11
  const ALLOWED_WRITES = {
12
12
  read_only: [],
13
- drafts_in_app: ["createReply", "updateDraftBody", "addAttachment", "sendDraft"],
14
- drafts_in_outlook: ["createReply", "updateDraftBody", "addAttachment", "sendDraft", "deleteDraft"],
13
+ approve_and_send: ["createReply", "updateDraftBody", "addAttachment", "sendDraft"],
15
14
  };
16
15
  const norm = (address) => address.trim().toLowerCase();
17
16
  const paused = () => ({ status: "paused" });
@@ -79,9 +78,5 @@ function guardedGateway(deps, mailbox, mode) {
79
78
  allow("sendDraft");
80
79
  return gw.sendDraft(id);
81
80
  },
82
- async deleteDraft(id) {
83
- allow("deleteDraft");
84
- return gw.deleteDraft(id);
85
- },
86
81
  };
87
82
  }
@@ -1,5 +1,4 @@
1
1
  export declare function renderCommentHtml(text: string): string;
2
2
  export declare function findQuoteStart(html: string): number;
3
3
  export declare function hasQuotedHistory(html: string): boolean;
4
- export declare function replaceDraftComment(placedBodyHtml: string, newComment: string): string;
5
4
  export declare function prependToDraftBody(bodyHtml: string, text: string): string;
@@ -3,9 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderCommentHtml = renderCommentHtml;
4
4
  exports.findQuoteStart = findQuoteStart;
5
5
  exports.hasQuotedHistory = hasQuotedHistory;
6
- exports.replaceDraftComment = replaceDraftComment;
7
6
  exports.prependToDraftBody = prependToDraftBody;
8
- const errors_1 = require("./errors");
9
7
  const QUOTE_MARKERS = [
10
8
  /<div[^>]*\bid=["']appendonsend["']/i,
11
9
  /<div[^>]*\bid=["']divRplyFwdMsg["']/i,
@@ -33,18 +31,6 @@ function findQuoteStart(html) {
33
31
  function hasQuotedHistory(html) {
34
32
  return findQuoteStart(html) >= 0;
35
33
  }
36
- function replaceDraftComment(placedBodyHtml, newComment) {
37
- const quoteAt = findQuoteStart(placedBodyHtml);
38
- if (quoteAt < 0)
39
- throw new errors_1.QuoteNotFoundError();
40
- const bodyTag = /<body\b[^>]*>/i.exec(placedBodyHtml);
41
- const commentAt = bodyTag ? bodyTag.index + bodyTag[0].length : 0;
42
- if (commentAt > quoteAt)
43
- throw new errors_1.QuoteNotFoundError();
44
- return (placedBodyHtml.slice(0, commentAt) +
45
- renderCommentHtml(newComment) +
46
- placedBodyHtml.slice(quoteAt));
47
- }
48
34
  function prependToDraftBody(bodyHtml, text) {
49
35
  const bodyTag = /<body\b[^>]*>/i.exec(bodyHtml);
50
36
  const at = bodyTag ? bodyTag.index + bodyTag[0].length : 0;
@@ -1,4 +1,4 @@
1
- import type { DraftResult, DraftRun, HelpdeskDeps, Paused, StoredMessage, Thread, WriteBlocked } from "./types";
1
+ import type { DraftResult, DraftRun, HelpdeskDeps, Paused, StoredMessage, Thread } from "./types";
2
2
  export declare function currentReplyRun(deps: HelpdeskDeps, thread: Thread, run: DraftRun | null): DraftRun | null;
3
3
  export type EnsureDraftOutcome = "started" | "exists" | "busy" | "not_needed" | "no_drafter" | "paused";
4
4
  export declare function ensureDraft(deps: HelpdeskDeps, threadId: string, message: StoredMessage): Promise<EnsureDraftOutcome>;
@@ -14,6 +14,5 @@ export declare function editDraft(deps: HelpdeskDeps, input: {
14
14
  threadId: string;
15
15
  text: string;
16
16
  }): Promise<DraftRun | Paused>;
17
- export declare function retryPlacement(deps: HelpdeskDeps, threadId: string): Promise<DraftRun | Paused | WriteBlocked>;
18
17
  export declare function sweepStuckDrafts(deps: HelpdeskDeps, mailbox: string): Promise<number | Paused>;
19
18
  export declare function unignoreThread(deps: HelpdeskDeps, threadId: string): Promise<Thread | Paused>;
package/dist/drafting.js CHANGED
@@ -6,12 +6,9 @@ exports.startDraftFor = startDraftFor;
6
6
  exports.completeDraft = completeDraft;
7
7
  exports.regenerateDraft = regenerateDraft;
8
8
  exports.editDraft = editDraft;
9
- exports.retryPlacement = retryPlacement;
10
9
  exports.sweepStuckDrafts = sweepStuckDrafts;
11
10
  exports.unignoreThread = unignoreThread;
12
- const cleanup_1 = require("./cleanup");
13
11
  const controls_1 = require("./controls");
14
- const draft_body_1 = require("./draft-body");
15
12
  const errors_1 = require("./errors");
16
13
  const internal_1 = require("./internal");
17
14
  const states_1 = require("./states");
@@ -29,8 +26,6 @@ function currentReplyRun(deps, thread, run) {
29
26
  function reviewReasonFor(deps, thread, run) {
30
27
  if (run.status === "failed")
31
28
  return "drafter_failed";
32
- if (run.placementError)
33
- return "placement_failed";
34
29
  if (thread.latestInboundId !== run.inboundMessageId)
35
30
  return "stale_draft";
36
31
  if ((run.confidence ?? 0) < (deps.config.minConfidence ?? DEFAULT_MIN_CONFIDENCE)) {
@@ -88,8 +83,6 @@ function newRun(deps, thread, messageId, generation) {
88
83
  placedDraftId: null,
89
84
  placedBodyHtml: null,
90
85
  commentText: null,
91
- placementError: null,
92
- draftCleanupError: null,
93
86
  };
94
87
  }
95
88
  async function ensureDraft(deps, threadId, message) {
@@ -132,31 +125,10 @@ async function startDraftFor(deps, threadId, message) {
132
125
  await startRun(deps, run, message);
133
126
  return "started";
134
127
  }
135
- async function place(deps, access, run, text) {
136
- let placed;
137
- try {
138
- const draft = await access.gateway().createReply(run.inboundMessageId, { comment: text });
139
- placed = await deps.store.updateDraftRun(run.idempotencyKey, {
140
- placedDraftId: draft.id,
141
- placedBodyHtml: draft.body.content,
142
- placementError: null,
143
- });
144
- }
145
- catch (err) {
146
- return deps.store.updateDraftRun(run.idempotencyKey, { placementError: (0, internal_1.errorText)(err) });
147
- }
148
- const previous = run.generation > 1
149
- ? await deps.store.getDraftRunGeneration(run.mailbox, run.inboundMessageId, run.generation - 1)
150
- : null;
151
- if (previous)
152
- await (0, cleanup_1.discardPlacedDraft)(deps, previous, access, run.threadId);
153
- return placed;
154
- }
155
128
  async function completeDraft(deps, input) {
156
- const controls = await (0, controls_1.loadControls)(deps);
157
- if (controls.paused)
129
+ if ((await (0, controls_1.loadControls)(deps)).paused)
158
130
  return (0, controls_1.paused)();
159
- const access = (0, controls_1.mailboxAccess)(deps, controls, input.mailbox);
131
+ (0, controls_1.assertInScope)(deps.config, input.mailbox);
160
132
  const run = await deps.store.getLatestDraftRun(input.mailbox, input.inboundMessageId);
161
133
  if (!run)
162
134
  throw new errors_1.DraftNotReadyError(input.inboundMessageId, "no draft run recorded");
@@ -169,7 +141,7 @@ async function completeDraft(deps, input) {
169
141
  await failRun(deps, run, result.error);
170
142
  return deps.store.updateDraftRun(run.idempotencyKey, {});
171
143
  }
172
- let current = await deps.store.updateDraftRun(run.idempotencyKey, {
144
+ const current = await deps.store.updateDraftRun(run.idempotencyKey, {
173
145
  status: "succeeded",
174
146
  text: result.text,
175
147
  commentText: result.text,
@@ -177,8 +149,6 @@ async function completeDraft(deps, input) {
177
149
  reason: result.reason,
178
150
  category: result.category ?? null,
179
151
  });
180
- if (access.mode === "drafts_in_outlook")
181
- current = await place(deps, access, current, result.text);
182
152
  const thread = await (0, internal_1.loadThread)(deps, run.threadId);
183
153
  if (thread.state === "new" || thread.state === "drafted")
184
154
  await moveToReview(deps, thread, current);
@@ -203,7 +173,7 @@ async function regenerateDraft(deps, threadId) {
203
173
  await startRun(deps, run, message);
204
174
  return run;
205
175
  }
206
- async function writeStaffReply(deps, access, thread, text) {
176
+ async function writeStaffReply(deps, thread, text) {
207
177
  const inboundId = thread.latestInboundId;
208
178
  if (!inboundId)
209
179
  throw new errors_1.DraftNotReadyError(thread.id, "no inbound message");
@@ -217,65 +187,27 @@ async function writeStaffReply(deps, access, thread, text) {
217
187
  });
218
188
  if (run.status !== "succeeded")
219
189
  throw new errors_1.DraftNotReadyError(thread.id, "a draft is being written");
220
- let current = await deps.store.updateDraftRun(run.idempotencyKey, { commentText: text });
221
- if (access.mode === "drafts_in_outlook")
222
- current = await place(deps, access, current, text);
223
- const reviewReason = current.placementError ? "placement_failed" : "staff_edited";
224
- await (0, internal_1.applyEvents)(deps, thread, ["redraft", "needs_review"], { reviewReason });
190
+ const current = await deps.store.updateDraftRun(run.idempotencyKey, { commentText: text });
191
+ await (0, internal_1.applyEvents)(deps, thread, ["redraft", "needs_review"], { reviewReason: "staff_edited" });
225
192
  return current;
226
193
  }
227
194
  async function editDraft(deps, input) {
228
- const controls = await (0, controls_1.loadControls)(deps);
229
- if (controls.paused)
195
+ if ((await (0, controls_1.loadControls)(deps)).paused)
230
196
  return (0, controls_1.paused)();
231
197
  const thread = await (0, internal_1.loadThread)(deps, input.threadId);
232
- const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
198
+ (0, controls_1.assertInScope)(deps.config, thread.mailbox);
233
199
  if (input.text.trim() === "")
234
200
  throw new errors_1.EmptyReplyError(thread.id);
235
201
  const run = currentReplyRun(deps, thread, await deps.store.getLatestDraftRunForThread(thread.id));
236
202
  if (!run)
237
- return writeStaffReply(deps, access, thread, input.text);
238
- if (access.mode !== "drafts_in_outlook") {
239
- if (run?.status !== "succeeded")
240
- throw new errors_1.DraftNotReadyError(thread.id, "no draft text to edit");
241
- (0, states_1.transition)(thread.state, "redraft");
242
- const updated = await deps.store.updateDraftRun(run.idempotencyKey, { commentText: input.text });
243
- await (0, internal_1.applyEvents)(deps, thread, ["redraft", "needs_review"], { reviewReason: "staff_edited" });
244
- return updated;
245
- }
246
- if (!run?.placedDraftId || run.placedBodyHtml === null) {
247
- throw new errors_1.DraftNotReadyError(thread.id, "no placed draft to edit");
248
- }
203
+ return writeStaffReply(deps, thread, input.text);
204
+ if (run.status !== "succeeded")
205
+ throw new errors_1.DraftNotReadyError(thread.id, "no draft text to edit");
249
206
  (0, states_1.transition)(thread.state, "redraft");
250
- const body = (0, draft_body_1.replaceDraftComment)(run.placedBodyHtml, input.text);
251
- await access.gateway().updateDraftBody(run.placedDraftId, { contentType: "html", content: body });
252
- const updated = await deps.store.updateDraftRun(run.idempotencyKey, {
253
- placedBodyHtml: body,
254
- commentText: input.text,
255
- });
207
+ const updated = await deps.store.updateDraftRun(run.idempotencyKey, { commentText: input.text });
256
208
  await (0, internal_1.applyEvents)(deps, thread, ["redraft", "needs_review"], { reviewReason: "staff_edited" });
257
209
  return updated;
258
210
  }
259
- async function retryPlacement(deps, threadId) {
260
- const controls = await (0, controls_1.loadControls)(deps);
261
- if (controls.paused)
262
- return (0, controls_1.paused)();
263
- const thread = await (0, internal_1.loadThread)(deps, threadId);
264
- const access = (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox);
265
- if (access.mode !== "drafts_in_outlook")
266
- return { status: "write_blocked", mode: access.mode };
267
- const run = await deps.store.getLatestDraftRunForThread(thread.id);
268
- const text = run?.status === "succeeded" ? (run.commentText ?? run.text) : null;
269
- if (!run || text === null)
270
- throw new errors_1.DraftNotReadyError(thread.id, "no stored draft text to place");
271
- if (run.placedDraftId)
272
- return run;
273
- const placed = await place(deps, access, run, text);
274
- if (!placed.placementError && thread.state === "review") {
275
- await (0, internal_1.applyEvents)(deps, thread, ["redraft", "needs_review"], { reviewReason: "policy" });
276
- }
277
- return placed;
278
- }
279
211
  async function sweepStuckDrafts(deps, mailbox) {
280
212
  if ((await (0, controls_1.loadControls)(deps)).paused)
281
213
  return (0, controls_1.paused)();
package/dist/errors.d.ts CHANGED
@@ -29,9 +29,6 @@ export declare class EmptyReplyError extends HelpdeskError {
29
29
  export declare class StaleDraftError extends HelpdeskError {
30
30
  constructor(threadId: string);
31
31
  }
32
- export declare class QuoteNotFoundError extends HelpdeskError {
33
- constructor();
34
- }
35
32
  export declare class MailboxNotInScopeError extends HelpdeskError {
36
33
  readonly mailbox: string;
37
34
  constructor(mailbox: string);
package/dist/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ThreadNotFoundError = exports.ReplyAttachmentNotFoundError = exports.AttachmentLimitError = exports.AttachmentsUnavailableError = exports.SendPendingError = exports.MailboxWriteBlockedError = exports.MailboxNotInScopeError = exports.QuoteNotFoundError = exports.StaleDraftError = exports.EmptyReplyError = exports.DraftNotReadyError = exports.DeltaLinkExpiredError = exports.InvalidDeltaPageError = exports.MailboxNotConnectedError = exports.StaleThreadError = exports.InvalidTransitionError = exports.HelpdeskError = void 0;
3
+ exports.ThreadNotFoundError = exports.ReplyAttachmentNotFoundError = exports.AttachmentLimitError = exports.AttachmentsUnavailableError = exports.SendPendingError = exports.MailboxWriteBlockedError = exports.MailboxNotInScopeError = exports.StaleDraftError = exports.EmptyReplyError = exports.DraftNotReadyError = exports.DeltaLinkExpiredError = exports.InvalidDeltaPageError = exports.MailboxNotConnectedError = exports.StaleThreadError = exports.InvalidTransitionError = exports.HelpdeskError = void 0;
4
4
  exports.isDeltaLinkExpired = isDeltaLinkExpired;
5
5
  exports.isTransientMailError = isTransientMailError;
6
6
  class HelpdeskError extends Error {
@@ -61,12 +61,6 @@ class StaleDraftError extends HelpdeskError {
61
61
  }
62
62
  }
63
63
  exports.StaleDraftError = StaleDraftError;
64
- class QuoteNotFoundError extends HelpdeskError {
65
- constructor() {
66
- super("quote_not_found", "Cannot find the quoted history in the draft body; refusing to edit");
67
- }
68
- }
69
- exports.QuoteNotFoundError = QuoteNotFoundError;
70
64
  class MailboxNotInScopeError extends HelpdeskError {
71
65
  constructor(mailbox) {
72
66
  super("mailbox_not_in_scope", `Mailbox ${mailbox} is not one of this helpdesk's mailboxes`);
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MAX_ATTACHMENT_DOWNLOAD_BYTES = void 0;
4
4
  exports.contentDisposition = contentDisposition;
5
5
  exports.createHelpdeskHandlers = createHelpdeskHandlers;
6
- const cleanup_1 = require("../cleanup");
6
+ const bounce_1 = require("../bounce");
7
7
  const controls_1 = require("../controls");
8
8
  const drafting_1 = require("../drafting");
9
9
  const errors_1 = require("../errors");
@@ -50,7 +50,6 @@ const DOMAIN_ERRORS = {
50
50
  draft_not_ready: [409, "draft_not_ready"],
51
51
  invalid_transition: [409, "invalid_transition"],
52
52
  stale_thread: [409, "stale_thread"],
53
- quote_not_found: [422, "quote_not_found"],
54
53
  send_in_progress: [409, "send_in_progress"],
55
54
  send_outcome_unknown: [409, "send_outcome_unknown"],
56
55
  empty_reply: [422, "empty_reply"],
@@ -87,25 +86,27 @@ function summary(thread, assignee) {
87
86
  assignee,
88
87
  };
89
88
  }
89
+ function bounceView(text) {
90
+ const report = (0, bounce_1.readBounceReport)(text);
91
+ return { ...report, reason: (0, messages_1.bounceReasonText)(report.statusCode, report.recipient) };
92
+ }
90
93
  async function latestIntent(deps, run) {
91
94
  if (!run)
92
95
  return null;
93
- const { perRun, perDraft } = (0, cleanup_1.sendIntentIds)(run);
94
- const found = await Promise.all([perRun, perDraft].map((id) => (id ? deps.store.getSendIntent(id) : null)));
95
- const intents = found.filter((i) => i !== null);
96
- return intents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null;
96
+ return deps.store.getSendIntent((0, sending_1.sendIntentId)(run));
97
97
  }
98
98
  function draftView(thread, run) {
99
99
  if (!run)
100
100
  return null;
101
+ const text = run.status === "succeeded" ? (run.commentText ?? run.text) : null;
101
102
  return {
102
103
  generation: run.generation,
103
104
  status: run.status,
104
- text: run.status === "succeeded" ? (run.commentText ?? run.text) : null,
105
+ text,
105
106
  confidence: run.confidence,
106
107
  category: run.category,
107
108
  stale: thread.latestInboundId !== run.inboundMessageId,
108
- inOutlook: run.placedDraftId !== null,
109
+ fromDrafter: text !== null && run.text !== null && text === run.text,
109
110
  };
110
111
  }
111
112
  function allowedActions(thread, run, intent, paused, mode, hasDrafter, canAttach = false) {
@@ -113,22 +114,16 @@ function allowedActions(thread, run, intent, paused, mode, hasDrafter, canAttach
113
114
  const actions = [];
114
115
  const hasText = run?.status === "succeeded";
115
116
  const fresh = run === null && thread.latestInboundId !== null;
116
- const placed = Boolean(run?.placedDraftId) && run?.placedBodyHtml !== null;
117
117
  const pending = intent !== null && ["sending", "unknown", "sent"].includes(intent.status);
118
118
  const sendUnderWay = intent !== null && ["sending", "unknown"].includes(intent.status);
119
119
  const bounced = s === "review" && thread.reviewReason === "delivery_failed";
120
120
  if (!paused) {
121
- if (!bounced && (0, states_1.canTransition)(s, "redraft") && ((hasText && (mode !== "drafts_in_outlook" || placed)) || fresh)) {
121
+ if (!bounced && (0, states_1.canTransition)(s, "redraft") && (hasText || fresh))
122
122
  actions.push("edit");
123
- }
124
123
  if (!bounced && (0, states_1.canTransition)(s, "redraft") && thread.latestInboundId && hasDrafter)
125
124
  actions.push("regenerate");
126
- const sendable = mode === "drafts_in_app" ? hasText : mode === "drafts_in_outlook" && Boolean(run?.placedDraftId);
127
- if (!bounced && (0, states_1.canTransition)(s, "approve") && (sendable || (fresh && mode !== "read_only")) && !pending)
125
+ if (!bounced && (0, states_1.canTransition)(s, "approve") && mode !== "read_only" && (hasText || fresh) && !pending)
128
126
  actions.push("send");
129
- if (!bounced && (0, states_1.canTransition)(s, "approve") && mode === "drafts_in_outlook" && hasText && !run?.placedDraftId) {
130
- actions.push("retry_placement");
131
- }
132
127
  if ((0, states_1.canTransition)(s, "approve"))
133
128
  actions.push("replied_externally");
134
129
  if ((0, states_1.canTransition)(s, "unignore"))
@@ -209,10 +204,14 @@ function createHelpdeskHandlers(config) {
209
204
  subject: m.subject,
210
205
  receivedAt: m.receivedAt,
211
206
  noise: m.noise.action === "ignore",
207
+ bounce: m.direction === "inbound" && m.noise.reason === "bounce",
212
208
  removed: m.removedAt !== null,
209
+ outbound: m.direction === "outbound",
213
210
  })),
214
211
  draft: draftView(thread, run ?? (latestRun?.status === "failed" ? latestRun : null)),
215
- send: intent ? { status: intent.status, at: intent.updatedAt } : null,
212
+ send: intent
213
+ ? { status: intent.status, at: intent.updatedAt, messageId: intent.status === "sent" ? intent.draftId : null }
214
+ : null,
216
215
  replyAttachments: await replyAttachmentViews(thread.id, intent),
217
216
  actions: allowedActions(thread, run, intent, controls.paused, mode, Boolean(deps.drafter), canAttach(thread.mailbox)),
218
217
  };
@@ -290,10 +289,10 @@ function createHelpdeskHandlers(config) {
290
289
  throw new RouteError(423, "paused");
291
290
  const thread = await loadThread(threadId);
292
291
  const messages = await inbox.listMessages(thread.id);
293
- if (!messages.some((m) => m.id === messageId && m.threadId === thread.id)) {
292
+ const stored = messages.find((m) => m.id === messageId && m.threadId === thread.id);
293
+ if (!stored)
294
294
  throw new RouteError(404, "message_not_found");
295
- }
296
- return (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox).gateway();
295
+ return { gateway: (0, controls_1.mailboxAccess)(deps, controls, thread.mailbox).gateway(), stored };
297
296
  }
298
297
  function attachmentView(threadId, messageId, a) {
299
298
  const unavailable = a.kind !== "file" ? a.kind : a.size > exports.MAX_ATTACHMENT_DOWNLOAD_BYTES ? "too_large" : null;
@@ -301,19 +300,20 @@ function createHelpdeskHandlers(config) {
301
300
  return { id: a.id, name: a.name, size: a.size, kind: a.kind, href: unavailable ? null : `${basePath}/threads/${path}`, unavailable };
302
301
  }
303
302
  async function messageBody({ threadId, messageId }) {
304
- const gateway = await ticketMessage(threadId, messageId);
303
+ const { gateway, stored } = await ticketMessage(threadId, messageId);
305
304
  const message = await gateway.getMessage(messageId, { bodyType: "text" });
306
305
  if (!message)
307
306
  throw new RouteError(404, "message_unavailable");
308
307
  const { contentType, content } = message.body;
309
308
  const text = contentType === "html" ? (0, plain_text_1.htmlToPlainText)(content) : content.replace(/\r\n?/g, "\n").trim();
310
- const listed = gateway.listAttachments && message.hasAttachments !== false ? await gateway.listAttachments(messageId) : [];
309
+ const isBounce = stored.direction === "inbound" && stored.noise.reason === "bounce";
310
+ const listed = !isBounce && gateway.listAttachments && message.hasAttachments !== false ? await gateway.listAttachments(messageId) : [];
311
311
  const attachments = listed.filter((a) => !a.isInline).map((a) => attachmentView(threadId, messageId, a));
312
- const body = { id: messageId, text, attachments };
312
+ const body = { id: messageId, text, attachments, bounce: isBounce ? bounceView(text) : null };
313
313
  return json(200, body);
314
314
  }
315
315
  async function attachment({ threadId, messageId, attachmentId }) {
316
- const gateway = await ticketMessage(threadId, messageId);
316
+ const { gateway } = await ticketMessage(threadId, messageId);
317
317
  if (!gateway.listAttachments || !gateway.downloadAttachment)
318
318
  throw new RouteError(404, "attachment_not_found");
319
319
  const found = (await gateway.listAttachments(messageId)).find((a) => a.id === attachmentId);
@@ -343,11 +343,11 @@ function createHelpdeskHandlers(config) {
343
343
  if (text === null || text.trim() === "" || text.length > MAX_DRAFT_CHARS)
344
344
  throw new RouteError(400, "invalid_input");
345
345
  const outcome = await (0, drafting_1.editDraft)(deps, { threadId: ctx.threadId, text });
346
- return refused(outcome) ?? result(ctx, "Concept opgeslagen.");
346
+ return refused(outcome) ?? result(ctx, "Opgeslagen.");
347
347
  }
348
348
  async function regenerate(ctx) {
349
349
  const outcome = await (0, drafting_1.regenerateDraft)(deps, ctx.threadId);
350
- return refused(outcome) ?? result(ctx, "Er wordt een nieuw concept geschreven.");
350
+ return refused(outcome) ?? result(ctx, "Er wordt een nieuw voorstel geschreven.");
351
351
  }
352
352
  async function send(ctx) {
353
353
  const body = await readJson(ctx.request);
@@ -363,14 +363,6 @@ function createHelpdeskHandlers(config) {
363
363
  const status = outcome.status;
364
364
  return result(ctx, SEND_MESSAGES[status], status, status === "in_progress" ? 202 : 200);
365
365
  }
366
- async function retry(ctx) {
367
- const outcome = await (0, drafting_1.retryPlacement)(deps, ctx.threadId);
368
- const blocked = refused(outcome);
369
- if (blocked)
370
- return blocked;
371
- const placed = outcome.placementError === null;
372
- return result(ctx, placed ? "Het concept staat nu in Outlook." : "Het concept kon nog steeds niet in Outlook worden gezet.");
373
- }
374
366
  async function assign(ctx) {
375
367
  await loadThread(ctx.threadId);
376
368
  await inbox.setAssignee(ctx.threadId, person(ctx.staff));
@@ -393,7 +385,7 @@ function createHelpdeskHandlers(config) {
393
385
  }
394
386
  async function unignore(ctx) {
395
387
  const outcome = await (0, drafting_1.unignoreThread)(deps, ctx.threadId);
396
- const next = deps.drafter ? "Er wordt een concept geschreven." : "Schrijf zelf het antwoord.";
388
+ const next = deps.drafter ? "Er wordt een voorstel geschreven." : "Schrijf zelf het antwoord.";
397
389
  return refused(outcome) ?? result(ctx, `Ticket teruggezet. ${next}`);
398
390
  }
399
391
  async function repliedExternally(ctx) {
@@ -454,7 +446,6 @@ function createHelpdeskHandlers(config) {
454
446
  draft: { PUT: saveDraft },
455
447
  regenerate: { POST: regenerate },
456
448
  send: { POST: send },
457
- "retry-placement": { POST: retry },
458
449
  assign: { POST: assign, DELETE: unassign },
459
450
  waiting: { POST: waiting },
460
451
  close: { POST: close },
@@ -1,4 +1,4 @@
1
- import type { MailboxWriteMode, ReviewReason, TicketState } from "../types";
1
+ import type { ReviewReason, TicketState } from "../types";
2
2
  export declare const HELPDESK_BASE_PATH = "/api/helpdesk";
3
3
  export declare const INBOX_ERROR_MESSAGES: {
4
4
  readonly not_production: "De helpdesk werkt alleen in de productieomgeving van de app.";
@@ -18,11 +18,10 @@ export declare const INBOX_ERROR_MESSAGES: {
18
18
  readonly paused: "De helpdesk staat op pauze. Er wordt niets opgehaald of verstuurd tot de pauze voorbij is.";
19
19
  readonly write_blocked: "Deze mailbox staat op alleen lezen. Antwoorden kan pas als de mailbox daarvoor is vrijgegeven.";
20
20
  readonly mailbox_not_in_scope: "Deze mailbox hoort niet bij de helpdesk.";
21
- readonly stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit concept. Lees die eerst, of verstuur toch.";
22
- readonly draft_not_ready: "Er is nog geen concept dat verstuurd of aangepast kan worden.";
21
+ readonly stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit antwoord. Lees die eerst, of verstuur toch.";
22
+ readonly draft_not_ready: "Er is nog geen antwoord dat verstuurd of aangepast kan worden.";
23
23
  readonly invalid_transition: "Dit kan niet in de huidige status van het ticket. Vernieuw de pagina.";
24
24
  readonly stale_thread: "Het ticket is net gewijzigd. Vernieuw de pagina en probeer het opnieuw.";
25
- readonly quote_not_found: "De eerdere mail in het concept is niet terug te vinden, daarom is de wijziging niet opgeslagen. Pas het concept aan in Outlook.";
26
25
  readonly empty_reply: "Het antwoord is leeg. Schrijf eerst een antwoord; alleen de eerdere mail wordt nooit verstuurd.";
27
26
  readonly send_in_progress: "Er wordt net een antwoord verstuurd. Vernieuw zo de pagina en kijk of het nog nodig is.";
28
27
  readonly send_outcome_unknown: "Het is niet zeker of het vorige antwoord is verstuurd. Geef eerst aan wat je in Outlook bij Verzonden items ziet.";
@@ -32,4 +31,4 @@ export declare const INBOX_ERROR_MESSAGES: {
32
31
  export type InboxErrorCode = keyof typeof INBOX_ERROR_MESSAGES;
33
32
  export declare const STATE_LABELS: Record<TicketState, string>;
34
33
  export declare const REVIEW_REASON_LABELS: Record<ReviewReason, string>;
35
- export declare const WRITE_MODE_LABELS: Record<MailboxWriteMode, string>;
34
+ export declare function bounceReasonText(statusCode: string | null, recipient: string | null): string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WRITE_MODE_LABELS = exports.REVIEW_REASON_LABELS = exports.STATE_LABELS = exports.INBOX_ERROR_MESSAGES = exports.HELPDESK_BASE_PATH = void 0;
3
+ exports.REVIEW_REASON_LABELS = exports.STATE_LABELS = exports.INBOX_ERROR_MESSAGES = exports.HELPDESK_BASE_PATH = void 0;
4
+ exports.bounceReasonText = bounceReasonText;
4
5
  exports.HELPDESK_BASE_PATH = "/api/helpdesk";
5
6
  exports.INBOX_ERROR_MESSAGES = {
6
7
  not_production: "De helpdesk werkt alleen in de productieomgeving van de app.",
@@ -20,11 +21,10 @@ exports.INBOX_ERROR_MESSAGES = {
20
21
  paused: "De helpdesk staat op pauze. Er wordt niets opgehaald of verstuurd tot de pauze voorbij is.",
21
22
  write_blocked: "Deze mailbox staat op alleen lezen. Antwoorden kan pas als de mailbox daarvoor is vrijgegeven.",
22
23
  mailbox_not_in_scope: "Deze mailbox hoort niet bij de helpdesk.",
23
- stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit concept. Lees die eerst, of verstuur toch.",
24
- draft_not_ready: "Er is nog geen concept dat verstuurd of aangepast kan worden.",
24
+ stale_draft: "Er is een nieuwere mail van de klant binnengekomen na dit antwoord. Lees die eerst, of verstuur toch.",
25
+ draft_not_ready: "Er is nog geen antwoord dat verstuurd of aangepast kan worden.",
25
26
  invalid_transition: "Dit kan niet in de huidige status van het ticket. Vernieuw de pagina.",
26
27
  stale_thread: "Het ticket is net gewijzigd. Vernieuw de pagina en probeer het opnieuw.",
27
- quote_not_found: "De eerdere mail in het concept is niet terug te vinden, daarom is de wijziging niet opgeslagen. Pas het concept aan in Outlook.",
28
28
  empty_reply: "Het antwoord is leeg. Schrijf eerst een antwoord; alleen de eerdere mail wordt nooit verstuurd.",
29
29
  send_in_progress: "Er wordt net een antwoord verstuurd. Vernieuw zo de pagina en kijk of het nog nodig is.",
30
30
  send_outcome_unknown: "Het is niet zeker of het vorige antwoord is verstuurd. Geef eerst aan wat je in Outlook bij Verzonden items ziet.",
@@ -34,24 +34,33 @@ exports.INBOX_ERROR_MESSAGES = {
34
34
  exports.STATE_LABELS = {
35
35
  new: "Nieuw",
36
36
  ignored: "Genegeerd",
37
- drafted: "Concept wordt gemaakt",
37
+ drafted: "Voorstel wordt geschreven",
38
38
  review: "Te beoordelen",
39
39
  sent: "Verstuurd",
40
40
  waiting: "Wacht op klant",
41
41
  closed: "Gesloten",
42
42
  };
43
43
  exports.REVIEW_REASON_LABELS = {
44
- low_confidence: "Concept twijfelachtig",
45
- policy: "Controle nodig",
46
- stale_draft: "Nieuwe mail na concept",
47
- drafter_failed: "Concept mislukt",
48
- placement_failed: "Niet in Outlook gezet",
44
+ low_confidence: "Voorstel twijfelachtig",
45
+ policy: "Voorstel klaar",
46
+ stale_draft: "Nieuwe mail na voorstel",
47
+ drafter_failed: "Voorstel mislukt",
49
48
  staff_edited: "Aangepast",
50
49
  no_draft: "Zelf antwoorden",
51
50
  delivery_failed: "Niet afgeleverd",
52
51
  };
53
- exports.WRITE_MODE_LABELS = {
54
- read_only: "Alleen lezen",
55
- drafts_in_app: "Concepten in de app",
56
- drafts_in_outlook: "Concepten in Outlook",
52
+ const BOUNCE_REASONS = {
53
+ "5.1.1": "Dit adres bestaat niet.",
54
+ "5.1.10": "Dit adres bestaat niet.",
55
+ "5.2.2": "De mailbox van de ontvanger is vol.",
56
+ "5.7.708": "Microsoft blokkeert mail vanaf deze mailbox naar adressen buiten jullie organisatie.",
57
+ "5.7.750": "Microsoft blokkeert mail vanaf deze mailbox naar adressen buiten jullie organisatie.",
58
+ "5.7.1": "De ontvangende server weigert mail van deze afzender.",
57
59
  };
60
+ function bounceReasonText(statusCode, recipient) {
61
+ const reason = statusCode ? BOUNCE_REASONS[statusCode] : undefined;
62
+ if (!recipient)
63
+ return reason ?? "De ontvangende server weigerde het bericht.";
64
+ const prefix = `De mailserver van ${recipient} weigerde het bericht.`;
65
+ return reason ? `${prefix} ${reason}` : prefix;
66
+ }
@@ -43,12 +43,21 @@ export interface InboxMessageView {
43
43
  subject: string;
44
44
  receivedAt: string;
45
45
  noise: boolean;
46
+ bounce: boolean;
46
47
  removed: boolean;
48
+ outbound: boolean;
49
+ }
50
+ export interface InboxBounceView {
51
+ recipient: string | null;
52
+ statusCode: string | null;
53
+ reason: string;
54
+ diagnostic: string;
47
55
  }
48
56
  export interface InboxMessageBody {
49
57
  id: string;
50
58
  text: string;
51
59
  attachments: InboxAttachmentView[];
60
+ bounce: InboxBounceView | null;
52
61
  }
53
62
  export interface InboxReplyAttachmentView {
54
63
  id: string;
@@ -76,9 +85,9 @@ export interface InboxDraftView {
76
85
  confidence: number | null;
77
86
  category: string | null;
78
87
  stale: boolean;
79
- inOutlook: boolean;
88
+ fromDrafter: boolean;
80
89
  }
81
- export type InboxAction = "edit" | "regenerate" | "send" | "retry_placement" | "replied_externally" | "mark_waiting" | "close" | "unignore" | "dismiss" | "resolve_send" | "attach";
90
+ export type InboxAction = "edit" | "regenerate" | "send" | "replied_externally" | "mark_waiting" | "close" | "unignore" | "dismiss" | "resolve_send" | "attach";
82
91
  export interface InboxThreadDetail {
83
92
  viewer: InboxPerson;
84
93
  thread: InboxThreadSummary & {
@@ -96,6 +105,7 @@ export interface InboxThreadDetail {
96
105
  send: {
97
106
  status: SendStatus;
98
107
  at: string;
108
+ messageId: string | null;
99
109
  } | null;
100
110
  replyAttachments: InboxReplyAttachmentView[];
101
111
  actions: InboxAction[];