@anthusai/papyrus 0.0.0 → 1.0.0-next.1

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.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,493 @@
1
+ import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
2
+ import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
3
+ import {
4
+ buildEmailSubmissionMessageInput,
5
+ classifyInboundEmailIntake,
6
+ countInboundAttachments,
7
+ extractDirectCitations,
8
+ extractRecipientsFromRawMime,
9
+ extractRecipientsFromSesMail,
10
+ extractSenderFromRawMime,
11
+ extractSenderFromSesMail,
12
+ extractUserComposedReplyText,
13
+ looksLikeResearchAssignmentRequest,
14
+ lookupRegisteredUserProfileId,
15
+ normalizeEmailAddress,
16
+ parseInboundEmailBody,
17
+ parseInboundMimeThreading,
18
+ resolveParentSubmissionMessageId,
19
+ UNREGISTERED_SENDER_RESPONSE_ERROR,
20
+ } from "../shared/email-submission";
21
+ import { parseInboundMimeForIntake } from "../shared/email-mime-intake";
22
+ import {
23
+ inboundMessageIdFromS3,
24
+ parseMessageMetadata,
25
+ shouldProcessInboundS3Key,
26
+ } from "../shared/inbound-email-intake";
27
+ import { getLambdaDataClient, LAMBDA_DATA_AUTH_MODE } from "../shared/lambda-data-client";
28
+ import { putTextModelPayload } from "../shared/model-payloads";
29
+
30
+ const PROCESSOR_FUNCTION_NAME = process.env.PAPYRUS_EMAIL_SUBMISSION_PROCESSOR_FUNCTION_NAME ?? "";
31
+ const INBOUND_LOCAL_PARTS = parseLocalParts(process.env.PAPYRUS_INBOUND_EMAIL_LOCAL_PARTS ?? "submissions,suggestions");
32
+ const INBOUND_DOMAIN = (process.env.PAPYRUS_INBOUND_EMAIL_DOMAIN ?? "p.apyr.us").trim().toLowerCase();
33
+ const MEDIA_BUCKET_NAME = (process.env.PAPYRUS_MEDIA_BUCKET_NAME ?? "").trim();
34
+
35
+ const s3Client = new S3Client({});
36
+ const lambdaClient = new LambdaClient({});
37
+
38
+ type InboundPayload = {
39
+ senderEmail: string;
40
+ recipients: string[];
41
+ subject: string;
42
+ bodyText: string;
43
+ citations: Array<Record<string, unknown>>;
44
+ s3Bucket: string | null;
45
+ s3Key: string | null;
46
+ sesMessageId: string | null;
47
+ inReplyTo: string | null;
48
+ references: string | null;
49
+ attachmentCount: number;
50
+ userComposedText: string;
51
+ parentSubmissionMessageId: string | null;
52
+ };
53
+
54
+ type ExistingMessage = {
55
+ id: string;
56
+ status?: string | null;
57
+ responseStatus?: string | null;
58
+ metadata?: unknown;
59
+ };
60
+
61
+ export const handler = async (event: Record<string, unknown>) => {
62
+ if (event.source === "papyrus.inbound-email" && event.action === "retry-pending") {
63
+ return retryPendingInboundMime();
64
+ }
65
+
66
+ const inbound = await resolveInboundPayload(event);
67
+ if (inbound.s3Key && !shouldProcessInboundS3Key(inbound.s3Key)) {
68
+ return { ok: true, skipped: true, reason: "inbound-s3-key-not-eligible", s3Key: inbound.s3Key };
69
+ }
70
+
71
+ return processInboundSubmission(inbound);
72
+ };
73
+
74
+ async function retryPendingInboundMime(): Promise<Record<string, unknown>> {
75
+ if (!MEDIA_BUCKET_NAME) {
76
+ throw new Error("PAPYRUS_MEDIA_BUCKET_NAME is not configured for inbound email retry.");
77
+ }
78
+
79
+ const results: Array<Record<string, unknown>> = [];
80
+ let continuationToken: string | undefined;
81
+ do {
82
+ const listing = await s3Client.send(new ListObjectsV2Command({
83
+ Bucket: MEDIA_BUCKET_NAME,
84
+ Prefix: "inbound-email/",
85
+ ContinuationToken: continuationToken,
86
+ MaxKeys: 100,
87
+ }));
88
+ for (const entry of listing.Contents ?? []) {
89
+ const key = entry.Key ?? "";
90
+ if (!shouldProcessInboundS3Key(key)) continue;
91
+ try {
92
+ const inbound = await loadInboundFromS3Object(MEDIA_BUCKET_NAME, key, sesMessageIdFromObjectKey(key));
93
+ results.push(await processInboundSubmission(inbound));
94
+ } catch (error) {
95
+ results.push({
96
+ ok: false,
97
+ s3Key: key,
98
+ error: error instanceof Error ? error.message : String(error),
99
+ });
100
+ }
101
+ }
102
+ continuationToken = listing.IsTruncated ? listing.NextContinuationToken : undefined;
103
+ } while (continuationToken);
104
+
105
+ return { ok: true, action: "retry-pending", processedCount: results.length, results };
106
+ }
107
+
108
+ async function processInboundSubmission(inbound: InboundPayload): Promise<Record<string, unknown>> {
109
+ const recipientEmail = inbound.recipients.find((entry) => isConfiguredInboundAddress(entry)) ?? inbound.recipients[0] ?? "";
110
+ if (!isConfiguredInboundAddress(recipientEmail)) {
111
+ throw new Error(`Inbound email recipient ${recipientEmail || "(missing)"} is not configured for this environment.`);
112
+ }
113
+
114
+ const messageId = inbound.s3Bucket && inbound.s3Key
115
+ ? inboundMessageIdFromS3(inbound.s3Bucket, inbound.s3Key)
116
+ : null;
117
+ const existing = messageId ? await getExistingInboundMessage(messageId) : null;
118
+ if (existing) {
119
+ return resumeExistingInboundSubmission(existing, inbound, recipientEmail);
120
+ }
121
+
122
+ const now = new Date().toISOString();
123
+ const resolvedMessageId = messageId ?? `message-email-submission-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
124
+ const dataClient = await getLambdaDataClient();
125
+ const profileId = await lookupRegisteredUserProfileId(dataClient, inbound.senderEmail);
126
+ const authorized = Boolean(profileId);
127
+ const citations = authorized
128
+ ? (inbound.citations.length > 0
129
+ ? inbound.citations
130
+ : extractDirectCitations(inbound.bodyText) as Array<Record<string, unknown>>)
131
+ : [];
132
+ const intakeClassification = classifyInboundEmailIntake({
133
+ bodyText: inbound.bodyText,
134
+ citations,
135
+ parentSubmissionMessageId: inbound.parentSubmissionMessageId,
136
+ attachmentCount: inbound.attachmentCount,
137
+ userComposedText: inbound.userComposedText,
138
+ });
139
+ const attachmentOnlyReply = intakeClassification === "attachment_only_reply";
140
+ const conversationalReply = intakeClassification === "conversational_reply";
141
+ const agentIntake = intakeClassification === "agent_intake";
142
+ const pdfOnlyIntake = intakeClassification === "pdf_only_intake";
143
+
144
+ let status = authorized ? "received" : "rejected";
145
+ let responseStatus = authorized ? "PENDING" : "REJECTED";
146
+ let responseError: string | null = authorized ? null : UNREGISTERED_SENDER_RESPONSE_ERROR;
147
+
148
+ if (authorized && looksLikeResearchAssignmentRequest(inbound.bodyText, citations.length)) {
149
+ status = "rejected";
150
+ responseStatus = "REJECTED";
151
+ responseError = "Submission looks like a research assignment request. Send direct citations (URLs or DOIs), not open-ended research tasks.";
152
+ } else if (
153
+ authorized
154
+ && citations.length === 0
155
+ && !attachmentOnlyReply
156
+ && !conversationalReply
157
+ && !agentIntake
158
+ && !pdfOnlyIntake
159
+ ) {
160
+ status = "rejected";
161
+ responseStatus = "REJECTED";
162
+ responseError = "No direct citations (URL or DOI) were found in the email body.";
163
+ }
164
+
165
+ const messageInput = buildEmailSubmissionMessageInput({
166
+ id: resolvedMessageId,
167
+ now,
168
+ subject: inbound.subject,
169
+ bodyText: inbound.bodyText,
170
+ senderEmail: inbound.senderEmail,
171
+ recipientEmail,
172
+ sesMessageId: inbound.sesMessageId,
173
+ s3Bucket: inbound.s3Bucket,
174
+ s3Key: inbound.s3Key,
175
+ authorized,
176
+ authorUserProfileId: profileId,
177
+ authorLabel: inbound.senderEmail || "unknown-sender",
178
+ citations,
179
+ status,
180
+ responseStatus,
181
+ responseError,
182
+ parentSubmissionMessageId: inbound.parentSubmissionMessageId,
183
+ intakeClassification,
184
+ inboundInReplyTo: inbound.inReplyTo,
185
+ inboundReferences: inbound.references,
186
+ inboundAttachmentCount: inbound.attachmentCount,
187
+ });
188
+
189
+ const createResponse = await dataClient.models.Message.create(
190
+ messageInput as never,
191
+ { authMode: LAMBDA_DATA_AUTH_MODE },
192
+ );
193
+ if (createResponse.errors?.length) {
194
+ const messages = createResponse.errors.map((entry) => entry?.message ?? String(entry)).join("; ");
195
+ throw new Error(`Failed to create inbound email submission message: ${messages}`);
196
+ }
197
+ await putTextModelPayload(
198
+ dataClient as any,
199
+ { ownerKind: "message", ownerId: resolvedMessageId, ownerLineageId: resolvedMessageId },
200
+ "message_body",
201
+ "message",
202
+ inbound.bodyText,
203
+ { filename: "message.txt", mediaType: "text/plain", now },
204
+ );
205
+
206
+ if (authorized && responseStatus === "PENDING" && PROCESSOR_FUNCTION_NAME) {
207
+ await invokeEmailProcessor(resolvedMessageId);
208
+ } else if (responseStatus === "REJECTED" && PROCESSOR_FUNCTION_NAME) {
209
+ await invokeEmailProcessor(resolvedMessageId, { sendFeedbackOnly: true });
210
+ }
211
+
212
+ return {
213
+ ok: true,
214
+ messageId: resolvedMessageId,
215
+ authorized,
216
+ status,
217
+ responseStatus,
218
+ directCitationCount: citations.length,
219
+ recipientEmail,
220
+ senderEmail: inbound.senderEmail,
221
+ idempotent: false,
222
+ };
223
+ }
224
+
225
+ async function resumeExistingInboundSubmission(
226
+ existing: ExistingMessage,
227
+ inbound: InboundPayload,
228
+ recipientEmail: string,
229
+ ): Promise<Record<string, unknown>> {
230
+ const metadata = parseMessageMetadata(existing.metadata);
231
+ const authorized = metadata.authorized === true;
232
+ const responseStatus = String(existing.responseStatus ?? metadata.responseStatus ?? "").toUpperCase();
233
+
234
+ if (responseStatus === "COMPLETED") {
235
+ const feedbackDeferred = metadata.feedbackEmailDeferred === true && !metadata.feedbackEmailSentAt;
236
+ if (feedbackDeferred && PROCESSOR_FUNCTION_NAME) {
237
+ await invokeEmailProcessor(existing.id, { sendFeedbackOnly: true });
238
+ return {
239
+ ok: true,
240
+ messageId: existing.id,
241
+ idempotent: true,
242
+ alreadyProcessed: true,
243
+ reattemptedDeferredFeedback: true,
244
+ recipientEmail,
245
+ senderEmail: inbound.senderEmail,
246
+ };
247
+ }
248
+ return {
249
+ ok: true,
250
+ messageId: existing.id,
251
+ idempotent: true,
252
+ alreadyProcessed: true,
253
+ recipientEmail,
254
+ senderEmail: inbound.senderEmail,
255
+ };
256
+ }
257
+
258
+ if (authorized && (responseStatus === "PENDING" || responseStatus === "FAILED" || responseStatus === "IN_PROGRESS")) {
259
+ if (PROCESSOR_FUNCTION_NAME) {
260
+ await invokeEmailProcessor(existing.id);
261
+ }
262
+ return {
263
+ ok: true,
264
+ messageId: existing.id,
265
+ idempotent: true,
266
+ reprocessed: true,
267
+ responseStatus,
268
+ recipientEmail,
269
+ senderEmail: inbound.senderEmail,
270
+ };
271
+ }
272
+
273
+ const responseError = String(metadata.responseError ?? "").toLowerCase();
274
+ const falseNoCitationRejection = responseStatus === "REJECTED"
275
+ && responseError.includes("no direct citations")
276
+ && inbound.citations.length > 0;
277
+ if (authorized && falseNoCitationRejection) {
278
+ if (PROCESSOR_FUNCTION_NAME) {
279
+ await invokeEmailProcessor(existing.id);
280
+ }
281
+ return {
282
+ ok: true,
283
+ messageId: existing.id,
284
+ idempotent: true,
285
+ reprocessed: true,
286
+ correctedCitations: true,
287
+ directCitationCount: inbound.citations.length,
288
+ responseStatus,
289
+ recipientEmail,
290
+ senderEmail: inbound.senderEmail,
291
+ };
292
+ }
293
+
294
+ return {
295
+ ok: true,
296
+ messageId: existing.id,
297
+ idempotent: true,
298
+ skipped: true,
299
+ responseStatus,
300
+ recipientEmail,
301
+ senderEmail: inbound.senderEmail,
302
+ };
303
+ }
304
+
305
+ async function getExistingInboundMessage(messageId: string): Promise<ExistingMessage | null> {
306
+ const client = await getLambdaDataClient();
307
+ const response = await client.models.Message.get(
308
+ { id: messageId },
309
+ { authMode: LAMBDA_DATA_AUTH_MODE },
310
+ );
311
+ if (response.errors?.length) {
312
+ const messages = response.errors.map((entry) => entry?.message ?? String(entry)).join("; ");
313
+ throw new Error(`Failed to load inbound email submission message: ${messages}`);
314
+ }
315
+ return (response.data as ExistingMessage | null) ?? null;
316
+ }
317
+
318
+ async function invokeEmailProcessor(
319
+ messageId: string,
320
+ options: { sendFeedbackOnly?: boolean } = {},
321
+ ): Promise<void> {
322
+ await lambdaClient.send(new InvokeCommand({
323
+ FunctionName: PROCESSOR_FUNCTION_NAME,
324
+ InvocationType: "Event",
325
+ Payload: Buffer.from(JSON.stringify({
326
+ messageId,
327
+ corpusKey: process.env.PAPYRUS_INBOUND_EMAIL_CORPUS_KEY ?? "AI-ML-research",
328
+ ...(options.sendFeedbackOnly ? { sendFeedbackOnly: true } : {}),
329
+ })),
330
+ }));
331
+ }
332
+
333
+ async function resolveInboundPayload(event: Record<string, unknown>): Promise<InboundPayload> {
334
+ if (event.source === "aws.s3" && event.detail && typeof event.detail === "object") {
335
+ return resolveFromS3ObjectCreated(event.detail as Record<string, unknown>);
336
+ }
337
+
338
+ const record = Array.isArray(event.Records) ? event.Records[0] : null;
339
+ if (record && typeof record === "object") {
340
+ const s3Record = record as { s3?: { bucket?: { name?: string }; object?: { key?: string } }; ses?: { mail?: Record<string, unknown>; receipt?: Record<string, unknown> } };
341
+ if (s3Record.s3) {
342
+ const bucket = normalizeOptionalString(s3Record.s3.bucket?.name);
343
+ const key = normalizeOptionalString(s3Record.s3.object?.key);
344
+ if (bucket && key) {
345
+ return loadInboundFromS3Object(bucket, key, null);
346
+ }
347
+ }
348
+
349
+ const mail = s3Record.ses?.mail;
350
+ if (mail && typeof mail === "object") {
351
+ return resolveFromSesMail(mail, s3Record.ses?.receipt);
352
+ }
353
+ }
354
+
355
+ throw new Error("Unsupported inbound email event payload.");
356
+ }
357
+
358
+ async function resolveFromS3ObjectCreated(detail: Record<string, unknown>): Promise<InboundPayload> {
359
+ const bucketInfo = detail.bucket && typeof detail.bucket === "object" ? detail.bucket as Record<string, unknown> : {};
360
+ const objectInfo = detail.object && typeof detail.object === "object" ? detail.object as Record<string, unknown> : {};
361
+ const bucket = normalizeOptionalString(bucketInfo.name);
362
+ const key = normalizeOptionalString(objectInfo.key);
363
+ if (!bucket || !key) throw new Error("S3 Object Created event did not include bucket and object key.");
364
+ return loadInboundFromS3Object(bucket, key, sesMessageIdFromObjectKey(key));
365
+ }
366
+
367
+ async function resolveFromSesMail(
368
+ mail: Record<string, unknown>,
369
+ receipt: Record<string, unknown> | undefined,
370
+ ): Promise<InboundPayload> {
371
+ const senderEmail = extractSenderFromSesMail(mail);
372
+ const recipients = extractRecipientsFromSesMail(mail);
373
+ const receiptData = receipt && typeof receipt === "object" ? receipt : {};
374
+ const action = receiptData.action && typeof receiptData.action === "object" ? receiptData.action as Record<string, unknown> : {};
375
+ const s3Bucket = normalizeOptionalString(action.bucketName);
376
+ const s3Key = normalizeOptionalString(action.objectKey);
377
+ const sesMessageId = normalizeOptionalString(mail.messageId);
378
+
379
+ const headers = mail.commonHeaders && typeof mail.commonHeaders === "object"
380
+ ? (mail.commonHeaders as Record<string, unknown>)
381
+ : {};
382
+ const subject = normalizeOptionalString(
383
+ Array.isArray(headers.subject) ? headers.subject[0] : headers.subject,
384
+ ) ?? "(no subject)";
385
+
386
+ if (s3Bucket && s3Key) {
387
+ const loaded = await loadInboundFromS3Object(s3Bucket, s3Key, sesMessageId);
388
+ return {
389
+ ...loaded,
390
+ senderEmail: loaded.senderEmail || senderEmail,
391
+ recipients: loaded.recipients.length > 0 ? loaded.recipients : recipients,
392
+ subject: loaded.subject !== "(no subject)" ? loaded.subject : subject,
393
+ };
394
+ }
395
+
396
+ return enrichInboundPayload({
397
+ senderEmail,
398
+ recipients,
399
+ subject,
400
+ bodyText: "",
401
+ citations: [],
402
+ s3Bucket,
403
+ s3Key,
404
+ sesMessageId,
405
+ inReplyTo: null,
406
+ references: null,
407
+ attachmentCount: 0,
408
+ userComposedText: "",
409
+ parentSubmissionMessageId: null,
410
+ });
411
+ }
412
+
413
+ async function loadInboundFromS3Object(
414
+ bucket: string,
415
+ key: string,
416
+ sesMessageId: string | null,
417
+ ): Promise<InboundPayload> {
418
+ const rawObject = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
419
+ const rawBytes = await streamToBuffer(rawObject.Body);
420
+ const intake = parseInboundMimeForIntake(rawBytes);
421
+ const threading = parseInboundMimeThreading(rawBytes);
422
+ return enrichInboundPayload({
423
+ senderEmail: extractSenderFromRawMime(rawBytes),
424
+ recipients: extractRecipientsFromRawMime(rawBytes),
425
+ subject: intake.subject || "(no subject)",
426
+ bodyText: intake.bodyText,
427
+ citations: intake.citations as Array<Record<string, unknown>>,
428
+ s3Bucket: bucket,
429
+ s3Key: key,
430
+ sesMessageId,
431
+ inReplyTo: threading.inReplyTo,
432
+ references: threading.references,
433
+ attachmentCount: countInboundAttachments(rawBytes),
434
+ userComposedText: extractUserComposedReplyText(intake.bodyText),
435
+ parentSubmissionMessageId: null,
436
+ });
437
+ }
438
+
439
+ function enrichInboundPayload(payload: InboundPayload): InboundPayload {
440
+ const parentSubmissionMessageId = payload.parentSubmissionMessageId
441
+ ?? resolveParentSubmissionMessageId(payload.inReplyTo, payload.references);
442
+ return {
443
+ ...payload,
444
+ parentSubmissionMessageId,
445
+ userComposedText: payload.userComposedText || extractUserComposedReplyText(payload.bodyText),
446
+ };
447
+ }
448
+
449
+ function sesMessageIdFromObjectKey(key: string): string | null {
450
+ const basename = key.split("/").pop() ?? "";
451
+ const match = basename.match(/^[A-Za-z0-9_-]+$/);
452
+ return match ? basename : null;
453
+ }
454
+
455
+ function isConfiguredInboundAddress(address: string): boolean {
456
+ const normalized = normalizeEmailAddress(address);
457
+ if (!normalized.includes("@")) return false;
458
+ const [localPart, domain] = normalized.split("@", 2);
459
+ return domain === INBOUND_DOMAIN && INBOUND_LOCAL_PARTS.includes(localPart);
460
+ }
461
+
462
+ function parseLocalParts(value: string): string[] {
463
+ return value
464
+ .split(",")
465
+ .map((entry) => entry.trim().toLowerCase())
466
+ .filter(Boolean);
467
+ }
468
+
469
+ function normalizeOptionalString(value: unknown): string | null {
470
+ if (typeof value !== "string") return null;
471
+ const normalized = value.trim();
472
+ return normalized.length > 0 ? normalized : null;
473
+ }
474
+
475
+ async function streamToBuffer(body: unknown): Promise<Uint8Array> {
476
+ if (!body) return new Uint8Array();
477
+ if (body instanceof Uint8Array) return body;
478
+ if (typeof (body as { transformToByteArray?: () => Promise<Uint8Array> }).transformToByteArray === "function") {
479
+ return (body as { transformToByteArray: () => Promise<Uint8Array> }).transformToByteArray();
480
+ }
481
+ const chunks: Uint8Array[] = [];
482
+ for await (const chunk of body as AsyncIterable<Uint8Array>) {
483
+ chunks.push(chunk);
484
+ }
485
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
486
+ const merged = new Uint8Array(total);
487
+ let offset = 0;
488
+ for (const chunk of chunks) {
489
+ merged.set(chunk, offset);
490
+ offset += chunk.length;
491
+ }
492
+ return merged;
493
+ }
@@ -0,0 +1,8 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ export const sesInboundReceive = defineFunction({
3
+ name: "papyrus-ses-inbound-receive",
4
+ entry: "./handler.ts",
5
+ timeoutSeconds: 60,
6
+ memoryMB: 512,
7
+ resourceGroupName: "data",
8
+ });
@@ -0,0 +1,9 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+
3
+ export const sesInboundReceive = defineFunction({
4
+ name: "papyrus-ses-inbound-receive",
5
+ entry: "./handler.ts",
6
+ timeoutSeconds: 60,
7
+ memoryMB: 512,
8
+ resourceGroupName: "data",
9
+ });
@@ -0,0 +1,65 @@
1
+ import { GetParameterCommand, GetParametersCommand, SSMClient } from "@aws-sdk/client-ssm";
2
+
3
+ const secretCache = new Map<string, string>();
4
+ let ssmClient: SSMClient | null = null;
5
+
6
+ export function isAmplifySecretPlaceholder(value: string): boolean {
7
+ return /^<.*will be resolved.*>$/i.test(value.trim());
8
+ }
9
+
10
+ export async function resolveAmplifySecret(name: string): Promise<string> {
11
+ const cached = secretCache.get(name);
12
+ if (cached) return cached;
13
+
14
+ const direct = normalizeOptionalString(process.env[name]);
15
+ if (direct && !isAmplifySecretPlaceholder(direct)) {
16
+ secretCache.set(name, direct);
17
+ return direct;
18
+ }
19
+
20
+ const parameterName = resolveAmplifySsmSecretPath(name);
21
+ if (!parameterName) {
22
+ throw new Error(`${name} is not configured. Set it with "npx ampx secret set ${name}" for this branch.`);
23
+ }
24
+
25
+ ssmClient ??= new SSMClient({});
26
+ let resolved: string | null = null;
27
+ try {
28
+ const batch = await ssmClient.send(new GetParametersCommand({
29
+ Names: [parameterName],
30
+ WithDecryption: true,
31
+ }));
32
+ resolved = normalizeOptionalString(batch.Parameters?.[0]?.Value);
33
+ } catch {
34
+ // Fall back for sandboxes that only grant ssm:GetParameter on a path.
35
+ }
36
+ if (!resolved) {
37
+ const single = await ssmClient.send(new GetParameterCommand({
38
+ Name: parameterName,
39
+ WithDecryption: true,
40
+ }));
41
+ resolved = normalizeOptionalString(single.Parameter?.Value);
42
+ }
43
+ if (!resolved) {
44
+ throw new Error(`SSM parameter ${parameterName} did not return a value for ${name}.`);
45
+ }
46
+ secretCache.set(name, resolved);
47
+ return resolved;
48
+ }
49
+
50
+ function resolveAmplifySsmSecretPath(name: string): string | null {
51
+ const rawConfig = normalizeOptionalString(process.env.AMPLIFY_SSM_ENV_CONFIG);
52
+ if (!rawConfig) return null;
53
+ try {
54
+ const config = JSON.parse(rawConfig) as Record<string, { path?: string; sharedPath?: string } | undefined>;
55
+ return normalizeOptionalString(config[name]?.path) ?? normalizeOptionalString(config[name]?.sharedPath);
56
+ } catch {
57
+ throw new Error("AMPLIFY_SSM_ENV_CONFIG contains invalid JSON.");
58
+ }
59
+ }
60
+
61
+ function normalizeOptionalString(value: unknown): string | null {
62
+ if (typeof value !== "string") return null;
63
+ const trimmed = value.trim();
64
+ return trimmed ? trimmed : null;
65
+ }
@@ -0,0 +1,117 @@
1
+ import { LAMBDA_DATA_AUTH_MODE, type LambdaDataClient } from "./lambda-data-client";
2
+ import { putTextModelPayload } from "./model-payloads";
3
+
4
+ export const CONSOLE_MESSAGE_KIND = "console_chat_turn";
5
+ export const CONSOLE_MESSAGE_DOMAIN = "conversation";
6
+ export const CONSOLE_NEWSROOM_FEED_KEY = "consoleChat";
7
+ export const CONSOLE_THREAD_ANCHOR_KEY = "site#papyrus";
8
+
9
+ export type EnqueueConsoleChatTurnInput = {
10
+ threadId: string;
11
+ threadTitle: string;
12
+ threadSummary: string;
13
+ primaryAnchorKind: string;
14
+ primaryAnchorId: string;
15
+ createdByLabel: string;
16
+ prompt: string;
17
+ messageSummary: string;
18
+ source: string;
19
+ channel: string;
20
+ channelMetadata: Record<string, unknown>;
21
+ threadMetadata?: Record<string, unknown>;
22
+ responseTarget?: string;
23
+ captureModelContext?: boolean;
24
+ chatMessageId?: string;
25
+ };
26
+
27
+ export function defaultConsoleResponseTarget(): string {
28
+ return (process.env.PAPYRUS_CONSOLE_RESPONSE_TARGET ?? "cloud").trim() || "cloud";
29
+ }
30
+
31
+ export async function enqueueConsoleChatTurn(
32
+ dataClient: LambdaDataClient,
33
+ input: EnqueueConsoleChatTurnInput,
34
+ ): Promise<{ queued: true; threadId: string; chatMessageId: string; responseTarget: string }> {
35
+ const now = new Date().toISOString();
36
+ const responseTarget = (input.responseTarget ?? defaultConsoleResponseTarget()).trim() || "cloud";
37
+ const threadMetadata = { channel: input.channel, ...(input.threadMetadata ?? {}) };
38
+ const messageMetadata: Record<string, unknown> = {
39
+ channel: input.channel,
40
+ ...input.channelMetadata,
41
+ };
42
+ if (input.captureModelContext) {
43
+ messageMetadata.captureModelContext = true;
44
+ }
45
+
46
+ const existingThread = await dataClient.models.MessageThread.get(
47
+ { id: input.threadId },
48
+ { authMode: LAMBDA_DATA_AUTH_MODE },
49
+ );
50
+ if (!existingThread.data) {
51
+ const createThread = await dataClient.models.MessageThread.create(
52
+ {
53
+ id: input.threadId,
54
+ threadKind: "console",
55
+ status: "active",
56
+ title: input.threadTitle,
57
+ summary: input.threadSummary,
58
+ primaryAnchorKind: input.primaryAnchorKind,
59
+ primaryAnchorId: input.primaryAnchorId,
60
+ primaryAnchorLineageId: input.primaryAnchorId,
61
+ primaryAnchorKey: CONSOLE_THREAD_ANCHOR_KEY,
62
+ createdByLabel: input.createdByLabel,
63
+ messageCount: 0,
64
+ metadata: JSON.stringify(threadMetadata),
65
+ createdAt: now,
66
+ updatedAt: now,
67
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
68
+ } as never,
69
+ { authMode: LAMBDA_DATA_AUTH_MODE },
70
+ );
71
+ if (createThread.errors?.length) {
72
+ throw new Error(createThread.errors.map((entry) => entry?.message ?? String(entry)).join("; "));
73
+ }
74
+ }
75
+
76
+ const sequenceNumber = Number(existingThread.data?.messageCount ?? 0) + 1;
77
+ const chatMessageId = input.chatMessageId
78
+ ?? `message-console-${input.channel}-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
79
+
80
+ const createMessage = await dataClient.models.Message.create(
81
+ {
82
+ id: chatMessageId,
83
+ threadId: input.threadId,
84
+ sequenceNumber,
85
+ role: "USER",
86
+ messageKind: CONSOLE_MESSAGE_KIND,
87
+ messageDomain: CONSOLE_MESSAGE_DOMAIN,
88
+ messageType: "MESSAGE",
89
+ status: "active",
90
+ summary: input.messageSummary.slice(0, 180),
91
+ source: input.source,
92
+ authorLabel: input.createdByLabel,
93
+ semanticLayer: "working_memory",
94
+ searchVisibility: "private",
95
+ responseTarget,
96
+ responseStatus: "PENDING",
97
+ metadata: JSON.stringify(messageMetadata),
98
+ createdAt: now,
99
+ updatedAt: now,
100
+ newsroomFeedKey: CONSOLE_NEWSROOM_FEED_KEY,
101
+ } as never,
102
+ { authMode: LAMBDA_DATA_AUTH_MODE },
103
+ );
104
+ if (createMessage.errors?.length) {
105
+ throw new Error(createMessage.errors.map((entry) => entry?.message ?? String(entry)).join("; "));
106
+ }
107
+ await putTextModelPayload(
108
+ dataClient as any,
109
+ { ownerKind: "message", ownerId: chatMessageId, ownerLineageId: chatMessageId },
110
+ "message_body",
111
+ "message",
112
+ input.prompt.trim(),
113
+ { filename: "message.txt", mediaType: "text/plain", now },
114
+ );
115
+
116
+ return { queued: true, threadId: input.threadId, chatMessageId, responseTarget };
117
+ }