# Prompt: An e-mail ticketing system on top of the Gmail API

> Source: [webmakeria.com/blog/email-ticketing-system-gmail-api-lessons-from-production/](https://www.webmakeria.com/blog/email-ticketing-system-gmail-api-lessons-from-production/) — Webmakeria (Webotvůrci s.r.o.). The document is anonymised: addresses, domains and paths are placeholders. Czech original: [webotvurci.cz/files/prompt-tiketovaci-system-gmail.md](https://www.webotvurci.cz/files/prompt-tiketovaci-system-gmail.md).

> **How to use this document:** Copy it whole as the system/opening prompt for your coding agent (Claude Code, Codex, Cursor…). It is written as a spec + "lessons learned" from running a similar system in production at a web agency (Laravel + React, ~4 mailboxes, thousands of tickets). Pick your own stack — the rules and edge cases matter, not the framework. Sections marked **⚠️ EDGE CASE** are things that actually bit us in production and cost us a bug-fix commit.

---

## 0. Role and goal

You are a senior backend/full-stack developer. You are building an internal ticketing system where **every e-mail thread = one ticket**. E-mails are read and sent via the **Gmail API** (one Google Workspace account, multiple send-as aliases = multiple "mailboxes"). Users (agents) reply from a web UI; the reply must go out so that the client sees it as a normal e-mail in the same thread, and their next reply lands in the same ticket again.

Priorities, in this order:
1. **Never silently lose an e-mail** (inbound or outbound). An occasional duplicate is better than a loss.
2. **Never write to someone we shouldn't** (our own addresses, form addresses, people the client dropped from the thread).
3. Correct threading in both Gmail and Outlook.
4. Only then UX.

---

## 1. Key architectural decisions

| Decision | Choice | Why |
|---|---|---|
| Gmail OAuth | **A separate OAuth client from login** | Login OAuth (identity) and Gmail OAuth (offline refresh token, `gmail.modify`) have different scopes and lifecycles. Don't mix them. |
| Gmail account | One account, multiple send-as aliases | Each alias = one mailbox in the app. Aliases come from `users/me/settings/sendAs`. |
| Receiving | **Polling every 5 min** (not Pub/Sub push) | Simpler, sufficient for internal support. Add push only when you need < 1 min latency. |
| What to poll | **Always only `label:{mailbox-label} is:unread`** | Never read the whole inbox. A mailbox without a label is skipped and logged. Labels are assigned by Gmail filters (set up by an admin in Gmail by To/alias). |
| After processing | **Archive** (remove `INBOX` + `UNREAD`), keep the label | The Gmail inbox stays clean, the message is still findable via the label, dedup by `gmail_message_id` prevents reprocessing. |
| Sending | `messages/send` with `threadId` | The e-mail shows up in Gmail's Sent folder and Gmail files it into the thread. |
| Dedup | `UNIQUE` on `ticket_messages.gmail_message_id` | An `exists()` guard is not enough (see the race below). |
| Threading | `gmail_thread_id` primarily, `In-Reply-To`/`References` as fallback | The Gmail thread ID is more reliable, but only covers messages Gmail itself linked. |
| SLA | Precomputed deadlines on the ticket (`first_response_due_at`, `resolution_due_at`) | Cheaper than computing on every read; a cron checks for breaches every minute. |
| Access | Per mailbox (`ticket_mailbox_user` pivot) | No mailbox access → the ticket is neither visible nor searchable. |
| AI | Draft → human review → send | AI never sends directly. |
| Tokens | Encrypted in the DB (`encrypted` cast) | A refresh token = permanent access to the mailbox. |

---

## 2. Google Cloud + OAuth

### Setup
1. Google Cloud Console → **APIs & Services → Library → Gmail API → Enable**.
2. **OAuth consent screen** — Internal (Workspace) or External. Scopes:
   - `https://www.googleapis.com/auth/gmail.modify` (read, send, labels)
   - `https://www.googleapis.com/auth/gmail.settings.basic` (send-as aliases)
3. **Credentials → OAuth 2.0 Client ID → Web application**, redirect URI exactly `https://your-domain/api/tickets/gmail/callback` (+ a localhost variant for dev).
4. ENV: `GMAIL_CLIENT_ID`, `GMAIL_CLIENT_SECRET`, `GMAIL_REDIRECT_URI`.

### Flow
```
1. Owner clicks "Connect Gmail" → GET /api/tickets/gmail/auth-url (authenticated endpoint)
   → backend generates a state (random 40 chars), stores it in CACHE (not session!)
     with user_id and a 10 min TTL, returns a URL to accounts.google.com/o/oauth2/v2/auth with:
     response_type=code, access_type=offline, prompt=consent, scope=..., state=...
2. Frontend redirects to the URL.
3. Google → callback GET /api/tickets/gmail/callback?code=&state=  (PUBLIC route, no auth)
   → Cache::pull(state) – must exist, otherwise redirect ?error=invalid_state
   → POST oauth2.googleapis.com/token (grant_type=authorization_code)
   → GET gmail/v1/users/me/profile → emailAddress
   → store GmailAccount (singleton) – access_token, refresh_token, token_expires_at, scopes
   → redirect to frontend /settings/mailboxes?connected=1
```

**⚠️ EDGE CASE – state in cache, not in session.** The callback from Google arrives without a Sanctum/Bearer token and outside the SPA session. When we kept the state in the session, the callback couldn't see it. Cache with a TTL + `pull()` (one-shot) solves it and also prevents replay.

**⚠️ EDGE CASE – `refresh_token` only arrives the first time.** Google returns a `refresh_token` only on the first consent (or with `prompt=consent`). When re-authorising an existing account, **don't overwrite the refresh_token with an empty value** — update it only when it's present in the response. When creating the account for the first time and the refresh_token is missing, refuse (`?error=no_refresh_token`), otherwise you'll have an account that stops working in an hour.

**⚠️ EDGE CASE – token refresh with an incomplete response.** Google occasionally returns HTTP 200 without an `access_token` (intermittent error). Without a guard we overwrote a valid token in the DB with `null` and the next request failed with "Invalid token" → manual re-authorisation required. Validate `access_token` in the response and, if it's absent, throw and don't touch the DB.

**Expiry:** treat the token as expired **5 minutes before** `token_expires_at` (no request may run with a token that expires mid-flight).

**Send-as aliases:** endpoint `GET /settings/sendAs`. In the mailbox-creation UI, offer only aliases not yet used by any mailbox. Aliases are available to all logged-in users (the reply editor needs them for the "From:" dropdown); managing the connection is owner-only.

---

## 3. Data model (minimum)

```
gmail_accounts        id, email, access_token(enc), refresh_token(enc), token_expires_at,
                      scopes(json), connected_by_user_id, last_polled_at, is_active

ticket_mailboxes      id, gmail_account_id, name, email_address, gmail_label_id, gmail_label_name,
                      signature, default_assignee_id, default_sla_policy_id, is_active, color, sort_order
ticket_mailbox_user   mailbox_id, user_id, role

tickets               id, number(unique seq), ticket_mailbox_id, assigned_user_id, client_id,
                      subject, status, priority, requester_name, requester_email,
                      requester_email_normalized(lower, index), cc_emails(json), tags(json),
                      source(email|manual), gmail_thread_id(UNIQUE nullable),
                      first_response_due_at, resolution_due_at, first_responded_at,
                      resolved_at, closed_at, sla_*_breached, last_message_at, last_inbound_at,
                      last_outbound_at, message_count, is_spam, merged_into_ticket_id

ticket_messages       id, ticket_id, user_id(null=inbound), direction(inbound|outbound),
                      from_email, from_name, reply_to, to_emails(json), cc_emails(json), bcc_emails(json),
                      subject, body_html, body_text, body_stripped, search_text,
                      gmail_message_id(UNIQUE nullable), gmail_thread_id,
                      message_id_header(INDEX!), in_reply_to, references_header,
                      attachments_count, attachments_meta(json), attachments_processed_at, sent_at

ticket_attachments    id, ticket_message_id, filename, mime_type, size_bytes,
                      gmail_attachment_id(TEXT, not varchar!), source(gmail|upload),
                      storage_path, storage_disk, is_downloaded, downloaded_at, purged_at

ticket_comments       id, ticket_id, user_id, type(note|system|ai_draft), content, is_promoted…
ticket_forwards       id, ticket_id, ticket_message_id, ticket_automation_rule_id, to_email,
                      subject, attachments_count, lease_owner, gmail_message_id, sent_at
                      UNIQUE(rule_id, message_id, to_email)
ticket_sla_policies, ticket_automation_rules(+logs), ticket_canned_responses, ticket_presences
```

**⚠️ EDGE CASE – `gmail_attachment_id` is long.** A Gmail attachment ID is commonly 200–400+ characters. `VARCHAR(255)` truncates/rejects it → attachments silently fail to download. Use `TEXT`.

**⚠️ EDGE CASE – index on `message_id_header`.** Fallback threading does `WHERE message_id_header IN (...)`. Without an index that's a full scan on every inbound e-mail.

**Ticket number:** in Postgres a dedicated `SEQUENCE`, not `MAX()+1` (race). Show users one identifier (we use `id`), not two.

**`requester_email_normalized`:** always lowercase, for search and client matching. Compare e-mail addresses **always case-insensitively** — wherever an `in_array` shows up, lowercase both sides.

---

## 4. Receiving e-mail (polling → ticket)

### Pipeline
```
cron every 5 min (withoutOverlapping!) → pollAll()
  for each active mailbox with an active Gmail account:
    - mailbox without gmail_label_name → skip + warning (never read the whole inbox)
    - listMessages("is:unread label:{label}") with pagination, cap 500 messages/poll
    - for each message: processMessage()
    - on 429 → stop this mailbox (the rest is picked up by the next cron); if >10 messages remain → Slack alert
    - store last_polled_at

processMessage(mailbox, gmailMessageId):
  1. Dedup: does ticket_messages.gmail_message_id exist? → archive in Gmail, skip
  2. getMessage(id, format=full)
  3. parseEmailHeaders() – From/To/Cc/Reply-To/Subject/Message-ID/In-Reply-To/References/Date/
     Auto-Submitted/X-Auto-Response-Suppress + recursive extractBody()
  4. isAutoReply()? → archive, skip
  5. isSentByUs()? (From is one of our mailboxes) → archive, skip
  6. Body via attachmentId? → fetch it
  7. stripHtmlWrapper(body_html), resolveInlineImages(cid: → data:URI)
  8. body_stripped = stripQuotedReply(text)
  9. DB transaction: findExistingTicket() ? addInboundMessage() : createFromEmail()
     catch UNIQUE violation on gmail_message_id → race, skip as duplicate
  10. After commit: download attachments, set attachments_processed_at
  11. archiveMessage() (removeLabelIds: [UNREAD, INBOX])
```

**⚠️ EDGE CASE – race between cron and a manual "Sync now".** Two pollers process the same message, both pass the `exists()` guard, both create a ticket. Fix: `UNIQUE(gmail_message_id)` + insert inside a transaction + catch the constraint violation → treat as duplicate. Additionally put manual sync behind a 60 s cooldown (cache key) and run it in the background via the queue, not in the request.

**⚠️ EDGE CASE – the message body is not inline.** The Gmail API sometimes returns not `body.data` but `body.attachmentId`, even for `text/html`/`text/plain` (larger bodies). You have to remember the ID and fetch it via `messages/{id}/attachments/{attId}`. Otherwise you get an empty ticket.

**⚠️ EDGE CASE – NDR/bounces create ghost tickets.** The `Auto-Submitted` header covers out-of-office, but bounces from mailer-daemons often lack it. Detect them by subject too (`mail delivery failed`, `undelivered mail returned`, `delivery status notification`, `failure notice`, `returned mail`, plus your local-language variants) and by sender (`mailer-daemon@`, `postmaster@`, `noreply@`, `no-reply@`, `bounce@`, `bounces@`). The value `Auto-Submitted: no` is NOT an auto-reply.

**⚠️ EDGE CASE – our own outbound e-mails in the inbox.** When someone replies to a client directly from Gmail (outside the app) and a Gmail filter labels the message, the poll sees it. Skip by `From ∈ our mailboxes`. (Replies sent by the app have their `gmail_message_id` stored, so dedup catches them.)

**⚠️ EDGE CASE – `References` with 1000 IDs.** Long forwarded threads carry hundreds of Message-IDs. A `whereIn` with a thousand values is slow and pointless — take **the last 20** (the newest are the most likely match).

**⚠️ EDGE CASE – Reply-To equal to From.** When `Reply-To == From`, ignore it (store null). Otherwise the "Reply-To takes precedence" logic needlessly complicates addressing.

**⚠️ EDGE CASE – Date header.** Parse with try/catch and a fallback to `now()`; convert to your timezone. Invalid Date headers exist.

### MIME parsing (extractBody, recursive)

Rules we had to add one by one:
- The filename is usually in `payload.filename`, **but sometimes only in `Content-Disposition: attachment; filename="..."`** or RFC 5987 `filename*=UTF-8''...` (URL-encoded) → decode it.
- **It's an attachment if:** (a) it has a filename + attachmentId; (b) it has a filename + inline data and isn't `text/html`/`text/plain`; (c) `Content-Disposition: attachment` even without a filename (fill in `attachment`); (d) it has an attachmentId, isn't an inline image and the MIME type isn't a body (`text/html`, `text/plain`, `multipart/*`).
- **Inline image** = has `Content-ID` + an `image/*` MIME type. Store a `cid → attachmentId` map; after parsing, download and replace `cid:xxx` (also `CID:`) with `data:image/...;base64,...` in `body_html`. Browsers won't render `cid:`. (Yes, it bloats the DB row; the alternative is serving images via your own endpoint. Beware: these are synchronous Gmail API calls per image — newsletters with 20 images slow the poll down; move to a job eventually.)
- Small attachments may arrive **inline in `body.data`** without an attachmentId → decode base64url (`-_` → `+/`) in **strict** mode; `false` = corrupted data, don't create the record.
- Log the MIME tree (`mimeType [filename] (data inline|attachmentId, size)`) at debug level — it saves hours when debugging "why is the attachment missing".
- **Strip `<!DOCTYPE>`, `<html>`, `<head>…</head>`, `<body>`** from `body_html` — you'll be embedding it in your own iframe/wrapper and a nested HTML document breaks rendering.
- `stripQuotedReply`: remove lines starting with `>`, `On … wrote:` blocks and your local-language equivalents. Store the result in `body_stripped` (list preview, AI context). Never delete the original.

---

## 5. Threading

### Inbound → existing ticket
1. `tickets.gmail_thread_id == message.threadId` → match.
2. Fallback: `In-Reply-To` + the last 20 of `References` → `ticket_messages.message_id_header IN (...)` → that message's ticket.
3. Nothing → new ticket.

### Outbound reply — headers
```
Message-ID:  <random32hex@your-domain.com>     (store in message_id_header!)
In-Reply-To: <Message-ID of the LAST message of the thread – regardless of direction>
References:  <that message's References> + ' ' + <its Message-ID>
+ Gmail API parameter threadId = ticket.gmail_thread_id
```

**⚠️ EDGE CASE – Outlook breaks the thread.** Originally we built `In-Reply-To` only from the last **inbound** message. When an agent 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 tolerantly merges it, Outlook doesn't → the client saw two threads. The parent must be **the last message regardless of direction** and `References` = the parent's `References` + the parent's `Message-ID` (the parent already carries the whole chain, so you get a complete RFC 5322 chain).

**⚠️ EDGE CASE – ORM default ordering.** We had a `messages()` relation with a default `orderBy('sent_at' ASC)`. `->orderByDesc('sent_at')->first()` was merely **appended** to it and returned the OLDEST message → wrong `In-Reply-To` and wrong recipient. If you have default ordering on a relation, explicitly reset it (`reorder()`) in every "last message" query. Always order by `sent_at DESC, id DESC` (two messages in the same second).

**⚠️ EDGE CASE – Gmail `threadId` on compose.** For a newly created outbound e-mail (compose), take the `threadId` from the `messages/send` response and store it on the ticket — otherwise the client's reply won't find the ticket via the primary path.

**⚠️ EDGE CASE – Gmail only files into a thread on a matching Subject.** If you send an e-mail with a different subject (e.g. `Fwd:`) into a `threadId`, Gmail rejects the request. Send forwards **without** a `threadId`, as a standalone e-mail.

**Header injection:** every value that goes into a header (Subject, From name, filename, In-Reply-To) passes through a function that **first strips `\r`, `\n`, `\0`** and only then RFC 2047-encodes if needed (`=?UTF-8?B?...?=` only for non-ASCII). The subject flows in from someone else's e-mail; `"...\r\nBcc: attacker@..."` would otherwise add a header to the e-mail you are sending.

---

## 6. Who the reply goes to (the most bugs of the whole project)

This is the area where we made **five** bug-fix commits. Introduce three clearly separated concepts and compute each in exactly one place:

### 6.1 `replyRecipientEmail(ticket)` — the **To** field
```
for inbound messages from newest (limit 20):
   candidate = message.reply_to  (if present)  ELSE message.from_email
   if candidate ∈ ourAddresses(ticket) → next message
   else → return candidate
fallback: ticket.requester_email
```

**⚠️ EDGE CASE – an enquiry from a web form.** A WordPress/Shoptet form sends a notification with `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 `requester_email` (= the form address, which has no mailbox) sent further replies into the void and the client remained only in CC. → Hence the order *Reply-To of the last inbound → its From → older inbound → requester*.

**⚠️ EDGE CASE – Reply-To shadows From only within its own message.** The first version blanket-excluded the From of every message that ever had a Reply-To, across the whole ticket. When the same address wrote again without a Reply-To, we skipped it and replied to the older Reply-To — potentially a completely different person. For **To**, shadowing applies only within a single message. For **CC** (see below) it's ticket-wide, because the risk there is the opposite (worst case someone is missing).

### 6.2 `requesterEmail(ticket)` — the client's **identity** (CRM matching, prefilling an order)
= `Reply-To ?: From` of the **FIRST** inbound message. Deliberately not `replyRecipientEmail`: that points at whoever wrote last, which may be a colleague from CC. The ticket's identity belongs to whoever opened it. When auto-linking to a client, try Reply-To first, then From, then the company domain (not freemail — disable domain matching for gmail.com and the like).

### 6.3 `ccFromLastMessage(ticket)` — **CC** prefill for the next reply
```
last message of the thread (any direction):
  outbound → its cc_emails minus replyRecipient
  inbound  → externalParticipants(To + CC + From) minus:
               ourAddresses(ticket), replyRecipient, that message's Reply-To and the From it shadows,
               ticket-wide shadowed From addresses (forms)
```

**⚠️ EDGE CASE – CC accumulated.** A writes with B in copy, we reply, then B replies and deliberately leaves A out. Our next reply went to A+B again, because we only ever added to `cc_emails` on the ticket. **CC must mirror 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.

**⚠️ EDGE CASE – the last message is ours.** The agent manually edits the CC on a reply. A recalculation that only looked at inbound messages would discard their choice. When the last message is outbound, the CC the agent actually used applies. `sendReply()` therefore stores the CC it used on the ticket.

**⚠️ EDGE CASE – sender from CC.** When the client's colleague (who was in CC) replies, they have us in "To" and the original client in CC. `externalParticipants` must therefore include the message's `From` too — otherwise, in our reply (To = original client), the colleague would drop out of the thread.

**⚠️ EDGE CASE – our send-as aliases aren't in the mailbox table.** An agent replied from an alias, the client hit Reply-All, and our alias got stuck in the ticket's CC. `ourAddresses(ticket)` = mailbox addresses **+ the `from_email` of every outbound message of the ticket** (the alias used is in the DB; no Gmail API call in the processing path).

**⚠️ EDGE CASE – the client twice.** Older tickets had the client in `cc_emails` (from the days when we treated them as a participant). Before sending, always: `CC minus To`, `BCC minus (To + CC)`, case-insensitive, dedup.

**⚠️ EDGE CASE – concurrent CC writes.** Cron + manual sync process two messages of one thread at the same time, both read the same stale CC, the later update discards the first one's addresses. Recalculate CC under a **row lock** (`SELECT … FOR UPDATE`) and from the newest message **in the DB**, not from the payload being processed.

**⚠️ EDGE CASE – caching our addresses.** We cached the list of mailbox addresses for 5 minutes. After adding a new mailbox, its address kept getting stuck in CC for 5 minutes. Invalidate the cache on every mailbox save/delete.

### 6.4 UI warning
If the **last inbound** message had external recipients the agent did not put in CC, return `warning: original_had_multiple_recipients` with the list after sending — the UI shows a toast. (The first version looked at the first message of the thread, so people added during the conversation never triggered the warning.)

### 6.5 Frontend — CC prefill
- Key the prefill on **`ticketId + recipient + join(cc_emails)`**, not just the ticket ID: a refetch with the same CC won't overwrite manual edits, but a CC change from an inbound message shows up immediately.
- When switching to another ticket, **reset the whole editor state** (CC, BCC, attachments, next status). If the component doesn't remount (the detail is drawn from the list cache), data from the previous ticket stays behind. Beware of React StrictMode — reset the "prefill already happened" ref together with the state.
- Show **"To: …"** next to the editor so the agent sees where the reply goes, plus a button to copy the address.
- E-mail validation stricter than HTML5: require a TLD ≥ 2 characters. Split chip-input entry on `,`, `;` (Outlook) and whitespace.

---

## 7. Sending a reply

### Building RFC 822
```
From: =?UTF-8?B?...?= <alias@domain.com>
To: <replyRecipient>
Cc: …            (only when non-empty)
Bcc: …
Subject: Re: <subject without an existing "Re: ">
MIME-Version: 1.0
Message-ID / In-Reply-To / References   (see §5)

Body (RFC 2387):
  with inline images + attachments: multipart/mixed > multipart/related > multipart/alternative
  with inline images:               multipart/related > multipart/alternative
  with attachments:                 multipart/mixed > multipart/alternative
  otherwise:                        multipart/alternative (text/plain + text/html, quoted-printable)
Inline image: Content-ID: <cid>, Content-Disposition: inline, base64 + chunk_split
Attachment:   Content-Disposition: attachment; filename="RFC2047", base64 + chunk_split
```
API payload: `raw = base64url(rfc822)` without padding, + `threadId`.

### Reply content
1. The agent's text (HTML from the editor).
2. **Signature**: the user's own signature takes precedence over the mailbox default. A template with variables (`{{name}}`, `{{email}}`, `{{phone}}`, `{{role}}`); escape the values; if the template is HTML, sanitise it (strip `script/style/iframe/object/embed/form`, `on*=` attributes, `javascript:` in href/src).
3. **Inline images**: before sending, the frontend extracts `<img src="data:…">` from the HTML into `File` objects with a CID (`inline_images[]` + `inline_image_cids[]` in FormData) and replaces them in the HTML with `src="cid:…"`. The backend does the same once more as defence in depth (fast path: if the HTML contains no `data:image`, skip the regex).
4. **Store** `body_html` in the DB with images **restored back to data URIs** (browsers won't render `cid:`) — but **without** the quoted history.
5. **Quoted history** (only into the sent MIME, not into the DB) — see below.

**⚠️ EDGE CASE – the 49 MB e-mail and OOM (the most expensive bug).** The first version quoted **the entire thread** from the full `body_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, 49 MB, `memory exhausted`. Rules:
   - Quote **only the last message of the thread** (Gmail and Outlook do the same; it carries the older quotes itself, growth is linear).
   - When the last message is **our outbound** (the agent replies twice in a row), also include **the last inbound** — outbound messages only have the written text in the DB, no quote, so a quote of our reply alone wouldn't contain the client's question.
   - **A 200 kB budget for the whole quote** (HTML and text separately); truncate with `mb_strcut` (never in the middle of a UTF-8 character) + `[…truncated…]`.
   - Replace `<img src="data:…">` in the quote with the placeholder `[embedded image]`.
   - Load messages **one at a time with a targeted query**, not `->get()` over the whole thread.

**⚠️ EDGE CASE – Gmail's 5 MB JSON limit vs. inline screenshots.** Base64 images in `body_html` easily exceed the JSON endpoint limit (5 MB after base64) and nginx buffers → an opaque 500 with no Sentry record. Fix: extraction into CID attachments (above) + **above ~3.5 MB of raw data, send via the upload endpoint** `upload/gmail/v1/users/me/messages/send?uploadType=multipart` (35 MB limit). Assemble the multipart body into a **stream** (`php://temp`), not a string — for a 20 MB message, concatenation + the HTTP client would hold 3 copies in memory. Decide the size from `strlen(raw)`, not `strlen(json_encode(payload))` (that makes another copy a third larger just to measure).

**⚠️ EDGE CASE – the hard 25 MB cap.** Gmail rejects anything over 25 MB. Check the size **before** building the payload and throw your own `MessageTooLargeException` → HTTP 413 with a human message ("The e-mail is X MB, the limit is 25 MB, send a link instead"). Don't report it to Sentry — it's not an application error.

**⚠️ EDGE CASE – the attachment limit is 17 MB, not 18.** Gmail measures the whole message **after** base64 (+33 %) and `chunk_split` (+2.6 %). 18 MB of data = 24.7 MB in MIME + body + quote → over the limit. **Count inline images towards the limit together with attachments** (originally they had no check at all and were the second road to OOM).

### After sending — DB consistency
```
sendResult = gmail.send(raw, threadId)
if (!sendResult.id) → RuntimeException "Gmail returned no ID; the e-mail MAY have been sent, check Gmail"
DB transaction:
  ticket.gmail_thread_id ??= sendResult.threadId
  TicketMessage(outbound, gmail_message_id, message_id_header, to/cc/bcc, body_html(display), …)
  ticket: message_count++, last_message_at, last_outbound_at, first_responded_at ??= now, cc_emails = CC used
catch → Log::critical + Slack "ORPHANED EMAIL" + exception "Do not send again, Gmail ID: …"
store attachments locally OUTSIDE the transaction (a failure must not sink an already-sent e-mail)
optionally: next_status (default 'resolved'), assigned_user_id (distinguish "not sent" from "explicitly null")
```

**⚠️ EDGE CASE – the e-mail went out, the DB write failed.** Without handling, the agent sees an error, clicks again, the client gets a duplicate. Hence: an incomplete Gmail response = an exception with a warning; a DB failure after sending = critical log + Slack + the explicit message "do not send again".

**Further sending rules:**
- Reject replies to a `closed` ticket (reopen first).
- Rate-limit `/reply` and `/compose` (we use 30/min/user) — protects the Gmail quota and against abuse.
- Production error messages **never expose `$e->getMessage()`** (it may contain tokens/paths); log in full, show the user a generic message.
- `Ctrl+Enter` sends; the Send button has a tooltip explaining why it's disabled.

---

## 8. Attachments

### Inbound
- A blocklist of **extensions** (`exe bat cmd scr pif com vbs vbe js jse wsf wsh ps1 msi dll svg svgz`) and **MIME types** (`application/x-msdownload`, `x-sh`, …). Gmail blocks them on the other side anyway.
- **Never SVG** — neither as an attachment nor in a preview (XSS via `<script>` in SVG).
- Max size 25 MB/file; skip larger ones with a log entry.
- Create the record, download the content; if the download fails and you have a `gmail_attachment_id`, **keep the record** (re-download on demand); if inline data can't be decoded and re-download isn't possible, **delete the record** (otherwise the download endpoint crashes on a missing file).
- `storeContent()` inside try/catch — a full disk creates a record without a file.
- Storage path: `ticket-attachments/YYYY/MM/{message_id}/{id}_{sanitized_filename}`.
- **Purge** local copies of Gmail attachments after 90 days (cron), never uploaded attachments. The download endpoint can re-fetch from Gmail by `gmail_attachment_id`.
- When done, set `ticket_messages.attachments_processed_at` — the only reliable "attachments are ready" signal (counts don't add up, because blocked/oversized ones never reach the DB).

### Outbound (upload from the UI)
- The same blocklist + a **magic bytes** check (`finfo`): a declared `image/*` must be detected as an image, `application/pdf` as a PDF; Office formats (docx/xlsx/pptx) are ZIPs inside → tolerate.
- Verify `getRealPath()` and read the content **before** creating the DB record — during a long request (sending takes seconds) the framework may clean up the tmp file.

**⚠️ EDGE CASE – a silently dropped attachment.** A colleague attached a `.js`, the frontend blocked it, the toast disappeared before she noticed, the e-mail went out without the attachment. The backend never received the file → no alert. Fix: a **persistent** red warning in the editor (not a toast), a tooltip listing the blocked extensions, and the backend logs + posts to Slack on rejection.

**⚠️ EDGE CASE – PHP/nginx limits.** Set `upload_max_filesize`/`post_max_size` to 25M+; **a PHP-FPM pool config without the `[www]` header is silently ignored** and the 2M/8M defaults apply. Nginx `client_max_body_size 25M` for `/api/*` + larger `fastcgi_buffers` (large PHP responses otherwise produce a 500).

---

## 9. Gmail API client — robustness

```
requestWithRetry(method, endpoint):
  ensureTokenValid()  (refresh 5 min before expiry)
  throttle 100 ms between requests
  loop max 4 retries:
    200 → return
    401 (first time) → refreshAccessToken(), retry
    429 → Retry-After header (cap 120 s) or exponential backoff 0.5/1/2/4 s + 0–30 % jitter
    403 reason ∈ {rateLimitExceeded, userRateLimitExceeded} → backoff; dailyLimitExceeded → throw immediately; other 403 → throw (permission)
    5xx → backoff
    other → throw
```
- `listAllMessages` with pagination and a cap (500); log when exceeded — silent truncation looks like "everything processed".
- `modifyLabels(add[], remove[])` in one call (moving between mailboxes).
- `archiveMessage` = one call with `removeLabelIds: [UNREAD, INBOX]`.
- Don't log the `response.body()` of error responses without sanitising (they may contain tokens).
- A test endpoint `users/me/profile` for "Test connection" in settings.

---

## 10. Statuses, reopen, SLA

- Statuses: `open` → `pending` (waiting for the client) → `on_hold` → `resolved` → `closed`. Loosen the transitions (from any to any except itself) — a strict state machine just annoyed people.
- **Auto-reopen:** an inbound message on a ticket in `pending`/`resolved`/`closed` → `open`, clear `resolved_at`/`closed_at`, system comment "reopened by an inbound e-mail". (We originally forgot `pending` — the client replied and the ticket stayed in "Waiting".)
- Default status after sending a reply: `resolved` (dropdown in the editor; the heuristic "ends with a question mark → pending" is a nice follow-up).
- Record every change of status/priority/assignment/mailbox as a **system comment** — that's your audit trail.
- SLA: a policy per mailbox with a global fallback; hours per priority; optionally business hours only (Mon–Fri 9–17, iterate by days with a max step count). Precompute deadlines on creation and recompute on a priority change (only if not yet responded). A cron marks breaches every minute and posts to Slack. Set `first_responded_at` only on the first reply; a forward **does not count** as a reply.

---

## 11. Automations and forwarding

Rules: trigger (`ticket_created`, `ticket_replied`, `status_changed`, …), conditions (AND/OR; subject contains/regex, sender domain, mailbox, priority, tag), actions (assign, set_priority, set_status, add_tag, set_sla, slack_alert, add_note, assign_to_agent, **forward_to_email**). User-supplied regex: length ≤ 200, `@preg_match` inside try/catch, timeout.

- Dispatch evaluation **`afterCommit`** — the ticket and message are created inside a transaction; the job would otherwise read uncommitted data.
- The `TicketCreated` event **carries the message** the ticket was created with. When we looked it up as "the last inbound", a reply had landed in the meantime during a delayed queue and the rule forwarded that instead of the original e-mail.

**Forward** = a separate job (not inline in the action), because:
- `executeActions()` swallows exceptions (so one broken action doesn't stop the others) → a Gmail outage would be silently discarded and, for a one-shot trigger, the e-mail would be gone for good.
- Downloading attachments + sending doesn't fit into the timeout of the rule-evaluation job.
- Attachments are stored only after the commit → the job **waits** for `attachments_processed_at` (release 15 s, max 8×; then it sends without attachments rather than not at all).

**⚠️ EDGE CASE – deduplication vs. a killed worker.** The `ticket_forwards` record is created **before** sending (UNIQUE protects against two concurrent workers), but a worker killed by a timeout never cleaned it up and the unique index blocked the forward forever. Fix: `sent_at` is the only proof of delivery; a reservation without `sent_at` expires after 10 min. **And also:** a retry of the same job arrives after `retry_after` (90 s) — deep inside the 10 min window — and judged its own reservation as "someone else is sending it". Hence `lease_owner = job UUID` (the same across attempts): take over your own reservation, respect a fresh foreign one, release an expired foreign one.

**⚠️ EDGE CASE – job timeout < the queue's `retry_after`.** With a 180 s timeout and `retry_after` of 90 s, a second worker picked up the running job, hit the fresh reservation, skipped the send and removed the job from the queue — had the first worker died, the e-mail would be lost. The job timeout **must be smaller** than `retry_after`.

- `tries` higher than the retry budget (a release while waiting for attachments counts as an attempt); watch real errors with `maxExceptions`.
- **Loops:** forbid forwarding to your own mailbox (new ticket → same rule → …). Check in the DB, not the cache.
- Forward headers: `Auto-Submitted: auto-forwarded` (RFC 3834 — neither the other side nor your own poll reacts to it), `X-Ticket: {id}`, `Subject: Fwd: …`, no `threadId`.
- A forward is **not recorded** as an outbound message (it would move `first_responded_at`/SLA); the trace is `ticket_forwards` + a system comment.
- A lower attachment cap for forwards (10 MB) — the whole MIME sits in the memory of a worker with a short timeout.
- Invalid rule configuration (invalid address, own mailbox) → `InvalidArgumentException` → **don't retry**, just log.

---

## 12. Moving a ticket between mailboxes

A Gmail filter occasionally files an e-mail wrongly. The "Move to mailbox" action: change `ticket_mailbox_id` and **best-effort** relabel all the ticket's Gmail messages (`modifyLabels(add new, remove old)`) — only when both mailboxes are under the same Gmail account and have a `gmail_label_id`. A Gmail API failure doesn't roll back the DB move, it's just noted in a system comment. On move, check that the assigned user has access to the target mailbox (otherwise reassign/unassign).

---

## 13. Frontend — e-mail display and editor

- **Render the e-mail body in an `<iframe srcDoc>` with `sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts"` — without `allow-same-origin`** (otherwise the sandbox isolates nothing). Run the content through DOMPurify first. `<base target="_blank">` for opening links. Handle auto-height with a script inside the iframe via `postMessage` with the message ID. Own CSS inside (white background, `max-width:100%` images, collapse `.gmail_quote`).
- Autolink URLs **before** sanitising, or in a way that can't re-inject HTML.
- The editor is `contentEditable`; before sending, DOMPurify + `extractInlineImages`.

**⚠️ EDGE CASE – an image pasted from another web app.** An image copied from Freelo/Notion etc. sits in the clipboard as HTML with `blob:https://other-domain/…` (won't load from your origin) + as binary. The browser picks the HTML → an empty frame in the editor and in the e-mail. Fix: in `onPaste`, **binary takes precedence** — insert it as a `data:` URI. Treat the `blob:`, `webkit-fake-url:`, `file:` schemes as dead. Downscale retina screenshots to max ~1600 px wide (e-mail is read in a 600 px column).

- A chip input for CC/BCC with autocomplete (addresses from ticket history), keyboard navigation.
- Poll the message list (30 s) with **ETag/304** — fingerprint = COUNT + MAX(updated_at) across messages, comments, attachments and authors (renaming a user must invalidate the ETag). Pause polling when the tab is in the background.
- Presence ("A colleague is viewing this ticket"): heartbeat POST every 20 s, viewer query every 10 s, viewer = `last_seen_at > now − 45 s`, DELETE on leave.
- Store the reply draft in localStorage per ticket.
- Mobile: inbox table `table-layout: fixed` + `word-break` in the subject, the reply editor as a bottom sheet.

---

## 14. Security and permissions

- Roles: owner (everything), PM (mailboxes they have access to), developer (replies only to **assigned** tickets in their mailboxes), client (nothing).
- Restrict full-text search to accessible mailboxes — always.
- Managing the Gmail connection and mailboxes: owner only.
- Tokens encrypted, never in a log/response.
- Attachment download via a signed/query token: verify the scope, not just the token's existence.
- Internal notes (`type=note`) never make it into a sent e-mail or a quote — keep them in a different table from messages.

---

## 15. Operations

- Queue: a dedicated `tickets` queue (`retry_after` 90 s, its own worker). Jobs: `ProcessInboundEmail` (tries 3, backoff 30/120/300, timeout 60), `EvaluateAutomations`, `ForwardMessage` (see §11).
- Cron: `poll-gmail` every 5 min `withoutOverlapping(10)`; `check-sla` every minute; `purge-attachments` daily.
- Slack alerting: definitive job failure, orphaned e-mail, rate limit with >10 unprocessed messages, rejected attachment, forward failed after all attempts.
- Logs: every processed message (subject, body lengths, attachment/inline image counts), the MIME tree at debug level.
- Backfill commands for data from before a fix (ours: `tickets:backfill-cc` with `--dry-run`) — expect to change the CC/recipient logic and to need to bring old tickets in line.

---

## 16. Checklist of scenarios the tests must cover

Receiving:
- [ ] New message → new ticket, SLA deadlines, auto-assignment from the mailbox, client auto-link.
- [ ] Reply with the same `threadId` → appended to the existing ticket.
- [ ] Reply without a `threadId` match but with `In-Reply-To` pointing at our outbound message → same ticket.
- [ ] The same message processed twice (cron + manual sync) → one DB row.
- [ ] Out-of-office, `Auto-Submitted`, NDR "Mail delivery failed", `mailer-daemon@` → no ticket, message archived.
- [ ] A message from our own mailbox → skip.
- [ ] Body only via `attachmentId` → body fetched.
- [ ] Inline image `cid:` → data URI in `body_html`.
- [ ] Attachment with the filename only in `Content-Disposition`, RFC 5987 filename.
- [ ] `.exe`/`.svg` attachment → blocked, the others stored.
- [ ] `References` with 500 IDs → no slowdown, takes the last 20.
- [ ] Inbound on `pending`/`resolved`/`closed` → reopen + system comment.
- [ ] Reply-To == From → ignored.

Addressing:
- [ ] Form (`From: wordpress@ours`, `Reply-To: client`) → To = client; after the client's own reply To = client still; the form address never in To or CC.
- [ ] A writes with B in CC → CC prefill = B. B replies without A → CC prefill empty (not A).
- [ ] A colleague from CC replies → the original client stays in CC.
- [ ] Agent replies from an alias, client Reply-All → the alias doesn't end up in CC.
- [ ] Client in `ticket.cc_emails` and also the recipient → only once in the sent e-mail.
- [ ] Ticket identity (client matching) = the first sender, even if a colleague wrote last.

Sending:
- [ ] `In-Reply-To` = the last message of the thread (ours too), `References` a complete chain.
- [ ] Second reply in a row → the quote contains our reply + the client's last question.
- [ ] The quote is never > 200 kB; data-URI images in the quote replaced with a placeholder.
- [ ] Subject with `\r\nBcc:` → header not injected.
- [ ] Non-ASCII subject/name/filename → RFC 2047.
- [ ] Inline image from the editor → CID part in MIME, data URI in the DB, e-mail < limit.
- [ ] 17 MB of attachments OK, 18 MB → 422; a 26 MB message → 413 with a message.
- [ ] Gmail returns 200 without an `id` → exception, nothing written to the DB, user warned.
- [ ] DB fails after sending → critical log + Slack + the "do not send again" message.
- [ ] Reply to `closed` → rejected.
- [ ] `next_status` and `assigned_user_id` (including explicit null) after sending.

Automations/forward:
- [ ] A rule on `ticket_created` forwards **the original** message, even if another arrived in the meantime.
- [ ] Forward to your own mailbox → rejected, the job doesn't retry.
- [ ] Killed worker → the retry takes over its own reservation (lease_owner) and sends.
- [ ] Foreign reservation < 10 min → skip; > 10 min → release and send.
- [ ] Attachments not yet downloaded → the job waits; after 8 attempts it sends without them.
- [ ] A forward doesn't set `first_responded_at`.

OAuth/client:
- [ ] Callback with an invalid/expired state → error, nothing stored.
- [ ] Re-authorisation without a `refresh_token` in the response → the old refresh token is kept.
- [ ] Refresh returns 200 without an `access_token` → exception, the DB token untouched.
- [ ] 429 with `Retry-After` → waits; 403 `dailyLimitExceeded` → immediate exception.

---

## 17. What we'd do differently today (known debt)

- **A mail gateway interface** (`MailGateway`) from the start — right now the Gmail client is called directly from the service layer and unit tests of sending are hard.
- Download inline images and attachments **asynchronously in a job**, not in the poll (a newsletter with 20 images = 20 synchronous API calls).
- Paginate the message timeline in the detail view (100+ messages = a multi-MB payload).
- Serve the extension blocklist and size limits to the frontend from **one config endpoint** instead of manually syncing two lists.
- Separate "build MIME" from "send" from "write to DB" into three classes — `sendReply()` and `compose()` share 80 % of their code.
- Consider Gmail push (Pub/Sub) instead of polling if you need latency < 5 min.
- Webhooks from automations (for developers who live in the terminal/Slack, not in the UI).

---

*Written from the production system "Paluba" (Laravel 12 + React 19, Gmail API, PostgreSQL), as of August 2026. Every edge case above is a real incident or a code-review finding, not a hypothesis.*
