Self-host the Mail inbox

The labs run anywhere static. The Mail inbox (unified triage, tickets, ideas, roadmap, webhooks) needs one Cloudflare Worker + one D1 database per community. This guide takes you from zero to a live inbox in ~20 minutes. Free-tier friendly.

πŸ€– Setting up with an AI assistant? Give it a machine-readable version of these instructions: /ai-setup.md. Prompt: β€œRead /ai-setup.md and help me self-host the Mail inbox; ask me for every input first and keep secrets out of the chat.”
Steps 0. What you get1. Prerequisites2. Install & log in3. Database4. Secrets5. Deploy6. Custom domain7. Mod access (Discord login)8. Connect Discord (webhooks)9. Connect Discourse10. Other forumsβ˜… β€œSave as Idea” commandβ˜… Bot integration: reading channelsβ˜… Roadmapβ˜… Multi-server mail11. Operate & troubleshoot

0What you get

One deployment serves the tools hub + all labs + the Mail UI. The inbox API (/api/*) and webhooks (/hooks/*) run in the same Worker, backed by D1. Two communities = two deployments (e.g. tools.example.com + mod.example.org), each with its own Worker, D1 and secrets β€” same codebase, two configs (wrangler.jsonc + wrangler.community.jsonc).

Branding, header and links are configurable in public/site.config.json (no rebuild needed β€” the shell script public/assets/site.js renders them from that file, or from a remote configUrl). The Powered by PowerCordKit footer credit must stay (LICENSE.md).

1Prerequisites

2Install & log in

git clone https://github.com/EmjayBot/powercordkit.git
cd powercordkit
npm install
npx wrangler login      # opens the browser β€” pick YOUR community's account
npx wrangler whoami     # confirm the right email + account ID
Running a second community later? npx wrangler logout && npx wrangler login switches accounts. Personal uses wrangler.jsonc; every community gets its own wrangler.<name>.jsonc (copy wrangler.community.jsonc).

3Database

npm run db:create:community          # or: npx wrangler d1 create powercordkit-mail-community --config wrangler.community.jsonc
# paste the printed database_id into wrangler.community.jsonc β†’ d1_databases[0]
npm run db:migrate:remote:community

Migrations build inbox_items, notes, server_tokens, poll_state, seen_threads, plus the roadmap columns (roadmap, roadmap_status, roadmap_date). Verify: npx wrangler d1 execute powercordkit-mail-community --remote --config wrangler.community.jsonc --command "SELECT count(*) FROM inbox_items".

4Secrets

Everything is set with wrangler secret put (write-only) or as plain vars in the wrangler config. Generate random secrets with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".

SecretUsed byNotes
DISCORD_CLIENT_IDDiscord login + β€œSave as Idea”Your Discord app's Application ID
DISCORD_CLIENT_SECRETDiscord loginOAuth2 client secret
SESSION_SECRETDiscord loginRandom; signs the session cookie. Rotating it logs everyone out
DISCORD_GUILD_IDDiscord loginThe guild members must belong to
DISCORD_MOD_ROLE_IDSDiscord loginComma-separated role IDs; any one grants inbox access
DISCORD_PUBLIC_KEYβ€œSave as Idea”App β†’ General Information β†’ Public Key (verifies interactions)
DISCORD_BOT_TOKENPoller + tag/close actionsBot token (View Channel, Read Message History, Manage Threads)
DISCORD_WEBHOOK_SECRETPOST /hooks/discordSent as header X-Powercordkit-Secret; also admin key for token minting
DISCOURSE_WEBHOOK_SECRETPOST /hooks/discourseDiscourse signs with it (HMAC-SHA256)
FORUM_WEBHOOK_SECRET (optional)POST /hooks/:nameHeader X-Forum-Secret (falls back to the Discord secret)
MOD_API_KEY (optional)all /api/*Fallback shared key (X-Mod-Key) for scripts and the workers.dev origin, if you'd rather not use Discord login
DISCORD_IDEA_ROLE_IDS (optional)β€œSave as Idea”Roles allowed to save ideas; defaults to DISCORD_MOD_ROLE_IDS
DISCORD_COMPLETE_TAG (optional)Complete actionForum tag to apply on complete; defaults to Completed
ACCESS_TEAM_DOMAIN, ACCESS_AUD (optional)Cloudflare Access loginAlternative to Discord login (see Β§7)
# example: store the two webhook secrets
npx wrangler secret put DISCORD_WEBHOOK_SECRET --config wrangler.community.jsonc
npx wrangler secret put DISCOURSE_WEBHOOK_SECRET --config wrangler.community.jsonc
Use fresh secrets per community β€” never reuse one inbox's secrets on another. Save them in a password manager; you'll paste them into Discord/Discourse below.

5Deploy

npm run deploy:community
# prints e.g. https://powercordkit-community.<you>.workers.dev
curl https://<your-worker>.workers.dev/api/health   # expect {"ok":true,"env":"community"…}

6Custom domain

If the community zone (e.g. your-domain.com) is in this Cloudflare account, add to the community wrangler config and redeploy:

"routes": [{ "pattern": "mail.your-domain.com/*", "zone_name": "your-domain.com" }]

Then add DNS: CNAME mail β†’ <worker>.workers.dev, proxied ON. If the zone lives elsewhere, point a proxied CNAME at the workers.dev hostname instead β€” the Worker answers on both.

7Mod access β€” Discord login

By default the inbox is locked. Mods log in with Discord; a signed cookie keeps them in for 30 days. Anyone without an allowed role is refused. Nothing shared to type.

  1. Discord Developer Portal β†’ your app β†’ OAuth2: add redirect URI https://<your-host>/api/auth/callback. Copy the Client ID and Client Secret.
  2. Set DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, a random SESSION_SECRET, DISCORD_GUILD_ID, and DISCORD_MOD_ROLE_IDS (comma-separated role IDs).
  3. Redeploy. Visit /mail/ β€” it bounces to Discord, then lands you in the inbox if you hold one of the roles.

Routes: GET /api/auth/login (start), GET /api/auth/callback (return), GET /api/auth/logout, GET /api/auth/me. OAuth scopes used: identify + guilds.members.read.

Alternative β€” Cloudflare Access. If you'd rather gate the host with an identity provider (Google, email PIN, SAML/OIDC) instead of Discord, create an Access app for the host and set ACCESS_TEAM_DOMAIN + ACCESS_AUD. The Worker verifies the Access JWT. The two methods can coexist.
Shared-key fallback. Set MOD_API_KEY to also accept X-Mod-Key (handy for scripts and the workers.dev origin). All three methods are checked; either one grants access.

8Connect Discord (webhooks)

For instant capture (or custom bots), POST JSON to /hooks/discord:

curl -X POST https://<your-host>/hooks/discord \
  -H 'Content-Type: application/json' \
  -H 'X-Powercordkit-Secret: <DISCORD_WEBHOOK_SECRET>' \
  -d '{"server":"main","type":"ticket","title":"Spam in #help",
       "content":"User @x posting invites (thread #ticket-128)",
       "author":"mod_bot","by":"auto-capture","url":"https://discord.com/channels/…"}'

Fields: server (default general, or DEFAULT_SERVER), type (idea|support|ticket|mailed), title*, content, author, by, url, prio. The Discord and Discourse webhooks accept up to 20000 chars of content; the poller and forum adapters cap at 4000. Missing/wrong auth β†’ 401; body over 256 KB β†’ 413; bad JSON β†’ 400.

9Connect Discourse

Discourse Admin β†’ API β†’ Webhooks β†’ New:

Test: create a forum post, then GET /api/inbox?server=<your-forum-slug> β€” it should appear with src: "discourse".

10Other forums (NodeBB, Flarum, Lemmy, custom)

curl -X POST https://<your-host>/hooks/nodebb \
  -H 'Content-Type: application/json' \
  -H 'X-Forum-Secret: <FORUM_WEBHOOK_SECRET>' \
  -d '{"server":"my-forum","type":"support","title":"Login broken",
       "content":"(max 4000 chars, truncated server-side)","author":"user1"}'

Unknown adapter names β†’ 404. To add a forum permanently, extend FORUM_ADAPTERS in src/index.ts β€” no other changes needed.

β˜…β€œSave as Idea” β€” message context menu

Right-click any message β†’ Apps β†’ Save as Idea files it into the inbox as an idea. It's a Discord message command, handled at POST /hooks/discord-interactions (Ed25519-verified with DISCORD_PUBLIC_KEY).

  1. Set DISCORD_PUBLIC_KEY (App β†’ General Information β†’ Public Key).
  2. Developer Portal β†’ App β†’ Interactions Endpoint URL = https://<your-host>/hooks/discord-interactions β†’ Save (Discord sends a PING; the Worker replies PONG).
  3. Register the command: POST /api/admin/register-commands (auth with your mod session or X-Mod-Key). It creates a guild command for instant availability.

Who can submit: any member holding a role in DISCORD_IDEA_ROLE_IDS (defaults to DISCORD_MOD_ROLE_IDS); others get a β€œno permission” reply. The app must be authorized with the applications.commands scope in the guild (re-invite with scope=bot applications.commands if the command doesn't appear).

β˜…Bot integration: reading channels

Option A β€” Cloudflare poller, no hosting (recommended). The Worker itself polls your channels every 2 minutes via the Discord REST API and mails new items straight into D1 β€” no server, no bot process. It reads: guild active threads, archived public threads, and archived private threads (tickets), plus plain channel messages; it resolves @user/@role/#channel mentions to names, and pulls each post's real author (the first human message for tickets). One human step: create a Discord app, invite its bot with View Channel + Read Message History + Manage Threads on the watched channels, then npx wrangler secret put DISCORD_BOT_TOKEN --config wrangler.community.jsonc. Feeds + schedule live in the community config (FEED_MAP, */2 * * * *); progress (last message IDs, seen threads) is tracked in D1. New threads are capped at 25/tick. ~2 min delay β€” the tradeoff for zero hosting.

