Back to Blog

How to Send Emails in Deno (2026 Guide)

18 min read

Deno has fetch built in. No extra packages needed to make HTTP requests. Email sending is one API call. Deno's permission model adds a security layer on top: your code can only access the network and environment variables if you explicitly allow it with --allow-net and --allow-env.

Most Deno email tutorials show a single fetch call and stop. This guide covers the full picture: email client abstraction, Fresh framework integration, React Email templates, Stripe webhooks, error handling, and Deno Deploy. If you're using Hono on Deno, see our dedicated Hono email guide. For other runtimes, check out Bun or Node.js.

Pick a Provider

Every code example below lets you switch between three providers.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. Native Stripe integration and built-in retries.
  • Resend is developer-friendly. Clean API, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise option. Feature-rich, high volume. Bigger API surface.

Create an Email Client

Deno's built-in fetch is all you need. No npm packages required. Create a typed client:

lib/email.ts
const SEQUENZY_API_KEY = Deno.env.get("SEQUENZY_API_KEY")!;
const BASE_URL = "https://api.sequenzy.com/v1";

interface SendResult {
id: string;
success: boolean;
}

export async function sendEmail(to: string, subject: string, body: string): Promise<SendResult> {
const response = await fetch(`${BASE_URL}/transactional/send`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ to, subject, body }),
});

if (!response.ok) {
  const error = await response.json().catch(() => ({ message: response.statusText }));
  throw new Error(`Email failed (${response.status}): ${error.message || response.statusText}`);
}

return response.json();
}
lib/email.ts
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!;

interface SendResult {
id: string;
}

export async function sendEmail(to: string, subject: string, html: string): Promise<SendResult> {
const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${RESEND_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "YourApp <noreply@yourdomain.com>",
    to,
    subject,
    html,
  }),
});

if (!response.ok) {
  const error = await response.json().catch(() => ({ message: response.statusText }));
  throw new Error(`Email failed (${response.status}): ${error.message || response.statusText}`);
}

return response.json();
}
lib/email.ts
const SENDGRID_API_KEY = Deno.env.get("SENDGRID_API_KEY")!;

export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${SENDGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: to }] }],
    from: { email: "noreply@yourdomain.com" },
    subject,
    content: [{ type: "text/html", value: html }],
  }),
});

if (!response.ok) {
  const error = await response.text();
  throw new Error(`Email failed (${response.status}): ${error}`);
}
}

No npm install needed. Deno's fetch is a first-class citizen that works exactly like the browser fetch API.

Send with Deno.serve

Deno.serve is the simplest way to create an HTTP server. Here's a complete API with routing:

main.ts
import { sendEmail } from "./lib/email.ts";

