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.
/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.β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).
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
npx wrangler logout && npx wrangler login switches accounts. Personal uses wrangler.jsonc; every community gets its own wrangler.<name>.jsonc (copy wrangler.community.jsonc).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".
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'))".
| Secret | Used by | Notes |
|---|---|---|
DISCORD_CLIENT_ID | Discord login + βSave as Ideaβ | Your Discord app's Application ID |
DISCORD_CLIENT_SECRET | Discord login | OAuth2 client secret |
SESSION_SECRET | Discord login | Random; signs the session cookie. Rotating it logs everyone out |
DISCORD_GUILD_ID | Discord login | The guild members must belong to |
DISCORD_MOD_ROLE_IDS | Discord login | Comma-separated role IDs; any one grants inbox access |
DISCORD_PUBLIC_KEY | βSave as Ideaβ | App β General Information β Public Key (verifies interactions) |
DISCORD_BOT_TOKEN | Poller + tag/close actions | Bot token (View Channel, Read Message History, Manage Threads) |
DISCORD_WEBHOOK_SECRET | POST /hooks/discord | Sent as header X-Powercordkit-Secret; also admin key for token minting |
DISCOURSE_WEBHOOK_SECRET | POST /hooks/discourse | Discourse signs with it (HMAC-SHA256) |
FORUM_WEBHOOK_SECRET (optional) | POST /hooks/:name | Header 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 action | Forum tag to apply on complete; defaults to Completed |
ACCESS_TEAM_DOMAIN, ACCESS_AUD (optional) | Cloudflare Access login | Alternative 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
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"β¦}
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.
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.
https://<your-host>/api/auth/callback. Copy the Client ID and Client Secret.DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, a random SESSION_SECRET, DISCORD_GUILD_ID, and DISCORD_MOD_ROLE_IDS (comma-separated role IDs)./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.
ACCESS_TEAM_DOMAIN + ACCESS_AUD. The Worker verifies the Access JWT. The two methods can coexist.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.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.
Discourse Admin β API β Webhooks β New:
POST https://<your-host>/hooks/discoursepost_created, topic_created β categories: your support + ideas categoriesDISCOURSE_WEBHOOK_SECRET (Discourse sends X-Discourse-Event-Signature: sha256=<hmac>; the Worker verifies with a timing-safe compare, 401 otherwise)Test: create a forum post, then GET /api/inbox?server=<your-forum-slug> β it should appear with src: "discourse".
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.
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).
DISCORD_PUBLIC_KEY (App β General Information β Public Key).https://<your-host>/hooks/discord-interactions β Save (Discord sends a PING; the Worker replies PONG).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).
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.
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).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.
One inbox watches many Discord servers (guilds) plus forums. The model is deliberately simple:
server string (e.g. a guild slug). The first mail from a new server creates it β GET /api/servers derives the live registry (open counts + per-type breakdowns) from the data, and the inbox filter bar renders from it.DEFAULT_SERVER per deployment for items mailed without a server. Bots authenticate per server: mint a token with POST /api/servers/:id/token (admin: global X-Powercordkit-Secret) and send it as X-Server-Token β only that server's mail is accepted with it. Rotate with POST again, revoke with DELETE /api/servers/:id/token. Tokens are SHA-256 hashed in D1; the plaintext shows once.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.| Symptom | Fix |
|---|---|
| 401 on webhooks | Wrong secret or header name β compare against wrangler secret list --config β¦ (values aren't shown; re-put to rotate) |
Stuck on Discord login / ?denied=role | The 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 ok | Check D1 has tables (d1 execute β¦ "SELECT * FROM inbox_items LIMIT 1"); re-run migrations |
| Custom domain doesn't resolve | CNAME name must be exactly the subdomain (e.g. mail), proxied ON; wait for DNS propagation |
| Need logs | npx 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).
The labs (Embed, Color, Markdown, Timestamp, Snowflake, Webhook, Slowmode) are backend-free. Anyone can run their own copy β on this site, or theirs:
<iframe src="https://<host>/embeds/" width="100%" height="700" style="border:0;border-radius:12px" title="Embed"></iframe>npm run build:tools # β dist/tools/ (labs + this guide, no Mail backend) npm run deploy:tools # Cloudflare Worker, then attach your domain
Customize the header, links and accent in public/site.config.json. You can turn the setup guide link off with "features": { "mailSetup": false }.
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.