npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-js

Usage

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.