Deno.serve({ port: 3000 }, async (req) => {
const url = new URL(req.url);

// Contact form endpoint
if (req.method === "POST" && url.pathname === "/api/contact") {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch (error) {
    console.error("Failed to send:", error);
    return Response.json({ error: "Failed to send message" }, { status: 500 });
  }
}

// Welcome email endpoint
if (req.method === "POST" && url.pathname === "/api/send-welcome") {
  const { email, name } = await req.json();

  if (!email || !name) {
    return Response.json({ error: "email and name required" }, { status: 400 });
  }

  try {
    const result = await sendEmail(
      email,
      `Welcome, ${name}!`,
      `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
    );
    return Response.json(result);
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
}

return new Response("Not Found", { status: 404 });
});
main.ts
import { sendEmail } from "./lib/email.ts";

Deno.serve({ port: 3000 }, async (req) => {
const url = new URL(req.url);

if (req.method === "POST" && url.pathname === "/api/contact") {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch (error) {
    console.error("Failed to send:", error);
    return Response.json({ error: "Failed to send message" }, { status: 500 });
  }
}

if (req.method === "POST" && url.pathname === "/api/send-welcome") {
  const { email, name } = await req.json();

  if (!email || !name) {
    return Response.json({ error: "email and name required" }, { status: 400 });
  }

  try {
    const result = await sendEmail(
      email,
      `Welcome, ${name}!`,
      `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
    );
    return Response.json(result);
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
}

return new Response("Not Found", { status: 404 });
});
main.ts
import { sendEmail } from "./lib/email.ts";

Deno.serve({ port: 3000 }, async (req) => {
const url = new URL(req.url);

if (req.method === "POST" && url.pathname === "/api/contact") {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch (error) {
    console.error("Failed to send:", error);
    return Response.json({ error: "Failed to send message" }, { status: 500 });
  }
}

if (req.method === "POST" && url.pathname === "/api/send-welcome") {
  const { email, name } = await req.json();

  if (!email || !name) {
    return Response.json({ error: "email and name required" }, { status: 400 });
  }

  try {
    await sendEmail(
      email,
      `Welcome, ${name}!`,
      `<h1>Welcome, ${name}</h1><p>Your account is ready.</p>`,
    );
    return Response.json({ success: true });
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
}

return new Response("Not Found", { status: 404 });
});

Run with:

deno run --allow-net --allow-env main.ts

The --allow-net flag enables network access and --allow-env allows reading environment variables. You can restrict these to specific domains and variables for tighter security:

deno run --allow-net=api.sequenzy.com,0.0.0.0:3000 --allow-env=SEQUENZY_API_KEY main.ts

Fresh Framework

Fresh is Deno's full-stack framework. It has file-based routing, islands for interactivity, and server-side rendering.

routes/api/contact.ts
import type { Handlers } from "$fresh/server.ts";
import { sendEmail } from "../../lib/email.ts";

export const handler: Handlers = {
async POST(req) {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
},
};
routes/api/contact.ts
import type { Handlers } from "$fresh/server.ts";
import { sendEmail } from "../../lib/email.ts";

export const handler: Handlers = {
async POST(req) {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
},
};
routes/api/contact.ts
import type { Handlers } from "$fresh/server.ts";
import { sendEmail } from "../../lib/email.ts";

export const handler: Handlers = {
async POST(req) {
  const { name, email, message } = await req.json();

  if (!name || !email || !message) {
    return Response.json({ error: "All fields are required" }, { status: 400 });
  }

  try {
    await sendEmail(
      "you@yourcompany.com",
      `Contact from ${name}`,
      `
        <h2>New Contact Form</h2>
        <p><strong>Name:</strong> ${name}</p>
        <p><strong>Email:</strong> ${email}</p>
        <p><strong>Message:</strong></p>
        <p>${message}</p>
      `,
    );
    return Response.json({ success: true });
  } catch {
    return Response.json({ error: "Failed to send" }, { status: 500 });
  }
},
};

Fresh can also handle form submissions server-side. The handler runs on the server, and the page renders with the result:

// routes/contact.tsx
import type { Handlers, PageProps } from "$fresh/server.ts";
import { sendEmail } from "../lib/email.ts";
 
interface Data {
  success?: boolean;
  error?: string;
}
 
export const handler: Handlers<Data> = {
  async POST(req, ctx) {
    const form = await req.formData();
    const name = form.get("name") as string;
    const email = form.get("email") as string;
    const message = form.get("message") as string;
 
    if (!name || !email || !message) {
      return ctx.render({ error: "All fields are required" });
    }
 
    try {
      await sendEmail(
        "you@yourcompany.com",
        `Contact from ${name}`,
        `<p><strong>${name}</strong> (${email}): ${message}</p>`,
      );
      return ctx.render({ success: true });
    } catch {
      return ctx.render({ error: "Failed to send" });
    }
  },
};
 
export default function ContactPage({ data }: PageProps<Data>) {
  return (
    <div>
      <h1>Contact Us</h1>
      {data?.success ? (
        <p style="color: green">Message sent!</p>
      ) : (
        <form method="POST">
          <input name="name" placeholder="Your name" required />
          <input name="email" type="email" placeholder="Your email" required />
          <textarea name="message" placeholder="Your message" required />
          <button type="submit">Send</button>
          {data?.error && <p style="color: red">{data.error}</p>}
        </form>
      )}
    </div>
  );
}

React Email Templates

React Email works in Deno since Deno supports npm packages. Install it:

deno add npm:@react-email/components npm:react npm:react-dom
// emails/welcome.tsx
import { Html, Body, Container, Text, Button, Heading, Hr } from "npm:@react-email/components";
 
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}
 
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container style={{
          backgroundColor: "#ffffff",
          padding: "40px",
          borderRadius: "8px",
          margin: "40px auto",
          maxWidth: "560px",
        }}>
          <Heading as="h1" style={{ fontSize: "24px", color: "#1a1a1a" }}>
            Welcome, {name}!
          </Heading>
          <Text style={{ fontSize: "16px", color: "#4a4a4a", lineHeight: "26px" }}>
            Your account is ready. Set up your first project, invite your team, and connect your integrations.
          </Text>
          <Button
            href={loginUrl}
            style={{
              backgroundColor: "#5046e5",
              color: "#ffffff",
              padding: "12px 24px",
              borderRadius: "6px",
              textDecoration: "none",
              display: "inline-block",
              marginTop: "16px",
            }}
          >
            Go to Dashboard
          </Button>
          <Hr style={{ borderColor: "#e6ebf1", margin: "32px 0" }} />
          <Text style={{ color: "#8898aa", fontSize: "12px" }}>
            YourApp Inc. · 123 Main St · San Francisco, CA
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Render and send:

lib/send-welcome.ts
import { render } from "npm:@react-email/components";
import { sendEmail } from "./email.ts";
import { WelcomeEmail } from "../emails/welcome.tsx";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return sendEmail(to, `Welcome to YourApp, ${name}!`, html);
}
lib/send-welcome.ts
import { render } from "npm:@react-email/components";
import { sendEmail } from "./email.ts";
import { WelcomeEmail } from "../emails/welcome.tsx";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return sendEmail(to, `Welcome to YourApp, ${name}!`, html);
}
lib/send-welcome.ts
import { render } from "npm:@react-email/components";
import { sendEmail } from "./email.ts";
import { WelcomeEmail } from "../emails/welcome.tsx";

