@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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,196 @@
1
+ import {
2
+ directCitationRationale,
3
+ extractDirectCitations,
4
+ parseInboundEmailBody,
5
+ titleFromUrl,
6
+ } from "./email-submission";
7
+
8
+ const HREF_PATTERN = /href\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/gi;
9
+ const SKIP_URL_MARKERS = [
10
+ "unsubscribe",
11
+ "list-unsubscribe",
12
+ "/unsubscribe",
13
+ "mailchi.mp/unsubscribe",
14
+ "/opt-out",
15
+ "preferences",
16
+ "email-preferences",
17
+ "doubleclick.net",
18
+ "facebook.com/sharer",
19
+ "twitter.com/intent",
20
+ "linkedin.com/sharing",
21
+ "fonts.googleapis.com",
22
+ ];
23
+ const SKIP_URL_PREFIXES = ["mailto:", "tel:", "javascript:", "cid:", "#"];
24
+
25
+ function decodeHtmlEntities(value: string): string {
26
+ return value
27
+ .replace(/&/gi, "&")
28
+ .replace(/&lt;/gi, "<")
29
+ .replace(/&gt;/gi, ">")
30
+ .replace(/&quot;/gi, '"')
31
+ .replace(/&#39;/g, "'");
32
+ }
33
+
34
+ export function normalizeHrefUrl(raw: string): string | null {
35
+ let url = decodeHtmlEntities(String(raw ?? "").trim());
36
+ if (!url) return null;
37
+ if (url.startsWith("//")) url = `https:${url}`;
38
+ if (url.startsWith("<") && url.endsWith(">")) url = url.slice(1, -1).trim();
39
+ const lowered = url.toLowerCase();
40
+ if (SKIP_URL_PREFIXES.some((prefix) => lowered.startsWith(prefix))) return null;
41
+ if (!/^https?:\/\//i.test(url)) return null;
42
+ return url.replace(/[.,);]+$/, "");
43
+ }
44
+
45
+ export function isSkippableNewsletterUrl(url: string): boolean {
46
+ const lowered = String(url ?? "").toLowerCase();
47
+ if (SKIP_URL_MARKERS.some((marker) => lowered.includes(marker))) return true;
48
+ if (/\.(png|jpe?g|gif|webp)(\?|$)/i.test(lowered)) return true;
49
+ return false;
50
+ }
51
+
52
+ export function extractHrefUrlsFromHtml(html: string): string[] {
53
+ const urls: string[] = [];
54
+ const seen = new Set<string>();
55
+ for (const match of html.matchAll(HREF_PATTERN)) {
56
+ const candidate = normalizeHrefUrl(match[1] ?? match[2] ?? match[3] ?? "");
57
+ if (!candidate || seen.has(candidate) || isSkippableNewsletterUrl(candidate)) continue;
58
+ seen.add(candidate);
59
+ urls.push(candidate);
60
+ }
61
+ return urls;
62
+ }
63
+
64
+ export function extractDirectCitationsFromIntake(input: {
65
+ bodyText: string;
66
+ htmlParts?: string[];
67
+ }): Array<{ kind: string; url: string; title: string; ingestion_rationale: string; doi?: string }> {
68
+ const citations = extractDirectCitations(input.bodyText);
69
+ const seen = new Set(citations.map((entry) => entry.url));
70
+ for (const htmlPart of input.htmlParts ?? []) {
71
+ for (const url of extractHrefUrlsFromHtml(htmlPart)) {
72
+ if (seen.has(url)) continue;
73
+ seen.add(url);
74
+ citations.push({
75
+ kind: "url",
76
+ url,
77
+ title: titleFromUrl(url),
78
+ ingestion_rationale: directCitationRationale(url),
79
+ });
80
+ }
81
+ }
82
+ return citations;
83
+ }
84
+
85
+ function decodeQuotedPrintable(value: string): string {
86
+ return value
87
+ .replace(/=\r?\n/g, "")
88
+ .replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
89
+ }
90
+
91
+ function decodePartBody(body: string, transferEncoding: string): string {
92
+ const encoding = transferEncoding.trim().toLowerCase();
93
+ if (encoding === "base64") {
94
+ return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
95
+ }
96
+ if (encoding === "quoted-printable") {
97
+ return decodeQuotedPrintable(body);
98
+ }
99
+ return body;
100
+ }
101
+
102
+ function htmlToText(html: string): string {
103
+ return html
104
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
105
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
106
+ .replace(/<[^>]+>/g, " ")
107
+ .replace(/\s+/g, " ")
108
+ .trim();
109
+ }
110
+
111
+ function collectMimeBodies(raw: string): { plain: string[]; html: string[]; subject: string } {
112
+ const subjectMatch = raw.match(/^Subject:\s*(.*)$/im);
113
+ const subject = subjectMatch?.[1]?.trim() ?? "";
114
+ const plain: string[] = [];
115
+ const html: string[] = [];
116
+ const partPattern =
117
+ /Content-Type:\s*(text\/(?:plain|html))[^\r\n]*(?:[\r\n]+(?![\r\n])[^\r\n]+)*[\r\n]+(?:Content-Transfer-Encoding:\s*([^\r\n]+)[\r\n]+)?[\r\n]+([\s\S]*?)(?=[\r\n]--[^\r\n]+[\r\n]|$)/gi;
118
+ let match: RegExpExecArray | null = partPattern.exec(raw);
119
+ while (match) {
120
+ const contentType = match[1]?.toLowerCase() ?? "";
121
+ const encoding = match[2] ?? "";
122
+ const body = decodePartBody(match[3] ?? "", encoding);
123
+ if (contentType === "text/plain" && body.trim()) plain.push(body.trim());
124
+ if (contentType === "text/html" && body.trim()) html.push(body.trim());
125
+ match = partPattern.exec(raw);
126
+ }
127
+ return { plain, html, subject };
128
+ }
129
+
130
+ function preprocessRawMimeForLinkScan(raw: string): string {
131
+ return raw
132
+ .replace(/=\r?\n/g, "")
133
+ .replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
134
+ }
135
+
136
+ function extractHrefUrlsFromRawMime(raw: string): string[] {
137
+ const decoded = preprocessRawMimeForLinkScan(raw);
138
+ const urls: string[] = [];
139
+ const seen = new Set<string>();
140
+ for (const match of decoded.matchAll(HREF_PATTERN)) {
141
+ const candidate = normalizeHrefUrl(match[1] ?? match[2] ?? match[3] ?? "");
142
+ if (!candidate || seen.has(candidate) || isSkippableNewsletterUrl(candidate)) continue;
143
+ seen.add(candidate);
144
+ urls.push(candidate);
145
+ }
146
+ return urls;
147
+ }
148
+
149
+ export function parseInboundMimeForIntake(rawBytes: Uint8Array): {
150
+ subject: string;
151
+ bodyText: string;
152
+ htmlParts: string[];
153
+ citations: Array<{ kind: string; url: string; title: string; ingestion_rationale: string; doi?: string }>;
154
+ } {
155
+ const raw = Buffer.from(rawBytes).toString("utf8");
156
+ let { plain, html, subject } = collectMimeBodies(raw);
157
+ let bodyText = plain.join("\n\n").trim();
158
+ if (!bodyText && html.length > 0) {
159
+ bodyText = htmlToText(html.join("\n\n"));
160
+ }
161
+ // Single-part messages (e.g. iPhone Mail) place Content-Type before other headers; the
162
+ // multipart-oriented partPattern can capture an empty or signature-only body. Prefer the
163
+ // robust parser when it finds URLs the part scanner missed.
164
+ const fallback = parseInboundEmailBody(rawBytes);
165
+ const primaryHasUrl = /https?:\/\//i.test(bodyText);
166
+ const fallbackHasUrl = /https?:\/\//i.test(fallback.text);
167
+ if (
168
+ fallback.text
169
+ && (!bodyText || (!primaryHasUrl && fallbackHasUrl))
170
+ ) {
171
+ bodyText = fallback.text;
172
+ }
173
+ if (!subject.trim() && fallback.subject) {
174
+ subject = fallback.subject;
175
+ }
176
+ const citations = extractDirectCitationsFromIntake({ bodyText, htmlParts: html });
177
+ if (citations.length === 0) {
178
+ const seen = new Set(citations.map((entry) => entry.url));
179
+ for (const url of extractHrefUrlsFromRawMime(raw)) {
180
+ if (seen.has(url)) continue;
181
+ seen.add(url);
182
+ citations.push({
183
+ kind: "url",
184
+ url,
185
+ title: titleFromUrl(url),
186
+ ingestion_rationale: directCitationRationale(url),
187
+ });
188
+ }
189
+ }
190
+ return {
191
+ subject: subject || "(no subject)",
192
+ bodyText,
193
+ htmlParts: html,
194
+ citations,
195
+ };
196
+ }
@@ -0,0 +1,415 @@
1
+ import { LAMBDA_DATA_AUTH_MODE, type LambdaDataClient } from "./lambda-data-client";
2
+
3
+ export const MESSAGE_KIND_EMAIL_SUBMISSION = "email_submission";
4
+ export const MESSAGE_DOMAIN_REFERENCE_INTAKE = "reference_intake";
5
+ export const MESSAGE_TYPE_INBOUND_EMAIL = "INBOUND_EMAIL";
6
+ export const RESPONSE_TARGET_EMAIL_PROCESSOR = "email_submission_processor";
7
+
8
+ /** Stored on rejected intake when the sender is not a registered Papyrus user. */
9
+ export const REJECTION_KIND_UNREGISTERED_SENDER = "unregistered_sender";
10
+
11
+ export const UNREGISTERED_SENDER_RESPONSE_ERROR =
12
+ "This submission was not accepted because only registered Papyrus users may send reference submissions by email.";
13
+
14
+ const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi;
15
+ const DOI_PATTERN = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi;
16
+ const RESEARCH_ASSIGNMENT_PHRASES = [
17
+ "research assignment",
18
+ "assignment to research",
19
+ "find sources on",
20
+ "research the topic",
21
+ "look into the topic",
22
+ "investigate whether",
23
+ ];
24
+
25
+ export function normalizeEmailAddress(value: string | null | undefined): string {
26
+ const raw = String(value ?? "").trim().toLowerCase();
27
+ if (!raw) return "";
28
+ const match = raw.match(/<([^>]+)>/);
29
+ return (match?.[1] ?? raw).trim().toLowerCase();
30
+ }
31
+
32
+ export function extractSenderFromSesMail(mail: Record<string, unknown>): string {
33
+ const headers = mail.commonHeaders && typeof mail.commonHeaders === "object"
34
+ ? (mail.commonHeaders as Record<string, unknown>)
35
+ : {};
36
+ const fromValues = Array.isArray(headers.from) ? headers.from : [];
37
+ if (fromValues.length > 0) return normalizeEmailAddress(String(fromValues[0]));
38
+ return normalizeEmailAddress(String(mail.source ?? ""));
39
+ }
40
+
41
+ export function extractSenderFromRawMime(rawBytes: Uint8Array): string {
42
+ const raw = Buffer.from(rawBytes).toString("utf8");
43
+ const fromMatch = raw.match(/^From:\s*(.*)$/im);
44
+ return normalizeEmailAddress(fromMatch?.[1] ?? "");
45
+ }
46
+
47
+ export function extractRecipientsFromRawMime(rawBytes: Uint8Array): string[] {
48
+ const raw = Buffer.from(rawBytes).toString("utf8");
49
+ const recipients: string[] = [];
50
+ for (const header of ["To", "Cc", "Delivered-To", "X-Original-To"]) {
51
+ const regex = new RegExp(`^${header}:\\s*(.*)$`, "gim");
52
+ let match: RegExpExecArray | null = regex.exec(raw);
53
+ while (match) {
54
+ for (const part of String(match[1]).split(",")) {
55
+ const normalized = normalizeEmailAddress(part);
56
+ if (normalized && !recipients.includes(normalized)) recipients.push(normalized);
57
+ }
58
+ match = regex.exec(raw);
59
+ }
60
+ }
61
+ return recipients;
62
+ }
63
+
64
+ export function extractRecipientsFromSesMail(mail: Record<string, unknown>): string[] {
65
+ const headers = mail.commonHeaders && typeof mail.commonHeaders === "object"
66
+ ? (mail.commonHeaders as Record<string, unknown>)
67
+ : {};
68
+ const recipients: string[] = [];
69
+ for (const key of ["to", "cc"] as const) {
70
+ const values = headers[key];
71
+ if (!Array.isArray(values)) continue;
72
+ for (const entry of values) {
73
+ const normalized = normalizeEmailAddress(String(entry));
74
+ if (normalized) recipients.push(normalized);
75
+ }
76
+ }
77
+ const destination = mail.destination;
78
+ if (Array.isArray(destination)) {
79
+ for (const entry of destination) {
80
+ const normalized = normalizeEmailAddress(String(entry));
81
+ if (normalized && !recipients.includes(normalized)) recipients.push(normalized);
82
+ }
83
+ }
84
+ return recipients;
85
+ }
86
+
87
+ const SUBMISSION_MESSAGE_ID_PATTERN = /^(message-email-submission-[a-f0-9]{20})(?:@|$)/i;
88
+
89
+ export function parseInboundMimeThreading(rawBytes: Uint8Array): { inReplyTo: string | null; references: string | null } {
90
+ const raw = Buffer.from(rawBytes).toString("utf8");
91
+ const inReplyTo = raw.match(/^In-Reply-To:\s*(.+)$/im)?.[1]?.trim() ?? null;
92
+ const references = raw.match(/^References:\s*(.+)$/im)?.[1]?.trim() ?? null;
93
+ return { inReplyTo, references };
94
+ }
95
+
96
+ export function parseSubmissionMessageIdFromRfcMessageId(value: string | null | undefined): string | null {
97
+ const raw = String(value ?? "").trim();
98
+ if (!raw) return null;
99
+ const tokens = raw.match(/<([^>]+)>/g)?.map((entry) => entry.slice(1, -1)) ?? [raw.replace(/^<|>$/g, "")];
100
+ for (const token of tokens) {
101
+ const match = SUBMISSION_MESSAGE_ID_PATTERN.exec(token.trim());
102
+ if (match) return match[1];
103
+ if (token.startsWith("message-email-submission-")) return token.split("@")[0];
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function resolveParentSubmissionMessageId(
109
+ inReplyTo: string | null,
110
+ references: string | null,
111
+ ): string | null {
112
+ for (const header of [inReplyTo, references]) {
113
+ const resolved = parseSubmissionMessageIdFromRfcMessageId(header);
114
+ if (resolved) return resolved;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ export function countInboundAttachments(rawBytes: Uint8Array): number {
120
+ const raw = Buffer.from(rawBytes).toString("utf8");
121
+ let count = 0;
122
+ const parts = raw.split(/\r?\n\r?\n/);
123
+ for (let index = 0; index < parts.length; index += 1) {
124
+ const headerBlock = parts[index];
125
+ if (!headerBlock || !/Content-Type:/i.test(headerBlock)) continue;
126
+ const disposition = headerBlock.match(/Content-Disposition:\s*([^\r\n;]+)/i)?.[1]?.trim().toLowerCase() ?? "";
127
+ const contentType = headerBlock.match(/Content-Type:\s*([^;\r\n]+)/i)?.[1]?.trim().toLowerCase() ?? "";
128
+ const filename = headerBlock.match(/(?:name|filename)=["']?([^"';\r\n]+)/i)?.[1]?.trim() ?? "";
129
+ const isPdf = contentType === "application/pdf" || filename.toLowerCase().endsWith(".pdf");
130
+ if (disposition === "attachment" || (disposition !== "inline" && isPdf && filename)) {
131
+ count += 1;
132
+ }
133
+ }
134
+ if (count > 0) return count;
135
+ const legacyMatches = raw.match(/Content-Disposition:\s*attachment/gi);
136
+ return legacyMatches?.length ?? 0;
137
+ }
138
+
139
+ const MIN_COMPOSED_PROSE_CHARS_FOR_AGENT = 120;
140
+
141
+ export function proseAfterStrippingCitations(
142
+ bodyText: string,
143
+ citations: Array<{ url?: string }>,
144
+ ): string {
145
+ let text = String(bodyText ?? "");
146
+ for (const citation of citations) {
147
+ const url = String(citation.url ?? "").trim();
148
+ if (url) text = text.split(url).join(" ");
149
+ }
150
+ text = text.replace(/https?:\/\/\S+/gi, " ");
151
+ return text.replace(/\s+/g, " ").trim();
152
+ }
153
+
154
+ export function hasSubstantialComposedProse(
155
+ bodyText: string,
156
+ citations: Array<{ url?: string }>,
157
+ ): boolean {
158
+ const prose = proseAfterStrippingCitations(bodyText, citations);
159
+ if (prose.length >= MIN_COMPOSED_PROSE_CHARS_FOR_AGENT) return true;
160
+ const clauses = prose.split(/[.!?\n]+/).map((entry) => entry.trim()).filter((entry) => entry.length >= 24);
161
+ return clauses.length >= 2;
162
+ }
163
+
164
+ export function classifyNewSubmissionIntake(
165
+ bodyText: string,
166
+ citations: Array<Record<string, unknown>>,
167
+ attachmentCount = 0,
168
+ ): string {
169
+ const hasPdfAttachments = attachmentCount > 0;
170
+ if (citations.length > 0 && hasPdfAttachments) return "agent_intake";
171
+ if (citations.length === 0 && hasPdfAttachments) {
172
+ if (attachmentCount > 1) return "agent_intake";
173
+ if (hasSubstantialComposedProse(bodyText, [])) return "agent_intake";
174
+ return "pdf_only_intake";
175
+ }
176
+ if (citations.length === 0) return "new_submission";
177
+ if (citations.length > 1) return "agent_intake";
178
+ if (hasSubstantialComposedProse(bodyText, citations as Array<{ url?: string }>)) return "agent_intake";
179
+ return "direct_citation_intake";
180
+ }
181
+
182
+ export function classifyInboundEmailIntake(input: {
183
+ bodyText: string;
184
+ citations: Array<Record<string, unknown>>;
185
+ parentSubmissionMessageId: string | null;
186
+ attachmentCount: number;
187
+ userComposedText: string;
188
+ }): string {
189
+ if (input.parentSubmissionMessageId) {
190
+ if (input.attachmentCount > 0 && !input.userComposedText) return "attachment_only_reply";
191
+ if (input.userComposedText || input.attachmentCount > 0) return "conversational_reply";
192
+ return "empty_reply";
193
+ }
194
+ return classifyNewSubmissionIntake(input.bodyText, input.citations, input.attachmentCount);
195
+ }
196
+
197
+ export function extractUserComposedReplyText(bodyText: string): string {
198
+ let text = String(bodyText ?? "").replace(/\r\n/g, "\n");
199
+ const markers = [
200
+ /\nOn .+ wrote:\s*\n/i,
201
+ /\n-{2,}\s*Original Message\s*-{2,}\s*\n/i,
202
+ /\nFrom:\s*.+\n/i,
203
+ ];
204
+ let earliest: number | null = null;
205
+ for (const marker of markers) {
206
+ const match = marker.exec(text);
207
+ if (match && (earliest === null || match.index < earliest)) earliest = match.index;
208
+ }
209
+ if (earliest !== null && earliest > 0) text = text.slice(0, earliest);
210
+ const lines: string[] = [];
211
+ for (const line of text.split("\n")) {
212
+ if (line.startsWith(">")) break;
213
+ lines.push(line);
214
+ }
215
+ return lines.join("\n").trim();
216
+ }
217
+
218
+ export function parseInboundEmailBody(rawBytes: Uint8Array): { subject: string; text: string } {
219
+ const raw = Buffer.from(rawBytes).toString("utf8");
220
+ const subjectMatch = raw.match(/^Subject:\s*(.*)$/im);
221
+ const subject = subjectMatch?.[1]?.trim() ?? "";
222
+ const plainMatch = raw.match(/Content-Type:\s*text\/plain[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--|$)/i);
223
+ let text = plainMatch?.[1]?.trim() ?? "";
224
+ const htmlMatch = !plainMatch
225
+ ? raw.match(/Content-Type:\s*text\/html[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--|$)/i)
226
+ : null;
227
+ if (!plainMatch && htmlMatch) {
228
+ text = htmlToText(htmlMatch[1] ?? "");
229
+ }
230
+ // For multipart MIME, do not fall back to the raw object (PDF parts may contain DOI-like tokens).
231
+ if (!plainMatch && !htmlMatch && !/Content-Type:\s*multipart\//i.test(raw)) {
232
+ const bodyIndex = raw.indexOf("\r\n\r\n");
233
+ text = bodyIndex >= 0 ? raw.slice(bodyIndex + 4).trim() : raw.trim();
234
+ }
235
+ return { subject, text };
236
+ }
237
+
238
+ function htmlToText(html: string): string {
239
+ return html
240
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
241
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
242
+ .replace(/<[^>]+>/g, " ")
243
+ .replace(/\s+/g, " ")
244
+ .trim();
245
+ }
246
+
247
+ export function extractDirectCitations(text: string): Array<{ kind: string; url: string; title: string; ingestion_rationale: string; doi?: string }> {
248
+ const citations: Array<{ kind: string; url: string; title: string; ingestion_rationale: string; doi?: string }> = [];
249
+ const seen = new Set<string>();
250
+ for (const match of text.matchAll(URL_PATTERN)) {
251
+ const url = match[0].replace(/[.,);]+$/, "");
252
+ if (seen.has(url)) continue;
253
+ seen.add(url);
254
+ citations.push({
255
+ kind: "url",
256
+ url,
257
+ title: titleFromUrl(url),
258
+ ingestion_rationale: directCitationRationale(url),
259
+ });
260
+ }
261
+ for (const match of text.matchAll(DOI_PATTERN)) {
262
+ const doi = match[0].replace(/[.,);]+$/, "");
263
+ const url = `https://doi.org/${doi}`;
264
+ if (seen.has(url)) continue;
265
+ seen.add(url);
266
+ citations.push({
267
+ kind: "doi",
268
+ url,
269
+ doi,
270
+ title: `DOI ${doi}`,
271
+ ingestion_rationale: directCitationRationale(url),
272
+ });
273
+ }
274
+ return citations;
275
+ }
276
+
277
+ export function looksLikeResearchAssignmentRequest(text: string, citationCount: number): boolean {
278
+ if (citationCount > 0) return false;
279
+ const lowered = text.toLowerCase();
280
+ return RESEARCH_ASSIGNMENT_PHRASES.some((phrase) => lowered.includes(phrase));
281
+ }
282
+
283
+ export async function lookupRegisteredUserProfileId(client: LambdaDataClient, senderEmail: string): Promise<string | null> {
284
+ const normalized = normalizeEmailAddress(senderEmail);
285
+ if (!normalized) return null;
286
+
287
+ const identityModel = client.models.UserIdentity as {
288
+ listUserIdentitiesByEmailAndStatus?: (
289
+ input: { email: string; status?: { eq?: string } },
290
+ options?: { limit?: number; authMode?: typeof LAMBDA_DATA_AUTH_MODE },
291
+ ) => Promise<{ data?: Array<{ userProfileId?: string | null; status?: string | null }> | null; errors?: unknown }>;
292
+ };
293
+ if (typeof identityModel.listUserIdentitiesByEmailAndStatus === "function") {
294
+ const response = await identityModel.listUserIdentitiesByEmailAndStatus(
295
+ { email: normalized, status: { eq: "active" } },
296
+ { limit: 10, authMode: LAMBDA_DATA_AUTH_MODE },
297
+ );
298
+ for (const identity of response.data ?? []) {
299
+ if (!identity) continue;
300
+ const profileId = String(identity.userProfileId ?? "").trim();
301
+ if (profileId) return profileId;
302
+ }
303
+ }
304
+
305
+ const profiles = await listAllRecords(client.models.UserProfile);
306
+ for (const profile of profiles) {
307
+ if (normalizeEmailAddress(String(profile.email ?? "")) === normalized) {
308
+ return String(profile.id ?? "").trim() || null;
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ async function listAllRecords(model: { list?: (options?: { limit?: number; nextToken?: string | null | undefined; authMode?: typeof LAMBDA_DATA_AUTH_MODE }) => Promise<{ data?: Array<Record<string, unknown>> | null; nextToken?: string | null }> }): Promise<Array<Record<string, unknown>>> {
315
+ const items: Array<Record<string, unknown>> = [];
316
+ let nextToken: string | null | undefined;
317
+ do {
318
+ const response = await model.list?.({
319
+ limit: 200,
320
+ nextToken: nextToken ?? undefined,
321
+ authMode: LAMBDA_DATA_AUTH_MODE,
322
+ });
323
+ items.push(...(response?.data ?? []));
324
+ nextToken = response?.nextToken;
325
+ } while (nextToken);
326
+ return items;
327
+ }
328
+
329
+ export function buildEmailSubmissionMessageInput(input: {
330
+ id: string;
331
+ now: string;
332
+ subject: string;
333
+ bodyText: string;
334
+ senderEmail: string;
335
+ recipientEmail: string;
336
+ sesMessageId: string | null;
337
+ s3Bucket: string | null;
338
+ s3Key: string | null;
339
+ authorized: boolean;
340
+ authorUserProfileId: string | null;
341
+ authorLabel: string;
342
+ citations: Array<Record<string, unknown>>;
343
+ status: string;
344
+ responseStatus: string;
345
+ responseError: string | null;
346
+ parentSubmissionMessageId?: string | null;
347
+ intakeClassification?: string | null;
348
+ inboundInReplyTo?: string | null;
349
+ inboundReferences?: string | null;
350
+ inboundAttachmentCount?: number;
351
+ }) {
352
+ const record: Record<string, unknown> = {
353
+ id: input.id,
354
+ messageKind: MESSAGE_KIND_EMAIL_SUBMISSION,
355
+ messageDomain: MESSAGE_DOMAIN_REFERENCE_INTAKE,
356
+ messageType: MESSAGE_TYPE_INBOUND_EMAIL,
357
+ status: input.status,
358
+ summary: input.subject || "Email submission",
359
+ source: "inbound-email",
360
+ authorLabel: input.authorLabel,
361
+ role: "submitter",
362
+ semanticLayer: "private",
363
+ searchVisibility: "private",
364
+ responseTarget: RESPONSE_TARGET_EMAIL_PROCESSOR,
365
+ responseStatus: input.responseStatus,
366
+ responseOwner: "papyrus-ses-inbound-receive",
367
+ responseStartedAt: input.responseStatus === "IN_PROGRESS" ? input.now : null,
368
+ responseCompletedAt: ["COMPLETED", "FAILED", "REJECTED"].includes(input.responseStatus) ? input.now : null,
369
+ responseError: input.responseError,
370
+ metadata: JSON.stringify({
371
+ channel: "email",
372
+ senderEmail: input.senderEmail,
373
+ recipientEmail: input.recipientEmail,
374
+ sesMessageId: input.sesMessageId,
375
+ s3Bucket: input.s3Bucket,
376
+ s3Key: input.s3Key,
377
+ authorized: input.authorized,
378
+ ...(input.authorized
379
+ ? {}
380
+ : {
381
+ rejectionKind: REJECTION_KIND_UNREGISTERED_SENDER,
382
+ }),
383
+ directCitationCount: input.citations.length,
384
+ directCitations: input.citations,
385
+ parentSubmissionMessageId: input.parentSubmissionMessageId ?? null,
386
+ intakeClassification: input.intakeClassification ?? null,
387
+ inboundInReplyTo: input.inboundInReplyTo ?? null,
388
+ inboundReferences: input.inboundReferences ?? null,
389
+ inboundAttachmentCount: input.inboundAttachmentCount ?? 0,
390
+ }),
391
+ createdAt: input.now,
392
+ updatedAt: input.now,
393
+ newsroomFeedKey: "submissions",
394
+ };
395
+ if (input.authorUserProfileId) {
396
+ record.authorUserProfileId = input.authorUserProfileId;
397
+ }
398
+ return record;
399
+ }
400
+
401
+ export function titleFromUrl(url: string): string {
402
+ try {
403
+ const parsed = new URL(url);
404
+ const host = parsed.hostname.replace(/^www\./, "");
405
+ const segment = parsed.pathname.split("/").filter(Boolean).pop() ?? "";
406
+ const cleaned = segment.replace(/[-_]+/g, " ").trim();
407
+ return cleaned ? cleaned.slice(0, 120) : host;
408
+ } catch {
409
+ return url;
410
+ }
411
+ }
412
+
413
+ export function directCitationRationale(source: string): string {
414
+ return `Direct citation submitted by email: ${source}. This is explicit source material for reference create/find/process intake, not a research assignment.`;
415
+ }
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const INBOUND_EMAIL_INTAKE_PREFIX = "inbound-email/";
4
+ export const INBOUND_EMAIL_ARCHIVE_PREFIX = "inbound-email-archived/";
5
+
6
+ export function shouldProcessInboundS3Key(key: string | null | undefined): boolean {
7
+ const normalized = (key ?? "").trim();
8
+ if (!normalized.startsWith(INBOUND_EMAIL_INTAKE_PREFIX)) return false;
9
+ if (normalized.startsWith(INBOUND_EMAIL_ARCHIVE_PREFIX)) return false;
10
+ const basename = normalized.split("/").pop() ?? "";
11
+ if (!basename || basename === "AMAZON_SES_SETUP_NOTIFICATION") return false;
12
+ const remainder = normalized.slice(INBOUND_EMAIL_INTAKE_PREFIX.length);
13
+ if (remainder.includes("/")) return false;
14
+ return true;
15
+ }
16
+
17
+ export function inboundMessageIdFromS3(bucket: string, key: string): string {
18
+ const digest = createHash("sha256").update(`${bucket}/${key}`, "utf8").digest("hex").slice(0, 20);
19
+ return `message-email-submission-${digest}`;
20
+ }
21
+
22
+ export function parseMessageMetadata(raw: unknown): Record<string, unknown> {
23
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
24
+ return raw as Record<string, unknown>;
25
+ }
26
+ if (typeof raw === "string" && raw.trim()) {
27
+ try {
28
+ const parsed = JSON.parse(raw) as unknown;
29
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
30
+ return parsed as Record<string, unknown>;
31
+ }
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+ return {};
37
+ }
@@ -0,0 +1,21 @@
1
+ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtime";
2
+ import { Amplify } from "aws-amplify";
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { Schema } from "../../data/resource";
5
+
6
+ export const LAMBDA_DATA_AUTH_MODE = "iam" as const;
7
+
8
+ export type LambdaDataClient = ReturnType<typeof generateClient<Schema>>;
9
+
10
+ let clientPromise: Promise<LambdaDataClient> | null = null;
11
+
12
+ export async function getLambdaDataClient(): Promise<LambdaDataClient> {
13
+ if (!clientPromise) {
14
+ clientPromise = (async () => {
15
+ const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
16
+ Amplify.configure(resourceConfig, libraryOptions);
17
+ return generateClient<Schema>();
18
+ })();
19
+ }
20
+ return clientPromise;
21
+ }