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.
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_invalidbefore 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.