export async function sendWelcomeEmail(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} loginUrl="https://app.yoursite.com/login" />);

return sendEmail(to, `Welcome to YourApp, ${name}!`, html);
}

Common SaaS Patterns

Password Reset

routes/api/forgot-password.ts
import { sendEmail } from "../../lib/email.ts";
import { render } from "npm:@react-email/components";
import { Html, Body, Container, Text, Button } from "npm:@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. Expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
      </Container>
    </Body>
  </Html>
);
}

export const handler: Handlers = {
async POST(req) {
  const { email } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  const user = await db.user.findByEmail(email);
  if (!user) return Response.json({ success: true });

  const token = crypto.randomUUID();
  await db.resetToken.create({ userId: user.id, token, expires: new Date(Date.now() + 3600000) });

  const html = await render(<ResetEmail url={`${Deno.env.get("APP_URL")}/reset-password?token=${token}`} />);
  await sendEmail(email, "Reset your password", html);

  return Response.json({ success: true });
},
};
routes/api/forgot-password.ts
import { sendEmail } from "../../lib/email.ts";
import { render } from "npm:@react-email/components";
import { Html, Body, Container, Text, Button } from "npm:@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. Expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
      </Container>
    </Body>
  </Html>
);
}

export const handler: Handlers = {
async POST(req) {
  const { email } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  const user = await db.user.findByEmail(email);
  if (!user) return Response.json({ success: true });

  const token = crypto.randomUUID();
  await db.resetToken.create({ userId: user.id, token, expires: new Date(Date.now() + 3600000) });

  const html = await render(<ResetEmail url={`${Deno.env.get("APP_URL")}/reset-password?token=${token}`} />);
  await sendEmail(email, "Reset your password", html);

  return Response.json({ success: true });
},
};
routes/api/forgot-password.ts
import { sendEmail } from "../../lib/email.ts";
import { render } from "npm:@react-email/components";
import { Html, Body, Container, Text, Button } from "npm:@react-email/components";

