Skip to content
Spacefast Docs
Esc
navigateopen⌘Jpreview
On this page

Email, spam, and Gravatar

Send email, check submissions for spam, and read Gravatar profiles from any Zero handler without holding a credential.

Every Zero handler context carries three platform services: ctx.gravatar reads public profiles, ctx.spam classifies visitor submissions, and ctx.email sends mail. The runtime brokers each call and attaches the credential outside your code. There is no mail password, spam API key, or Gravatar token to configure, and none can leak from a published bundle.

What a handler receives follows what it can do. A query can be replayed or re-run to refresh a live subscription, so a query receives only the calls that have no effects outside the database:

Handler ctx.gravatar ctx.spam ctx.email
query Yes check only No
mutation Yes check plus corrections Yes, transactional
action Yes check plus corrections Yes

sf dev brokers none of them. A local call to ctx.email, ctx.spam, or ctx.gravatar.profile throws zero_email_unavailable, zero_spam_unavailable, or zero_gravatar_unavailable, so a handler that reaches one on every write cannot run locally end to end: guard it, or publish the Space and exercise it there. ctx.gravatar.avatarUrl is the exception – it is a hash and a query string, so it works offline like everything else that never leaves the process.

Gravatar

ctx.gravatar.avatarUrl(email, options) builds an avatar URL. An avatar URL is a hash and a query string, so the call is synchronous, never touches the network, and cannot fail:

queries: {
  members: query(async (ctx) => {
    const rows = await ctx.db.members.withIndex("by_creation").collect();
    return rows.map((row) => ({
      ...row,
      avatar: ctx.gravatar.avatarUrl(row.email, { size: 96, default: "retro" }),
    }));
  }),
},

The options:

Option What it does
size Square edge in pixels, 1–2048. Larger values are refused, not clamped.
default Image when the address has no Gravatar: 404, mp, identicon, monsterid, wavatar, retro, robohash, or blank.
rating Maximum permitted self-rating: g, pg, r, or x.
forceDefault Serve default even when the address has a Gravatar.

To detect absence by status code instead of receiving a generated face, pass default: "404".

ctx.gravatar.profile(email) returns the profile behind an address, or null when the address has none. The profile carries hash, displayName, profileUrl, avatarUrl, location, description, pronouns, and verifiedAccounts (each with service, url, and label). The lookup sends a hash of the address, not the address itself. The address is trimmed, converted to lowercase, then hashed with SHA-256.

Spam checking

ctx.spam.check(submission) classifies one visitor submission and returns a verdict:

mutations: {
  sign: mutation(async (ctx, body: string, authorName: string) => {
    const verdict = await ctx.spam.check({
      content: body,
      type: "comment",
      authorName,
    });
    if (verdict.spam) return { held: true };
    await ctx.db.entries.insert({ body, authorName });
    return { held: false };
  }),
},

You supply the content. The runtime supplies the network evidence. The visitor’s IP, user agent, referrer, and page URL come from the trusted request envelope, never from handler arguments, so a browser cannot claim its own IP through a mutation’s inputs. IP and user agent are the heaviest signals the classifier uses.

The submission fields:

Field What it carries
content The text being classified. Required.
type comment (the default), reply, forum-post, contact-form, signup, or message.
authorName, authorEmail, authorUrl What the visitor claimed about themselves.
authorRole Set "administrator" for a trusted, signed-in operator of the space; their submissions skip the check.
createdAt ISO 8601 UTC. Defaults to the moment of the call.
languages ISO 639-1 hints, for example ["en"].

The verdict has two fields. spam says the submission classifies as spam and belongs in a review queue. discard flags blatant, pervasive spam that is safe to drop outright instead of holding for review.

Mutations and actions also get the corrections. Call ctx.spam.reportSpam(submission) when something passed that should not have. Call ctx.spam.reportHam(submission) when something was held that should not have been. Corrections usually run later, outside the original visitor request, so they take the complete submission, including a userIp field of your own.

Email

ctx.email.send(message) accepts one message and returns { messageId }. The identifier names the message. It is not a claim that anything was delivered.

mutations: {
  invite: mutation(async (ctx, email: string) => {
    await ctx.db.invitations.insert({ email, invitedBy: ctx.auth.userId });
    await ctx.email.send({
      from: { email: "team@example.com", name: "Example" },
      to: email,
      subject: "You're invited",
      text: "Join us at https://example.com/join",
    });
  }),
},

In a mutation, the send is accepted into the space’s outbox on the handler’s own transaction. A mutation that writes a row and queues its mail commits both or neither, and a throw after send() rolls back the row and the message together. When you want to scope that boundary explicitly, use ctx.transaction(handler). Actions run outside a parent transaction because they exist to touch the world. Their sends go out when the handler succeeds, not atomically with database writes.

Under sf dev the boundary is the whole invocation rather than the block you declared: a mutation or endpoint that throws rolls back everything it wrote, including writes made outside ctx.transaction(), and an error you catch yourself leaves that block’s writes in place. Actions get no rollback at all locally. Publish the capsule when a test depends on the exact transaction boundary.

The message shape:

Field What it carries
from, to Required. An address string or { email, name }. to also takes an array.
subject Required.
text, html The body. At least one is required, and sending both is fine.
cc, bcc More recipients, same shapes as to.
replyTo One address.
headers Extra headers as string pairs. Spacefast validates names and values, and refuses line breaks.

Email is metered per space. See Plans, limits, and usage and Monitoring.

What the platform handles

  • Delivery. Queued mail goes out through the platform’s own sender. There is no SMTP relay to stand up.
  • Validation. Malformed input throws in your handler with the code service_payload_invalid before anything leaves the runtime. That covers an avatar size over 2048, a message with no body, and a header with a line break.

Like database rows and storage objects, none of this is tied to a version. A rollback changes which code runs, not which services it can reach.

Last updated on August 28, 2026

Was this page helpful?