@agent-native/core 0.141.3 → 0.141.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +1 -1
  3. package/corpus/templates/analytics/actions/gong-calls.ts +56 -18
  4. package/corpus/templates/analytics/changelog/2026-08-05-analytics-queries-keep-every-table-tenant-scoped.md +6 -0
  5. package/corpus/templates/analytics/changelog/2026-08-05-fixed-sign-in-and-dashboard-requests-failing-when-background.md +6 -0
  6. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +6 -0
  7. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  8. package/corpus/templates/analytics/server/lib/dashboard-report.ts +2 -0
  9. package/corpus/templates/analytics/server/lib/emails.ts +51 -0
  10. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +8 -12
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -0
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +262 -13
  13. package/corpus/templates/analytics/server/lib/gong.ts +132 -33
  14. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  15. package/corpus/templates/analytics/server/plugins/auth.ts +21 -2
  16. package/corpus/templates/analytics/server/plugins/db.ts +11 -3
  17. package/corpus/templates/analytics/server/plugins/transactional-emails.ts +5 -0
  18. package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +17 -7
  19. package/corpus/templates/calendar/server/lib/booking-emails.ts +154 -70
  20. package/corpus/templates/calendar/server/lib/emails.ts +167 -0
  21. package/corpus/templates/calendar/server/lib/event-guest-notifications.ts +64 -28
  22. package/corpus/templates/calendar/server/plugins/transactional-emails.ts +5 -0
  23. package/corpus/templates/clips/actions/invite-member.ts +39 -16
  24. package/corpus/templates/clips/app/components/player/share-dialog.tsx +73 -1
  25. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  26. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -25
  27. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  28. package/corpus/templates/clips/changelog/2026-08-05-activity-is-now-the-default-clips-recording-tab-and-ai-setup.md +6 -0
  29. package/corpus/templates/clips/changelog/2026-08-05-clips-can-copy-a-linked-email-preview-for-public-recordings.md +6 -0
  30. package/corpus/templates/clips/changelog/2026-08-05-preparing-recording-now-uses-a-clean-shadow-free-status-card.md +6 -0
  31. package/corpus/templates/clips/desktop/src/styles.css +0 -3
  32. package/corpus/templates/clips/server/lib/emails.ts +256 -0
  33. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +24 -0
  34. package/corpus/templates/clips/server/plugins/transactional-email-catalog.ts +5 -0
  35. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +4 -1
  36. package/corpus/templates/clips/shared/email-preview.ts +61 -0
  37. package/corpus/templates/content/server/lib/comment-notifications.ts +57 -26
  38. package/corpus/templates/content/server/lib/emails.ts +70 -0
  39. package/corpus/templates/content/server/plugins/transactional-emails.ts +5 -0
  40. package/corpus/templates/design/actions/import-design-tokens.ts +11 -8
  41. package/corpus/templates/design/actions/index-design-tokens.ts +13 -6
  42. package/corpus/templates/design/changelog/2026-08-05-design-token-imports-classify-typography-dimensions-correctly.md +6 -0
  43. package/corpus/templates/dispatch/app/routes/transactional-email.$appId.$id.tsx +4 -0
  44. package/corpus/templates/dispatch/app/routes/transactional-email.tsx +4 -0
  45. package/corpus/templates/dispatch/changelog/2026-08-05-transactional-email-catalogs-and-delivery-activity-are-available-again.md +6 -0
  46. package/corpus/templates/factory/AGENTS.md +18 -17
  47. package/corpus/templates/factory/actions/get-factory-automation-health.ts +50 -0
  48. package/corpus/templates/factory/actions/get-triage-config.ts +8 -0
  49. package/corpus/templates/factory/actions/save-triage-config.ts +26 -0
  50. package/corpus/templates/factory/app/i18n/en-US.ts +27 -0
  51. package/corpus/templates/factory/app/routes/factory.tsx +376 -206
  52. package/corpus/templates/factory/changelog/2026-08-05-factory-now-keeps-production-automations-running-shows-sched.md +6 -0
  53. package/corpus/templates/factory/server/db/schema.ts +6 -0
  54. package/corpus/templates/factory/server/plugins/factory-migrations.ts +10 -0
  55. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +141 -2
  56. package/corpus/templates/forms/app/pages/FormsListPage.tsx +41 -21
  57. package/corpus/templates/forms/changelog/2026-08-05-form-links-can-now-be-cmd-clicked-to-open-in-a-new-tab.md +6 -0
  58. package/corpus/templates/forms/server/lib/emails.ts +52 -0
  59. package/corpus/templates/forms/server/lib/response-email.ts +2 -0
  60. package/corpus/templates/forms/server/plugins/transactional-emails.ts +5 -0
  61. package/corpus/templates/plan/actions/request-plan-access.ts +33 -12
  62. package/corpus/templates/plan/server/lib/comment-notifications.ts +53 -27
  63. package/corpus/templates/plan/server/lib/emails.ts +67 -0
  64. package/corpus/templates/plan/server/plugins/transactional-emails.ts +5 -0
  65. package/corpus/templates/slides/actions/import-from-url.ts +1 -0
  66. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +492 -381
  67. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +8 -7
  68. package/corpus/templates/slides/app/components/layout/Layout.tsx +27 -23
  69. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +313 -0
  70. package/corpus/templates/slides/app/components/ui/command.tsx +1 -0
  71. package/corpus/templates/slides/app/i18n/en-US.ts +8 -2
  72. package/corpus/templates/slides/app/lib/create-deck-generation.ts +305 -0
  73. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -12
  74. package/corpus/templates/slides/app/pages/Index.tsx +127 -137
  75. package/corpus/templates/slides/app/root.tsx +8 -0
  76. package/corpus/templates/slides/changelog/2026-08-05-deck-browsing-keeps-all-and-mine-in-the-header-with-a-tighte.md +6 -0
  77. package/corpus/templates/slides/changelog/2026-08-05-new-deck-creation-now-flows-through-a-clean-full-screen-refe.md +6 -0
  78. package/corpus/templates/slides/changelog/2026-08-05-website-imports-now-include-linked-stylesheets-for-more-accu.md +6 -0
  79. package/corpus/templates/slides/server/lib/comment-notifications.ts +44 -21
  80. package/corpus/templates/slides/server/lib/emails.ts +40 -0
  81. package/corpus/templates/slides/server/plugins/transactional-emails.ts +5 -0
  82. package/dist/brand-kit/index.d.ts +1 -0
  83. package/dist/brand-kit/index.js +1 -0
  84. package/dist/brand-kit/tokens.d.ts +91 -0
  85. package/dist/brand-kit/tokens.js +279 -0
  86. package/dist/brand-kit/types.d.ts +25 -0
  87. package/dist/client/AssistantChat.js +7 -7
  88. package/dist/client/onboarding/FirstRunOnboarding.js +26 -2
  89. package/dist/client/onboarding/first-run-registry.d.ts +25 -0
  90. package/dist/client/onboarding/first-run-registry.js +18 -0
  91. package/dist/client/onboarding/index.d.ts +2 -0
  92. package/dist/client/onboarding/index.js +1 -0
  93. package/dist/collab/routes.d.ts +1 -1
  94. package/dist/deploy/build.d.ts +8 -0
  95. package/dist/deploy/build.js +92 -3
  96. package/dist/deploy/workspace-deploy.js +86 -3
  97. package/dist/email-catalog/actions/list-email-activity.d.ts +5 -0
  98. package/dist/email-catalog/actions/list-email-activity.js +19 -0
  99. package/dist/email-catalog/actions/list-email-engagement.d.ts +5 -0
  100. package/dist/email-catalog/actions/list-email-engagement.js +15 -0
  101. package/dist/email-catalog/actions/list-email-log.d.ts +7 -0
  102. package/dist/email-catalog/actions/list-email-log.js +23 -0
  103. package/dist/email-catalog/actions/list-transactional-emails.d.ts +22 -0
  104. package/dist/email-catalog/actions/list-transactional-emails.js +67 -0
  105. package/dist/email-catalog/actions/render-transactional-email-preview.d.ts +9 -0
  106. package/dist/email-catalog/actions/render-transactional-email-preview.js +25 -0
  107. package/dist/email-catalog/authorize.d.ts +1 -0
  108. package/dist/email-catalog/authorize.js +9 -0
  109. package/dist/email-catalog/log.d.ts +60 -0
  110. package/dist/email-catalog/log.js +142 -0
  111. package/dist/email-catalog/provider-metrics.d.ts +34 -0
  112. package/dist/email-catalog/provider-metrics.js +182 -0
  113. package/dist/email-catalog/registry.d.ts +78 -0
  114. package/dist/email-catalog/registry.js +70 -0
  115. package/dist/email-catalog/schema.d.ts +231 -0
  116. package/dist/email-catalog/schema.js +53 -0
  117. package/dist/email-catalog/system-emails.d.ts +13 -0
  118. package/dist/email-catalog/system-emails.js +67 -0
  119. package/dist/jobs/index.d.ts +2 -0
  120. package/dist/jobs/index.js +2 -0
  121. package/dist/jobs/run-history.js +48 -1
  122. package/dist/jobs/scheduler-dispatch.d.ts +7 -0
  123. package/dist/jobs/scheduler-dispatch.js +7 -0
  124. package/dist/jobs/scheduler-health.d.ts +22 -0
  125. package/dist/jobs/scheduler-health.js +172 -0
  126. package/dist/jobs/scheduler.js +67 -2
  127. package/dist/localization/default-messages.d.ts +45 -0
  128. package/dist/localization/default-messages.js +45 -0
  129. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  130. package/dist/notifications/routes.d.ts +3 -3
  131. package/dist/observability/routes.d.ts +5 -5
  132. package/dist/org/handlers.js +9 -1
  133. package/dist/progress/routes.d.ts +1 -1
  134. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  135. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  136. package/dist/secrets/routes.d.ts +9 -9
  137. package/dist/server/action-discovery.js +22 -0
  138. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +7 -1
  139. package/dist/server/agent-chat/recurring-jobs-runtime.js +12 -0
  140. package/dist/server/agent-chat-plugin.d.ts +2 -1
  141. package/dist/server/agent-chat-plugin.js +39 -1
  142. package/dist/server/better-auth-instance.js +17 -2
  143. package/dist/server/design-token-utils.d.ts +6 -1
  144. package/dist/server/design-token-utils.js +252 -59
  145. package/dist/server/email.d.ts +11 -0
  146. package/dist/server/email.js +54 -3
  147. package/dist/server/index.d.ts +1 -0
  148. package/dist/server/index.js +1 -0
  149. package/dist/server/realtime-token.d.ts +1 -1
  150. package/dist/server/transcribe-voice.d.ts +1 -1
  151. package/dist/vite/client.js +20 -14
  152. package/package.json +2 -1
