Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion components/AccountDetail/AccountDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"use client";

import { useState } from "react";
import { useAccountTaskRuns } from "@/hooks/useAccountTaskRuns";
import AccountBreadcrumb from "./AccountBreadcrumb";
import TaskRunsTable from "./TaskRunsTable";
import PulseEmailModal from "./PulseEmailModal";
import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
import type { TaskRun } from "@/types/sandbox";

const TASK_RUN_COLUMNS = ["Task", "Status", "Started", "Duration", "Run ID"];

Expand All @@ -13,6 +16,7 @@ interface AccountDetailPageProps {

export default function AccountDetailPage({ accountId }: AccountDetailPageProps) {
const { data: runs, isLoading, error } = useAccountTaskRuns(accountId);
const [selectedRun, setSelectedRun] = useState<TaskRun | null>(null);

const pulseRuns = runs?.filter(r => r.taskIdentifier === "send-pulse-task") ?? [];

Expand Down Expand Up @@ -52,7 +56,12 @@ export default function AccountDetailPage({ accountId }: AccountDetailPageProps)
</div>
)}

{!isLoading && !error && <TaskRunsTable runs={pulseRuns} />}
{!isLoading && !error && (
<TaskRunsTable
runs={pulseRuns}
onRunClick={(run) => setSelectedRun(run)}
/>
)}

{!isLoading && !error && runs && runs.length > pulseRuns.length && (
<details className="mt-6">
Expand All @@ -65,6 +74,13 @@ export default function AccountDetailPage({ accountId }: AccountDetailPageProps)
</details>
)}
</section>

{selectedRun && (
<PulseEmailModal
run={selectedRun}
onClose={() => setSelectedRun(null)}
/>
)}
</main>
);
}
49 changes: 49 additions & 0 deletions components/AccountDetail/PulseEmailModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { usePulseEmail } from "@/hooks/usePulseEmail";
import { getEmailIdFromTags } from "@/lib/tasks/getEmailIdFromTags";
import type { TaskRun } from "@/types/sandbox";
import PulseEmailModalHeader from "./PulseEmailModalHeader";
import PulseEmailModalBody from "./PulseEmailModalBody";

interface PulseEmailModalProps {
run: TaskRun;
onClose: () => void;
}

export default function PulseEmailModal({ run, onClose }: PulseEmailModalProps) {
const emailId = getEmailIdFromTags(run.tags);
const { data: email, isLoading, error } = usePulseEmail(emailId);

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="relative flex flex-col w-full max-w-3xl max-h-[90vh] rounded-xl bg-white dark:bg-gray-900 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<PulseEmailModalHeader
email={email ?? null}
runId={run.id}
onClose={onClose}
/>

<div className="flex-1 overflow-auto">
<PulseEmailModalBody
email={email ?? null}
isLoading={isLoading}
error={error as Error | null}
/>
</div>

{email && (
<div className="px-6 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<p className="text-xs text-gray-400 font-mono">Resend ID: {email.id}</p>
</div>
)}
</div>
</div>
Comment on lines +19 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add modal a11y semantics and Escape-key dismissal.

At Lines 19-47, the overlay behaves like a dialog but lacks role="dialog", aria-modal, and keyboard Escape handling, which can block keyboard/screen-reader workflows.

Proposed fix
 "use client";
 
+import { useEffect } from "react";
 import { usePulseEmail } from "@/hooks/usePulseEmail";
 import { getEmailIdFromTags } from "@/lib/tasks/getEmailIdFromTags";
 import type { TaskRun } from "@/types/sandbox";
@@
 export default function PulseEmailModal({ run, onClose }: PulseEmailModalProps) {
   const emailId = getEmailIdFromTags(run.tags);
   const { data: email, isLoading, error } = usePulseEmail(emailId);
+
+  useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") onClose();
+    };
+    document.addEventListener("keydown", onKeyDown);
+    return () => document.removeEventListener("keydown", onKeyDown);
+  }, [onClose]);
@@
       <div
         className="relative flex flex-col w-full max-w-3xl max-h-[90vh] rounded-xl bg-white dark:bg-gray-900 shadow-2xl overflow-hidden"
         onClick={(e) => e.stopPropagation()}
