Gmail Bridge
A Node.js support-ticket bridge that connects Gmail, Discord, Zammad, and MongoDB into a unified helpdesk system. Incoming support emails are automatically turned into Discord ticket channels, staff replies in Discord are relayed back to the sender via Gmail, and every interaction is synced to Zammad and persisted in MongoDB.
Built for game-server hosting support (Indifferent Broccoli), with built-in game detection, configurable automation, and a rich set of Discord slash commands.
Table of Contents
- Features
- Architecture
- Prerequisites
- Installation
- Configuration
- Running the Bot
- Discord Commands
- Tag System
- Panel System
- Project Structure
- Database Schema
- API Integrations
- Healthcheck
- Troubleshooting
- License
Features
Email-to-Discord Ticketing
- Polls Gmail every 30 seconds for unread emails in the primary inbox
- Creates a dedicated Discord channel per ticket (
ticket-{sender}-{number}) - Detects the game from the email subject/body and tags the ticket accordingly
- Sends a rich embed with ticket metadata and action buttons (Claim, Close)
Discord-to-Email Replies
- Staff messages in a ticket channel are forwarded to the original sender via Gmail
- Replies are threaded in Gmail so the sender sees a continuous conversation
- Each reply is also recorded as an article in Zammad
Zammad Integration
- Automatically creates Zammad tickets with game info, priority, and group assignment
- Syncs user data (email, Discord ID) between MongoDB and Zammad
- Closes Zammad tickets when Discord tickets are resolved
Ticket Management
- Claim / Unclaim -- Staff can claim tickets; optional auto-unclaim after inactivity
- Priority Levels -- Low, Normal, High with color-coded embeds
- Escalation -- Move urgent tickets to a dedicated escalation category
- Transfer / Move -- Reassign tickets between staff or categories
- Close Confirmation -- Prevents accidental closes with a confirmation prompt
- Transcripts -- Full conversation transcripts posted to a dedicated channel on close
- Auto-Close -- Automatically close tickets after configurable hours of inactivity
- Inactivity Reminders -- Notify the channel when a ticket goes stale
Panel System
- Deploy a "Open Ticket" button panel to any channel with
/panel - Users click the button, fill out a modal form, and a ticket is created
Tag System (Saved Responses)
- Set ticket category with
/tag(dropdown); create reusable response templates with/response create - Dynamic template variables:
{ticket.user},{staff.name},{server.name},{date}, etc. - Autocomplete-enabled
/tagcommand for instant use
Account Info Lookup
/accountinfosearches website users by email or Discord ID- Results show linked servers, game details, and user metadata
Analytics & Logging
- In-memory tracking of command usage, button clicks, and errors
/statsshows uptime, interaction counts, and error rate- Configurable logging channel for ticket lifecycle events
Architecture
┌────────────────────────────────────────────────────────────────────┐
│ GMAIL BRIDGE │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ Gmail │─────>│ gmail-poll.js │─────>│ Discord │ │
│ │ (inbox) │ │ (every 30s) │ │ (ticket channel)│ │
│ └───────────┘ └───────┬────────┘ └───────▲──────────┘ │
│ │ │ │
│ v │ │
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ Zammad │<────>│ services/ │<────>│ handlers/ │ │
│ │ (tickets) │ │ gmail.js │ │ messages.js │ │
│ └───────────┘ │ zammad.js │ │ buttons.js │ │
│ │ tickets.js │ │ commands.js │ │
│ └───────┬────────┘ └──────────────────┘ │
│ │ │
│ v │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ MongoDB │ │ Express │ │
│ │ (Mongoose) │ │ (healthcheck) │ │
│ └────────────────┘ └──────────────────┘ │
│ │
│ Events: │
│ ready → Connect DB, register commands, start jobs │
│ interactionCreate → Buttons, slash commands, modals, menus │
│ messageCreate → Discord replies → Gmail + Zammad │
└────────────────────────────────────────────────────────────────────┘
Ticket lifecycle:
- Inbound email -- Gmail poll detects a new unread message, creates a Discord channel, a Zammad ticket, and a MongoDB record.
- Staff reply -- A message in the Discord ticket channel is forwarded to the sender via Gmail and added as an article in Zammad.
- Close -- A transcript is generated, a closure email is sent, the Zammad ticket is closed, and the Discord channel is deleted.
Prerequisites
| Requirement | Version |
|---|---|
| Node.js | >= 18.x |
| npm | >= 9.x |
| MongoDB | >= 5.x (Atlas or self-hosted) |
| Zammad | >= 6.x |
You will also need:
- A Discord bot with the following intents enabled: Guilds, Guild Messages, Message Content, Guild Members
- A Google Cloud project with the Gmail API enabled and OAuth2 credentials (Client ID, Client Secret, Refresh Token)
- A Zammad instance accessible via URL with an API token
Installation
# Clone the repository
git clone <your-repo-url>
cd gmail-bridge
# Install dependencies
npm install
Configuration
Create a .env file in the project root. All configuration is loaded via environment variables.
Important: After changing
.env, you must restart the process (npm start/node zammad-discord.js) for new values to take effect. If you add or change slash commands (e.g./escalate,/email-routing,/paneloptions), restart the bot so it can re-register commands with Discord; otherwise new or updated commands may not appear.
Discord
| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
Yes | Bot token from the Discord Developer Portal |
DISCORD_GUILD_ID |
Yes | Server (guild) ID where the bot operates |
DISCORD_APPLICATION_ID |
Yes | Application ID for registering slash commands |
TICKET_CATEGORY_ID |
Yes | Channel category ID where email ticket channels are created |
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS |
No | Comma-separated category IDs; used when main email category has 50 channels |
DISCORD_TICKET_CATEGORY_ID |
No | Category for Discord panel tickets (defaults to TICKET_CATEGORY_ID) |
DISCORD_TICKET_OVERFLOW_CATEGORY_IDS |
No | Comma-separated category IDs; used when main Discord ticket category has 50 channels |
ROLE_ID_TO_PING |
Yes | Role ID to ping when a new ticket arrives |
TRANSCRIPT_CHANNEL_ID |
No | Channel ID for posting ticket transcripts |
LOGGING_CHANNEL_ID |
No | Channel ID for lifecycle log messages |
DEBUGGING_CHANNEL_ID |
No | Channel ID for error logs (escalate, deescalate, email-routing, Gmail poll, etc.) |
BACKUP_EXPORT_CHANNEL_ID |
No | Channel ID where /backup and /export post ticket dump files |
ACCOUNT_INFO_CHANNEL_ID |
No | Channel ID for account info lookups |
EMAIL_ESCALATED_CATEGORY_ID |
No | Category ID for escalated email tickets |
ESCALATION_MESSAGE |
No | Message sent when a ticket is escalated |
Google OAuth2 / Gmail
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
Yes | OAuth2 Client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
Yes | OAuth2 Client Secret |
REFRESH_TOKEN |
Yes | OAuth2 Refresh Token for the support inbox |
MY_EMAIL |
Yes | The support email address (e.g. support@example.com) |
Zammad
| Variable | Required | Description |
|---|---|---|
ZAMMAD_URL |
Yes | Base URL of your Zammad instance |
ZAMMAD_TOKEN |
Yes | Zammad API token |
ZAMMAD_EMAIL_GROUP |
No | Zammad group for email-sourced tickets (default: Email Users) |
ZAMMAD_DISCORD_GROUP |
No | Zammad group for Discord-sourced tickets (default: Discord Users) |
Tip: If your Zammad instance is behind ngrok, set
NGROK_URLand useZAMMAD_URL=${NGROK_URL}-- dotenv-expand will resolve it.
MongoDB
| Variable | Required | Description |
|---|---|---|
MONGODB_URI |
Yes | MongoDB connection string (e.g. mongodb+srv://user:pass@cluster/dbname) |
Branding & Messages
| Variable | Default | Description |
|---|---|---|
SUPPORT_NAME |
-- | Display name for the support system |
LOGO_URL |
-- | URL to the logo shown in embeds |
EMAIL_SIGNATURE |
-- | HTML signature appended to outgoing emails (use \n for line breaks) |
TICKET_CLOSE_SUBJECT_PREFIX |
[Resolved] |
Prefix added to the subject of closure emails |
TICKET_CLOSE_MESSAGE |
(see config.js) | Body of the ticket closure email |
TICKET_CLOSE_SIGNATURE |
(see config.js) | Signature on the closure email |
TICKET_WELCOME_MESSAGE |
(see config.js) | Message posted when a ticket channel is created |
TICKET_CLAIMED_MESSAGE |
(see config.js) | Message posted when a ticket is claimed (supports {staff_name}) |
TICKET_UNCLAIMED_MESSAGE |
(see config.js) | Message posted when a ticket is unclaimed |
Automation
| Variable | Default | Description |
|---|---|---|
AUTO_CLOSE_ENABLED |
false |
Enable automatic ticket closure after inactivity |
AUTO_CLOSE_AFTER_HOURS |
72 |
Hours of inactivity before auto-close triggers |
AUTO_CLOSE_MESSAGE |
(see config.js) | Message sent when a ticket is auto-closed |
REMINDER_ENABLED |
false |
Enable inactivity reminder messages |
REMINDER_AFTER_HOURS |
24 |
Hours of inactivity before a reminder is sent |
REMINDER_MESSAGE |
(see config.js) | Reminder message (supports {hours} variable) |
Ticket Limits & Permissions
| Variable | Default | Description |
|---|---|---|
GLOBAL_TICKET_LIMIT |
5 |
Maximum concurrent open tickets globally |
TICKET_LIMIT_PER_CATEGORY |
3 |
Maximum tickets per category |
RATE_LIMIT_TICKETS_PER_USER |
0 |
Max tickets a user can create per window (0 = disabled) |
RATE_LIMIT_WINDOW_MINUTES |
60 |
Window in minutes for per-user ticket creation limit |
BLACKLISTED_ROLES |
-- | Comma-separated role IDs that cannot open tickets |
ADDITIONAL_STAFF_ROLES |
-- | Comma-separated role IDs with staff-level permissions |
Priority Levels
| Variable | Default | Description |
|---|---|---|
PRIORITY_ENABLED |
false |
Enable the priority system |
DEFAULT_PRIORITY |
normal |
Default priority for new tickets |
PRIORITY_HIGH_EMOJI |
🔴 |
Emoji for high-priority tickets |
PRIORITY_MEDIUM_EMOJI |
🟡 |
Emoji for normal/medium-priority tickets (default level is normal) |
PRIORITY_LOW_EMOJI |
🟢 |
Emoji for low-priority tickets |
Claiming Options
| Variable | Default | Description |
|---|---|---|
AUTO_UNCLAIM_ENABLED |
false |
Automatically unclaim tickets after inactivity |
AUTO_UNCLAIM_AFTER_HOURS |
24 |
Hours before auto-unclaim triggers |
ALLOW_CLAIM_OVERWRITE |
false |
Allow claiming an already-claimed ticket |
CLAIM_TIMEOUT_ENABLED |
false |
Enable claim timeout |
CLAIM_TIMEOUT_HOURS |
48 |
Hours before a claim times out |
Channel rename rate limit
Ticket channels are renamed automatically when you claim, unclaim, escalate, or deescalate. Discord’s API allows 2 channel renames per 10 minutes per channel. The bot enforces this: if the limit is reached, the rename is skipped and the channel gets:
Channel renamed too quickly. Try again <t:unlock_timestamp:R>.
The timestamp is a Discord relative-time marker (e.g. “in 8 minutes”). After the window resets, the next claim/unclaim/escalate/deescalate can rename again.
Button & Embed Customization
| Variable | Default | Description |
|---|---|---|
BUTTON_LABEL_CLOSE |
Close Ticket |
Label for the close button |
BUTTON_LABEL_CLAIM |
Claim |
Label for the claim button |
BUTTON_LABEL_UNCLAIM |
Unclaim |
Label for the unclaim button |
BUTTON_EMOJI_CLOSE |
🔒 |
Emoji on the close button |
BUTTON_EMOJI_CLAIM |
📌 |
Emoji on the claim button |
BUTTON_EMOJI_UNCLAIM |
🔓 |
Emoji on the unclaim button |
EMBED_COLOR_OPEN |
0x00FF00 |
Embed color for open tickets |
EMBED_COLOR_CLOSED |
0xFF0000 |
Embed color for closed tickets |
EMBED_COLOR_CLAIMED |
0xFFFF00 |
Embed color for claimed tickets |
EMBED_COLOR_ESCALATED |
0xFF6600 |
Embed color for escalated tickets |
EMBED_COLOR_INFO |
0x1e2124 |
Embed color for info messages (and embeds next to ticket buttons) |
Game List
Set GAME_LIST to a comma-separated list of game names. The bot uses this list for auto-detection from email subjects/bodies:
GAME_LIST=Project Zomboid, Satisfactory, Palworld, Minecraft, Valheim, ...
Running the Bot
# Start the bot
npm start
# Or directly
node zammad-discord.js
On startup the bot will:
- Validate required environment variables
- Connect to MongoDB (with automatic reconnection)
- Register all slash commands to the configured guild
- Begin polling Gmail every 30 seconds
- Start background jobs (auto-close, reminders, auto-unclaim)
- Launch an Express healthcheck server
Note: Changing .env requires restarting the bot. Slash commands are registered on startup; if commands don’t update, run npm run register (or restart) to re-register.
Optional: Create Zammad Groups
If your Zammad instance doesn't already have the required groups:
npm run create-zammad-objects
This creates the "Email Users" and "Discord Users" groups and lists available priorities and states.
Discord Commands
Ticket Management
| Command | Description |
|---|---|
/claim |
Claim the current ticket |
/unclaim |
Release your claim on the current ticket |
/close |
Close the current ticket (with confirmation) |
/force-close |
Close the current ticket without confirmation |
/priority <level> |
Set ticket priority (low, normal, medium, high). Posts: upgraded to [Emoji][Level][Emoji], downgraded to..., or returned to Normal. Email sent when set to high. |
/topic <text> |
Set the ticket channel topic |
/escalate [reason] [tier] |
Escalate the ticket to tier 2 or 3 (optional tier; buttons also available) |
/deescalate |
De-escalate the ticket one step |
User & Channel Management
| Command | Description |
|---|---|
/add <user> |
Add a user to the current ticket channel |
/remove <user> |
Remove a user from the current ticket channel |
/transfer <staff> |
Transfer the ticket to another staff member |
/move <category> |
Move the ticket to a different category |
Tags & Saved Responses
| Command | Description |
|---|---|
/tag |
Set ticket category (dropdown: ⬇️ Server Down, ⏳ Stuck Restarting, 📵 Can't Connect, 🐌 Server Lag, 💳 Billing, 💸 Refund Request, 🔧 Mod Help, 💾 Backup Restore, 🌍 World / Save, ⚙️ Server Config). Posts: Your ticket has been categorized as [Emoji][Tag][Emoji]. |
/response send <name> |
Send a saved response (autocomplete-enabled) |
/response create <name> <content> |
Create a new saved response |
/response edit <name> <content> |
Edit an existing saved response |
/response delete <name> |
Delete a saved response |
/response list |
List all saved responses |
Utilities
| Command | Description |
|---|---|
/panel [channel] [type] [title] [description] |
Deploy a ticket-creation panel (type: thread, category, or both) |
/email-routing |
Switch where new email tickets are created (threads or category channels) |
/accountinfo <email or discord> |
Look up a user's account information |
/search <query> |
Search tickets |
/stats |
Show bot statistics and analytics |
/backup |
Export full ticket list to a .txt file in the backup/export channel |
/export [status] [limit] |
Export tickets (optional filter and limit) to a .txt file in the backup/export channel |
/help |
Display the command reference |
Context Menus
| Menu | Description |
|---|---|
| Create Ticket From Message | Right-click a message to create a ticket from it |
Tag & Response System
Ticket category (/tag)
Use /tag in a ticket channel and pick a category from the dropdown (e.g. ⬇️ Server Down, 💳 Billing, 🔧 Mod Help). The bot posts: Your ticket has been categorized as [Emoji][Tag][Emoji]. Channel name is not changed.
Saved response tags (/response)
Saved responses are reusable templates stored in MongoDB. Use /response send, /response create, etc. They support dynamic variables that are replaced at send time:
| Variable | Resolves To |
|---|---|
{ticket.user} |
Ticket sender's name |
{ticket.email} |
Ticket sender's email |
{ticket.number} |
Ticket number |
{ticket.subject} |
Ticket subject line |
{staff.name} |
Current staff member's display name |
{staff.mention} |
Current staff member's mention |
{server.name} |
Discord server name |
{date} |
Current date |
{time} |
Current time |
Example:
/response create name:greeting content:Hi {ticket.user}! Thanks for reaching out about "{ticket.subject}". I'm {staff.name} and I'll be helping you today.
Panel System
The panel system allows users to create tickets directly from Discord without sending an email.
- Deploy a panel:
/panel #support title:Need Help? description:Click below to open a ticket! - Users click the Open Ticket button
- A modal form appears asking for subject, description, and priority
- On submission, a ticket channel is created with all the same features as email tickets
Project Structure
gmail-bridge/
├── zammad-discord.js # Entry point - initializes bot, events, and jobs
├── config.js # Environment variable loading and CONFIG export
├── db-connection.js # MongoDB connection with reconnect logic
├── models.js # Mongoose schemas (Ticket, User, Tag, etc.)
├── utils.js # Text processing, game detection, template vars
├── gmail-poll.js # Gmail polling loop and ticket creation
├── game-options.json # Game configuration data
│
├── commands/
│ └── register.js # Slash command and context menu registration
│
├── handlers/
│ ├── accountinfo.js # /accountinfo command and button handler
│ ├── analytics.js # In-memory analytics and error tracking
│ ├── buttons.js # Button interactions (claim, close, priority, etc.)
│ ├── commands.js # All slash command handlers
│ └── messages.js # Discord → Gmail reply forwarding
│
├── services/
│ ├── gmail.js # Gmail OAuth2, send replies, closure emails
│ ├── tickets.js # Ticket CRUD, auto-close, reminders, auto-unclaim
│ └── zammad.js # Zammad API client (tickets, users, articles)
│
├── scripts/
│ └── create-zammad-objects.js # Utility to create Zammad groups
│
├── docs/ # Additional documentation
├── .env # Environment variables (not committed)
├── package.json
└── package-lock.json
Database Schema
The bot uses MongoDB via Mongoose. Key collections:
| Collection | Purpose |
|---|---|
Ticket |
Core ticket data: Gmail thread ID, Discord channel ID, Zammad ticket ID, sender info, status, priority, claimed-by, timestamps |
TicketCounter |
Auto-incrementing ticket numbers per sender |
Transcript |
Transcript message references for closed tickets |
Tag |
Saved response templates (name, content, creator) |
CloseRequest |
Tracks pending close confirmations |
User |
Website user accounts (email, Discord ID, linked servers) |
Host |
Game server/host metadata and metrics |
DashboardMetrics |
Aggregated dashboard statistics |
ErrorLog |
Persisted error records |
API Integrations
Gmail API
- Authentication: OAuth2 with Client ID, Client Secret, and Refresh Token
- Polling:
users.messages.listfor unread messages in the primary inbox - Reading:
users.messages.getto fetch full message content - Sending:
users.messages.sendfor threaded replies and closure emails
Discord API (discord.js v14)
- Intents: Guilds, GuildMessages, MessageContent, GuildMembers
- Interactions: Slash commands, buttons, modals, context menus, autocomplete
- Channels: Create/delete ticket channels, manage permissions per user
Zammad API
- Authentication: Token-based via
Authorization: Token token=... - Tickets: Create (
POST /api/v1/tickets), update/close (PATCH /api/v1/tickets/:id) - Articles: Add notes and replies (
POST /api/v1/ticket_articles) - Users: Search (
GET /api/v1/users/search), create (POST /api/v1/users), update (PATCH /api/v1/users/:id)
Healthcheck
An Express server runs on the port defined by DISCORD_ONLY_PORT (default: 5000).
GET / → "Active"
Use this endpoint for uptime monitoring or container health probes.
Troubleshooting
Slash commands not appearing in Discord
- Commands are registered per-guild on startup. Wait up to one hour for Discord to propagate.
- Verify
DISCORD_APPLICATION_IDandDISCORD_GUILD_IDare correct. - Restart the bot.
Gmail polling not working
- Ensure
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, andREFRESH_TOKENare set correctly. - The refresh token may have expired -- regenerate it via the Google OAuth2 Playground.
- Check that the Gmail API is enabled in your Google Cloud Console project.
MongoDB connection failures
- Verify
MONGODB_URIis correct and the database is accessible. - If using MongoDB Atlas, ensure your IP is whitelisted.
- The bot has automatic reconnection -- check logs for retry attempts.
Zammad API errors
- Confirm
ZAMMAD_URLis reachable (if using ngrok, ensure the tunnel is active). - Verify the
ZAMMAD_TOKENhas sufficient permissions. - Run
npm run create-zammad-objectsto ensure required groups exist.
Tickets not creating
- Check that
TICKET_CATEGORY_IDpoints to a valid Discord category. - Ensure the bot has
Manage ChannelsandView Channelpermissions in that category. - Review the logging channel for error messages.
Modal not appearing when clicking "Open Ticket"
- Verify the bot has proper guild permissions.
- Try in a different channel.
- Restart the bot.
License
ISC