function ResetEmail({ url }: { url: string }) {
return (
  <Html>
    <Body style={{ fontFamily: "sans-serif", backgroundColor: "#f6f9fc" }}>
      <Container style={{ backgroundColor: "#fff", padding: "40px", borderRadius: "8px", margin: "40px auto" }}>
        <Text style={{ fontSize: "20px", fontWeight: "bold" }}>Reset your password</Text>
        <Text style={{ color: "#4a4a4a" }}>Click below to choose a new password. Expires in 1 hour.</Text>
        <Button href={url} style={{ backgroundColor: "#5046e5", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}>
          Reset Password
        </Button>
      </Container>
    </Body>
  </Html>
);
}

export const handler: Handlers = {
async POST(req) {
  const { email } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  const user = await db.user.findByEmail(email);
  if (!user) return Response.json({ success: true });

  const token = crypto.randomUUID();
  await db.resetToken.create({ userId: user.id, token, expires: new Date(Date.now() + 3600000) });

  const html = await render(<ResetEmail url={`${Deno.env.get("APP_URL")}/reset-password?token=${token}`} />);
  await sendEmail(email, "Reset your password", html);

  return Response.json({ success: true });
},
};

Note: Deno has crypto.randomUUID() built in (web-standard), no import needed.

Stripe Webhook

Deno uses web-standard Request, so request.text() gives you the raw body:

routes/api/stripe-webhook.ts
import Stripe from "npm:stripe";
import { sendEmail } from "../../lib/email.ts";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);

export const handler: Handlers = {
async POST(req) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, signature, Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
    );
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const email = session.customer_details?.email;
      if (email) {
        await sendEmail(
          email,
          "Payment received — thank you!",
          `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
        );
      }
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.customer_email) {
        await sendEmail(
          invoice.customer_email,
          "Payment failed — action required",
          `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
        );
      }
      break;
    }
  }

  return Response.json({ received: true });
},
};
routes/api/stripe-webhook.ts
import Stripe from "npm:stripe";
import { sendEmail } from "../../lib/email.ts";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);

export const handler: Handlers = {
async POST(req) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, signature, Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
    );
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const email = session.customer_details?.email;
      if (email) {
        await sendEmail(
          email,
          "Payment received — thank you!",
          `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
        );
      }
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.customer_email) {
        await sendEmail(
          invoice.customer_email,
          "Payment failed — action required",
          `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
        );
      }
      break;
    }
  }

  return Response.json({ received: true });
},
};
routes/api/stripe-webhook.ts
import Stripe from "npm:stripe";
import { sendEmail } from "../../lib/email.ts";

const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);

export const handler: Handlers = {
async POST(req) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, signature, Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
    );
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const email = session.customer_details?.email;
      if (email) {
        await sendEmail(
          email,
          "Payment received — thank you!",
          `<h2>Payment Receipt</h2><p>Thanks for your purchase of $${(session.amount_total! / 100).toFixed(2)}.</p>`,
        );
      }
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      if (invoice.customer_email) {
        await sendEmail(
          invoice.customer_email,
          "Payment failed — action required",
          `<h2>Your payment didn't go through</h2><p>Please update your payment method.</p>`,
        );
      }
      break;
    }
  }

  return Response.json({ received: true });
},
};

Error Handling

Build a safe wrapper with retry logic:

// lib/send-email-safe.ts
import { sendEmail } from "./email.ts";
 
interface SendResult {
  success: boolean;
  error?: string;
}
 
export async function sendEmailSafe(
  to: string,
  subject: string,
  body: string,
): Promise<SendResult> {
  try {
    await sendEmail(to, subject, body);
    return { success: true };
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    console.error(`Email error: ${message}`);
 
    if (message.includes("429") || message.includes("rate")) {
      return { success: false, error: "Too many emails. Try again later." };
    }
    if (message.includes("401") || message.includes("403")) {
      return { success: false, error: "Email service configuration error" };
    }
 
    return { success: false, error: "Failed to send email" };
  }
}
 