@@ -50,3 +50,15 @@ export function shouldDisableRecurringJobsRuntime(env = process.env) {
50
50
  }
51
51
  return isLocalRuntime;
52
52
  }
53
+ /**
54
+ * Hosted Netlify deploys get a durable scheduled sweep emitted by the build.
55
+ * The in-process timer must stay off there: a scale-to-zero recycle destroys
56
+ * that timer, which is exactly the failure mode the emitted sweep fixes.
57
+ */
58
+ export function isNetlifyRecurringJobsRuntime(env = process.env) {
59
+ if (env.NETLIFY_LOCAL === "true")
60
+ return false;
61
+ if (env.NETLIFY === "false")
62
+ return false;
63
+ return Boolean((env.NETLIFY && env.NETLIFY !== "false") || env.SITE_ID);
64
+ }
@@ -8,7 +8,7 @@ import { _agentChatPromptSectionsForTests } from "./agent-chat/framework-prompts
8
8
  import { type AgentChatPluginOptions, type NitroPluginDef } from "./agent-chat/plugin-options.js";
9
9
  import { finalizeClaimedAgentChatProcessRunFailure } from "./agent-chat/process-run-failure.js";
10
10
  import { loadResourcesForPrompt } from "./agent-chat/prompt-resources.js";
11
- import { shouldDisableRecurringJobsRuntime } from "./agent-chat/recurring-jobs-runtime.js";
11
+ import { isNetlifyRecurringJobsRuntime, shouldDisableRecurringJobsRuntime } from "./agent-chat/recurring-jobs-runtime.js";
12
12
  import { shouldBlockInProductCodeEditingSurface } from "./agent-chat/request-surface.js";