+        role="dialog"
+        aria-modal="true"
+        aria-label="Pulse Email Preview"
       >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/AccountDetail/PulseEmailModal.tsx` around lines 19 - 47, The modal
overlay in PulseEmailModal is missing accessibility semantics and Escape-key
dismissal: add role="dialog" and aria-modal="true" to the inner modal container,
set an accessible label via aria-labelledby pointing to the
PulseEmailModalHeader title element (ensure the header provides an id), make the
modal container focusable (tabIndex={-1}) and programmatically focus it when
mounted, and add a keydown listener (mounted in PulseEmailModal) to call
onClose() when Escape is pressed (cleanup listener on unmount); ensure onClick
propagation stop and existing onClose logic remain unchanged.

);
}
51 changes: 51 additions & 0 deletions components/AccountDetail/PulseEmailModalBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { PulseEmail } from "@/lib/recoup/fetchAccountPulseEmails";

interface PulseEmailModalBodyProps {
email: PulseEmail | null;
isLoading: boolean;
error: Error | null;
}

export default function PulseEmailModalBody({ email, isLoading, error }: PulseEmailModalBodyProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-sm text-gray-500 dark:text-gray-400">
Loading email…
</div>
);
}

if (error) {
return (
<div className="flex items-center justify-center py-20 text-sm text-red-500">
Failed to load email: {error.message}
</div>
);
}

if (!email) {
return (
<div className="flex items-center justify-center py-20 text-sm text-gray-500 dark:text-gray-400">
No email found for this run.
</div>
);
}

if (email.html) {
return (
<iframe
srcDoc={email.html}
title="Email preview"
sandbox="allow-same-origin"
className="w-full border-0"
Comment on lines +36 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten iframe sandbox for untrusted email HTML.

At Line 39, allow-same-origin reduces isolation for third-party email markup. For a passive preview, keep the iframe fully sandboxed.

Proposed fix
       <iframe
         srcDoc={email.html}
         title="Email preview"
-        sandbox="allow-same-origin"
+        sandbox=""
         className="w-full border-0"
         style={{ minHeight: "500px" }}
       />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<iframe
srcDoc={email.html}
title="Email preview"
sandbox="allow-same-origin"
className="w-full border-0"
<iframe
srcDoc={email.html}
title="Email preview"
sandbox=""
className="w-full border-0"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/AccountDetail/PulseEmailModalBody.tsx` around lines 36 - 40, The
iframe used for the email preview (srcDoc={email.html}, title="Email preview")
is too permissive because it includes "allow-same-origin"; remove that flag so
the iframe is fully sandboxed for passive previews. Update the iframe's sandbox
attribute to an empty sandbox (e.g., sandbox="" or the JSX boolean sandbox prop
without any allow-* tokens) and ensure no other allow-* flags are added so
third-party email markup cannot escape isolation.

style={{ minHeight: "500px" }}
/>
);
}

return (
<div className="px-6 py-8 text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">
(No HTML content — email may have been text-only)
</div>
);
}
41 changes: 41 additions & 0 deletions components/AccountDetail/PulseEmailModalHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { PulseEmail } from "@/lib/recoup/fetchAccountPulseEmails";

interface PulseEmailModalHeaderProps {
email: PulseEmail | null;
runId: string;
onClose: () => void;
}