export async function sendEmailWithRetry(
  to: string,
  subject: string,
  body: string,
  maxAttempts = 3,
): Promise<void> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      await sendEmail(to, subject, body);
      return;
    } catch (error) {
      if (attempt === maxAttempts) throw error;
      const delay = Math.min(1000 * 2 ** (attempt - 1), 10000);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

Deno KV for Rate Limiting

Deno KV is a built-in key-value store that works locally and on Deno Deploy:

// lib/rate-limit.ts
const kv = await Deno.openKv();
 
export async function checkRateLimit(
  key: string,
  max = 5,
  windowMs = 60_000,
): Promise<boolean> {
  const kvKey = ["rate-limit", key];
  const entry = await kv.get<{ count: number; resetAt: number }>(kvKey);
 
  const now = Date.now();
 
  if (!entry.value || now > entry.value.resetAt) {
    await kv.set(kvKey, { count: 1, resetAt: now + windowMs });
    return true;
  }
 
  if (entry.value.count >= max) {
    return false;
  }
 
  await kv.set(kvKey, { count: entry.value.count + 1, resetAt: entry.value.resetAt });
  return true;
}

Use it in your handlers:

import { checkRateLimit } from "../lib/rate-limit.ts";
 
// In your handler:
const ip = req.headers.get("x-forwarded-for") || "unknown";
if (!await checkRateLimit(ip)) {
  return Response.json({ error: "Too many requests" }, { status: 429 });
}

Production Checklist

1. Verify Your Sending Domain

RecordTypePurpose
SPFTXTAuthorizes servers to send
DKIMTXTCryptographic signature
DMARCTXTPolicy for failed checks

For a detailed walkthrough, see our SPF, DKIM, and DMARC setup guide.

2. Permission Flags

Lock down permissions in production:

# Only allow what's needed
deno run \
  --allow-net=api.sequenzy.com,0.0.0.0:3000 \
  --allow-env=SEQUENZY_API_KEY,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET \
  main.ts

3. Deno Deploy

Deno Deploy runs your code globally at the edge. Set environment variables in the dashboard, and deploy with:

deployctl deploy --project=your-project main.ts

On Deno Deploy, permissions are automatically granted and Deno KV works without configuration.

4. Input Validation

Deno has no built-in validation, but npm packages work:

import { z } from "npm:zod";
 
const contactSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  message: z.string().min(10).max(5000),
});
 
// In handler:
const body = await req.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
  return Response.json({ error: parsed.error.issues[0].message }, { status: 400 });
}

Beyond Transactional

routes/api/subscribe.ts
const SEQUENZY_API_KEY = Deno.env.get("SEQUENZY_API_KEY")!;

export const handler: Handlers = {
async POST(req) {
  const { email, name } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  await fetch("https://api.sequenzy.com/v1/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SEQUENZY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email,
      attributes: { name },
      tags: ["signed-up"],
    }),
  });

  return Response.json({ success: true });
},
};

// Sequenzy triggers welcome sequences automatically
// when the "signed-up" tag is applied.
routes/api/subscribe.ts
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!;
const AUDIENCE_ID = Deno.env.get("RESEND_AUDIENCE_ID")!;

