@pithy-sh/support 0.1.2 → 0.1.4
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/ai/classify.d.ts +92 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +153 -0
- package/dist/attachment/store.d.ts +55 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +69 -0
- package/dist/audit/actions.d.ts +70 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +70 -0
- package/dist/capability.d.ts +95 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +189 -0
- package/dist/client/projection.d.ts +58 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +2 -0
- package/dist/config/config.d.ts +217 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +171 -0
- package/dist/data/attachment.d.ts +32 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +34 -0
- package/dist/data/billingScope.d.ts +32 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +34 -0
- package/dist/data/categories.d.ts +56 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +99 -0
- package/dist/data/classification.d.ts +36 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +30 -0
- package/dist/data/enums.d.ts +84 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +72 -0
- package/dist/data/flag.d.ts +27 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +29 -0
- package/dist/data/message.d.ts +83 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +86 -0
- package/dist/data/tables.d.ts +162 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +46 -0
- package/dist/data/thread.d.ts +79 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +67 -0
- package/dist/error/errors.d.ts +66 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +84 -0
- package/dist/http/guards.d.ts +46 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +55 -0
- package/dist/http/handlers.d.ts +91 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +250 -0
- package/dist/http/resolve.d.ts +46 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +74 -0
- package/dist/http/responses.d.ts +831 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +305 -0
- package/dist/http/routes.d.ts +111 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +64 -0
- package/dist/http/schemas.d.ts +123 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +91 -0
- package/dist/http/scopes.d.ts +58 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +109 -0
- package/dist/http/views.d.ts +38 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +146 -0
- package/dist/inbound/authenticity.d.ts +83 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +63 -0
- package/dist/inbound/guard.d.ts +76 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +80 -0
- package/dist/inbound/handler.d.ts +30 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +84 -0
- package/dist/inbound/ingest.d.ts +106 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +328 -0
- package/dist/inbound/recipient.d.ts +18 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +48 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/link/sender.d.ts +162 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +202 -0
- package/dist/migrations/0001_threads.d.ts +28 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +105 -0
- package/dist/mime/address.d.ts +24 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +30 -0
- package/dist/mime/parse.d.ts +98 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +148 -0
- package/dist/mime/sanitize.d.ts +27 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +214 -0
- package/dist/mime/threading.d.ts +81 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +113 -0
- package/dist/mime/truncate.d.ts +19 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +49 -0
- package/dist/provision/provisionSupport.d.ts +160 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +112 -0
- package/dist/provision/resolveSupportConfig.d.ts +47 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +24 -0
- package/dist/reply/send.d.ts +125 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +155 -0
- package/dist/reply/snippets.d.ts +81 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +137 -0
- package/dist/secret/registry.d.ts +20 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +23 -0
- package/dist/seeds/example.d.ts +5 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +352 -0
- package/dist/store/paging.d.ts +20 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +22 -0
- package/dist/store/search.d.ts +69 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +162 -0
- package/dist/store/searchIndex.d.ts +55 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +68 -0
- package/dist/store/threads.d.ts +149 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +199 -0
- package/dist/submission/encoding.d.ts +23 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +57 -0
- package/dist/submission/guard.d.ts +73 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +48 -0
- package/dist/submission/submit.d.ts +154 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +321 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/workflows/classify.d.ts +65 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +70 -0
- package/dist/workflows/retryPolicy.d.ts +41 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +45 -0
- package/dist/workflows/specs.d.ts +45 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +48 -0
- package/dist/workflows/worker.d.ts +50 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +39 -0
- package/package.json +24 -15
- package/src/http/handlers.ts +1 -1
- package/src/submission/encoding.ts +1 -1
- package/src/submission/submit.ts +1 -1
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SupportNotFoundError } from "../error/errors.js";
|
|
4
|
+
import { SupportAttachment } from "../data/attachment.js";
|
|
5
|
+
import { SupportMessage } from "../data/message.js";
|
|
6
|
+
import { SupportThread } from "../data/thread.js";
|
|
7
|
+
import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
|
|
8
|
+
import { isSearchable, searchPredicate } from "./search.js";
|
|
9
|
+
import "./paging.js";
|
|
10
|
+
import { sql } from "kysely";
|
|
11
|
+
//#region src/store/threads.ts
|
|
12
|
+
/**
|
|
13
|
+
* Encode a cursor for the wire.
|
|
14
|
+
*
|
|
15
|
+
* Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
|
|
16
|
+
* cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
|
|
17
|
+
* change later without breaking every caller. It is not a secret — it holds a timestamp and an id
|
|
18
|
+
* the caller was just given.
|
|
19
|
+
*/
|
|
20
|
+
function encodeCursor(cursor) {
|
|
21
|
+
return btoa(JSON.stringify(cursor)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
/** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
|
|
24
|
+
function decodeCursor(value) {
|
|
25
|
+
if (!value) return void 0;
|
|
26
|
+
try {
|
|
27
|
+
const json = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
28
|
+
const parsed = JSON.parse(json);
|
|
29
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
30
|
+
const { lastMessageAt, id } = parsed;
|
|
31
|
+
if (typeof lastMessageAt !== "number" || typeof id !== "string") return void 0;
|
|
32
|
+
return {
|
|
33
|
+
lastMessageAt,
|
|
34
|
+
id
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* List threads, as one viewer sees them.
|
|
42
|
+
*
|
|
43
|
+
* The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
|
|
44
|
+
* here**: a snooze that was stored and never consulted is a button that does nothing, and `read` that
|
|
45
|
+
* is never projected is a column a dashboard cannot render. Both are left-joined, so a thread with no
|
|
46
|
+
* flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
|
|
47
|
+
*/
|
|
48
|
+
async function listThreads(db, query, options) {
|
|
49
|
+
try {
|
|
50
|
+
return await runListThreads(db, query, options);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (options.fts && isMissingSearchTable(error)) {
|
|
53
|
+
options.log?.warn("support FTS index is configured but absent — falling back to a LIKE scan", { action: "Run `pithy support provision` to create and backfill it, or set `search.fts: false`." });
|
|
54
|
+
return runListThreads(db, query, {
|
|
55
|
+
...options,
|
|
56
|
+
fts: false
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Whether an error is SQLite saying the full-text index does not exist. */
|
|
63
|
+
function isMissingSearchTable(error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
return message.includes("no such table") && message.includes("pithy_support_search");
|
|
66
|
+
}
|
|
67
|
+
/** The listing itself, for one chosen search backend. */
|
|
68
|
+
async function runListThreads(db, query, options) {
|
|
69
|
+
const limit = Math.min(Math.max(query.limit ?? 25, 1), 100);
|
|
70
|
+
const cursor = decodeCursor(query.cursor);
|
|
71
|
+
let builder = db.selectFrom(SUPPORT_THREADS_TABLE).leftJoin(SUPPORT_FLAGS_TABLE, (join) => join.onRef(`${SUPPORT_FLAGS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`).on(`${SUPPORT_FLAGS_TABLE}.viewer`, "=", options.viewer)).selectAll(SUPPORT_THREADS_TABLE).select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`]).where((eb) => eb.or([eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "is", null), eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "<=", options.now.getTime())])).where(`${SUPPORT_THREADS_TABLE}.archived`, "=", query.archived === true ? 1 : 0);
|
|
72
|
+
if (query.category !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.category`, "=", query.category);
|
|
73
|
+
if (query.declaredCategory !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", query.declaredCategory);
|
|
74
|
+
if (query.priority !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", query.priority);
|
|
75
|
+
if (query.sentiment !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", query.sentiment);
|
|
76
|
+
if (query.channel !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", query.channel);
|
|
77
|
+
if (query.inbox !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", query.inbox);
|
|
78
|
+
if (query.q !== void 0) {
|
|
79
|
+
if (isSearchable(query.q, options.fts)) builder = builder.where(searchPredicate(query.q, options));
|
|
80
|
+
else builder = builder.where(sql`1 = 0`);
|
|
81
|
+
}
|
|
82
|
+
if (cursor) builder = builder.where((eb) => eb.or([eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "<", cursor.lastMessageAt), eb.and([eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "=", cursor.lastMessageAt), eb(`${SUPPORT_THREADS_TABLE}.id`, "<", cursor.id)])]));
|
|
83
|
+
const rows = await builder.orderBy(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "desc").orderBy(`${SUPPORT_THREADS_TABLE}.id`, "desc").limit(limit + 1).execute();
|
|
84
|
+
const page = rows.slice(0, limit).map((row) => ({
|
|
85
|
+
...SupportThread.parse(row),
|
|
86
|
+
read: row.viewerRead === 1,
|
|
87
|
+
snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil))
|
|
88
|
+
}));
|
|
89
|
+
const last = page[page.length - 1];
|
|
90
|
+
return {
|
|
91
|
+
threads: page,
|
|
92
|
+
nextCursor: rows.length > limit && last ? encodeCursor({
|
|
93
|
+
lastMessageAt: last.lastMessageAt.getTime(),
|
|
94
|
+
id: last.id
|
|
95
|
+
}) : null
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Read one thread in full. Throws `support/not_found` when it does not exist. */
|
|
99
|
+
async function readThread(db, threadId) {
|
|
100
|
+
const row = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
|
|
101
|
+
if (!row) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
|
|
102
|
+
const [messages, attachments] = await Promise.all([db.selectFrom(SUPPORT_MESSAGES_TABLE).selectAll().where("threadId", "=", threadId).orderBy("receivedAt", "asc").orderBy("id", "asc").execute(), db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute()]);
|
|
103
|
+
return {
|
|
104
|
+
thread: SupportThread.parse(row),
|
|
105
|
+
messages: messages.map((message) => SupportMessage.parse(message)),
|
|
106
|
+
attachments: attachments.map((attachment) => SupportAttachment.parse(attachment))
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* List one account's own in-app conversations.
|
|
111
|
+
*
|
|
112
|
+
* **Two conditions on the `where`, and the second is not redundant.** `userId` alone would also return
|
|
113
|
+
* every *email* thread this capability linked to the account — and that link was matched against an
|
|
114
|
+
* address in a header nobody proved, so it names an account the sender merely claimed to be. Serving
|
|
115
|
+
* those to whoever currently holds the address would turn the mail path's known weakness into a read
|
|
116
|
+
* primitive, which is precisely the trade this channel exists to avoid making.
|
|
117
|
+
*
|
|
118
|
+
* Archived threads are included, unlike the operator inbox. An inbox hides done threads because it is a
|
|
119
|
+
* work queue; a person looking at their own requests is looking for the one that was answered.
|
|
120
|
+
*/
|
|
121
|
+
async function listOwnThreads(db, userId, query) {
|
|
122
|
+
const limit = Math.min(Math.max(query.limit ?? 25, 1), 100);
|
|
123
|
+
const cursor = decodeCursor(query.cursor);
|
|
124
|
+
let builder = db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("userId", "=", userId).where("channel", "=", "app");
|
|
125
|
+
if (cursor) builder = builder.where((eb) => eb.or([eb("lastMessageAt", "<", cursor.lastMessageAt), eb.and([eb("lastMessageAt", "=", cursor.lastMessageAt), eb("id", "<", cursor.id)])]));
|
|
126
|
+
const rows = await builder.orderBy("lastMessageAt", "desc").orderBy("id", "desc").limit(limit + 1).execute();
|
|
127
|
+
const page = rows.slice(0, limit).map((row) => SupportThread.parse(row));
|
|
128
|
+
const last = page[page.length - 1];
|
|
129
|
+
return {
|
|
130
|
+
threads: page,
|
|
131
|
+
nextCursor: rows.length > limit && last ? encodeCursor({
|
|
132
|
+
lastMessageAt: last.lastMessageAt.getTime(),
|
|
133
|
+
id: last.id
|
|
134
|
+
}) : null
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Read one of an account's own in-app conversations.
|
|
139
|
+
*
|
|
140
|
+
* **A 404 for somebody else's thread, never a 403.** The two are the same answer on purpose: a 403
|
|
141
|
+
* confirms the id names a real conversation, and on an inbox of other people's correspondence that
|
|
142
|
+
* confirmation is itself the disclosure. The scoping is in the `where`, not in a check after the read,
|
|
143
|
+
* so there is no version of this that fetches the row first and forgets to compare.
|
|
144
|
+
*/
|
|
145
|
+
async function readOwnThread(db, threadId, userId) {
|
|
146
|
+
const row = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).where("userId", "=", userId).where("channel", "=", "app").executeTakeFirst();
|
|
147
|
+
if (!row) throw new SupportNotFoundError({ detail: `no app thread ${threadId} owned by ${userId}` });
|
|
148
|
+
const [messages, attachments] = await Promise.all([db.selectFrom(SUPPORT_MESSAGES_TABLE).selectAll().where("threadId", "=", threadId).orderBy("receivedAt", "asc").orderBy("id", "asc").execute(), db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute()]);
|
|
149
|
+
return {
|
|
150
|
+
thread: SupportThread.parse(row),
|
|
151
|
+
messages: messages.map((message) => SupportMessage.parse(message)),
|
|
152
|
+
attachments: attachments.map((attachment) => SupportAttachment.parse(attachment))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Mark a thread done, or reopen it.
|
|
157
|
+
*
|
|
158
|
+
* The one shared piece of state in the model, and the write is unconditional rather than a
|
|
159
|
+
* compare-and-set: archiving something already archived is not a conflict, it is the same outcome,
|
|
160
|
+
* and a support console that threw on a double-click would be worse than one that did nothing.
|
|
161
|
+
*/
|
|
162
|
+
async function setArchived(db, threadId, archived, viewer, now) {
|
|
163
|
+
const existing = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
|
|
164
|
+
if (!existing) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
|
|
165
|
+
await db.updateTable(SUPPORT_THREADS_TABLE).set({
|
|
166
|
+
archived: archived ? 1 : 0,
|
|
167
|
+
archivedAt: archived ? now.getTime() : null,
|
|
168
|
+
archivedBy: archived ? viewer : null,
|
|
169
|
+
updatedAt: now.getTime()
|
|
170
|
+
}).where("id", "=", threadId).execute();
|
|
171
|
+
return SupportThread.parse({
|
|
172
|
+
...existing,
|
|
173
|
+
archived: archived ? 1 : 0,
|
|
174
|
+
archivedAt: archived ? now.getTime() : null,
|
|
175
|
+
archivedBy: archived ? viewer : null,
|
|
176
|
+
updatedAt: now.getTime()
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Set one viewer's private flags on a thread. Upserts on `(threadId, viewer)`. */
|
|
180
|
+
async function setFlags(db, options) {
|
|
181
|
+
if (!await db.selectFrom("pithySupportThreads").select(["id"]).where("id", "=", options.threadId).executeTakeFirst()) throw new SupportNotFoundError({ detail: `no support thread ${options.threadId}` });
|
|
182
|
+
const read = options.read === void 0 ? void 0 : options.read ? 1 : 0;
|
|
183
|
+
const snoozed = options.snoozedUntil === void 0 ? void 0 : options.snoozedUntil?.getTime() ?? null;
|
|
184
|
+
await db.insertInto(SUPPORT_FLAGS_TABLE).values({
|
|
185
|
+
id: options.newId(),
|
|
186
|
+
threadId: options.threadId,
|
|
187
|
+
viewer: options.viewer,
|
|
188
|
+
read: read ?? 0,
|
|
189
|
+
snoozedUntil: snoozed ?? null,
|
|
190
|
+
createdAt: options.now.getTime(),
|
|
191
|
+
updatedAt: options.now.getTime()
|
|
192
|
+
}).onConflict((oc) => oc.columns(["threadId", "viewer"]).doUpdateSet((eb) => ({
|
|
193
|
+
read: read === void 0 ? eb.ref(`${SUPPORT_FLAGS_TABLE}.read`) : read,
|
|
194
|
+
snoozedUntil: snoozed === void 0 ? eb.ref(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`) : snoozed,
|
|
195
|
+
updatedAt: options.now.getTime()
|
|
196
|
+
}))).execute();
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
export { decodeCursor, encodeCursor, listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The largest base64 string worth decoding, derived from a decoded-byte bound.
|
|
5
|
+
*
|
|
6
|
+
* Checked **before** decoding, not after. `atob` materializes the whole result, so a client that sends
|
|
7
|
+
* fifty megabytes of base64 has already been allocated fifty megabytes of Worker memory by the time a
|
|
8
|
+
* post-hoc size check could refuse it — and the request that does that is the one an attacker sends.
|
|
9
|
+
* A little slack for padding and any whitespace a client's encoder inserted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function maxEncodedLength(maxBytes: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Decode one base64 attachment payload.
|
|
14
|
+
*
|
|
15
|
+
* Accepts the URL-safe alphabet too: a client that reached for `base64url` because its platform's
|
|
16
|
+
* default encoder produces it has not made a mistake worth a 400, and the two differ by two
|
|
17
|
+
* characters. Anything that is not base64 at all is a `validation/invalid_input`, which is what the
|
|
18
|
+
* route's own validator would have raised had the string been checkable there.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decodeBase64(value: string, options: {
|
|
21
|
+
maxBytes: number;
|
|
22
|
+
}): Uint8Array;
|
|
23
|
+
//# sourceMappingURL=encoding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/submission/encoding.ts"],"names":[],"mappings":"AAkBA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,UAAU,CA2BrF"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
|
+
//#region src/submission/encoding.ts
|
|
5
|
+
/**
|
|
6
|
+
* Turning a base64 attachment back into bytes.
|
|
7
|
+
*
|
|
8
|
+
* Base64 rather than `multipart/form-data` for the submission route, and the reason is the contract
|
|
9
|
+
* rather than the encoding: every other route in this capability declares what it accepts as a Zod
|
|
10
|
+
* object over JSON, validated on the route line, and a multipart body is the one shape that cannot be.
|
|
11
|
+
* The cost is a third more bytes on the wire, which is why `attachments.maxBytes` is documented as a
|
|
12
|
+
* bound on the **decoded** size — the number that reaches R2 and the adopter's bill.
|
|
13
|
+
*/
|
|
14
|
+
/** How much larger a base64 string is than the bytes it encodes: four characters per three bytes. */
|
|
15
|
+
const BASE64_EXPANSION = 4 / 3;
|
|
16
|
+
/**
|
|
17
|
+
* The largest base64 string worth decoding, derived from a decoded-byte bound.
|
|
18
|
+
*
|
|
19
|
+
* Checked **before** decoding, not after. `atob` materializes the whole result, so a client that sends
|
|
20
|
+
* fifty megabytes of base64 has already been allocated fifty megabytes of Worker memory by the time a
|
|
21
|
+
* post-hoc size check could refuse it — and the request that does that is the one an attacker sends.
|
|
22
|
+
* A little slack for padding and any whitespace a client's encoder inserted.
|
|
23
|
+
*/
|
|
24
|
+
function maxEncodedLength(maxBytes) {
|
|
25
|
+
return Math.ceil(maxBytes * BASE64_EXPANSION) + 4;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Decode one base64 attachment payload.
|
|
29
|
+
*
|
|
30
|
+
* Accepts the URL-safe alphabet too: a client that reached for `base64url` because its platform's
|
|
31
|
+
* default encoder produces it has not made a mistake worth a 400, and the two differ by two
|
|
32
|
+
* characters. Anything that is not base64 at all is a `validation/invalid_input`, which is what the
|
|
33
|
+
* route's own validator would have raised had the string been checkable there.
|
|
34
|
+
*/
|
|
35
|
+
function decodeBase64(value, options) {
|
|
36
|
+
if (value.length > maxEncodedLength(options.maxBytes)) throw new ValidationError({
|
|
37
|
+
message: "That attachment is too large.",
|
|
38
|
+
action: `Attach a file under ${options.maxBytes} bytes.`,
|
|
39
|
+
detail: `encoded attachment is ${value.length} characters, over the ${maxEncodedLength(options.maxBytes)} the ${options.maxBytes} byte bound allows`
|
|
40
|
+
});
|
|
41
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
42
|
+
let binary;
|
|
43
|
+
try {
|
|
44
|
+
binary = atob(normalized);
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
throw new ValidationError({
|
|
47
|
+
message: "That attachment could not be read.",
|
|
48
|
+
action: "Send the file's bytes as a base64 string.",
|
|
49
|
+
detail: "attachment payload is not valid base64"
|
|
50
|
+
}, { cause });
|
|
51
|
+
}
|
|
52
|
+
const bytes = new Uint8Array(binary.length);
|
|
53
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
54
|
+
return bytes;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { decodeBase64, maxEncodedLength };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { SupportSubmissionConfig } from "../config/config";
|
|
4
|
+
import { type SupportDatabase } from "../data/tables";
|
|
5
|
+
/**
|
|
6
|
+
* The in-app submission guard — the bound between "a signed-in user may write in" and "one account is
|
|
7
|
+
* the whole inbox".
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not the mail guard with a different column
|
|
10
|
+
*
|
|
11
|
+
* `inbound/guard.ts` exists because a public address is a public write endpoint reachable by anybody
|
|
12
|
+
* with an SMTP client, and its bounds are the shape of that problem: a size cap before parsing, a
|
|
13
|
+
* per-address rate for the broken auto-responder, a global rate for the distributed flood where no
|
|
14
|
+
* single address stands out.
|
|
15
|
+
*
|
|
16
|
+
* None of that describes this surface. **A submission is attributable and revocable**: it carries an
|
|
17
|
+
* account the adopter issued, so the flood the mail guard's global bound exists for cannot happen
|
|
18
|
+
* anonymously here — an attacker needs an account per bucket of ten, and every one of them is a row an
|
|
19
|
+
* adopter can disable. What remains is one real failure, and it is the one this file bounds: a single
|
|
20
|
+
* account, hostile or looping, filling the inbox.
|
|
21
|
+
*
|
|
22
|
+
* So there is one check rather than three, and it counts on the **account** rather than the address.
|
|
23
|
+
* Not the same thing: an address is a claim in a header and an account is an identity a session
|
|
24
|
+
* proved, which is the whole distinction this channel exists to exploit.
|
|
25
|
+
*
|
|
26
|
+
* ## Counted from the messages table, and only against its own channel
|
|
27
|
+
*
|
|
28
|
+
* The same reasoning `inbound/guard.ts` states — the rows already exist, a counter row would be a
|
|
29
|
+
* second write that overcounts a failed store and undercounts a flood — over the same sliding hour,
|
|
30
|
+
* and over an index (`submitted_by_user_id`, `received_at`) that the migration creates for this.
|
|
31
|
+
*
|
|
32
|
+
* **`channel = "app"` is on the count deliberately.** Neither surface may starve the other: a customer
|
|
33
|
+
* who emailed twenty times about a billing dispute must still be able to file a bug from inside the
|
|
34
|
+
* app, and somebody filing bug reports must never consume the mail inbox's capacity. Sharing one
|
|
35
|
+
* counter would couple two threat models that have nothing in common.
|
|
36
|
+
*/
|
|
37
|
+
/** Why a submission was refused. The value lands in the audit event's `metadata.reason`. */
|
|
38
|
+
export type SupportSubmissionRejectionReason = "account_rate" | "attachment_too_large" | "attachment_type";
|
|
39
|
+
/** The guard's answer: accept, or refuse with a reason. */
|
|
40
|
+
export type SubmissionVerdict = {
|
|
41
|
+
accepted: true;
|
|
42
|
+
} | {
|
|
43
|
+
accepted: false;
|
|
44
|
+
reason: SupportSubmissionRejectionReason;
|
|
45
|
+
detail: string;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Check how much this account has already filed inside the window.
|
|
49
|
+
*
|
|
50
|
+
* `>=` rather than `>`, matching the mail guard: the bound is how many an account *may land*, so the
|
|
51
|
+
* request that would be the eleventh is refused when ten are already stored.
|
|
52
|
+
*/
|
|
53
|
+
export declare function checkAccountRate(db: SupportDatabase, config: SupportSubmissionConfig, input: {
|
|
54
|
+
userId: string;
|
|
55
|
+
now: Date;
|
|
56
|
+
}): Promise<SubmissionVerdict>;
|
|
57
|
+
/**
|
|
58
|
+
* Check one attachment against the declared bounds.
|
|
59
|
+
*
|
|
60
|
+
* Size is measured on the **decoded** bytes, which is the number that matters and not the one the
|
|
61
|
+
* request carried: a base64 payload is a third larger than what lands in R2, and bounding the encoded
|
|
62
|
+
* form would silently make the effective limit whatever `maxBytes * 0.75` happens to be.
|
|
63
|
+
*
|
|
64
|
+
* The type check is an **allowlist**, so a type nobody thought about is refused rather than accepted —
|
|
65
|
+
* the opposite default from the mail path, where refusing an unexpected type would lose a customer's
|
|
66
|
+
* bug report. It bounds what lands in the adopter's bucket; it is not what stops a stored-XSS, which
|
|
67
|
+
* is `putAttachment` writing every object as `application/octet-stream` whatever was declared.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkAttachment(config: SupportSubmissionConfig, input: {
|
|
70
|
+
contentType: string;
|
|
71
|
+
bytes: number;
|
|
72
|
+
}): SubmissionVerdict;
|
|
73
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/submission/guard.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAG9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,4FAA4F;AAC5F,MAAM,MAAM,gCAAgC,GAAG,cAAc,GAAG,sBAAsB,GAAG,iBAAiB,CAAC;AAE3G,2DAA2D;AAC3D,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAClB;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gCAAgC,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAElF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,uBAAuB,EAC/B,KAAK,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,IAAI,CAAA;CAAE,GACnC,OAAO,CAAC,iBAAiB,CAAC,CAyB5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,uBAAuB,EAC/B,KAAK,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC5C,iBAAiB,CAmBnB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
|
|
4
|
+
import { GUARD_WINDOW_MS } from "../inbound/guard.js";
|
|
5
|
+
//#region src/submission/guard.ts
|
|
6
|
+
/**
|
|
7
|
+
* Check how much this account has already filed inside the window.
|
|
8
|
+
*
|
|
9
|
+
* `>=` rather than `>`, matching the mail guard: the bound is how many an account *may land*, so the
|
|
10
|
+
* request that would be the eleventh is refused when ten are already stored.
|
|
11
|
+
*/
|
|
12
|
+
async function checkAccountRate(db, config, input) {
|
|
13
|
+
const since = new Date(input.now.getTime() - GUARD_WINDOW_MS);
|
|
14
|
+
const filed = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select((eb) => eb.fn.countAll().as("count")).where("channel", "=", "app").where("direction", "=", "inbound").where("submittedByUserId", "=", input.userId).where("receivedAt", ">=", since.getTime()).executeTakeFirst();
|
|
15
|
+
if ((filed?.count ?? 0) >= config.maxPerAccountPerHour) return {
|
|
16
|
+
accepted: false,
|
|
17
|
+
reason: "account_rate",
|
|
18
|
+
detail: `account ${input.userId} has filed ${filed?.count} submissions in the last hour, at or over the ${config.maxPerAccountPerHour} bound`
|
|
19
|
+
};
|
|
20
|
+
return { accepted: true };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Check one attachment against the declared bounds.
|
|
24
|
+
*
|
|
25
|
+
* Size is measured on the **decoded** bytes, which is the number that matters and not the one the
|
|
26
|
+
* request carried: a base64 payload is a third larger than what lands in R2, and bounding the encoded
|
|
27
|
+
* form would silently make the effective limit whatever `maxBytes * 0.75` happens to be.
|
|
28
|
+
*
|
|
29
|
+
* The type check is an **allowlist**, so a type nobody thought about is refused rather than accepted —
|
|
30
|
+
* the opposite default from the mail path, where refusing an unexpected type would lose a customer's
|
|
31
|
+
* bug report. It bounds what lands in the adopter's bucket; it is not what stops a stored-XSS, which
|
|
32
|
+
* is `putAttachment` writing every object as `application/octet-stream` whatever was declared.
|
|
33
|
+
*/
|
|
34
|
+
function checkAttachment(config, input) {
|
|
35
|
+
if (input.bytes > config.attachments.maxBytes) return {
|
|
36
|
+
accepted: false,
|
|
37
|
+
reason: "attachment_too_large",
|
|
38
|
+
detail: `attachment is ${input.bytes} decoded bytes, over the ${config.attachments.maxBytes} byte bound`
|
|
39
|
+
};
|
|
40
|
+
if (!config.attachments.allowedContentTypes.includes(input.contentType)) return {
|
|
41
|
+
accepted: false,
|
|
42
|
+
reason: "attachment_type",
|
|
43
|
+
detail: `attachment declares ${input.contentType}, which is not in the configured allowlist`
|
|
44
|
+
};
|
|
45
|
+
return { accepted: true };
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { checkAccountRate, checkAttachment };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { R2Bucket } from "@cloudflare/workers-types";
|
|
4
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
5
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import type { SupportConfig } from "../config/config";
|
|
7
|
+
import { type SupportCategories } from "../data/categories";
|
|
8
|
+
import { type SupportSubmissionContext } from "../data/message";
|
|
9
|
+
import { type SupportDatabase } from "../data/tables";
|
|
10
|
+
/**
|
|
11
|
+
* The in-app submission path — a signed-in user opening a support thread from inside the app, without
|
|
12
|
+
* leaving it for their mail client.
|
|
13
|
+
*
|
|
14
|
+
* This is `inbound/ingest.ts` for the other channel, and it is deliberately the *same* destination:
|
|
15
|
+
* one threads table, one messages table, one classifier, one taxonomy, one console. A second package
|
|
16
|
+
* would have been a second inbox and a support person with two of them, neither holding the whole
|
|
17
|
+
* conversation — and the reply to an in-app report goes out by email regardless, because that is where
|
|
18
|
+
* the person will read it.
|
|
19
|
+
*
|
|
20
|
+
* ## What this path does not have to do
|
|
21
|
+
*
|
|
22
|
+
* `inbound/authenticity.ts` is two hundred lines earning the right to say a thread belongs to a
|
|
23
|
+
* customer, because a `From:` header is an unauthenticated claim and decorating a spoofed thread with a
|
|
24
|
+
* real customer's billing history is the opening move of support-driven account takeover.
|
|
25
|
+
*
|
|
26
|
+
* **There is no `From:` here to spoof.** `requireAuth()` proved the session before the handler ran, so
|
|
27
|
+
* the account arrives already established — and the thread records that with `accountLinkSource:
|
|
28
|
+
* "session"`, so a console can tell a link that *is* the identity from one that was matched against a
|
|
29
|
+
* header. Nothing in the submitted payload names an account, and nothing here reads one from it.
|
|
30
|
+
*
|
|
31
|
+
* ## The order of operations
|
|
32
|
+
*
|
|
33
|
+
* The mail path's rule, applied to a different shape: **refuse on the cheapest true reason first.**
|
|
34
|
+
* The two shape bounds a client controls the cost of — how many files, and how large each encoded one
|
|
35
|
+
* is — are applied at the transport boundary in `http/handlers.ts` *before* anything is decoded,
|
|
36
|
+
* because `atob` materializes its whole result and a size check after it has already been handed the
|
|
37
|
+
* allocation it meant to refuse. What is left arrives here as bytes:
|
|
38
|
+
*
|
|
39
|
+
* 0. **The declared category**, which needs no I/O at all — it is a comparison against a taxonomy this
|
|
40
|
+
* process already holds, so it is genuinely the cheapest true reason there is.
|
|
41
|
+
* 1. **The account**, because everything else depends on the address a reply will come back to, and a
|
|
42
|
+
* report nobody can answer is not worth storing.
|
|
43
|
+
* 2. **The rate bound**, before anything is written.
|
|
44
|
+
* 3. **The attachments**, every one checked before one is written, so a refusal never leaves half a
|
|
45
|
+
* submission's files in the bucket.
|
|
46
|
+
* 4. **Store**, which must not fail.
|
|
47
|
+
* 5. **Dispatch classification**, which is allowed to fail — the same rule the mail path follows, and
|
|
48
|
+
* for the same reason: a model that is briefly down must never take somebody's report with it.
|
|
49
|
+
*
|
|
50
|
+
* The count bound is checked in both places on purpose. The handler's is what keeps the decode
|
|
51
|
+
* bounded; this one is what keeps the bound true for any caller that reaches this function directly,
|
|
52
|
+
* which is every test and every future transport.
|
|
53
|
+
*/
|
|
54
|
+
/** What a submission produced. */
|
|
55
|
+
export interface SubmitOutcome {
|
|
56
|
+
/** The thread it landed in — new, or the one it continued. */
|
|
57
|
+
threadId: string;
|
|
58
|
+
/** The message row it created. */
|
|
59
|
+
messageId: string;
|
|
60
|
+
/** Whether it opened a thread rather than continuing one. */
|
|
61
|
+
newThread: boolean;
|
|
62
|
+
/** How many attachments were stored. */
|
|
63
|
+
attachments: number;
|
|
64
|
+
/** Whether a classification was dispatched. */
|
|
65
|
+
classifying: boolean;
|
|
66
|
+
}
|
|
67
|
+
/** One file a submitter attached, already decoded. */
|
|
68
|
+
export interface SubmittedAttachment {
|
|
69
|
+
/** The filename as the client declared it. Untrusted; recorded, never honored. */
|
|
70
|
+
filename: string;
|
|
71
|
+
/** The MIME type as the client declared it, checked against the configured allowlist. */
|
|
72
|
+
contentType: string;
|
|
73
|
+
/** The bytes themselves. */
|
|
74
|
+
bytes: Uint8Array;
|
|
75
|
+
}
|
|
76
|
+
/** Everything a submission needs, all injectable — nothing below this reads a binding. */
|
|
77
|
+
export interface SubmitDeps {
|
|
78
|
+
/** The support tables. */
|
|
79
|
+
db: SupportDatabase;
|
|
80
|
+
/** The resolved config. */
|
|
81
|
+
config: SupportConfig;
|
|
82
|
+
/**
|
|
83
|
+
* The effective taxonomy — the shipped defaults plus the adopter's, already merged.
|
|
84
|
+
*
|
|
85
|
+
* Taken as a dep rather than re-derived from `config.categories` here, because two resolutions of one
|
|
86
|
+
* taxonomy are two things free to disagree: the classifier parses against the capability's merged
|
|
87
|
+
* set, and a submitted claim validated against a differently-merged one would be accepted or refused
|
|
88
|
+
* on a vocabulary nothing else in the process uses.
|
|
89
|
+
*/
|
|
90
|
+
categories: SupportCategories;
|
|
91
|
+
/** The R2 bucket attachments are written to. Absent means none are stored. */
|
|
92
|
+
bucket?: R2Bucket;
|
|
93
|
+
/** Whether the FTS5 index is composed. */
|
|
94
|
+
fts: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The submitter's account, by id. Returns null when `@pithy-sh/auth` is absent or the row is gone —
|
|
97
|
+
* both of which contradict the session that got here, so the caller treats it as a fault rather than
|
|
98
|
+
* as a supported state.
|
|
99
|
+
*/
|
|
100
|
+
resolveAccount: (userId: string) => Promise<{
|
|
101
|
+
email: string;
|
|
102
|
+
name?: string;
|
|
103
|
+
} | null>;
|
|
104
|
+
/** Start a classification for a stored message. Allowed to fail and allowed to decline. */
|
|
105
|
+
dispatchClassify: (messageId: string) => Promise<boolean>;
|
|
106
|
+
/** The audit seam. */
|
|
107
|
+
emit: AuditEmit;
|
|
108
|
+
/** The request logger. */
|
|
109
|
+
log: Logger;
|
|
110
|
+
/** Generate a row id. */
|
|
111
|
+
newId: () => string;
|
|
112
|
+
/** Now. */
|
|
113
|
+
now: () => Date;
|
|
114
|
+
}
|
|
115
|
+
/** What the submitter sent, with the identity taken from the session rather than from the payload. */
|
|
116
|
+
export interface SubmitInput {
|
|
117
|
+
/** The authenticated user id. **From `c.var.auth`, never from the body.** */
|
|
118
|
+
userId: string;
|
|
119
|
+
/** What the report is about. Becomes the thread's name, and the subject of every reply on it. */
|
|
120
|
+
subject: string;
|
|
121
|
+
/** The report itself, as the person wrote it. */
|
|
122
|
+
body: string;
|
|
123
|
+
/**
|
|
124
|
+
* What the submitter says this is about, from the app's own chooser. Their claim, not a
|
|
125
|
+
* classification — validated against the effective taxonomy, stored on the thread it opens, and never
|
|
126
|
+
* read by the classifier. Absent when the client offers no chooser, which stays the ordinary case.
|
|
127
|
+
*
|
|
128
|
+
* Accepted only on a submission that **opens** a thread; sending it with `threadId` is refused rather
|
|
129
|
+
* than ignored (see {@link checkDeclaredCategory}).
|
|
130
|
+
*/
|
|
131
|
+
declaredCategory?: string;
|
|
132
|
+
/** Continue this thread instead of opening one. Refused unless it is this account's own app thread. */
|
|
133
|
+
threadId?: string;
|
|
134
|
+
/** The bounded context the app supplied. */
|
|
135
|
+
context?: SupportSubmissionContext;
|
|
136
|
+
/** The files attached, already decoded. */
|
|
137
|
+
attachments: readonly SubmittedAttachment[];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Mint an RFC 5322 message id for a submission.
|
|
141
|
+
*
|
|
142
|
+
* **Without this the conversation forks the first time the customer answers.** A reply to an app thread
|
|
143
|
+
* leaves by email carrying `In-Reply-To`, the customer's client puts that id in its `References`, and
|
|
144
|
+
* `parentCandidates` looks it up in `pithy_support_messages` — so a submission stored with no id of its
|
|
145
|
+
* own is a thread whose every answer opens a new one. The whole reason `mime/threading.ts` refuses to
|
|
146
|
+
* match on subject is that fragmented conversations are the failure mode worth engineering against.
|
|
147
|
+
*
|
|
148
|
+
* The domain comes from the inbox address, because that is the domain the reply will actually leave
|
|
149
|
+
* from; with no address configured there is no reply and therefore nothing to thread.
|
|
150
|
+
*/
|
|
151
|
+
export declare function submissionMessageId(messageId: string, inboxAddress: string | null | undefined): string | null;
|
|
152
|
+
/** Take one in-app submission. */
|
|
153
|
+
export declare function submitFeedback(deps: SubmitDeps, input: SubmitInput): Promise<SubmitOutcome>;
|
|
154
|
+
//# sourceMappingURL=submit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/submission/submit.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAGnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAkB,KAAK,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAChF,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAOxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,kCAAkC;AAClC,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,EAAE,OAAO,CAAC;IACnB,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,sDAAsD;AACtD,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;;OAOG;IACH,UAAU,EAAE,iBAAiB,CAAC;IAC9B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,GAAG,EAAE,OAAO,CAAC;IACb;;;;OAIG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACrF,2FAA2F;IAC3F,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,sGAAsG;AACtG,MAAM,WAAW,WAAW;IAC1B,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,iGAAiG;IACjG,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uGAAuG;IACvG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,2CAA2C;IAC3C,WAAW,EAAE,SAAS,mBAAmB,EAAE,CAAC;CAC7C;AA0MD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG7G;AAED,kCAAkC;AAClC,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAmKjG"}
|