export default function PulseEmailModalHeader({ email, runId, onClose }: PulseEmailModalHeaderProps) {
return (
<div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="min-w-0">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
Pulse Email Preview
</p>
{email ? (
<>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{email.subject ?? "(no subject)"}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
To: {email.to.join(", ")} &middot;{" "}
{new Date(email.created_at).toLocaleString()}
</p>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 font-mono">{runId}</p>
)}
</div>
<button
onClick={onClose}
className="shrink-0 rounded-lg p-1.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
9 changes: 7 additions & 2 deletions components/AccountDetail/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { formatRunDuration } from "@/lib/tasks/formatRunDuration";

interface TaskRunsTableProps {
runs: TaskRun[];
onRunClick?: (run: TaskRun) => void;
}

export default function TaskRunsTable({ runs }: TaskRunsTableProps) {
export default function TaskRunsTable({ runs, onRunClick }: TaskRunsTableProps) {
if (runs.length === 0) {
return null;
}
Expand All @@ -27,7 +28,11 @@ export default function TaskRunsTable({ runs }: TaskRunsTableProps) {
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
{runs.map((run) => (
<tr key={run.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<tr
key={run.id}
className={`hover:bg-gray-50 dark:hover:bg-gray-800 ${onRunClick ? "cursor-pointer" : ""}`}
onClick={() => onRunClick?.(run)}
>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{run.taskIdentifier}
</td>
Expand Down
23 changes: 23 additions & 0 deletions hooks/usePulseEmail.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are both hooks/usePulseEmail.ts and hooks/usePulseEmails.ts being used?

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { fetchPulseEmailById } from "@/lib/recoup/fetchPulseEmailById";

/**
* Fetches a single Resend email by ID.
* Query key includes emailId so switching runs invalidates stale data.
*/
export function usePulseEmail(emailId: string | null) {
const { ready, authenticated, getAccessToken } = usePrivy();

return useQuery({
queryKey: ["admin", "pulse-email", emailId],
queryFn: async () => {
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchPulseEmailById(token, emailId!);
},
enabled: ready && authenticated && !!emailId,
});
}
49 changes: 49 additions & 0 deletions lib/recoup/fetchAccountPulseEmails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { API_BASE_URL } from "@/lib/consts";

/**
* Full Resend GetEmailResponseSuccess shape.
* See https://resend.com/docs/api-reference/emails/retrieve-email
*/
export interface PulseEmail {
id: string;
from: string;
to: string[];
cc: string[] | null;
bcc: string[] | null;
reply_to: string[] | null;
subject: string;
html: string | null;
text: string | null;
created_at: string;
scheduled_at: string | null;
last_event: string;
tags?: { name: string; value: string }[];
}
Comment thread
sweetmantech marked this conversation as resolved.

/**
* Fetches all Resend emails sent for a specific account from GET /api/admins/emails?account_id=<id>.
* Authenticates using the caller's Privy access token (admin Bearer auth).
*
* @param accessToken - Privy access token from getAccessToken()
* @param accountId - The account ID to fetch emails for
* @returns Array of pulse emails with HTML content
*/
export async function fetchAccountPulseEmails(
accessToken: string,
accountId: string,
): Promise<PulseEmail[]> {
const url = new URL(`${API_BASE_URL}/api/admins/emails`);
url.searchParams.set("account_id", accountId);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
return data.emails ?? [];
}
29 changes: 29 additions & 0 deletions lib/recoup/fetchPulseEmailById.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { API_BASE_URL } from "@/lib/consts";
import type { PulseEmail } from "./fetchAccountPulseEmails";

/**
* Fetches a single Resend email by ID from GET /api/admins/emails?email_id=<id>.
*
* @param accessToken - Privy access token
* @param emailId - The Resend email ID
* @returns The email object or null if not found
*/
export async function fetchPulseEmailById(
accessToken: string,
emailId: string,
): Promise<PulseEmail | null> {
const url = new URL(`${API_BASE_URL}/api/admins/emails`);
url.searchParams.set("email_id", emailId);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
return data.emails?.[0] ?? null;
}
11 changes: 11 additions & 0 deletions lib/tasks/getEmailIdFromTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Extracts the Resend email ID from a task run's tags.
* Looks for tags matching the pattern "email:<emailId>".
*
* @param tags - Array of tag strings from the task run
* @returns The Resend email ID or null if not found
*/
export function getEmailIdFromTags(tags: string[]): string | null {
const emailTag = tags.find((t) => t.startsWith("email:"));
return emailTag ? emailTag.slice("email:".length) : null;
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize extracted IDs so empty tags don’t leak through.

At Line 10, a tag like email: returns "" instead of null, which breaks the “ID or null” contract and can propagate invalid identifiers.

Proposed fix
 export function getEmailIdFromTags(tags: string[]): string | null {
   const emailTag = tags.find((t) => t.startsWith("email:"));
-  return emailTag ? emailTag.slice("email:".length) : null;
+  if (!emailTag) return null;
+  const id = emailTag.slice("email:".length).trim();
+  return id.length > 0 ? id : null;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const emailTag = tags.find((t) => t.startsWith("email:"));
return emailTag ? emailTag.slice("email:".length) : null;
export function getEmailIdFromTags(tags: string[]): string | null {
const emailTag = tags.find((t) => t.startsWith("email:"));
if (!emailTag) return null;
const id = emailTag.slice("email:".length).trim();
return id.length > 0 ? id : null;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/tasks/getEmailIdFromTags.ts` around lines 9 - 10, The function
getEmailIdFromTags currently slices the found tag with
emailTag.slice("email:".length) which returns an empty string for a bare
"email:" tag; change logic so after finding emailTag (from tags.find) you
extract the id, trim it, and return null if the resulting id is an empty string
(i.e., treat empty/whitespace-only ids as null) so the function always returns
either a non-empty id or null.

}
Loading