Email the owner
The form checks a visitor's details, but the owner still receives nothing. We'll connect the validated submission to Resend and report whether the provider accepted it. Then we'll check the inbox separately.
Start on workshop-interest in the website folder. Confirm the previous checkpoint is saved:
git status
git branch --show-currentKeep the email key out of fx. We can ask it to write code using environment-variable names without giving it the values.
Outcome
Send inquiries from the server to the configured owner address, and show useful feedback when sending fails.
Hands-on exercise 4.4
Check the files we'll change
The settings from 4.3 are ready. We'll connect them to the form by changing the temporary action and adding an email helper. Our review should answer two questions: does invalid input stop before sending, and does every valid inquiry go to the configured owner?
| File | What to inspect |
|---|---|
lib/submit-interest.ts | Checks the submission, builds the message, and handles a failed send |
app/actions/interest.ts | Reads the server settings and calls Resend |
components/interest-form.tsx | Shows the sending message and updated privacy note |
Use the file map from 4.2 if your project keeps these files under src. Keep credentials out of the agent session. fx only needs the variable names.
Install the email package
A software development kit, or SDK, is a package for calling a service from code. Install the course version of Resend's SDK with your project's package manager:
npm install resend@6.28.1For pnpm, use pnpm add resend@6.28.1. This updates package.json and its existing lockfile. We'll save both with the integration.
Add the sending logic
Start fx with /permissions ask and give it this request:
Connect the existing interest form to Resend using RESEND_API_KEY,
INTEREST_FROM_EMAIL, and INTEREST_TO_EMAIL on the server.
Keep validateInterest in lib/interest.ts. Add submitInterest in
lib/submit-interest.ts, accepting form data, config, a send function,
and a log function. The action in app/actions/interest.ts should
read settings and supply the Resend sender. Keep the existing src
structure if this project uses it.
Validate before sending plain-text email. Keep the recipient fixed
in server configuration and use the visitor's email only as reply-to.
Preserve entered values after failure. Log fixed event names only,
never submitted details, credentials, or raw provider errors.
Update the form's pending text and privacy note for Resend.
Do not read .env files, send email, commit, push, or deploy.After fx finishes, review the changed files against the table above. The reference panels provide complete implementations if a piece is missing. If you use them, add the helper before replacing the action that imports it.
In lib/submit-interest.ts, find the validation call before the send. Check that the destination comes from configuration. Compare with the full helper if needed:
Advanced: complete email helper (optional)
import { validateInterest, type InterestState } from "./interest";
import { getWorkshop } from "./workshops";
export type EmailConfig = {
apiKey?: string;
from?: string;
to?: string;
};
export type InterestEmail = {
from: string;
to: string;
replyTo: string;
subject: string;
text: string;
};
export type SendEmail = (email: InterestEmail) => Promise<{ accepted: boolean }>;
export type LogFailure = (event: string) => void;
// Pure orchestration: tests supply a fake sender, never credentials or a network call.
export async function submitInterest(
form: FormData,
config: EmailConfig,
send: SendEmail,
log: LogFailure,
): Promise<InterestState> {
const result = validateInterest(form);
if (!result.ok) return result.state;
if (!config.apiKey?.trim() || !config.from?.trim() || !config.to?.trim()) {
log("interest.email_unconfigured");
return { status: "error", message: "The interest form isn't connected to email yet. Please try again later." };
}
const workshop = getWorkshop(result.interest.workshopSlug)!;
const { name, email, message } = result.interest;
try {
const delivery = await send({
from: config.from,
to: config.to,
replyTo: email,
subject: `Workshop interest: ${workshop.title}`,
// Plain text prevents user-provided markup from becoming email HTML.
text: [
`Workshop: ${workshop.title}`,
`Name: ${name}`,
`Email: ${email}`,
"",
"Message:",
message || "No additional message.",
"",
"This visitor agreed to be contacted about this workshop only.",
].join("\n"),
});
if (!delivery.accepted) {
log("interest.email_rejected");
return { status: "error", message: "We couldn't send your request. Please try again in a moment." };
}
} catch {
// Never log credentials, form contents, or raw provider responses.
log("interest.email_failed");
return { status: "error", message: "We couldn't send your request. Please try again in a moment." };
}
return { status: "success", message: "Your request was accepted for sending. The studio will email you about the next date. This doesn't reserve a place." };
}The send argument is a function. In the application it calls Resend; in a test it can return a prepared result without contacting an email service. That separation lets us check failures without repeatedly sending email.
The helper validates first and checks configuration before calling the sender. A provider rejection logs interest.email_rejected; an exception logs interest.email_failed. These fixed names help us investigate a failure without writing the submitted name, email, or message into runtime logs.
Check that fx replaced the temporary action with the Resend adapter shown in the Solution. The form should now describe sending through Resend to the owner and use “Sending request…” while pending. Keep the demo and no-booking explanation.
Before testing, locate the owner recipient and visitor reply-to in the adapter. Leave the supplied error handling in place. We'll test its different outcomes without sending more email in 4.5.
Try It
On the local gardening page, submit one controlled inquiry using your own reply-to address and a short test message. The successful state should say the request was accepted for sending. Look in Resend for that send and check the owner inbox, including spam if necessary.
Verify the workshop title and message body. Inspect the reply-to address without sending a reply. Provider acceptance means Resend accepted the API request; it doesn't establish that an inbox received it. Record both observations separately.
Keep the submission count small. We'll test rejected and failed sends with a fake sender in the next lesson. Don't paste provider responses containing personal details into prompts.
The form says email isn't connected
Check that each required variable exists in Development, then restart the local server after pulling the values. Confirm the folder is linked to the intended project. The reference emits interest.email_unconfigured when a required value is missing or blank.
Resend rejects the request
Review the provider's dashboard for the controlled send. Check the API key's sending permission and the sender/recipient restrictions. With the testing sender, the recipient must be your Resend account address. Changing the visitor's reply-to address won't fix an unauthorized destination.
Commit
Review the source diff while keeping secret files closed. Stage the source and dependency files explicitly:
git add app/actions/interest.ts lib/submit-interest.ts components/interest-form.tsx package.json package-lock.json
git diff --cached
git commit -m "feat(interest): send workshop inquiries to the owner"Substitute pnpm-lock.yaml for package-lock.json in a pnpm project. Confirm .env.local isn't staged. Keep the branch ready for the preview checks.
Done-When
- The server reads all email settings from ignored environment configuration.
- The visitor's address is used only as reply-to.
- A controlled request is accepted and its delivery is checked in the owner inbox.
- The UI preserves the demo disclosure and doesn't claim a booking.
Solution
Use this complete adapter in app/actions/interest.ts if the generated connection needs repair:
Advanced: complete server adapter (optional)
"use server";
import { Resend } from "resend";
import type { InterestState } from "@/lib/interest";
import { submitInterest } from "@/lib/submit-interest";
export async function requestWorkshopInfo(
_previousState: InterestState,
formData: FormData,
): Promise<InterestState> {
// Read configuration only when the form is submitted. Pages work without it.
const config = {
apiKey: process.env.RESEND_API_KEY,
from: process.env.INTEREST_FROM_EMAIL,
to: process.env.INTEREST_TO_EMAIL,
};
return submitInterest(
formData,
config,
async (email) => {
const resend = new Resend(config.apiKey);
const { data, error } = await resend.emails.send({
from: email.from,
to: [email.to],
replyTo: email.replyTo,
subject: email.subject,
text: email.text,
});
return { accepted: !error && Boolean(data?.id) };
},
(event) => console.error(event),
);
}When a submission arrives, the adapter reads the settings and supplies a Resend sender to submitInterest. Success requires a message identifier from Resend with no error.
In components/interest-form.tsx, use this privacy note:
<p id="privacy-note" className="form-note">Your details are sent to the studio owner through Resend to respond to this inquiry. This form doesn't add you to a mailing list or reserve a place. This is a course demo; use test details.</p>Also change the pending button text to “Sending request…”. The remaining form and validator stay as written in 4.2. Check the controlled send before preparing a release.
Was this helpful?