Skip to main content
Version: 2.0.0

Send an email from your module

Prerequisites

You need a module and a client connected to it, and an API key from an email provider. This guide uses Resend. If you don't have a module yet, follow the quickstart for your language first.

There's no C++ version of this guide yet.

In this guide, you'll send emails to players from your module, such as a welcome email when a player adds their address. Your module calls your email provider's HTTP API, so the provider's API key never reaches the client. You'll queue each email in a schedule table, send it from a procedure, and store the API key in an environment variable.

How it works​

  • An EMAIL_API_KEY environment variable stores your provider API key on the database. You set it when you publish.
  • A private player_email table holds each player's email address. A set_email reducer saves it.
  • A private email_outbox schedule table holds the emails to send. A queue_email function adds one, from any reducer.
  • Each row of email_outbox runs the send_email procedure, which sends the email to your provider. If the provider can't take it right now, send_email queues it again a minute later, up to 3 attempts in all.

Reducers can't make HTTP requests, because they run inside a transaction, so they queue emails instead of sending them. The procedure only sends an email once the transaction that queued it has committed. If a reducer fails, its changes are undone, including its rows in email_outbox, so its emails are never sent.

Define the tables and the API key​

Declare an EMAIL_API_KEY environment variable in your module. It's required, so SpacetimeDB refuses to publish the module without a value, and your procedure can always read it.

Then add the two tables. Both are private, so clients can't read anyone's address, or the emails waiting to be sent:

  • player_email has one row per player who added an address.
  • email_outbox is the schedule table. Each row is an email: its recipient, subject and text, and attempt, which counts the times it has been tried. Its scheduled_at column says when to send it, and SpacetimeDB then calls send_email with the row.
import { ScheduleAt, TimeDuration, type Infer } from 'spacetimedb';
import { schema, table, t, SenderError, type InferSchema, type ProcedureCtx, type ReducerCtx } from 'spacetimedb/server';

const playerEmail = table(
  { name: 'player_email' },
  {
    identity: t.identity().primaryKey(),
    email: t.string(),
  }
);

const emailOutbox = table(
  { name: 'email_outbox' },
  {
    scheduledId: t.u64().primaryKey().autoInc(),
    scheduledAt: t.scheduleAt(),
    to: t.string(),
    subject: t.string(),
    text: t.string(),
    attempt: t.u32(),
  }
);

const spacetimedb = schema(
  { playerEmail, emailOutbox },
  { env: { EMAIL_API_KEY: t.string() } }
);
export default spacetimedb;

type Ctx = ReducerCtx<InferSchema<typeof spacetimedb>>;
type ProcCtx = ProcedureCtx<InferSchema<typeof spacetimedb>>;
type Email = Infer<typeof emailOutbox.rowType>;

Set the key when you publish, by putting it in the publishing command's environment:

EMAIL_API_KEY='your-api-key' spacetime publish my-game

The key stays in the database across later publishes, so you only pass it once. To replace it, publish again with --env-only, which updates environment variables without uploading your module:

EMAIL_API_KEY='your-new-api-key' spacetime publish my-game --env-only

Publishing with --delete-data erases the key along with your other data, so pass it again when you do. See Environment Variables for every way to supply it.

warning

Keep the key away from anything clients can read: don't put it in a spacetime.json file you commit, and don't return it from a procedure or a view, or write it to the logs.

Queue emails​

Add a queue_email function that inserts an email into email_outbox, scheduled for the current time. Call it from any reducer that needs to send an email. SpacetimeDB runs send_email with the row once the reducer's transaction commits.

// Queues an email. It's sent once the transaction commits, and never if it fails.
function queueEmail(ctx: Ctx, to: string, subject: string, text: string) {
  ctx.db.emailOutbox.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch),
    to,
    subject,
    text,
    attempt: 1,
  });
}

Send queued emails​

Add a post_email function that sends an email to Resend's send email API, and returns the HTTP status of the reply, or 0 if the request didn't get a reply. It's the only provider-specific code: other providers, such as Postmark, SendGrid or Amazon SES, work the same way, with a different URL, headers and body.

Replace the from address with one on a domain you've verified with your provider. Providers reject emails from other domains.

Then add the send_email procedure. SpacetimeDB calls it with each row of email_outbox when the row's time comes, and deletes the row first. send_email sends the email with post_email, then looks at the status:

  • A 2xx status means the provider accepted the email.
  • No reply, a 429 status (too many requests) or a 5xx status (a server error) means the provider couldn't take the email right now. send_email inserts it again, a minute later, until it has tried 3 times.
  • Any other status means the provider rejected the email, for example because the address is invalid, or the API key is wrong. Trying again wouldn't help, so send_email logs the status and drops the email.
const FROM = 'My Game <noreply@mygame.example>';
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MICROS = 60_000_000n; // 1 minute

