@lovable.dev/email-js
v0.7.0
Published
Lovable Email JS
Readme
@lovable.dev/email-js
Utilities for Lovable email hooks and delivery.
Installation
npm install @lovable.dev/email-jsUsage
Sending with a run_id (build-context emails)
When sending from a webhook where a build run exists, pass the run_id from
the payload. You can override the idempotency key (defaults to run_id).
import { parseEmailWebhookPayload, sendLovableEmail } from "@lovable.dev/email-js";
import { verifyWebhookRequest, type EmailWebhookPayload } from "@lovable.dev/webhooks-js";
const apiKey = Deno.env.get("LOVABLE_API_KEY");
export async function handleWebhook(req: Request) {
if (!apiKey) {
throw new Error("Missing Lovable API key");
}
const { body } = await verifyWebhookRequest<EmailWebhookPayload>({
req,
secret: apiKey,
});
const payload = parseEmailWebhookPayload(body);
if (payload.version !== "1") {
throw new Error(`Unsupported payload version: ${payload.version}`);
}
if (!payload.run_id) {
throw new Error("Missing run_id");
}
const apiBaseUrl = payload.data?.api_base_url ?? "https://api.lovable.dev";
await sendLovableEmail(
{
run_id: payload.run_id,
to: payload.data?.email ?? "",
from: { name: "My App", address: "[email protected]" },
subject: "Welcome",
html: "<p>Hello</p>",
text: "Hello",
purpose: "transactional",
},
{ apiKey, apiBaseUrl },
);
}The from field
from takes either a raw RFC 5322 header string
("My App <[email protected]>") or a parts object
({ name: "My App", address: "[email protected]" }). Prefer the parts
form: the SDK (formatEmailFrom, also exported) quotes and escapes the
display name, so brand names containing , : ; or non-ASCII characters
cannot produce an unparseable header the send API would reject.
Sending without a run_id (runtime-triggered emails)
For transactional emails sent outside a build context (e.g., webhook handlers,
admin actions, database triggers), run_id is not required. Provide an
idempotency_key with purpose: "transactional" and the API creates a
transactional run automatically:
await sendLovableEmail(
{
to: "[email protected]",
from: "App <[email protected]>",
sender_domain: "notify.example.com",
subject: "Your request was approved",
html: "<p>Approved!</p>",
text: "Approved!",
purpose: "transactional",
idempotency_key: `approval-${requestId}`,
unsubscribe_token: token,
},
{ apiKey },
);All clients take the client options ({ apiKey, apiBaseUrl?, authHeader? })
as their last argument.
Cc and bcc
cc and bcc take arrays of addresses ("[email protected]" or "Name <[email protected]>").
Cc recipients appear in the message's Cc header; bcc recipients receive the
message without appearing in any header. A send is limited to 5 recipients
across to, cc, and bcc, and the addresses must be distinct.
await sendLovableEmail(
{
to: "[email protected]",
cc: ["[email protected]"],
bcc: ["[email protected]"],
// ... as above
},
{ apiKey },
);Two things to know: a cc/bcc recipient who previously unsubscribed or bounced
is silently dropped from the send (the primary recipient still gets it), and
unsubscribing is scoped to the primary to recipient — the link in the
message body and the mail client's native unsubscribe button both carry the
primary's token, so a cc'd reader using either one unsubscribes the primary
recipient.
Listing email logs
listEmailLogs returns recent delivery events for the calling project.
since/until are RFC3339 timestamps, clamped server-side to the workspace
plan's retention window. Results are cursor-paginated (max 100 per page).
import { listEmailLogs } from "@lovable.dev/email-js";
const { data, pagination } = await listEmailLogs(
{ recipient: "[email protected]", event_type: "delivered", since: "2026-06-01T00:00:00Z", limit: 50 },
{ apiKey },
);
if (pagination.next_cursor) {
const nextPage = await listEmailLogs({ cursor: pagination.next_cursor }, { apiKey });
}Managing unsubscribes
Unsubscribe state is scoped to the recipient and the sender domain's apex.
Setting subscribed: true lifts a sender-driven unsubscribe; recipients who
filed a spam complaint cannot be resubscribed — the API responds 409 and the
thrown EmailAPIError has code === "complaint_not_liftable".
import { getEmailUnsubscribe, setEmailUnsubscribe } from "@lovable.dev/email-js";
const state = await getEmailUnsubscribe(
{ recipient: "[email protected]", domain: "notify.example.com" },
{ apiKey },
);
await setEmailUnsubscribe(
{ recipient: "[email protected]", domain: "notify.example.com", subscribed: false },
{ apiKey },
);Handling email webhooks
createEmailWebhookHandler verifies the X-Lovable-Signature on each delivery
(signed with the project's LOVABLE_API_KEY) and dispatches to the handler
registered for the event type. Write only handler bodies — verification stays
inside the package.
import { createEmailWebhookHandler } from "@lovable.dev/email-js";
const handler = createEmailWebhookHandler({
apiKey: Deno.env.get("LOVABLE_API_KEY")!,
on: {
"email.bounced": async (event) => {
await markEmailUndeliverable(event.data.recipient);
},
"email.unsubscribed": async (event) => {
await recordOptOut(event.data.recipient);
},
},
});
Deno.serve(handler);Event types: email.bounced, email.complaint, email.unsubscribed,
email.resubscribed (defined but not yet emitted). Events without a
registered handler are acknowledged with 200. A handler that throws returns
500 and the delivery is retried (up to 5 attempts in total, with backoff) —
use event.event_id (also sent as the X-Lovable-Delivery header) to
deduplicate redeliveries.
Handling auth email webhooks
createAuthEmailHandler is the auth counterpart: it verifies the delivery the
same way, then renders and sends the requested auth email (requires the
optional react and @react-email/render peer dependencies). The emails
map is total — all six action types (signup, invite, magiclink,
recovery, email_change, reauthentication) must be present, so a missing
handler is a compile error rather than a silently-unsent password reset.
import { createAuthEmailHandler } from "@lovable.dev/email-js";
const handler = createAuthEmailHandler({
apiKey: process.env.LOVABLE_API_KEY!,
from: { name: "My App", address: "[email protected]" }, // sender domain derived from the address
replyTo: { name: "My App Support", address: "[email protected]" },
sendUrl: process.env.LOVABLE_SEND_URL,
emails: {
signup: { subject: "Confirm your email", render: (data) => <SignupEmail confirmationUrl={data.url} /> },
invite: { subject: "You've been invited", render: (data) => <InviteEmail confirmationUrl={data.url} /> },
magiclink: { subject: "Your login link", render: (data) => <MagicLinkEmail confirmationUrl={data.url} /> },
recovery: { subject: "Reset your password", render: (data) => <RecoveryEmail confirmationUrl={data.url} /> },
email_change: { subject: "Confirm your new email", render: (data) => <EmailChangeEmail confirmationUrl={data.url} /> },
reauthentication: { subject: "Your verification code", render: (data) => <ReauthenticationEmail token={data.token ?? ""} /> },
},
});Projects on Lovable Auth pass provider: "lovable_auth". Lovable Auth sends
one-time codes rather than links, so the emails map is total over
signup_confirmation, sign_in_otp and recovery_otp, and render receives
code, expires_at (ISO 8601) and locale (the BCP 47 tag of the language the
user saw on the hosted sign-in page, en when unknown) instead of url and
token. The default provider is "lovable_cloud", the handler shown above.
Two more actions are optional. email_change_otp is the code sent to the
address a signed-in user wants as their new sign-in email, with the same
code and expires_at as the other codes. email_changed_notice goes to the
previous address once the change has landed; its render receives
LovableAuthNoticeEmailHookData, which has new_email, the address the
account now signs in with, and no code. An action the map leaves out is sent
by Lovable with its built-in copy. A hook that was already active when an
optional action is added keeps the built-in copy for it until custom auth
emails are turned on again, which records the new keys.
const handler = createAuthEmailHandler({
provider: "lovable_auth",
apiKey: process.env.LOVABLE_API_KEY!,
from: { name: "My App", address: "[email protected]" },
sendUrl: process.env.LOVABLE_SEND_URL,
emails: {
signup_confirmation: { subject: "Confirm your email", render: (data) => <CodeEmail code={data.code} expiresAt={data.expires_at} /> },
sign_in_otp: { subject: "Your sign-in code", render: (data) => <CodeEmail code={data.code} expiresAt={data.expires_at} /> },
recovery_otp: { subject: "Reset your password", render: (data) => <CodeEmail code={data.code} expiresAt={data.expires_at} /> },
email_change_otp: { subject: "Confirm your new email", render: (data) => <CodeEmail code={data.code} expiresAt={data.expires_at} /> },
email_changed_notice: { subject: "Security alert: your email address was changed", render: (data) => <EmailChangedEmail oldEmail={data.email} newEmail={data.new_email} /> },
},
});replyTo takes the same string-or-parts form as from and sets the Reply-To
header, so a user who replies to a confirmation email reaches a monitored
inbox instead of the noreply sender. Omit it and replies go to from.
render may be async — look up personalization data first, and fall back
(?? "friend") instead of throwing when it's missing: auth emails are
load-bearing. A per-type entry can also be a single async function returning
{ subject, element } when subject and body share one lookup.
The auth webhook is a work request, not a notification, so its failure
semantics differ from createEmailWebhookHandler: unknown action types and
payload versions return 400 (a permanent delivery failure recorded on the
run) instead of being acked, and a render failure or retryable send failure
returns 500 so the delivery is retried — the payload's run_id doubles as
the send idempotency key, so retries cannot double-send. Permanent send
rejections (for example an expired run) also return 400 rather than burning
retries on a deterministic failure. The 400 for an unknown action type carries
{ "error": "unsupported_action_type", "action_type": "<type>", "supported_action_types": [...] },
the keys of your emails map, so the caller can tell a missing template from
any other rejection.