Backfill/repair any items that predate a config change: POST /api/admin/backfill-threads?limit=12 (repeat until remaining is 0).

Option B β€” self-hosted bot (instant). A tiny discord.js bot POSTs to /hooks/discord as events happen.

1. Portal setup β€” discord.com/developers/applications β†’ your app β†’ Bot: enable Message Content Intent (privileged), copy the bot token. Invite the bot with applications.commands + View Channel, Read Message History on the watched channels.

2. Minimal bot (Node 22+, save as bot.cjs, run npm i discord.js@14 β€” intents: Guilds + GuildMessages + MessageContent):

const { Client, GatewayIntentBits } = require('discord.js');
const HOST = 'https://mail.example.com';           // your inbox host
const TOKENS = {                                   // server slug -> X-Server-Token
  main: process.env.TOKEN_MAIN,
  'server-two': process.env.TOKEN_TWO,
};
// watched forum parents -> mail type ("ticket" or "support")
const FORUMS = { '<forum-channel-id-a>': 'ticket', '<forum-channel-id-b>': 'support' };
// watched text channels -> server slug
const CHANNELS = { '<ticket-channel-id>': 'server-two' };
const jump = (g, c, t) => `https://discord.com/channels/${g}/${t ?? c}`;
async function mail(server, payload) {
  const r = await fetch(HOST + '/hooks/discord', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Server-Token': TOKENS[server] },
    body: JSON.stringify({ server, by: 'auto-capture', src: 'discord', ...payload }),
  });
  if (!r.ok) console.error('mail failed', server, r.status, await r.text());
}
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
client.on('threadCreate', (th) => {
  const kind = FORUMS[th.parentId];
  if (!kind) return;
  mail('main', { type: kind, title: th.name.slice(0, 80),
    content: `New ${kind} by ${th.ownerId ?? 'unknown'}`, author: th.ownerId ?? 'unknown',
    url: jump(th.guildId, th.parentId, th.id) });
});
const seen = new Set();
client.on('messageCreate', (m) => {
  if (m.author.bot || seen.has(m.id)) return; seen.add(m.id);
  const server = CHANNELS[m.channelId];
  if (!server) return;
  mail(server, { type: 'ticket', title: m.content.slice(0, 80) || '(attachment)',
    content: m.content.slice(0, 4000), author: m.author.tag,
    url: jump(m.guildId, m.channelId, m.id) });
});
client.login(process.env.DISCORD_BOT_TOKEN);

