Skip to content
Open
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
49 changes: 49 additions & 0 deletions packages/preview-server/src/actions/send-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use server';

import { Resend } from 'resend';
import { z } from 'zod';
import { fromAddress, resendApiKey } from '../app/env';
import { baseActionClient } from './safe-action';

export const sendEmail = baseActionClient
.metadata({
actionName: 'sendEmail',
})
.inputSchema(
z.object({
to: z.string().email(),
subject: z.string().min(1),
html: z.string(),
}),
)
.action(async ({ parsedInput }) => {
if (!resendApiKey) {
return {
status: 'failed' as const,
error: 'Resend API key is not configured',
};
}

if (!fromAddress) {
return {
status: 'failed' as const,
error: 'Sender address is not configured. Use --from to set it.',
};
}

const resend = new Resend(resendApiKey);

const response = await resend.emails.send({
Copy link
Contributor

Choose a reason for hiding this comment

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

P1: Custom agent: API Key Permission Check SDK Methods

This PR introduces a new Resend SDK method (emails.send) but does not include an explicit permission-readiness check/reminder for production API keys. Per the API Key Permission Check SDK Methods rule, confirm and document that deployed Resend keys have send-email permission before rollout to prevent runtime authorization failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/preview-server/src/actions/send-email.ts, line 30:

<comment>This PR introduces a new Resend SDK method (`emails.send`) but does not include an explicit permission-readiness check/reminder for production API keys. Per the API Key Permission Check SDK Methods rule, confirm and document that deployed Resend keys have send-email permission before rollout to prevent runtime authorization failures.</comment>

<file context>
@@ -0,0 +1,43 @@
+
+    const resend = new Resend(resendApiKey);
+
+    const response = await resend.emails.send({
+      from: fromAddress,
+      to: [parsedInput.to],
</file context>

from: fromAddress,
to: [parsedInput.to],
subject: parsedInput.subject,
html: parsedInput.html,
});

if (response.error) {
console.error('Error sending email', response.error);
return { status: 'failed' as const, error: response.error.message };
}

return { status: 'succeeded' as const };
});
3 changes: 3 additions & 0 deletions packages/preview-server/src/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const emailsDirectoryAbsolutePath =
/** ONLY ACCESSIBLE ON THE SERVER */
export const resendApiKey = process.env.REACT_EMAIL_INTERNAL_RESEND_API_KEY;

/** ONLY ACCESSIBLE ON THE SERVER */
export const fromAddress = process.env.REACT_EMAIL_INTERNAL_FROM_ADDRESS;

export const isBuilding = process.env.NEXT_PUBLIC_IS_BUILDING === 'true';

export const isPreviewDevelopment =
Expand Down
9 changes: 8 additions & 1 deletion packages/preview-server/src/app/preview/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getLintingSources, loadLintingRowsFrom } from '../../../utils/linting';
import { loadStream } from '../../../utils/load-stream';
import {
emailsDirectoryAbsolutePath,
fromAddress,
isBuilding,
resendApiKey,
} from '../../env';
Expand Down Expand Up @@ -129,7 +130,13 @@ This is most likely not an issue with the preview server. Maybe there was a typo
{/* on the build of the preview server de-opting into */}
{/* client-side rendering on build */}
<Suspense>
<Preview emailTitle={path.basename(emailPath)} />
<Preview
emailTitle={path.basename(emailPath)}
canSendLocally={
(resendApiKey ?? '').trim().length > 0 &&
(fromAddress ?? '').trim().length > 0
}
/>

<ToolbarProvider hasApiKey={(resendApiKey ?? '').trim().length > 0}>
<Toolbar
Expand Down
13 changes: 11 additions & 2 deletions packages/preview-server/src/app/preview/[...slug]/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ import { ErrorOverlay } from './error-overlay';

interface PreviewProps extends React.ComponentProps<'div'> {
emailTitle: string;
canSendLocally: boolean;
}

const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
const Preview = ({
emailTitle,
canSendLocally,
className,
...props
}: PreviewProps) => {
const { renderingResult, renderedEmailMetadata } = usePreviewContext();

const router = useRouter();
Expand Down Expand Up @@ -128,7 +134,10 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
/>
{hasRenderingMetadata ? (
<div className="flex justify-end">
<Send markup={renderedEmailMetadata.markup} />
<Send
markup={renderedEmailMetadata.markup}
canSendLocally={canSendLocally}
/>
</div>
) : null}
</Topbar>
Expand Down
54 changes: 39 additions & 15 deletions packages/preview-server/src/components/send.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,60 @@
import * as Popover from '@radix-ui/react-popover';
import { useAction } from 'next-safe-action/hooks';
import { useId, useState } from 'react';
import { toast } from 'sonner';
import { sendEmail } from '../actions/send-email';
import { Button } from './button';
import { Text } from './text';

export const Send = ({ markup }: { markup: string }) => {
export const Send = ({
markup,
canSendLocally,
}: {
markup: string;
canSendLocally: boolean;
}) => {
const [to, setTo] = useState('');
const [subject, setSubject] = useState('Testing React Email');
const [isSending, setIsSending] = useState(false);
const [isPopOverOpen, setIsPopOverOpen] = useState(false);

const { executeAsync: executeSendEmail } = useAction(sendEmail);

const onFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSending(true);

try {
const response = await fetch('https://react.email/api/send/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to,
subject,
html: markup,
}),
});
if (canSendLocally) {
const result = await executeSendEmail({ to, subject, html: markup });

if (response.ok) {
toast.success('Email sent! Check your inbox.');
} else if (response.status === 429) {
toast.error('Too many requests. Try again in around 1 minute');
if (result?.data?.status === 'succeeded') {
toast.success('Email sent! Check your inbox.');
} else {
const errorMessage =
result?.data?.status === 'failed'
? result.data.error
: 'Something went wrong. Please try again.';
toast.error(errorMessage);
}
} else {
toast.error('Something went wrong. Please try again.');
const response = await fetch('https://react.email/api/send/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to,
subject,
html: markup,
}),
});

if (response.ok) {
toast.success('Email sent! Check your inbox.');
} else if (response.status === 429) {
toast.error('Too many requests. Try again in around 1 minute');
} else {
toast.error('Something went wrong. Please try again.');
}
}
} catch {
toast.error('Something went wrong. Please try again.');
Expand Down
11 changes: 10 additions & 1 deletion packages/react-email/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import { setupHotreloading, startDevServer } from '../utils/index.js';
interface Args {
dir: string;
port: string;
resendApiKey?: string;
from?: string;
}

export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => {
export const dev = async ({
dir: emailsDirRelativePath,
port,
resendApiKey,
from,
}: Args) => {
try {
if (!fs.existsSync(emailsDirRelativePath)) {
console.error(`Missing ${emailsDirRelativePath} folder`);
Expand All @@ -17,6 +24,8 @@ export const dev = async ({ dir: emailsDirRelativePath, port }: Args) => {
emailsDirRelativePath,
emailsDirRelativePath, // defaults to ./emails/static for the static files that are served to the preview
Number.parseInt(port, 10),
resendApiKey,
from,
);

await setupHotreloading(devServer, emailsDirRelativePath);
Expand Down
8 changes: 8 additions & 0 deletions packages/react-email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ if (!hasRequiredFlags) {
'./emails',
)
.option('-p --port <port>', 'Port to run dev server on', '3000')
.option(
'--resend-api-key <key>',
'Resend API key for the preview app (overrides stored config)',
)
.option(
'--from <address>',
'Default sender address for test emails (e.g. "You <you@example.com>")',
)
.action(dev);

program
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const getEnvVariablesForPreviewApp = (
previewServerLocation: string,
cwd: string,
resendApiKey?: string,
from?: string,
) => {
return {
REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH:
Expand All @@ -16,5 +17,6 @@ export const getEnvVariablesForPreviewApp = (
REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation,
REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: cwd,
REACT_EMAIL_INTERNAL_RESEND_API_KEY: resendApiKey,
REACT_EMAIL_INTERNAL_FROM_ADDRESS: from,
} as const;
};
7 changes: 6 additions & 1 deletion packages/react-email/src/utils/preview/start-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const startDevServer = async (
emailsDirRelativePath: string,
staticBaseDirRelativePath: string,
port: number,
resendApiKey?: string,
from?: string,
): Promise<http.Server> => {
const [majorNodeVersion] = process.versions.node.split('.');
if (majorNodeVersion && Number.parseInt(majorNodeVersion, 10) < 20) {
Expand Down Expand Up @@ -97,6 +99,8 @@ export const startDevServer = async (
emailsDirRelativePath,
staticBaseDirRelativePath,
nextPortToTry,
resendApiKey,
from,
);
}

Expand Down Expand Up @@ -132,7 +136,8 @@ export const startDevServer = async (
path.normalize(emailsDirRelativePath),
previewServerLocation,
process.cwd(),
conf.get('resendApiKey'),
resendApiKey ?? conf.get('resendApiKey'),
from,
),
};

Expand Down
Loading