+420 728 089 029CZ

Gmail API ticketing system: lessons from production

· ⏱ 9 min read

Gmail API ticketing system: lessons from productionThe support logbook

TL;DR: We first built our own ERP because we needed to run numbers, projects and people from one place. Then tickets moved in – every e-mail thread is one ticket, read and sent through the Gmail API. The biggest win isn't the ticketing system itself (there are plenty of those) but the fact that next to a ticket you see whether the client owes you money, add them to the CRM with one click, and let an agent set up an entire project from the e-mail. And it saves us over €2,800 a year that we used to pay for an off-the-shelf ticketing tool. Along the way, a number of things bit us that the Gmail documentation never mentions. We wrote them down – and at the end of the article there's a complete spec to download that lets you build something similar with a coding agent.

Why our own ERP, and why tickets in it

We're a small agency – Shoptet Premium online stores, add-ons, custom AI. As the number of projects grew, we stopped being able to keep track: how many hours went where, what's been invoiced, what a client still hasn't paid, who's overloaded and who has capacity. That's how Paluba ("the Deck") came to be – our internal system that pulls tasks and time reports from Freelo, watches project budgets, generates monthly reports and issues invoices.

Client communication, however, still lived elsewhere. We had a Czech ticketing tool for it – a decent product, but it didn't fit us: it knew nothing about our projects or invoices, so people still forwarded e-mails asking "who's handling this?" and searched across several windows at once. We built our own tickets so that e-mail would live next to everything else. In practice, it looks like this today:

  • I open a ticket and immediately see which client it belongs to, what projects they have and whether they owe us money – no digging through invoicing.
  • An enquiry that came in by e-mail becomes a CRM lead with one click via AI. Name, company, what they want – the AI pulls all of it from the thread itself.
  • From a ticket I let an agent set up the whole project including tasks, the moment the client approves the quote.
  • AI agents draft replies but never send anything themselves. Draft → human review → send.

No off-the-shelf helpdesk can do this, because it doesn't know our invoices or projects. Having everything integrated in one place is the whole reason to build it ourselves.

What it saves

We did the maths in money, too. For the previous ticketing tool we paid roughly 12 seats × 490 CZK (about €20) a month, i.e. over 70,000 CZK (about €2,800) a year – and that's just licences, without the invoicing and CRM integrations nobody would have offered us anyway. The bigger item is efficiency: AI drafts the reply, MCP gives agents access to the data, integrations save the manual copying of enquiries into the CRM and setting up projects. That's hours every week that used to disappear switching between five tools.

Technically it's Laravel 12 + React 19, PostgreSQL, a single Google Workspace account with multiple send-as aliases (each alias = one mailbox in the app), incoming mail is polled every five minutes, and threads are tracked by Gmail threadId with In-Reply-To/References as a fallback. Most of the code was written in tandem with an AI coding agent – which is why the lessons below are written so that an agent can understand them.

What bit us in production

There are hundreds of generic "how to use the Gmail API" tutorials. These are the things you won't find in them, because they only show up with real clients, real e-mail clients and thousands of tickets. For each: what happened, why, and the rule we took away.

1. Who are you actually replying to?

The most bug-fix commits of the entire project. It sounds trivial – you reply to whoever wrote. Except:

An enquiry from a web form arrives as From: wordpress@our-domain.com, Reply-To: client@company.com. The first reply correctly went to Reply-To. But as soon as the client replied themselves (their message no longer has a Reply-To), the fallback to "the original sender" sent every further reply to the form's address – into the void. The client was only in CC and wondered why nobody was answering.

CCs accumulated. Client A wrote with colleague B in copy. We replied. Then B replied and deliberately left A out. Our next reply went to A and B again, because we only ever added CCs on the ticket.

Our own alias got stuck in CC. A colleague replied from a send-as alias, the client hit Reply-All, and our alias showed up on the ticket as an external participant.

The rules we arrived at: the recipient is found by walking back from the newest inbound message (Reply-To, otherwise From), skipping everything that's "ours" – including aliases used in outbound messages. CC mirrors the last message of the thread, exactly like Reply-All in a mail client: whoever was dropped must not come back; whoever was added is there immediately. And the ticket's identity (who the client is for CRM matching) is whoever opened it – not whoever wrote last, because that may be a colleague from CC.

2. Outlook breaks the thread, Gmail quietly hides it