13
13
  import { loadRunCodeToolEntries } from "./agent-chat/run-code-tools.js";
14
14
  export { loadResourcesForPrompt };
@@ -22,6 +22,7 @@ export { createA2AEngineToolSurface };
22
22
  export { DEFAULT_DELEGATED_MAX_ITERATIONS, DEFAULT_DELEGATED_MAX_RUN_INPUT_TOKENS, DEFAULT_DELEGATED_MAX_TOOL_RESULT_CHARS, };
23
23
  export { shouldBlockInProductCodeEditingSurface };
24
24
  export { loadRunCodeToolEntries };
25
+ export { isNetlifyRecurringJobsRuntime };
25
26
  export { shouldDisableRecurringJobsRuntime };
26
27
  export { finalizeClaimedAgentChatProcessRunFailure };
27
28
  export declare function resolveInteractiveAgentRunOptions(options?: Pick<AgentChatPluginOptions, "runSoftTimeoutMs" | "runNoProgressTimeoutMs" | "durableBackgroundRuns">): {
@@ -25,6 +25,7 @@ import { isCheckpointRestorePath } from "../checkpoints/route-match.js";
25
25
  import { createDbAdminAgentTools } from "../db-admin/agent-tools.js";
26
26
  import { isTransientDatabaseError } from "../db/client.js";
27
27
  import { verifyInternalToken, extractBearerToken, } from "../integrations/internal-token.js";
28
+ import { RECURRING_JOBS_SWEEP_PATH, RECURRING_JOBS_SWEEP_TOKEN_SUBJECT, } from "../jobs/scheduler-dispatch.js";
28
29
  import { McpClientManager, loadMcpConfig, autoDetectMcpConfig, mcpToolsToActionEntries, syncMcpActionEntries, mountMcpServersRoutes, mountMcpHubRoutes, buildMergedConfig, startMcpConfigRefresh, getHubStatus, isHubServeEnabled, } from "../mcp-client/index.js";
29
30
  import { setProgressPreListHook } from "../progress/store.js";
30
31
  import { getSkillNameFromPath } from "../resources/metadata.js";
@@ -109,7 +110,7 @@ import { _agentChatPromptSectionsForTests, buildFrameworkPrompts, buildSchemaBlo
109
110
  import { resolveA2AAgentDelegationEnabled, } from "./agent-chat/plugin-options.js";
110
111
  import { finalizeClaimedAgentChatProcessRunFailure } from "./agent-chat/process-run-failure.js";
111
112
  import { loadResourcesForPrompt, promptResourceManifestSections, resourceScopeForOwner, } from "./agent-chat/prompt-resources.js";
112
- import { shouldDisableRecurringJobsRuntime } from "./agent-chat/recurring-jobs-runtime.js";
113
+ import { isNetlifyRecurringJobsRuntime, shouldDisableRecurringJobsRuntime, } from "./agent-chat/recurring-jobs-runtime.js";
113
114
  import { isLocalhost, shouldBlockInProductCodeEditingSurface, } from "./agent-chat/request-surface.js";
114
115
  import { loadRunCodeToolEntries } from "./agent-chat/run-code-tools.js";
115
116
  import { createAgentEngineScriptEntries, createAgentLoopSettingsScriptEntries, createCallAgentScriptEntry, createChatScriptEntries, createDbScriptEntries, createDocsScriptEntries, createResourceScriptEntries, } from "./agent-chat/script-entries.js";
@@ -124,6 +125,7 @@ export { createA2AEngineToolSurface };
124
125
  export { DEFAULT_DELEGATED_MAX_ITERATIONS, DEFAULT_DELEGATED_MAX_RUN_INPUT_TOKENS, DEFAULT_DELEGATED_MAX_TOOL_RESULT_CHARS, };
125
126
  export { shouldBlockInProductCodeEditingSurface };
126
127
  export { loadRunCodeToolEntries };
128
+ export { isNetlifyRecurringJobsRuntime };
127
129
  export { shouldDisableRecurringJobsRuntime };
128
130
  export { finalizeClaimedAgentChatProcessRunFailure };
129
131
  export function resolveInteractiveAgentRunOptions(options) {
@@ -4697,11 +4699,47 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
4697
4699
  appId: options?.appId,
4698
4700
  };
4699
4701
  runNowSchedulerDeps = schedulerDeps;
4702
+ // Platform schedulers use the existing durable background function as
4703
+ // the long-lived worker. Keeping the sweep behind a signed, fixed
4704
+ // route prevents a public request from choosing an owner or job.
4705
+ getH3App(nitroApp).use(RECURRING_JOBS_SWEEP_PATH, defineEventHandler(async (event) => {
4706
+ if (getMethod(event) !== "POST") {
4707
+ setResponseStatus(event, 405);
4708
+ return { error: "Method not allowed" };
4709
+ }
4710
+ const token = extractBearerToken(getHeader(event, "authorization"));
4711
+ if (!token ||
4712
+ !verifyInternalToken(RECURRING_JOBS_SWEEP_TOKEN_SUBJECT, token)) {
4713
+ setResponseStatus(event, 401);
4714
+ return { error: "Invalid or expired internal token" };
4715
+ }
4716
+ if (isNetlifyRecurringJobsRuntime() &&
4717
+ !isInBackgroundFunctionRuntime()) {
4718
+ setResponseStatus(event, 503);
4719
+ return {
4720
+ error: "Recurring-job sweep reached the synchronous server instead of the durable background worker.",
4721
+ };
4722
+ }
4723
+ try {
4724
+ await processRecurringJobs(schedulerDeps);
4725
+ return { ok: true };
4726
+ }
4727
+ catch (error) {
4728
+ console.error("[recurring-jobs] Sweep route failed:", error);
4729
+ setResponseStatus(event, 500);
4730
+ return { error: "Recurring-job sweep failed" };
4731
+ }
4732
+ }));
4700
4733
  if (disableRecurringJobsRuntime) {
4701
4734
  if (process.env.DEBUG) {
4702
4735
  console.log("[recurring-jobs] Scheduler disabled for local development");
4703
4736
  }
4704
4737
  }
4738
+ else if (isNetlifyRecurringJobsRuntime()) {
4739
+ if (process.env.DEBUG) {
4740
+ console.log("[recurring-jobs] Using the durable Netlify scheduled sweep");
4741
+ }
4742
+ }
4705
4743
  else {
4706
4744
  // Start after a 10-second delay to let the server fully initialize
4707
4745
  setTimeout(() => {
@@ -18,6 +18,7 @@ import { TEMPLATES } from "../cli/templates-meta.js";
18
18
  import { getDbExec, isPostgres } from "../db/client.js";
19
19
  import { getDialect, getDatabaseUrl, getDatabaseAuthToken, closePgliteClients, getPgliteClient, isPgliteUrl, loadPgliteDrizzle, pgPoolOptions, neonPoolMax, attachNeonPoolErrorLogger, sharedDbPool, onSharedDbPoolsClosed, onSharedDbPoolReplaced, } from "../db/client.js";
20
20
  import { ensureTableExists } from "../db/ddl-guard.js";
21
+ import { CORE_RESET_PASSWORD_EMAIL_ID, CORE_VERIFY_SIGNUP_EMAIL_ID, } from "../email-catalog/system-emails.js";
21
22
  import { saveOAuthTokens } from "../oauth-tokens/store.js";
22
23
  import { acceptPendingInvitationsForEmail } from "../org/accept-pending.js";
23
24
  import { getAuthEmailForUserId, getRequiredAuthProviderForEmail, } from "../org/auth-policy.js";
@@ -918,7 +919,14 @@ async function createBetterAuthInstance(config) {
918
919
  email: user.email,
919
920
  resetUrl,
920
921
  });
921
- await sendEmail({ to: user.email, subject, html, text, appSender });
922
+ await sendEmail({
923
+ to: user.email,
924
+ subject,
925
+ html,
926
+ text,
927
+ appSender,
928
+ templateId: CORE_RESET_PASSWORD_EMAIL_ID,
929
+ });
922
930
  },
923
931
  },
924
932
  emailVerification: {
@@ -942,7 +950,14 @@ async function createBetterAuthInstance(config) {
942
950
  email: user.email,
943
951
  verifyUrl,
944
952
  });
945
- await sendEmail({ to: user.email, subject, html, text, appSender });
953
+ await sendEmail({
954
+ to: user.email,
955
+ subject,
956
+ html,
957
+ text,
958
+ appSender,
959
+ templateId: CORE_VERIFY_SIGNUP_EMAIL_ID,
960
+ });
946
961
  },
947
962
  },