Run it: set DISCORD_BOT_TOKEN=… && set TOKEN_MAIN=… && set TOKEN_TWO=… && node bot.cjs. Keep it alive with a process manager (pm2, systemd, a container).

3. Verify without a bot β€” copy the curl shape from Β§8 with your real IDs and one server's X-Server-Token, then check GET /api/inbox?server=<your-server> (with X-Mod-Key). Archive the test from the inbox when done.

Notes for the bot dev: keep seen bounded in production (an LRU of the last 5k IDs). Set prio:"high" when a message mentions @everyone/@here or matches an invite-link regex. Attachments: put m.attachments.first()?.url into content. The inbox truncates safely (title 300, content 4000 for poller items / 20000 for webhooks).

β˜…Roadmap

Promote an idea to the public roadmap with a status (planned / in-progress / shipped) and an optional target date. Manage it from the inbox item detail, or:

curl -X POST https://<your-host>/api/inbox/<item-id>/roadmap \
  -H 'X-Mod-Key: <MOD_API_KEY>' -H 'Content-Type: application/json' \
  -d '{"status":"in-progress","date":"2026-01-31"}'

GET /api/roadmap is public (read-only) and powers the /roadmap/ page (Planned / In Progress / Shipped). Set {"roadmap":false} to remove an item.