Originally we built In-Reply-To from the last inbound message. When a colleague replied twice in a row (the client hadn't written in between), the second reply referenced the client's old message and our first reply was missing from the chain. Gmail merged it, Outlook didn't – the client saw two threads and replied to the wrong one.

The parent of an outbound message must be the last message regardless of direction, and References = the parent's References plus the parent's Message-ID. A bonus from this fix: the ORM relation had a default ordering of sent_at ASC, to which orderByDesc was merely appended – so "the last message" was in fact the oldest. If a relation has a default ordering, explicitly reset it in every "last message" query.

3. How a reply grew to 49 MB

The most expensive bug. The first version quoted the entire thread in full HTML into every reply. The client's mail client sent our quote back to us, we stored it in full and quoted it again next time. Every exchange roughly doubled the size. After 26 messages the e-mail was 49 MB and PHP died with memory exhausted.

Today we quote only the last message of the thread (it carries the older quotes itself, so growth is linear – Gmail and Outlook do the same), the whole quote has a 200 kB budget, data: URI images in the quote are replaced with a placeholder, and messages are loaded one at a time with a targeted query, never the whole thread at once.

Related: Gmail's limits. The JSON send endpoint takes 5 MB, so a pasted base64 screenshot easily exceeds it and the result is an opaque 500. Above ~3.5 MB we send via the upload endpoint (35 MB limit) and assemble the multipart body into a stream, not a string. The hard 25 MB cap is checked before building the payload, with a human message ("The e-mail is 31 MB, the limit is 25 MB – send a link instead"). And our attachment limit is 17 MB, not 18 – Gmail measures the message after base64 (+33 %).

4. The token that deleted itself

Google occasionally returns HTTP 200 without an access_token when refreshing. Without a guard we overwrote a valid token in the database with null, the next request failed with "Invalid token" and the mailbox stopped working until someone reconnected it manually. Same class of bug: Google only returns a refresh_token on the first consent – on re-authorisation you must not overwrite it with an empty value.

Rule: validate the token refresh response and, if the token is missing, don't touch the database. And treat a token as expired five minutes before it actually is – no request may run with a token that expires mid-flight.

5. Two pollers, two tickets

Cron pulls mail every five minutes. Meanwhile a colleague clicked "Sync now". Both passed the "this message doesn't exist yet" check, both created a ticket. An exists() guard is no protection against concurrency – the only reliable one is a UNIQUE index on the message ID and catching the constraint violation as "duplicate, skip". We also gave manual sync a 60-second cooldown and run it in the background via the queue.

6. An empty ticket, and an attachment that never downloaded

For larger bodies the Gmail API doesn't return body.data but body.attachmentId – even for plain text/html. If you don't expect it, you get an empty ticket. And a Gmail attachment ID can easily be 300+ characters; in a VARCHAR(255) it gets truncated and attachments silently fail to download. Just use TEXT.

Same category: bounces from mailer-daemons often lack the Auto-Submitted header, so every delivery failure spawns a ghost ticket. Detect them by subject too ("Mail delivery failed", "Undelivered mail returned") and by sender (mailer-daemon@, postmaster@, noreply@).

7. The attachment that quietly vanished

A colleague attached a .js file to a reply. The frontend correctly blocked it (Gmail rejects executable attachments anyway), but the toast disappeared before she noticed, and the e-mail went out without the attachment. The backend never received the file, so it couldn't log anything either.

A rule that applies well beyond ticketing: destructive warnings must not be toasts. A persistent red box in the editor, a tooltip listing the blocked extensions, and a backend that logs the rejection and posts it to Slack.

8. The e-mail went out, the database write didn't

Gmail sent the message, then the database write failed. Without handling, the colleague sees an error, clicks again and the client gets a duplicate. Hence: an incomplete Gmail response (200 without an ID) is an exception with the warning "the e-mail MAY have been sent, check Gmail"; a database failure after sending is a critical log, a Slack alert and the explicit message "Do not send again, Gmail ID: …". Priority number one of the whole system is never to silently lose an e-mail – better one duplicate now and then than a loss.

What we'd do differently today

  • A mail gateway interface from day one. We call the Gmail client directly from the service layer, which makes unit-testing the sending path hard.
  • Download attachments and inline images asynchronously. A newsletter with twenty images = twenty synchronous API calls in the middle of a poll.
  • Separate MIME assembly, sending and the DB write into three classes. Reply and compose currently share 80 % of their code.
  • Push notifications via Pub/Sub instead of polling, if you need latency under five minutes. For internal support, five minutes is fine for us.

A spec for your coding agent

Everything above – plus roughly forty more edge cases that didn't fit in the article (forwards with a lease lock, moving tickets between mailboxes, SLA, automations, header security, sandboxing the e-mail preview) – is written up in a single document. It's not a tutorial; it's a spec written for a coding agent: architectural decisions, a minimal data model, the receive and send pipelines, addressing rules, and a checklist of scenarios the tests must cover.

Paste it as the opening prompt into Claude Code, Codex or Cursor and pick your own stack. The rules matter, not the framework.

Download the spec: prompt-email-ticketing-system-gmail.md

The document is anonymised – no real addresses, clients or ticket numbers. Every edge case in it is a real incident or a code-review finding, not a hypothesis.

Want something similar?

For us, the ticketing system is just one tile. The point is that e-mail, invoicing, CRM, projects and AI agents share one set of data. If you're facing the same problem – the company is growing and the overview is crumbling across five tools – get in touch. We build internal systems and custom AI applications and we're happy to show you how ours is put together.