// Sends the email, and returns the HTTP status of the reply, or 0 if there was no reply.
function postEmail(ctx: ProcCtx, email: Email): number {
  try {
    const response = ctx.http.fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${ctx.env.EMAIL_API_KEY}`,
      },
      body: JSON.stringify({
        from: FROM,
        to: [email.to],
        subject: email.subject,
        text: email.text,
      }),
      timeout: TimeDuration.fromMillis(10_000),
    });
    return response.status;
  } catch (error) {
    console.error(`Could not reach the email API: ${error}`);
    return 0;
  }
}

export const sendEmail = spacetimedb.procedure(
  { onSchedule: emailOutbox },
  { email: emailOutbox.rowType },
  t.unit(),
  (ctx, { email }) => {
    const status = postEmail(ctx, email);
    if (status >= 200 && status < 300) {
      return {};
    }
    const canRetry = status === 0 || status === 429 || status >= 500;
    if (!canRetry || email.attempt >= MAX_ATTEMPTS) {
      console.error(`Dropping email ${email.scheduledId} after attempt ${email.attempt}: status ${status}`);
      return {};
    }
    ctx.withTx(tx => {
      tx.db.emailOutbox.insert({
        ...email,
        scheduledId: 0n,
        scheduledAt: ScheduleAt.time(tx.timestamp.microsSinceUnixEpoch + RETRY_DELAY_MICROS),
        attempt: email.attempt + 1,
      });
    });
    return {};
  }
);

The logs don't include the recipient or the text of the email, so they don't reveal players' addresses. Clients can't call send_email themselves: scheduled procedures are private, so only the scheduler, the database owner and team collaborators can run them. That's what stops a client from sending any email it likes through your account.

Let players add their address​

Add a check_email function that removes the spaces at both ends of an address, and rejects it if it's too long, contains spaces, or doesn't have an @ followed by a domain with a dot. This catches typos, but only an email the player receives proves that the address is theirs.

Then add a set_email reducer that saves the caller's address. The first time a player adds an address, it also queues a welcome email. Changing the address later doesn't send anything, so a player can't use set_email to send email after email.

const MAX_EMAIL_LENGTH = 254;

// Removes the spaces at both ends of an address, and checks that it looks like one.
function checkEmail(email: string) {
  const trimmed = email.trim();
  const at = trimmed.indexOf('@');
  if (
    trimmed.length > MAX_EMAIL_LENGTH ||
    /\s/.test(trimmed) ||
    at < 1 ||
    !trimmed.slice(at + 1).includes('.')
  ) {
    throw new SenderError("That doesn't look like an email address");
  }
  return trimmed;
}

export const setEmail = spacetimedb.reducer({ email: t.string() }, (ctx, { email }) => {
  const address = checkEmail(email);
  const existing = ctx.db.playerEmail.identity.find(ctx.sender);
  if (existing) {
    ctx.db.playerEmail.identity.update({ ...existing, email: address });
    return;
  }
  ctx.db.playerEmail.insert({ identity: ctx.sender, email: address });
  queueEmail(ctx, address, 'Welcome to My Game', "Thanks for adding your email address. We'll write when something needs your attention.");
});
warning

Anyone can connect with a new identity, so anyone can still make your module email any address, once per identity. Before you go live, let only signed-in players add an address, send a confirmation link before you send anything else, and limit how many emails your module sends in total. Providers suspend accounts that send too many unwanted emails.

Call it from a client​

On the client, call set_email with the address the player entered, and show the error if the reducer rejects it.

import { DbConnection } from './module_bindings';

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .build();

// Call this when the player saves their email address.
async function saveEmail(email: string) {
  try {
    await conn.reducers.setEmail({ email });
  } catch (error) {
    console.error(`Could not save the email address: ${error}`);
  }
}

What's next?​

You now send emails from your module:

  • Reducers queue emails in a private schedule table, so an email only goes out if the change that asked for it commits.
  • A scheduled procedure sends each email, with the API key from an environment variable, and tries again when the provider can't take it right now.
  • Players' addresses are in a private table, and clients can't send emails directly.

To adapt it to your app:

  • Notify players who aren't online. Call queue_email from the reducers where something happens that a player shouldn't miss, such as send_direct_message in Chat rooms and private DMs. To only email players who aren't connected, record who is online with the client_connected and client_disconnected lifecycle reducers.
  • Confirm addresses. Add a code to player_email, send it in the welcome email, and add a reducer that marks the address as confirmed when the player enters it. Only send other emails to confirmed addresses.
  • Duplicate emails. If the provider accepts an email but its reply is lost, send_email sends it again. To avoid that, store an id with each email that stays the same across attempts, and send it in Resend's Idempotency-Key header.
  • HTML emails. Send an html field along with text. Keep text for email apps that don't show HTML.
  • Unsubscribe. Add a reducer that deletes the caller's player_email row, and tell players about it in each email.
  • Track delivery. In a TypeScript module, the Resend submodule, a package you add to your module, sends email through Resend and stores whether each email was delivered, delayed or bounced, from Resend's webhooks.
  • Wait longer between attempts. In a TypeScript module, the Retry submodule retries background tasks with a limit on attempts, and waits twice as long before each new attempt.

To learn more about the features used here, see Procedures, Schedule Tables and Environment Variables.

Next, you can call an external API from your module.