export const handler: Handlers = {
async POST(req) {
  const { email, name } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  const res = await fetch(`https://api.resend.com/audiences/${AUDIENCE_ID}/contacts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email, first_name: name }),
  });

  if (!res.ok) {
    return Response.json({ error: "Failed to subscribe" }, { status: 500 });
  }

  return Response.json({ success: true });
},
};
routes/api/subscribe.ts
const SENDGRID_API_KEY = Deno.env.get("SENDGRID_API_KEY")!;
const LIST_ID = Deno.env.get("SENDGRID_LIST_ID")!;

export const handler: Handlers = {
async POST(req) {
  const { email, name } = await req.json();
  if (!email) {
    return Response.json({ error: "Email is required" }, { status: 400 });
  }

  const res = await fetch("https://api.sendgrid.com/v3/marketing/contacts", {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${SENDGRID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      list_ids: [LIST_ID],
      contacts: [{ email, first_name: name }],
    }),
  });

  if (!res.ok) {
    return Response.json({ error: "Failed to subscribe" }, { status: 500 });
  }

  return Response.json({ success: true });
},
};

Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one SDK. Native Stripe integration tags subscribers automatically when they purchase, cancel, or churn.

FAQ

Do I need npm packages to send emails in Deno?

No. Deno's built-in fetch is all you need. Email providers have REST APIs, one HTTP call sends an email. You only need npm packages for React Email templates or if you prefer SDK wrappers over raw fetch calls.

How do Deno permissions protect my API keys?

Deno's permission system requires you to explicitly grant network and env access. Without --allow-env, your code can't read SEQUENZY_API_KEY. Without --allow-net, it can't make HTTP requests. You can restrict both to specific values (--allow-env=SEQUENZY_API_KEY and --allow-net=api.sequenzy.com).

Can I use npm packages in Deno?

Yes. Deno supports npm packages via the npm: specifier. Use import Stripe from "npm:stripe" or add them with deno add npm:stripe. Most npm packages work out of the box.

What's the difference between Deno.serve and Fresh?

Deno.serve is the built-in HTTP server. You handle routing manually. Fresh is a full-stack framework with file-based routing, islands (interactive components), and server-side rendering. Use Deno.serve for APIs and microservices. Use Fresh for full-stack web apps.

Does Deno Deploy support Stripe webhooks?

Yes. Deno Deploy uses web-standard Request objects, so request.text() gives you the raw body for Stripe's signature verification. No special configuration needed.

How do I use Deno KV for email features?

Deno KV is perfect for rate limiting (shown above), storing email delivery status, tracking retry counts, or caching template renders. It's built in, requires no setup, and works on both local Deno and Deno Deploy.

Can I use React Email with Fresh?

Yes. Fresh uses Preact, not React, but React Email runs server-side to produce HTML strings. It doesn't interact with Fresh's rendering. Import React Email components in your server-side handler, call render(), and pass the HTML string to your email provider.

How do I handle background email sending?

For non-blocking sends, use void:

// Don't await — sends in background
void sendEmail(email, subject, body);
return Response.json({ success: true });

For reliable background processing on Deno Deploy, use Deno Queues:

const kv = await Deno.openKv();
await kv.enqueue({ type: "send-email", to: email, subject, body });
 
// Listen for queued messages
kv.listenQueue(async (msg: { type: string; to: string; subject: string; body: string }) => {
  if (msg.type === "send-email") {
    await sendEmail(msg.to, msg.subject, msg.body);
  }
});

Wrapping Up

  1. Built-in fetch means zero dependencies for email sending
  2. Deno.serve creates lightweight HTTP servers for APIs
  3. Fresh provides full-stack routing with server-side form handling
  4. Permission flags lock down network and env access per-command
  5. Deno KV handles rate limiting and background queues natively
  6. React Email works via npm: imports for maintainable templates
  7. Deno Deploy runs your email code globally at the edge

Pick your provider, copy the patterns, and ship.

Frequently Asked Questions

Can I use npm email SDKs in Deno?

Yes. Deno supports npm packages via the npm: specifier (e.g., import Sequenzy from "npm:sequenzy"). Most email SDKs work out of the box since they use standard fetch internally, which Deno supports natively.

How do I handle environment variables for API keys in Deno?

Use Deno.env.get("API_KEY") to read environment variables. For local development, create a .env file and load it with --env flag (deno run --env script.ts). On Deno Deploy, set environment variables in the project dashboard.

Does Deno Deploy support email sending?

Yes. Deno Deploy supports outbound fetch requests, which is all email SDKs need. There are no cold start issues since Deploy uses V8 isolates. Just import your SDK and call it from your handler—it works the same as local development.

How do I send emails from a Deno Fresh application?

Create an API route in the routes/api/ directory and call your email SDK from the handler function. Fresh routes run server-side, so API keys stay safe. Use Fresh's Handlers type for proper request/response typing.

Can I use React Email templates with Deno?

React Email works with Deno via npm compatibility. Import the components with npm:@react-email/components and render them as you would in Node.js. Deno's JSX support handles the template compilation natively.

How do I test email sending in Deno?

Use Deno's built-in test runner (deno test) with mocks. Stub the email SDK's send function to verify it's called with correct parameters without making real API calls. Deno's testing API includes assertions and mocking utilities out of the box.

Is Deno's permission system an issue for email sending?

You need to grant network access with --allow-net for the email SDK to make HTTP requests. For environment variables, use --allow-env. In production (Deno Deploy), permissions are automatically granted. Locally, use --allow-all for development or specify exact permissions.

How do I send bulk emails without hitting Deno Deploy's request limits?

Deno Deploy has a 30-second execution limit per request. For bulk sends, use Deno's Deno.cron() (Deno KV cron) to schedule batch jobs, or use Deno Queues to process emails asynchronously across multiple invocations.

How do I handle webhook-triggered emails in Deno?

Create a handler that validates the webhook signature, parses the payload, and triggers the email send. Deno's crypto.subtle API handles HMAC signature verification natively. Return a 200 response quickly and process the email asynchronously if possible.

What's the advantage of Deno over Node.js for email sending?

Deno offers built-in TypeScript support, native fetch, and a secure-by-default permission system. For email sending specifically, the practical difference is small since both call the same HTTP APIs. The main advantage is developer experience—no tsconfig.json, no node_modules, and cleaner imports.