Hogsend is brand new.Chat to Doug
Hogsend
Recipes

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 flow is fully ephemeral and never echoes the email or code in a message body:

StepWhat happens
/linkOpens an email modal (the primary UX)
Email modal submitValidates 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 buttonOpens 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" with skipPreferenceCheck: true means the code is never dropped by an unsubscribe or a frequency cap — routing it through the journey-category sendEmail would silently lose it for unsubscribed users.
  • The throttles run before the mint. createLinkCode counts 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 /link returns ok: false with no email sent. An optional Redis /verify throttle (10/user/15 min, fail-open) blunts brute-force redeem traffic.
  • redeemLinkCode is 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-mounted secrets/wire admin routes that apps/api does not mount, so that CLI 404s today. The in-Discord /link modal is the primary, live path.
  • /verify is 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.

On this page