Link a Discord account to an email
How the in-Discord /link modal loop attaches an email to a Discord account — a transactional 6-digit code, single-use with a 15-minute TTL and rate limits, the /verify fallback, and reading the resulting discord_id and contacts.properties.discord on a contact.
A Discord member and an email subscriber are the same person on two surfaces, but nothing ties them together until the member links their email. The @hogsend/plugin-discord connector does this inside Discord: /link opens an email modal, Hogsend mails a single-use code through a transactional send, and /verify <code> (or an Enter code button) folds the email onto the discord_id contact. After that, discord_id and contacts.properties.discord are on the contact row, and the member is both emailable and credited for Discord activity.
This is not a journey — it is the consumer wiring that makes the loop work, plus how a journey reads the result.
The /link loop
The flow is fully ephemeral and never echoes the email or code in a message body:
| Step | What happens |
|---|---|
/link | Opens an email modal (the primary UX) |
| Email modal submit | Validates the address synchronously first — a bad address gets an instant inline error; a valid one defers, mints a code, mails it, and PATCHes an Enter code button |
| Enter code button | Opens the code modal — the mandatory bridge, because Discord forbids returning a modal from a modal submit |
Code modal submit (or /verify <code>) | Redeems the code and resolves the contact, then replies with an ephemeral success card |
Every interaction is ed25519-verified with a ±300s replay window before any work runs.
The wiring
createDiscordConnector injects the engine helpers the plugin must not read itself; the connector is then passed to createHogsendClient in both entry points. The four callbacks below are what make /link work:
// src/discord.ts
import {
createLinkCode,
getEmailService,
redeemLinkCode,
resolveOrCreateContact,
} from "@hogsend/engine";
import { createDiscordConnector } from "@hogsend/plugin-discord";
export const discordConnector = createDiscordConnector({
applicationId: env.DISCORD_APPLICATION_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
publicKeyHex: env.DISCORD_PUBLIC_KEY,
redirectUri: `${base}/v1/connectors/discord/oauth/callback`,
studioIntegrationsUrl: `${base}/studio/integrations`,
saveDerived: async (patch) => {
/* read-merge-write into the derived credential */
},
// Mint a single-use code. The engine runs the anti-email-bomb throttle
// (5/user + 3/email per 15 min) BEFORE minting; over-cap returns ok:false.
mintCode: async ({ discordUserId, email }) => {
const r = await createLinkCode({
db: requireDb(),
connectorId: "discord",
platformUserId: discordUserId,
email,
});
return r.ok
? { ok: true, code: r.code }
: { ok: false, reason: "throttled" };
},
// TRANSACTIONAL send — skipPreferenceCheck so a verification code is NEVER
// dropped by unsubscribe/frequency suppression.
sendLinkCode: async ({ email, code }) => {
await getEmailService().send({
template: "transactional/discord-link-code",
props: { code },
to: email,
userId: email,
userEmail: email,
subject: "Your Discord verification code",
category: "transactional",
skipPreferenceCheck: true,
});
},
// Redeem — single-use (atomic claim), 15-min TTL, identity-bound to the
// invoking Discord user (constant-time). Resolves through the discord Kind.
redeemCode: ({ discordUserId, code }) =>
redeemLinkCode({
db: requireDb(),
connectorId: "discord",
platformUserId: discordUserId,
code,
}),
});Three guarantees fall out of this wiring:
- The code rides a transactional send.
category: "transactional"withskipPreferenceCheck: truemeans the code is never dropped by an unsubscribe or a frequency cap — routing it through the journey-categorysendEmailwould silently lose it for unsubscribed users. - The throttles run before the mint.
createLinkCodecounts mints per invoking Discord user (5) and per target email (3) in a rolling 15-minute window before issuing a code, so an over-cap/linkreturnsok: falsewith no email sent. An optional Redis/verifythrottle (10/user/15 min, fail-open) blunts brute-force redeem traffic. redeemLinkCodeis an atomic claim. A code works exactly once, expires after 15 minutes, and the engine re-checks the invoking Discord user with a constant-time compare — a code minted for one account cannot be redeemed by another.
Reading the linked identity in a journey
JourneyUser carries id, email, and properties, but not the nested Discord metadata. Read the authoritative contacts row: the discord_id column is the merge key, contact.email tells you linked vs Discord-only, and contacts.properties.discord holds the read-only metadata.
// src/journeys/react-to-linked-state.ts
import { contacts } from "@hogsend/db";
import { days, defineJourney, getDb, hours, sendEmail } from "@hogsend/engine";
import { eq } from "drizzle-orm";
import { Events, Templates } from "./constants/index.js";
export const reactToLinkedState = defineJourney({
meta: {
id: "react-to-linked-state",
name: "Discord — react to link state",
enabled: true,
trigger: { event: Events.DISCORD_MESSAGE_SENT }, // "discord.message_sent"
entryLimit: "once_per_period",
entryPeriod: days(7),
suppress: hours(12),
},
run: async (user, ctx) => {
const db = getDb();
const contact = await db.query.contacts.findFirst({
where: eq(contacts.id, user.id),
});
const meta = (contact?.properties?.discord ?? {}) as {
username?: string;
};
if (!contact?.email) {
// Discord-only — no address. Nudge to link via the channel.
await ctx.trigger({
event: Events.DISCORD_NUDGE_LINK,
userId: user.id,
properties: { username: meta.username ?? null },
});
return;
}
await sendEmail({
to: contact.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.DISCORD_ACTIVE_THANKS,
subject: `Thanks for being active, ${meta.username ?? "friend"}`,
journeyName: user.journeyName,
});
},
});discord_id is the sole merge key — redeemCode resolves through resolveOrCreateContact with the discord identity Kind, so the raw snowflake lands in the indexed discord_id column. contacts.properties.discord is decorative metadata (username, global_name, avatar, joined_at, roles, and the derived last_seen), deep-merged and non-clobbering — never a resolution key.
- The linked email is the one typed, carried through the engine-verified state. The OAuth member-link fallback uses the address the link was issued for, never the OAuth-reported Discord email — using the latter as a resolution key would let a member attach an address they do not own.
- The OAuth member-link is not wired end to end yet. The one-click install / member-link (
hogsend connect discord) needs consumer-mountedsecrets/wireadmin routes thatapps/apidoes not mount, so that CLI 404s today. The in-Discord/linkmodal is the primary, live path. /verifyis the typed fallback. The verification email instructs the user to run/verify {code}— the modal is primary, but the email steers to the typed command.
Related: Welcome new Discord members gates its welcome on this link, Re-engage quiet Discord members reads the same contact metadata, and the Discord integration documents the loop's security model.
Welcome new Discord members
A welcome journey triggered by discord.member_joined — wait for the member to link an email with ctx.waitForEvent(), send the welcome the instant they link, and nudge the still-unlinked in-channel via the Discord destination.
Trial conversion sequence
The full trial arc as two defineJourney()s and one bucket — a day-1 value email, a mid-trial branch on real usage via ctx.history.hasEvent, a bucket-triggered T-3 push, and a hard exit on subscription.created.