948
963
  socialProviders,
@@ -77,6 +77,11 @@ export interface UrlExtractionResult {
77
77
  googleFonts?: string[];
78
78
  ogImage?: string;
79
79
  favicon?: string;
80
+ stylesheetUrls?: string[];
81
+ stylesheetFailures?: {
82
+ url: string;
83
+ error: string;
84
+ }[];
80
85
  }
81
86
  export interface GitHubFetchOptions {
82
87
  token?: string | null;
@@ -145,5 +150,5 @@ export declare function extractDocumentFonts(text: string): string[];
145
150
  export declare function classifyFile(fileType: string): ContentType;
146
151
  /** Return per-type suggestions for how to use a document for design extraction. */
147
152
  export declare function suggestionsForType(contentType: ContentType, hasText: boolean): string[];
148
- /** Fetch and extract design tokens from a URL's HTML. */
153
+ /** Fetch and extract design tokens from a URL's HTML and linked CSS. */
149
154
  export declare function extractDesignTokensFromUrl(rawUrl: string): Promise<UrlExtractionResult>;
@@ -663,91 +663,284 @@ export function suggestionsForType(contentType, hasText) {
663
663
  // ---------------------------------------------------------------------------
664
664
  // URL scraping helpers (import-from-url)
665
665
  // ---------------------------------------------------------------------------
666
- /** Fetch and extract design tokens from a URL's HTML. */
666
+ const URL_FETCH_TIMEOUT = 10_000;
667
+ const MAX_URL_HTML_CHARS = 1_000_000;
668
+ const MAX_URL_STYLESHEETS = 10;
669
+ const MAX_URL_STYLESHEET_CHARS = 256 * 1024;
670
+ const MAX_URL_STYLESHEET_TOTAL_CHARS = 512 * 1024;
671
+ function normalizeUrlInput(rawUrl) {
672
+ const trimmed = rawUrl.trim();
673
+ if (!trimmed)
674
+ throw new Error("Website URL is required");
675
+ const candidate = /^[a-z][a-z\d+.-]*:/i.test(trimmed)
676
+ ? trimmed
677
+ : `https://${trimmed}`;
678
+ return new URL(candidate).href;
679
+ }
680
+ function decodeHtmlAttribute(value) {
681
+ return value
682
+ .replace(/&amp;/gi, "&")
683
+ .replace(/&quot;/gi, '"')
684
+ .replace(/&#39;|&apos;/gi, "'");
685
+ }
686
+ function readHtmlAttribute(tag, attribute) {
687
+ const match = tag.match(new RegExp(`\\b${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
688
+ const value = match?.[1] ?? match?.[2] ?? match?.[3];
689
+ return value === undefined ? undefined : decodeHtmlAttribute(value.trim());
690
+ }
691
+ function resolveHttpUrl(value, baseUrl) {
692
+ if (!value)
693
+ return { kind: "missing" };
694
+ try {
695
+ const parsed = new URL(value, baseUrl);
696
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
697
+ return { kind: "missing" };
698
+ }
699
+ return { kind: "resolved", url: parsed.href };
700
+ }
701
+ catch {
702
+ return { kind: "invalid", value };
703
+ }
704
+ }
705
+ function readMetaContent(html, attribute, value) {
706
+ for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
707
+ const tag = match[0];
708
+ if (readHtmlAttribute(tag, attribute)?.toLowerCase() !== value)
709
+ continue;
710
+ const content = readHtmlAttribute(tag, "content");
711
+ if (content !== undefined)
712
+ return content;
713
+ }
714
+ return undefined;
715
+ }
716
+ function extractStylesheetUrls(html, baseUrl) {
717
+ const urls = new Set();
718
+ for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
719
+ const tag = match[0];
720
+ const rel = readHtmlAttribute(tag, "rel");
721
+ if (!rel?.split(/\s+/).some((token) => token.toLowerCase() === "stylesheet")) {
722
+ continue;
723
+ }
724
+ const resolved = resolveHttpUrl(readHtmlAttribute(tag, "href"), baseUrl);
725
+ if (resolved.kind === "resolved")
726
+ urls.add(resolved.url);
727
+ }
728
+ return [...urls];
729
+ }
730
+ function extractInlineCss(html) {
731
+ const parts = [];
732
+ for (const match of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
733
+ parts.push(match[1]);
734
+ }
735
+ for (const match of html.matchAll(/<[a-z][^>]*>/gi)) {
736
+ const style = readHtmlAttribute(match[0], "style");
737
+ if (style)
738
+ parts.push(style);
739
+ }
740
+ return parts.join("\n");
741
+ }
742
+ function extractFontFaces(content) {
743
+ const fonts = [];
744
+ for (const match of content.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
745
+ const block = match[1];
746
+ const familyMatch = block.match(/font-family\s*:\s*["']?([^"';]+)/);
747
+ const srcMatch = block.match(/src\s*:\s*([^;]+)/);
748
+ fonts.push({
749
+ family: familyMatch?.[1]?.trim(),
750
+ src: srcMatch?.[1]?.trim()?.slice(0, 200),
751
+ });
752
+ }
753
+ return fonts;
754
+ }
755
+ function extractGoogleFontUrls(content) {
756
+ return [
757
+ ...content.matchAll(/fonts\.googleapis\.com\/css2?\?[^"'>\s)]+/g),
758
+ ].map((match) => match[0]);
759
+ }
760
+ function addColorMatches(content, colors) {
761
+ for (const match of content.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
762
+ colors.add(match[0]);
763
+ }
764
+ for (const match of content.matchAll(/\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([^)]*\)/gi)) {
765
+ colors.add(match[0]);
766
+ }
767
+ }
768
+ async function readBoundedResponseText(response, maxChars, label) {
769
+ const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
770
+ if (Number.isFinite(contentLength) && contentLength > maxChars) {
771
+ throw new Error(`${label} exceeds the ${maxChars}-character limit`);
772
+ }
773
+ if (!response.body) {
774
+ const text = await response.text();
775
+ if (text.length > maxChars) {
776
+ throw new Error(`${label} exceeds the ${maxChars}-character limit`);
777
+ }
778
+ return text;
779
+ }
780
+ const reader = response.body.getReader();
781
+ const decoder = new TextDecoder();
782
+ let text = "";
783
+ try {
784
+ while (true) {
785
+ const chunk = await reader.read();
786
+ if (chunk.done) {
787
+ text += decoder.decode();
788
+ break;
789
+ }
790
+ text += decoder.decode(chunk.value, { stream: true });
791
+ if (text.length > maxChars) {
792
+ await reader.cancel().catch(() => undefined);
793
+ throw new Error(`${label} exceeds the ${maxChars}-character limit`);
794
+ }
795
+ }
796
+ }
797
+ finally {
798
+ reader.releaseLock();
799
+ }
800
+ return text;
801
+ }
802
+ async function fetchStylesheets(pageUrl, html) {
803
+ const discovered = extractStylesheetUrls(html, pageUrl);
804
+ const fetched = [];
805
+ const urls = [];
806
+ const failures = [];
807
+ let totalChars = 0;
808
+ for (const stylesheetUrl of discovered.slice(0, MAX_URL_STYLESHEETS)) {
809
+ if (totalChars >= MAX_URL_STYLESHEET_TOTAL_CHARS) {
810
+ failures.push({
811
+ url: stylesheetUrl,
812
+ error: `Skipped because the ${MAX_URL_STYLESHEET_TOTAL_CHARS}-character stylesheet budget was reached`,
813
+ });
814
+ continue;
815
+ }
816
+ try {
817
+ const response = await ssrfSafeFetch(stylesheetUrl, {
818
+ headers: {
819
+ "User-Agent": "Mozilla/5.0 (compatible; AgentNative/1.0; +https://agent-native.com)",
820
+ },
821
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT),
822
+ }, { maxRedirects: 3 });
823
+ if (!response.ok) {
824
+ throw new Error(`HTTP ${response.status}`);
825
+ }
826
+ const remainingChars = MAX_URL_STYLESHEET_TOTAL_CHARS - totalChars;
827
+ const content = await readBoundedResponseText(response, Math.min(MAX_URL_STYLESHEET_CHARS, remainingChars), `Stylesheet ${stylesheetUrl}`);
828
+ totalChars += content.length;
829
+ fetched.push({ url: stylesheetUrl, content });
830
+ urls.push(stylesheetUrl);
831
+ }
832
+ catch (error) {
833
+ failures.push({
834
+ url: stylesheetUrl,
835
+ error: error instanceof Error ? error.message : String(error),
836
+ });
837
+ }
838
+ }
839
+ if (discovered.length > MAX_URL_STYLESHEETS) {
840
+ failures.push({
841
+ url: pageUrl,
842
+ error: `Skipped ${discovered.length - MAX_URL_STYLESHEETS} stylesheet(s) after the ${MAX_URL_STYLESHEETS}-stylesheet limit`,
843
+ });
844
+ }
845
+ return { fetched, urls, failures };
846
+ }
847
+ /** Fetch and extract design tokens from a URL's HTML and linked CSS. */
667
848
  export async function extractDesignTokensFromUrl(rawUrl) {
668
- const url = rawUrl.startsWith("http") ? rawUrl : `https://${rawUrl}`;
849
+ const url = normalizeUrlInput(rawUrl);
669
850
  validateUrl(url);
670
851
  const response = await ssrfSafeFetch(url, {
671
852
  headers: {
672
853
  "User-Agent": "Mozilla/5.0 (compatible; AgentNative/1.0; +https://agent-native.com)",
673
854
  },
674
- signal: AbortSignal.timeout(10000),
675
- });
676
- const html = await response.text();
855
+ signal: AbortSignal.timeout(URL_FETCH_TIMEOUT),
856
+ }, { maxRedirects: 3 });
857
+ if (!response.ok) {
858
+ throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
859
+ }
860
+ const html = await readBoundedResponseText(response, MAX_URL_HTML_CHARS, `Website ${url}`);
861
+ const pageUrl = response.url || url;
677
862
  const result = { url };
678
863
  // Title
679
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
864
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
680
865
  if (titleMatch) {
681
866
  result.pageTitle = titleMatch[1].trim();
682
867
  }
683
- // Meta description
684
- const descMatch = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i);
685
- if (descMatch) {
686
- result.metaDescription = descMatch[1];
868
+ const metaDescription = readMetaContent(html, "name", "description");
869
+ if (metaDescription !== undefined) {
870
+ result.metaDescription = metaDescription;
687
871
  }
688
- // Meta theme-color
689
- const themeColorMatch = html.match(/<meta[^>]*name=["']theme-color["'][^>]*content=["']([^"']+)["']/i);
690
- if (themeColorMatch) {
691
- result.themeColor = themeColorMatch[1];
872
+ const themeColor = readMetaContent(html, "name", "theme-color");
873
+ if (themeColor !== undefined) {
874
+ result.themeColor = themeColor;
692
875
  }
693
- // CSS custom properties
694
- const cssVarMatches = html.matchAll(/--([\w-]+)\s*:\s*([^;}\n]+)/g);
695
- const cssVars = {};
696
- for (const match of cssVarMatches) {
697
- cssVars[`--${match[1]}`] = match[2].trim();
876
+ const inlineCss = extractInlineCss(html);
877
+ const pageCss = parseCss(inlineCss);
878
+ const stylesheetResult = await fetchStylesheets(pageUrl, html);
879
+ const stylesheetCssVars = {};
880
+ const fonts = extractFontFaces(inlineCss);
881
+ const googleFonts = new Set(extractGoogleFontUrls(html));
882
+ const colorMatches = new Set();
883
+ addColorMatches(html, colorMatches);
884
+ for (const stylesheet of stylesheetResult.fetched) {
885
+ const parsed = parseCss(stylesheet.content);
886
+ Object.assign(stylesheetCssVars, parsed.cssCustomProperties);
887
+ fonts.push(...extractFontFaces(stylesheet.content));
888
+ for (const fontUrl of extractGoogleFontUrls(stylesheet.content)) {
889
+ googleFonts.add(fontUrl);
890
+ }
891
+ addColorMatches(stylesheet.content, colorMatches);
698
892
  }
893
+ const cssVars = {
894
+ ...stylesheetCssVars,
895
+ ...(pageCss.cssCustomProperties ?? {}),
896
+ };
699
897
  if (Object.keys(cssVars).length > 0) {
700
898
  const entries = Object.entries(cssVars).slice(0, 50);
701
899
  result.cssCustomProperties = Object.fromEntries(entries);
702
900
  }
703
- // Inline colors (hex, rgb)
704
- const colorMatches = new Set();
705
- const hexPattern = /#[0-9a-fA-F]{3,8}\b/g;
706
- let hexMatch;
707
- while ((hexMatch = hexPattern.exec(html)) !== null) {
708
- colorMatches.add(hexMatch[0]);
709
- }
710
- const rgbPattern = /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)/g;
711
- let rgbMatch;
712
- while ((rgbMatch = rgbPattern.exec(html)) !== null) {
713
- colorMatches.add(rgbMatch[0]);
714
- }
715
901
  if (colorMatches.size > 0) {
716
902
  result.colors = [...colorMatches].slice(0, 30);
717
903
  }
718
- // @font-face
719
- const fontFaceMatches = html.matchAll(/@font-face\s*\{([^}]+)\}/g);
720
- const fonts = [];
721
- for (const match of fontFaceMatches) {
722
- const block = match[1];
723
- const familyMatch = block.match(/font-family\s*:\s*["']?([^"';]+)["']?/);
724
- const srcMatch = block.match(/src\s*:\s*([^;]+)/);
725
- fonts.push({
726
- family: familyMatch?.[1]?.trim(),
727
- src: srcMatch?.[1]?.trim()?.slice(0, 200),
728
- });
729
- }
730
904
  if (fonts.length > 0) {
731
- result.fontFaces = fonts.slice(0, 20);
732
- }
733
- // Google Fonts
734
- const googleFontMatches = html.matchAll(/fonts\.googleapis\.com\/css2?\?[^"'>\s]+/g);
735
- const googleFonts = [];
736
- for (const match of googleFontMatches) {
737
- googleFonts.push(match[0]);
738
- }
739
- if (googleFonts.length > 0) {
740
- result.googleFonts = googleFonts;
905
+ const seenFonts = new Set();
906
+ result.fontFaces = fonts
907
+ .filter((font) => {
908
+ const key = `${font.family ?? ""}\u0000${font.src ?? ""}`;
909
+ if (seenFonts.has(key))
910
+ return false;
911
+ seenFonts.add(key);
912
+ return true;
913
+ })
914
+ .slice(0, 20);
915
+ }
916
+ if (googleFonts.size > 0) {
917
+ result.googleFonts = [...googleFonts];
741
918
  }
742
919
  // OG image
743
- const ogImageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i);
744
- if (ogImageMatch) {
745
- result.ogImage = ogImageMatch[1];
920
+ const ogImage = readMetaContent(html, "property", "og:image");
921
+ if (ogImage) {
922
+ const resolved = resolveHttpUrl(ogImage, pageUrl);
923
+ result.ogImage = resolved.kind === "resolved" ? resolved.url : ogImage;
746
924
  }
747
925
  // Favicon
748
- const faviconMatch = html.match(/<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"']+)["']/i);
749
- if (faviconMatch) {
750
- result.favicon = faviconMatch[1];
926
+ for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
927
+ const tag = match[0];
928
+ const rel = readHtmlAttribute(tag, "rel");
929
+ if (!rel?.split(/\s+/).some((token) => token.toLowerCase() === "icon")) {
930
+ continue;
931
+ }
932
+ const href = readHtmlAttribute(tag, "href");
933
+ if (href && !/^data:/i.test(href)) {
934
+ const resolved = resolveHttpUrl(href, pageUrl);
935
+ result.favicon = resolved.kind === "resolved" ? resolved.url : href;
936
+ }
937
+ break;
938
+ }
939
+ if (stylesheetResult.urls.length > 0) {
940
+ result.stylesheetUrls = stylesheetResult.urls;
941
+ }
942
+ if (stylesheetResult.failures.length > 0) {
943
+ result.stylesheetFailures = stylesheetResult.failures;
751
944
  }
752
945
  return result;
753
946
  }
@@ -58,6 +58,17 @@ export interface SendEmailArgs {
58
58
  references?: string;
59
59
  attachments?: EmailAttachment[];
60
60
  timeoutMs?: number;
61
+ /**
62
+ * Registered transactional email id (see `defineTransactionalEmail`), e.g.
63
+ * `calendar.booking-confirmed`. Tags the message at the provider so delivery
64
+ * and open metrics attribute to one email instead of to the whole account,
65
+ * and keys the row written to `email_log`. Omit for genuinely one-off sends.
66
+ */
67
+ templateId?: string;
68
+ /** App slug that owns the send. Defaults to the running app. */
69
+ app?: string;
70
+ /** Organization that owns the send. Defaults to the current request org. */
71
+ orgId?: string;
61
72
  }
62
73
  export declare function isEmailConfigured(): Promise<boolean>;
63
74
  /**
@@ -10,8 +10,11 @@
10
10
  * so the reset-password flow still works end-to-end for local development.
11
11
  */
12
12
  import { FAVICON_PNG_BASE64 } from "../assets/branding/favicon-base64.js";
13
+ import { getScopedEmailProviderCategory, recordEmailSend, } from "../email-catalog/log.js";
14
+ import { getAppSlug } from "./app-name.js";
13
15
  import { resolveSecret } from "./credential-provider.js";
14
16
  import { AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID } from "./email-template.js";
17
+ import { getRequestOrgId } from "./request-context.js";
15
18
  let cachedAgentNativeLogo;
16
19
  function getAgentNativeLogoAttachment() {
17
20
  cachedAgentNativeLogo ??= Buffer.from(FAVICON_PNG_BASE64, "base64");
@@ -154,7 +157,7 @@ function resolveAppSender(configuredFrom, appSender) {
154
157
  replyTo: appSender.replyTo,
155
158
  };
156
159
  }
157
- async function sendEmailWithSignal(args, signal) {
160
+ async function deliverEmail(args, signal) {
158
161
  const config = await resolveEmailTransport();
159
162
  signal?.throwIfAborted();
160
163
  const provider = config.provider;
@@ -206,7 +209,7 @@ async function sendEmailWithSignal(args, signal) {
206
209
  const body = await res.text().catch(() => "");
207
210
  throw new Error(`Resend error ${res.status}: ${body}`);
208
211
  }
209
- return;
212
+ return { provider, from };
210
213
  }
211
214
  if (provider === "sendgrid") {
212
215
  const personalization = {
@@ -227,6 +230,19 @@ async function sendEmailWithSignal(args, signal) {
227
230
  };
228
231
  if (replyTo)
229
232
  sgPayload.reply_to = parseSendGridFrom(replyTo);
233
+ // Categories are how per-email delivery/open stats are attributed. Without
234
+ // them every send lands in one undifferentiated account-wide bucket, which
235
+ // is indistinguishable from an email that never sent.
236
+ const orgId = args.orgId ?? getRequestOrgId();
237
+ const categories = [
238
+ args.templateId,
239
+ args.app ?? getAppSlug(),
240
+ args.templateId && orgId
241
+ ? getScopedEmailProviderCategory(args.templateId, orgId)
242
+ : undefined,
243
+ ].filter((value) => Boolean(value));
244
+ if (categories.length)
245
+ sgPayload.categories = categories;
230
246
  const sgHeaders = {};
231
247
  if (args.inReplyTo)
232
248
  sgHeaders["In-Reply-To"] = args.inReplyTo;
@@ -258,7 +274,7 @@ async function sendEmailWithSignal(args, signal) {
258
274
  const body = await res.text().catch(() => "");
259
275
  throw new Error(`SendGrid error ${res.status}: ${body}`);
260
276
  }
261
- return;
277
+ return { provider, from };
262
278
  }
263
279
  // Dev fallback — no provider configured. Logging the full body exposes
264
280
  // reset tokens, so only do it outside production. In production, refuse
@@ -270,6 +286,41 @@ async function sendEmailWithSignal(args, signal) {
270
286
  `Save RESEND_API_KEY or SENDGRID_API_KEY in settings to send real emails.\n` +
271
287
  `---\nTo: ${args.to}\nFrom: ${from}\nSubject: ${args.subject}\n\n` +
272
288
  `${args.text || stripHtml(args.html)}\n---\n`);
289
+ return { provider, from };
290
+ }
291
+ /**
292
+ * Deliver, then record the attempt. Recording lives here rather than in each
293
+ * provider branch so a new transport cannot be added without being logged.
294
+ */
295
+ async function sendEmailWithSignal(args, signal) {
296
+ let outcome;
297
+ try {
298
+ outcome = await deliverEmail(args, signal);
299
+ }
300
+ catch (error) {
301
+ await recordEmailSend({
302
+ templateId: args.templateId,
303
+ app: args.app ?? getAppSlug() ?? "unknown",
304
+ orgId: args.orgId ?? getRequestOrgId(),
305
+ recipient: args.to,
306
+ sender: outcome?.from ?? args.from ?? "unknown",
307
+ subject: args.subject,
308
+ status: "failed",
309
+ error: error instanceof Error ? error.message : String(error),
310
+ provider: outcome?.provider ?? "unknown",
311
+ });
312
+ throw error;
313
+ }
314
+ await recordEmailSend({
315
+ templateId: args.templateId,
316
+ app: args.app ?? getAppSlug() ?? "unknown",
317
+ orgId: args.orgId ?? getRequestOrgId(),
318
+ recipient: args.to,
319
+ sender: outcome.from,
320
+ subject: args.subject,
321
+ status: "sent",
322
+ provider: outcome.provider,
323
+ });
273
324
  }
274
325
  export async function sendEmail(args) {
275
326
  const requestedTimeoutMs = Number(args.timeoutMs);
@@ -66,6 +66,7 @@ export { builderDesignSystemUrl, builderProjectBranchUrl, buildBuilderDesignSyst
66
66
  export { getBuilderBranchProjectId, isBuilderBranchingEnabled, requestBuilderBrowserConnection, resolveBuilderBranchProjectId, resolveIsBuilderBranchingEnabled, runBuilderAgent, type RunBuilderAgentResult, } from "./builder-browser.js";
67
67
  export { ensureFusionContainer, sendFusionBranchMessage, pushFusionBranch, reserveFusionHostingSlug, deployFusionProject, getFusionDeploys, getFusionBranchEditorUrl, getFusionHostingUrl, type FusionBranchRef, type EnsureFusionContainerResult, type SendFusionMessageResult, } from "./fusion-app.js";
68
68
  export { sendEmail, isEmailConfigured, getEmailReadiness, getEmailProvider, type EmailAttachment, type EmailReadiness, type EmailProvider, type SendEmailArgs, } from "./email.js";
69
+ export { defineTransactionalEmail, listTransactionalEmails, getTransactionalEmail, renderTransactionalEmailPreview, type TransactionalEmailDefinition, type RegisteredTransactionalEmail, } from "../email-catalog/registry.js";
69
70
  export { notifyActivity, runActivityNotification, resolveActivityRecipients, type ActivityDeliveryFailure, type ActivityNotificationResult, type ActivityNotificationStatus, type NotifyActivityInput, type ResolveActivityRecipientsInput, } from "./activity-notifications.js";
70
71
  export { renderEmail, emailStrong, emailLink, type RenderEmailArgs, type RenderedEmail, type EmailCta, } from "./email-template.js";
71
72
  export { getAppProductionUrl, getFirstPartyProdUrl } from "./app-url.js";
@@ -67,6 +67,7 @@ export { builderDesignSystemUrl, builderProjectBranchUrl, buildBuilderDesignSyst
67
67
  export { getBuilderBranchProjectId, isBuilderBranchingEnabled, requestBuilderBrowserConnection, resolveBuilderBranchProjectId, resolveIsBuilderBranchingEnabled, runBuilderAgent, } from "./builder-browser.js";
68
68
  export { ensureFusionContainer, sendFusionBranchMessage, pushFusionBranch, reserveFusionHostingSlug, deployFusionProject, getFusionDeploys, getFusionBranchEditorUrl, getFusionHostingUrl, } from "./fusion-app.js";
69
69
  export { sendEmail, isEmailConfigured, getEmailReadiness, getEmailProvider, } from "./email.js";
70
+ export { defineTransactionalEmail, listTransactionalEmails, getTransactionalEmail, renderTransactionalEmailPreview, } from "../email-catalog/registry.js";
70
71
  export { notifyActivity, runActivityNotification, resolveActivityRecipients, } from "./activity-notifications.js";
71
72
  export { renderEmail, emailStrong, emailLink, } from "./email-template.js";
72
73
  export { getAppProductionUrl, getFirstPartyProdUrl } from "./app-url.js";
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
+ error?: undefined;
29
30
  token: string;
30
31
  expiresAt: string;
31
32
  ttlSeconds: number;
32
- error?: undefined;
33
33
  }>>;