@anthusai/papyrus 1.0.0-next.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/auth/prospectus-google-client.js +66 -0
- package/amplify/auth/prospectus-google-client.ts +71 -0
- package/amplify/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +23 -5
- package/amplify/auth/resource.ts +31 -5
- package/amplify/data/resource.js +42 -80
- package/amplify/data/resource.ts +42 -80
- package/amplify/data/schema.js +45 -62
- package/amplify/data/schema.ts +47 -62
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +143 -1
- package/amplify/functions/content-actions/requirements.txt +2 -1
- package/amplify/functions/content-actions/resource.js +6 -1
- package/amplify/functions/content-actions/resource.ts +9 -2
- package/amplify/functions/email-submission-processor/handler.py +3 -1
- package/amplify/functions/renditions/handler.py +120 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
- package/amplify/functions/shared/email-submission.ts +109 -1
- package/amplify/functions/shared/python-bundle.js +42 -2
- package/amplify/functions/shared/python-bundle.ts +47 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/plugins/registry.js +19 -0
- package/amplify/plugins/registry.ts +23 -0
- package/amplify/plugins/signup/function/handler.py +200 -0
- package/amplify/plugins/signup/function/resource.js +27 -0
- package/amplify/plugins/signup/function/resource.ts +35 -0
- package/amplify/plugins/signup/papyrus.plugin.js +59 -0
- package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
- package/amplify/plugins/signup/schema.js +40 -0
- package/amplify/plugins/signup/schema.ts +41 -0
- package/amplify/plugins/signup/waf.js +48 -0
- package/amplify/plugins/signup/waf.ts +51 -0
- package/amplify/plugins/types.js +1 -0
- package/amplify/plugins/types.ts +39 -0
- package/amplify/plugins/wire.js +24 -0
- package/amplify/plugins/wire.ts +26 -0
- package/amplify/publication/default-publication.js +18 -0
- package/amplify/publication/default-publication.ts +20 -0
- package/amplify/publication/invalidation-alarm.js +26 -0
- package/amplify/publication/invalidation-alarm.ts +30 -0
- package/amplify/publication/redirect-and-index.js +37 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +51 -0
- package/amplify/publication/renditions-settings.ts +58 -0
- package/amplify/publication/reproject-on-deploy.js +73 -0
- package/amplify/publication/reproject-on-deploy.ts +78 -0
- package/amplify/publication/s3-projection-settings.js +67 -0
- package/amplify/publication/s3-projection-settings.ts +101 -0
- package/amplify/publication/s3-projection.js +151 -0
- package/amplify/publication/s3-projection.ts +201 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/revalidate-invalidation-settings.js +30 -0
- package/amplify/revalidate-invalidation-settings.ts +35 -0
- package/amplify/revalidate-secret-policy.js +13 -0
- package/amplify/revalidate-secret-policy.ts +19 -0
- package/amplify/site-backend-config.js +65 -1
- package/amplify/site-backend-config.ts +153 -2
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +150 -176
- package/amplify/site-backend.ts +189 -201
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
- package/app/[year]/[month]/[day]/page.tsx +1 -1
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
- package/app/api/archive/editions/route.ts +1 -1
- package/app/api/auth/prospectus/callback/route.ts +9 -0
- package/app/api/auth/prospectus/selection/route.ts +9 -0
- package/app/api/auth/prospectus/session/route.ts +9 -0
- package/app/api/auth/prospectus/sign-in/route.ts +9 -0
- package/app/api/auth/prospectus/sign-out/route.ts +9 -0
- package/app/api/cyclotron/priming-proof/route.ts +11 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +15 -7
- package/app/archive/page.tsx +1 -1
- package/app/articles/[slug]/page.tsx +7 -5
- package/app/blog-rhythm.css +278 -0
- package/app/cyclotron/page.tsx +13 -0
- package/app/globals.css +2 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
- package/app/newsroom/[section]/page.tsx +6 -3
- package/app/newsroom/layout.tsx +4 -9
- package/app/newsroom/settings/page.tsx +6 -0
- package/app/newsroom/work/page.tsx +10 -0
- package/app/page.tsx +22 -8
- package/app/robots.ts +2 -5
- package/app/tailwind.css +1 -6
- package/components/amplify-client-provider.tsx +5 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +152 -5
- package/components/blog-page-background.tsx +23 -6
- package/components/news-desk-page.tsx +4 -15
- package/components/news-desk-taxonomy-client.ts +90 -3
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +2 -1
- package/components/newsroom-board-view.tsx +99 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-curation-provider.tsx +17 -0
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
- package/components/newsroom-newsprint-board.css +48 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +7 -1
- package/components/newsroom-organizer-shell.tsx +130 -0
- package/components/newsroom-organizer.css +63 -0
- package/components/newsroom-references-view.tsx +92 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/priming-proof-page.tsx +94 -0
- package/components/publication-cyclotron-panel.tsx +57 -0
- package/components/reader-auth-control.tsx +8 -5
- package/components/reference-curation-controls.tsx +77 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-priming.tsx +18 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/signup-entries-panel.tsx +94 -0
- package/components/topic-steering-workspace.tsx +78 -8
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-near-viewport.ts +30 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +166 -27
- package/infra/amplify-app-shell.ts +180 -31
- package/infra/build-specs.js +23 -7
- package/infra/build-specs.ts +27 -10
- package/infra/site-config.js +248 -36
- package/infra/site-config.ts +313 -51
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/amplify-server-runtime.ts +36 -1
- package/lib/approved-priming-proof-reports.ts +6 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +43 -93
- package/lib/content-item-lifecycle.ts +55 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-types.ts +4 -0
- package/lib/curation-client.ts +61 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +56 -22
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-board.ts +99 -0
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-nav.ts +3 -1
- package/lib/newsroom-organizer-nav.ts +37 -0
- package/lib/newsroom-section-routing.ts +20 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/plugin-settings.ts +11 -0
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/priming-proof-report.ts +90 -0
- package/lib/prospectus-publication-access.ts +53 -0
- package/lib/prospectus-session.ts +196 -0
- package/lib/prospectus-sign-in-routes.ts +284 -0
- package/lib/prospectus-site-identity.ts +303 -0
- package/lib/publication-cyclotron.ts +74 -0
- package/lib/rate-limit-retry.ts +55 -0
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-cache-lifetime.ts +5 -0
- package/lib/reader-static-params.ts +85 -4
- package/lib/reader-storage-url.ts +9 -12
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-prewarm.ts +53 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/shared-credentials-provider.ts +61 -0
- package/lib/signup-csv.ts +24 -0
- package/lib/site-brand.ts +59 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +21 -5
- package/lib/test-editor-override.ts +4 -0
- package/lib/video-loading.ts +22 -0
- package/lib/video-script.ts +14 -0
- package/middleware.ts +11 -19
- package/package.json +73 -8
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +123 -12
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.d.mts +9 -0
- package/src/with-papyrus.mjs +48 -3
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/components/ai-elements/message.tsx +0 -360
|
@@ -4,8 +4,17 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
|
|
7
8
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
8
9
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
10
|
+
from papyrus_content.projection.deploy_render import deploy_render, drift_metric_line
|
|
11
|
+
from papyrus_content.projection.engine import project, project_all, project_item_pages
|
|
12
|
+
from papyrus_content.projection.runtime import (
|
|
13
|
+
DEAD_LETTER_QUEUE_ENV,
|
|
14
|
+
context_from_environment,
|
|
15
|
+
projection_configured,
|
|
16
|
+
public_item_url,
|
|
17
|
+
)
|
|
9
18
|
from papyrus_content.rebuild_trigger import (
|
|
10
19
|
DEFAULT_STAGING_BRANCH,
|
|
11
20
|
reader_target_from_environment,
|
|
@@ -56,6 +65,127 @@ def derive_markus_action(arguments: dict) -> dict:
|
|
|
56
65
|
return response
|
|
57
66
|
|
|
58
67
|
|
|
68
|
+
PROJECTION_TASK_KEY = "projectionTask"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProjectionTaskFailed(Exception):
|
|
72
|
+
"""Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
|
|
76
|
+
queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
|
|
77
|
+
if not queue_url:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
import boto3
|
|
81
|
+
|
|
82
|
+
boto3.client("sqs").send_message(
|
|
83
|
+
QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
|
|
84
|
+
)
|
|
85
|
+
except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def schedule_projection_task(task: dict, error: str) -> bool:
|
|
90
|
+
"""Run a projection task later, in this same function, as an asynchronous invocation.
|
|
91
|
+
|
|
92
|
+
The function has an on-failure destination (the dead letter queue), so a task
|
|
93
|
+
that keeps failing ends up there for ``papyrus ops content project --reconcile``.
|
|
94
|
+
"""
|
|
95
|
+
function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
|
|
96
|
+
if not function_name:
|
|
97
|
+
send_to_projection_dead_letter_queue(task, error)
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
import boto3
|
|
101
|
+
|
|
102
|
+
boto3.client("lambda").invoke(
|
|
103
|
+
FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
|
|
104
|
+
)
|
|
105
|
+
return True
|
|
106
|
+
except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
|
|
107
|
+
send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def projection_context(client):
|
|
112
|
+
context = context_from_environment(client)
|
|
113
|
+
context.defer_follow_up = True
|
|
114
|
+
return context
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
118
|
+
if not projection_configured():
|
|
119
|
+
return {}
|
|
120
|
+
liveness = {} if unpublished else {"live": False}
|
|
121
|
+
task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
|
|
122
|
+
try:
|
|
123
|
+
report = project(projection_context(client), item_id, unpublished=unpublished)
|
|
124
|
+
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
125
|
+
message = f"{type(error).__name__}: {error}"
|
|
126
|
+
schedule_projection_task(task, message)
|
|
127
|
+
return {**liveness, "projected": False, "paths": [], "error": message}
|
|
128
|
+
response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
|
|
129
|
+
if not unpublished:
|
|
130
|
+
response["live"] = report.ok
|
|
131
|
+
url = public_item_url(os.environ, report.item_paths)
|
|
132
|
+
if url:
|
|
133
|
+
response["url"] = url
|
|
134
|
+
if not report.ok:
|
|
135
|
+
message = "; ".join(report.errors)
|
|
136
|
+
schedule_projection_task(task, message)
|
|
137
|
+
response["error"] = message
|
|
138
|
+
if report.unpurged:
|
|
139
|
+
response["edgePurged"] = False
|
|
140
|
+
schedule_unpurged_invalidation(report.unpurged)
|
|
141
|
+
if report.follow_up_item_pages:
|
|
142
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def schedule_unpurged_invalidation(keys: list[str]) -> None:
|
|
147
|
+
schedule_projection_task({"task": "invalidate", "keys": keys}, "edge invalidation failed")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_deploy_render_task(client) -> dict:
|
|
151
|
+
"""Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
|
|
152
|
+
context = projection_context(client)
|
|
153
|
+
report = deploy_render(context)
|
|
154
|
+
print(drift_metric_line(report))
|
|
155
|
+
if report.render.unpurged:
|
|
156
|
+
schedule_unpurged_invalidation(report.render.unpurged)
|
|
157
|
+
if not report.ok:
|
|
158
|
+
raise ProjectionTaskFailed("; ".join([*report.render.errors, *report.drift.errors]))
|
|
159
|
+
return report.to_dict()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_projection_task(task: dict) -> dict:
|
|
163
|
+
"""An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
|
|
164
|
+
client, _claims = create_authoring_client()
|
|
165
|
+
if task.get("task") == "item-pages":
|
|
166
|
+
report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
|
|
167
|
+
elif task.get("task") == "item":
|
|
168
|
+
report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
|
|
169
|
+
if report.follow_up_item_pages:
|
|
170
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
|
|
171
|
+
elif task.get("task") == "all":
|
|
172
|
+
report = project_all(projection_context(client))
|
|
173
|
+
elif task.get("task") == "deploy-render":
|
|
174
|
+
return run_deploy_render_task(client)
|
|
175
|
+
elif task.get("task") == "invalidate":
|
|
176
|
+
invalidate = projection_context(client).invalidate
|
|
177
|
+
if invalidate is None:
|
|
178
|
+
return {"ok": True, "paths": []}
|
|
179
|
+
return {"ok": True, "paths": invalidate(list(task["keys"]))}
|
|
180
|
+
else:
|
|
181
|
+
raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
|
|
182
|
+
if report.unpurged:
|
|
183
|
+
schedule_unpurged_invalidation(report.unpurged)
|
|
184
|
+
if not report.ok:
|
|
185
|
+
raise ProjectionTaskFailed("; ".join(report.errors))
|
|
186
|
+
return {"ok": True, "paths": [*report.written, *report.deleted]}
|
|
187
|
+
|
|
188
|
+
|
|
59
189
|
def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
60
190
|
client, _claims = create_authoring_client()
|
|
61
191
|
fields = ItemFields(
|
|
@@ -68,7 +198,7 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
68
198
|
id=arguments.get("id"),
|
|
69
199
|
)
|
|
70
200
|
record = save_item(client, fields, actor=actor, expected_content_hash=arguments.get("expectedContentHash"))
|
|
71
|
-
|
|
201
|
+
response = {
|
|
72
202
|
"ok": True,
|
|
73
203
|
"item": {
|
|
74
204
|
"id": record["id"],
|
|
@@ -78,6 +208,11 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
78
208
|
"versionNumber": record.get("versionNumber"),
|
|
79
209
|
},
|
|
80
210
|
}
|
|
211
|
+
if record["status"] == "published" and projection_configured():
|
|
212
|
+
published = publish_item(client, record["id"], actor=actor)
|
|
213
|
+
response["item"]["versionNumber"] = published.version_number
|
|
214
|
+
response.update(project_item_action(client, record["id"], unpublished=False))
|
|
215
|
+
return response
|
|
81
216
|
|
|
82
217
|
|
|
83
218
|
def site_triggers_for(client, item_id: str) -> dict:
|
|
@@ -90,6 +225,9 @@ def site_triggers_for(client, item_id: str) -> dict:
|
|
|
90
225
|
slugs = [item["slug"]] if item.get("slug") else []
|
|
91
226
|
revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
|
|
92
227
|
triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
|
|
228
|
+
distribution_ids = distribution_ids_from_environment(os.environ)
|
|
229
|
+
if distribution_ids and revalidated is not None:
|
|
230
|
+
triggers["edgeInvalidated"] = invalidate_revalidated_paths(distribution_ids, revalidated)
|
|
93
231
|
except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
|
|
94
232
|
triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
|
|
95
233
|
if reader_app_id:
|
|
@@ -108,6 +246,7 @@ def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
108
246
|
}
|
|
109
247
|
if result.changed:
|
|
110
248
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
249
|
+
response.update(project_item_action(client, arguments["id"], unpublished=False))
|
|
111
250
|
return response
|
|
112
251
|
|
|
113
252
|
|
|
@@ -117,6 +256,7 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
|
|
|
117
256
|
response = {"ok": True, "changed": result.changed}
|
|
118
257
|
if result.changed:
|
|
119
258
|
response.update(site_triggers_for(client, arguments["id"]))
|
|
259
|
+
response.update(project_item_action(client, arguments["id"], unpublished=True))
|
|
120
260
|
return response
|
|
121
261
|
|
|
122
262
|
|
|
@@ -130,6 +270,8 @@ def request_staging_build_action() -> dict:
|
|
|
130
270
|
|
|
131
271
|
|
|
132
272
|
def handler(event, context):
|
|
273
|
+
if PROJECTION_TASK_KEY in event:
|
|
274
|
+
return run_projection_task(event[PROJECTION_TASK_KEY])
|
|
133
275
|
field = event["fieldName"]
|
|
134
276
|
arguments = parse_input(event["arguments"]["input"])
|
|
135
277
|
actor = actor_from_event(event)
|
|
@@ -7,11 +7,15 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { pythonLambdaCode } from "../shared/python-bundle.js";
|
|
8
8
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
10
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
10
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
11
11
|
function installCheckoutRequirements(outputDir) {
|
|
12
12
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
13
13
|
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
14
14
|
}
|
|
15
|
+
let siteFilesForNextContentActionsBundle;
|
|
16
|
+
export function setSiteFilesForContentActionsBundle(siteFiles) {
|
|
17
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
18
|
+
}
|
|
15
19
|
export const contentActions = defineFunction((scope) => {
|
|
16
20
|
return new Function(scope, "papyrus-content-actions", {
|
|
17
21
|
runtime: Runtime.PYTHON_3_12,
|
|
@@ -25,6 +29,7 @@ export const contentActions = defineFunction((scope) => {
|
|
|
25
29
|
requirements: contentActionsRequirements,
|
|
26
30
|
noDeps: true,
|
|
27
31
|
checkoutExtras: installCheckoutRequirements,
|
|
32
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
28
33
|
}, [
|
|
29
34
|
"bash",
|
|
30
35
|
"-c",
|
|
@@ -5,12 +5,12 @@ import { Construct } from "constructs";
|
|
|
5
5
|
import { execSync } from "node:child_process";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { pythonLambdaCode } from "../shared/python-bundle";
|
|
8
|
+
import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
|
|
9
9
|
|
|
10
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
12
12
|
|
|
13
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
13
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
14
14
|
|
|
15
15
|
function installCheckoutRequirements(outputDir: string): void {
|
|
16
16
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
@@ -20,6 +20,12 @@ function installCheckoutRequirements(outputDir: string): void {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
let siteFilesForNextContentActionsBundle: SiteBundleFile[] | undefined;
|
|
24
|
+
|
|
25
|
+
export function setSiteFilesForContentActionsBundle(siteFiles: SiteBundleFile[] | undefined): void {
|
|
26
|
+
siteFilesForNextContentActionsBundle = siteFiles;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export const contentActions = defineFunction(
|
|
24
30
|
(scope: Construct) => {
|
|
25
31
|
return new Function(scope, "papyrus-content-actions", {
|
|
@@ -35,6 +41,7 @@ export const contentActions = defineFunction(
|
|
|
35
41
|
requirements: contentActionsRequirements,
|
|
36
42
|
noDeps: true,
|
|
37
43
|
checkoutExtras: installCheckoutRequirements,
|
|
44
|
+
...(siteFilesForNextContentActionsBundle !== undefined ? { siteFiles: siteFilesForNextContentActionsBundle } : {}),
|
|
38
45
|
},
|
|
39
46
|
[
|
|
40
47
|
"bash",
|
|
@@ -30,8 +30,10 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
|
|
|
30
30
|
event.get("corpusKey")
|
|
31
31
|
or event.get("corpus_key")
|
|
32
32
|
or os.environ.get("PAPYRUS_INBOUND_EMAIL_CORPUS_KEY")
|
|
33
|
-
or "
|
|
33
|
+
or ""
|
|
34
34
|
).strip()
|
|
35
|
+
if not corpus_key:
|
|
36
|
+
raise ValueError("corpusKey or PAPYRUS_INBOUND_EMAIL_CORPUS_KEY is required.")
|
|
35
37
|
|
|
36
38
|
from papyrus_content.graphql_authoring import PapyrusGraphQLAuthoringClient
|
|
37
39
|
from papyrus_newsroom.email_submissions import process_email_submission_message
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
|
|
10
|
+
from papyrus_content.renditions import (
|
|
11
|
+
RenditionSettings,
|
|
12
|
+
S3RenditionStore,
|
|
13
|
+
make_renditions,
|
|
14
|
+
media_originals_enabled,
|
|
15
|
+
meta_key,
|
|
16
|
+
original_site_key,
|
|
17
|
+
ORIGINAL_CACHE_CONTROL,
|
|
18
|
+
settings_from_static_directory,
|
|
19
|
+
source_from_media_key,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_loaded: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configured_settings() -> RenditionSettings | None:
|
|
26
|
+
if "settings" not in _loaded:
|
|
27
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
28
|
+
_loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
|
|
29
|
+
return _loaded["settings"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def originals_enabled() -> bool:
|
|
33
|
+
if "originals" not in _loaded:
|
|
34
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
35
|
+
_loaded["originals"] = media_originals_enabled(Path(static_directory) if static_directory else None)
|
|
36
|
+
return _loaded["originals"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def site_store() -> S3RenditionStore:
|
|
40
|
+
if "store" not in _loaded:
|
|
41
|
+
_loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
|
|
42
|
+
return _loaded["store"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def s3_client():
|
|
46
|
+
if "s3" not in _loaded:
|
|
47
|
+
import boto3
|
|
48
|
+
|
|
49
|
+
_loaded["s3"] = boto3.client("s3")
|
|
50
|
+
return _loaded["s3"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def media_objects(event: dict) -> list[tuple[str, str]]:
|
|
54
|
+
if event.get("source") == "aws.s3" and "detail" in event:
|
|
55
|
+
detail = event["detail"]
|
|
56
|
+
return [(detail["bucket"]["name"], detail["object"]["key"])]
|
|
57
|
+
if "bucket" in event and "key" in event:
|
|
58
|
+
return [(event["bucket"], event["key"])]
|
|
59
|
+
raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_media_object(
|
|
63
|
+
settings: RenditionSettings | None,
|
|
64
|
+
bucket: str,
|
|
65
|
+
key: str,
|
|
66
|
+
*,
|
|
67
|
+
originals: bool = False,
|
|
68
|
+
skip_existing: bool = False,
|
|
69
|
+
dry_run: bool = False,
|
|
70
|
+
) -> dict:
|
|
71
|
+
source = source_from_media_key(key) if settings is not None else None
|
|
72
|
+
original_key = original_site_key(key) if originals else None
|
|
73
|
+
if source is None and original_key is None:
|
|
74
|
+
return {"key": key, "action": "ignored", "reason": "not an image under media/"}
|
|
75
|
+
if skip_existing:
|
|
76
|
+
renditions_exist = source is None or site_store().get(meta_key(settings.pipeline, source)) is not None
|
|
77
|
+
original_exists = original_key is None or site_store().get(original_key) is not None
|
|
78
|
+
if renditions_exist and original_exists:
|
|
79
|
+
return {"key": key, "source": source or original_key, "action": "skipped-existing"}
|
|
80
|
+
if dry_run:
|
|
81
|
+
return {"key": key, "source": source or original_key, "action": "would-render" if source else "would-copy"}
|
|
82
|
+
started = time.perf_counter()
|
|
83
|
+
data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
84
|
+
downloaded = time.perf_counter()
|
|
85
|
+
if original_key is not None:
|
|
86
|
+
site_store().put(original_key, data, cache_control=ORIGINAL_CACHE_CONTROL)
|
|
87
|
+
if source is None:
|
|
88
|
+
return {"key": key, "source": original_key, "action": "copied", "written": 1, "sourceBytes": len(data)}
|
|
89
|
+
result = make_renditions(settings, site_store(), source, data)
|
|
90
|
+
finished = time.perf_counter()
|
|
91
|
+
return {
|
|
92
|
+
"key": key,
|
|
93
|
+
"source": source,
|
|
94
|
+
"action": "rendered",
|
|
95
|
+
"kind": result["kind"],
|
|
96
|
+
"written": result["written"] + (1 if original_key is not None else 0),
|
|
97
|
+
"sourceBytes": len(data),
|
|
98
|
+
"downloadMilliseconds": round((downloaded - started) * 1000),
|
|
99
|
+
"renditionMilliseconds": round((finished - downloaded) * 1000),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def handler(event, context):
|
|
104
|
+
settings = configured_settings()
|
|
105
|
+
originals = originals_enabled()
|
|
106
|
+
if settings is None and not originals:
|
|
107
|
+
return {"ok": True, "results": [], "skipped": "this site has no images or media settings in projection.json"}
|
|
108
|
+
results = [
|
|
109
|
+
render_media_object(
|
|
110
|
+
settings,
|
|
111
|
+
bucket,
|
|
112
|
+
key,
|
|
113
|
+
originals=originals,
|
|
114
|
+
skip_existing=event.get("skipExisting") is True,
|
|
115
|
+
dry_run=event.get("dryRun") is True,
|
|
116
|
+
)
|
|
117
|
+
for bucket, key in media_objects(event)
|
|
118
|
+
]
|
|
119
|
+
print(json.dumps({"renditions": results}))
|
|
120
|
+
return {"ok": True, "results": results}
|
|
@@ -2,6 +2,13 @@ import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
|
|
|
2
2
|
import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
|
3
3
|
import {
|
|
4
4
|
buildEmailSubmissionMessageInput,
|
|
5
|
+
isInboundSenderAuthenticated,
|
|
6
|
+
readInboundSenderAuthenticationFromRawMime,
|
|
7
|
+
readInboundScanVerdictsFromSesReceipt,
|
|
8
|
+
REJECTION_KIND_UNAUTHENTICATED_SENDER,
|
|
9
|
+
UNAUTHENTICATED_SENDER_RESPONSE_ERROR,
|
|
10
|
+
UNVERIFIED_SENDER_AUTHENTICATION,
|
|
11
|
+
type InboundSenderAuthentication,
|
|
5
12
|
classifyInboundEmailIntake,
|
|
6
13
|
countInboundAttachments,
|
|
7
14
|
extractDirectCitations,
|
|
@@ -49,6 +56,7 @@ type InboundPayload = {
|
|
|
49
56
|
attachmentCount: number;
|
|
50
57
|
userComposedText: string;
|
|
51
58
|
parentSubmissionMessageId: string | null;
|
|
59
|
+
senderAuthentication: InboundSenderAuthentication;
|
|
52
60
|
};
|
|
53
61
|
|
|
54
62
|
type ExistingMessage = {
|
|
@@ -123,7 +131,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
|
|
|
123
131
|
const resolvedMessageId = messageId ?? `message-email-submission-${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
|
124
132
|
const dataClient = await getLambdaDataClient();
|
|
125
133
|
const profileId = await lookupRegisteredUserProfileId(dataClient, inbound.senderEmail);
|
|
126
|
-
const
|
|
134
|
+
const senderAuthenticated = isInboundSenderAuthenticated(inbound.senderAuthentication, inbound.senderEmail);
|
|
135
|
+
const authorized = Boolean(profileId) && senderAuthenticated;
|
|
127
136
|
const citations = authorized
|
|
128
137
|
? (inbound.citations.length > 0
|
|
129
138
|
? inbound.citations
|
|
@@ -143,7 +152,9 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
|
|
|
143
152
|
|
|
144
153
|
let status = authorized ? "received" : "rejected";
|
|
145
154
|
let responseStatus = authorized ? "PENDING" : "REJECTED";
|
|
146
|
-
let responseError: string | null = authorized
|
|
155
|
+
let responseError: string | null = authorized
|
|
156
|
+
? null
|
|
157
|
+
: (senderAuthenticated ? UNREGISTERED_SENDER_RESPONSE_ERROR : UNAUTHENTICATED_SENDER_RESPONSE_ERROR);
|
|
147
158
|
|
|
148
159
|
if (authorized && looksLikeResearchAssignmentRequest(inbound.bodyText, citations.length)) {
|
|
149
160
|
status = "rejected";
|
|
@@ -173,7 +184,8 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
|
|
|
173
184
|
s3Bucket: inbound.s3Bucket,
|
|
174
185
|
s3Key: inbound.s3Key,
|
|
175
186
|
authorized,
|
|
176
|
-
|
|
187
|
+
rejectionKind: senderAuthenticated ? undefined : REJECTION_KIND_UNAUTHENTICATED_SENDER,
|
|
188
|
+
authorUserProfileId: authorized ? profileId : null,
|
|
177
189
|
authorLabel: inbound.senderEmail || "unknown-sender",
|
|
178
190
|
citations,
|
|
179
191
|
status,
|
|
@@ -205,7 +217,7 @@ async function processInboundSubmission(inbound: InboundPayload): Promise<Record
|
|
|
205
217
|
|
|
206
218
|
if (authorized && responseStatus === "PENDING" && PROCESSOR_FUNCTION_NAME) {
|
|
207
219
|
await invokeEmailProcessor(resolvedMessageId);
|
|
208
|
-
} else if (responseStatus === "REJECTED" && PROCESSOR_FUNCTION_NAME) {
|
|
220
|
+
} else if (responseStatus === "REJECTED" && senderAuthenticated && PROCESSOR_FUNCTION_NAME) {
|
|
209
221
|
await invokeEmailProcessor(resolvedMessageId, { sendFeedbackOnly: true });
|
|
210
222
|
}
|
|
211
223
|
|
|
@@ -390,6 +402,7 @@ async function resolveFromSesMail(
|
|
|
390
402
|
senderEmail: loaded.senderEmail || senderEmail,
|
|
391
403
|
recipients: loaded.recipients.length > 0 ? loaded.recipients : recipients,
|
|
392
404
|
subject: loaded.subject !== "(no subject)" ? loaded.subject : subject,
|
|
405
|
+
senderAuthentication: mergeScanVerdicts(loaded.senderAuthentication, readInboundScanVerdictsFromSesReceipt(receipt)),
|
|
393
406
|
};
|
|
394
407
|
}
|
|
395
408
|
|
|
@@ -407,6 +420,7 @@ async function resolveFromSesMail(
|
|
|
407
420
|
attachmentCount: 0,
|
|
408
421
|
userComposedText: "",
|
|
409
422
|
parentSubmissionMessageId: null,
|
|
423
|
+
senderAuthentication: { ...UNVERIFIED_SENDER_AUTHENTICATION, ...readInboundScanVerdictsFromSesReceipt(receipt) },
|
|
410
424
|
});
|
|
411
425
|
}
|
|
412
426
|
|
|
@@ -433,6 +447,7 @@ async function loadInboundFromS3Object(
|
|
|
433
447
|
attachmentCount: countInboundAttachments(rawBytes),
|
|
434
448
|
userComposedText: extractUserComposedReplyText(intake.bodyText),
|
|
435
449
|
parentSubmissionMessageId: null,
|
|
450
|
+
senderAuthentication: readInboundSenderAuthenticationFromRawMime(rawBytes),
|
|
436
451
|
});
|
|
437
452
|
}
|
|
438
453
|
|
|
@@ -446,6 +461,19 @@ function enrichInboundPayload(payload: InboundPayload): InboundPayload {
|
|
|
446
461
|
};
|
|
447
462
|
}
|
|
448
463
|
|
|
464
|
+
function mergeScanVerdicts(
|
|
465
|
+
authentication: InboundSenderAuthentication,
|
|
466
|
+
receiptVerdicts: Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict">,
|
|
467
|
+
): InboundSenderAuthentication {
|
|
468
|
+
const worst = (fromMime: string, fromReceipt: string) =>
|
|
469
|
+
fromMime === "fail" || fromReceipt === "fail" ? "fail" : fromMime !== "unknown" ? fromMime : fromReceipt;
|
|
470
|
+
return {
|
|
471
|
+
...authentication,
|
|
472
|
+
spamVerdict: worst(authentication.spamVerdict, receiptVerdicts.spamVerdict),
|
|
473
|
+
virusVerdict: worst(authentication.virusVerdict, receiptVerdicts.virusVerdict),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
function sesMessageIdFromObjectKey(key: string): string | null {
|
|
450
478
|
const basename = key.split("/").pop() ?? "";
|
|
451
479
|
const match = basename.match(/^[A-Za-z0-9_-]+$/);
|
|
@@ -11,6 +11,113 @@ export const REJECTION_KIND_UNREGISTERED_SENDER = "unregistered_sender";
|
|
|
11
11
|
export const UNREGISTERED_SENDER_RESPONSE_ERROR =
|
|
12
12
|
"This submission was not accepted because only registered Papyrus users may send reference submissions by email.";
|
|
13
13
|
|
|
14
|
+
export const UNAUTHENTICATED_SENDER_RESPONSE_ERROR =
|
|
15
|
+
"This submission was not accepted because the sender could not be authenticated (SPF or DKIM must pass).";
|
|
16
|
+
export const REJECTION_KIND_UNAUTHENTICATED_SENDER = "unauthenticated_sender";
|
|
17
|
+
|
|
18
|
+
export type InboundSenderAuthentication = {
|
|
19
|
+
spfPassDomains: string[];
|
|
20
|
+
dkimPassDomains: string[];
|
|
21
|
+
spamVerdict: string;
|
|
22
|
+
virusVerdict: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID = "amazonses.com";
|
|
26
|
+
|
|
27
|
+
export const UNVERIFIED_SENDER_AUTHENTICATION: InboundSenderAuthentication = {
|
|
28
|
+
spfPassDomains: [],
|
|
29
|
+
dkimPassDomains: [],
|
|
30
|
+
spamVerdict: "unknown",
|
|
31
|
+
virusVerdict: "unknown",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function domainOfAddress(address: string): string {
|
|
35
|
+
return normalizeEmailAddress(address).split("@")[1] ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function domainsAreAligned(authenticatedDomain: string, fromDomain: string): boolean {
|
|
39
|
+
if (!authenticatedDomain || !fromDomain) return false;
|
|
40
|
+
return authenticatedDomain === fromDomain
|
|
41
|
+
|| fromDomain.endsWith(`.${authenticatedDomain}`)
|
|
42
|
+
|| authenticatedDomain.endsWith(`.${fromDomain}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isInboundSenderAuthenticated(
|
|
46
|
+
authentication: InboundSenderAuthentication,
|
|
47
|
+
fromAddress: string,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (authentication.spamVerdict === "fail" || authentication.virusVerdict === "fail") return false;
|
|
50
|
+
const fromDomain = domainOfAddress(fromAddress);
|
|
51
|
+
return [...authentication.spfPassDomains, ...authentication.dkimPassDomains]
|
|
52
|
+
.some((domain) => domainsAreAligned(domain, fromDomain));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readInboundScanVerdictsFromSesReceipt(
|
|
56
|
+
receipt: Record<string, unknown> | undefined,
|
|
57
|
+
): Pick<InboundSenderAuthentication, "spamVerdict" | "virusVerdict"> {
|
|
58
|
+
const verdictStatus = (key: string): string => {
|
|
59
|
+
const verdict = receipt?.[key];
|
|
60
|
+
const status = verdict && typeof verdict === "object" ? (verdict as { status?: unknown }).status : null;
|
|
61
|
+
return typeof status === "string" ? status.trim().toLowerCase() : "unknown";
|
|
62
|
+
};
|
|
63
|
+
return { spamVerdict: verdictStatus("spamVerdict"), virusVerdict: verdictStatus("virusVerdict") };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readHeaderBlock(rawBytes: Uint8Array): string[] {
|
|
67
|
+
const raw = Buffer.from(rawBytes).toString("utf8");
|
|
68
|
+
const headerBlockEnd = raw.search(/\r?\n\r?\n/);
|
|
69
|
+
const headerBlock = headerBlockEnd === -1 ? raw : raw.slice(0, headerBlockEnd);
|
|
70
|
+
return headerBlock.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function firstHeaderValue(headerLines: string[], name: string): string | null {
|
|
74
|
+
const prefix = new RegExp(`^${name}:\\s*`, "i");
|
|
75
|
+
const line = headerLines.find((entry) => prefix.test(entry));
|
|
76
|
+
return line ? line.replace(prefix, "") : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readInboundSenderAuthenticationFromRawMime(rawBytes: Uint8Array): InboundSenderAuthentication {
|
|
80
|
+
const headerLines = readHeaderBlock(rawBytes);
|
|
81
|
+
const verdictHeader = (name: string): string =>
|
|
82
|
+
(firstHeaderValue(headerLines, name) ?? "unknown").trim().toLowerCase();
|
|
83
|
+
const scanVerdicts = {
|
|
84
|
+
spamVerdict: verdictHeader("X-SES-Spam-Verdict"),
|
|
85
|
+
virusVerdict: verdictHeader("X-SES-Virus-Verdict"),
|
|
86
|
+
};
|
|
87
|
+
const topmostResults = firstHeaderValue(headerLines, "Authentication-Results");
|
|
88
|
+
if (!topmostResults) return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
|
|
89
|
+
const [serverId, ...parts] = topmostResults.replace(/\([^)]*\)/g, "").split(";").map((part) => part.trim());
|
|
90
|
+
if (serverId.toLowerCase() !== TRUSTED_AUTHENTICATION_RESULTS_SERVER_ID) {
|
|
91
|
+
return { ...UNVERIFIED_SENDER_AUTHENTICATION, ...scanVerdicts };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const spfPassDomains: string[] = [];
|
|
95
|
+
const dkimPassDomains: string[] = [];
|
|
96
|
+
let spfPassed = false;
|
|
97
|
+
let envelopeFromDomain = "";
|
|
98
|
+
let heloDomain = "";
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
const result = /^(spf|dkim)\s*=\s*([a-z]+)/i.exec(part);
|
|
101
|
+
if (result) {
|
|
102
|
+
const passed = result[2].toLowerCase() === "pass";
|
|
103
|
+
if (result[1].toLowerCase() === "spf") spfPassed = spfPassed || passed;
|
|
104
|
+
else if (passed) {
|
|
105
|
+
const signingDomain = /header\.(?:i|d)\s*=\s*@?([^\s;]+)/i.exec(part)?.[1];
|
|
106
|
+
if (signingDomain) dkimPassDomains.push(signingDomain.toLowerCase().replace(/^.*@/, ""));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const envelopeFrom = /^(?:envelope-from|smtp\.mailfrom)\s*=\s*(\S+)/i.exec(part)?.[1];
|
|
110
|
+
if (envelopeFrom) envelopeFromDomain = domainOfAddress(envelopeFrom.replace(/^<|>$/g, ""));
|
|
111
|
+
const helo = /^(?:helo|smtp\.helo)\s*=\s*(\S+)/i.exec(part)?.[1];
|
|
112
|
+
if (helo) heloDomain = helo.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
if (spfPassed) {
|
|
115
|
+
const spfDomain = envelopeFromDomain || heloDomain;
|
|
116
|
+
if (spfDomain) spfPassDomains.push(spfDomain);
|
|
117
|
+
}
|
|
118
|
+
return { spfPassDomains, dkimPassDomains, ...scanVerdicts };
|
|
119
|
+
}
|
|
120
|
+
|
|
14
121
|
const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
15
122
|
const DOI_PATTERN = /\b10\.\d{4,9}\/[-._;()/:A-Z0-9]+/gi;
|
|
16
123
|
const RESEARCH_ASSIGNMENT_PHRASES = [
|
|
@@ -337,6 +444,7 @@ export function buildEmailSubmissionMessageInput(input: {
|
|
|
337
444
|
s3Bucket: string | null;
|
|
338
445
|
s3Key: string | null;
|
|
339
446
|
authorized: boolean;
|
|
447
|
+
rejectionKind?: string;
|
|
340
448
|
authorUserProfileId: string | null;
|
|
341
449
|
authorLabel: string;
|
|
342
450
|
citations: Array<Record<string, unknown>>;
|
|
@@ -378,7 +486,7 @@ export function buildEmailSubmissionMessageInput(input: {
|
|
|
378
486
|
...(input.authorized
|
|
379
487
|
? {}
|
|
380
488
|
: {
|
|
381
|
-
rejectionKind: REJECTION_KIND_UNREGISTERED_SENDER,
|
|
489
|
+
rejectionKind: input.rejectionKind ?? REJECTION_KIND_UNREGISTERED_SENDER,
|
|
382
490
|
}),
|
|
383
491
|
directCitationCount: input.citations.length,
|
|
384
492
|
directCitations: input.citations,
|