β˜…How mail works across multiple servers

One inbox watches many Discord servers (guilds) plus forums. The model is deliberately simple:

Working an item: E completes it β€” the inbox tags it Completed and (for Discord threads) applies the forum's Completed tag and archives+locks the thread; Reopen reverses it. Assignment and private notes are silent (inbox-only). Reads cap at 200 items per query; a daily cron (0 3 * * *) purges items older than 90 days.

11Operate & troubleshoot

SymptomFix
401 on webhooksWrong secret or header name β€” compare against wrangler secret list --config … (values aren't shown; re-put to rotate)
Stuck on Discord login / ?denied=roleThe account lacks a role in DISCORD_MOD_ROLE_IDS, or isn't in DISCORD_GUILD_ID. Check the redirect URI matches exactly.
No new posts appear (poller)Bot missing View Channel / Read Message History / Manage Threads, or the feed isn't in FEED_MAP. Empty message content β‡’ enable Message Content Intent. Private ticket threads need Manage Threads.
Empty inbox, API okCheck D1 has tables (d1 execute … "SELECT * FROM inbox_items LIMIT 1"); re-run migrations
Custom domain doesn't resolveCNAME name must be exactly the subdomain (e.g. mail), proxied ON; wait for DNS propagation
Need logsnpx wrangler tail --config wrangler.community.jsonc β€” structured JSON logs

Cost: comfortably inside Cloudflare's free tier for community-sized Discords (Workers + D1 + tiny static assets).

β˜…Just want the tools? Self-host the toolset

The labs (Embed, Color, Markdown, Timestamp, Snowflake, Webhook, Slowmode) are backend-free. Anyone can run their own copy β€” on this site, or theirs:

Customize the header, links and accent in public/site.config.json. You can turn the setup guide link off with "features": { "mailSetup": false }.

One condition (LICENSE.md, MIT + credit): keep the β€œPowered by PowerCordKit” footer credit and link back on any copy, embed, or self-hosted toolset. Removing the credit while keeping the code isn't permitted.

← Back to tools Β· Mail overview