# Prompt: E-mailový tiketovací systém nad Gmail API

> Zdroj: [webotvurci.cz/blog/tiketovaci-system-gmail-api/](https://www.webotvurci.cz/blog/tiketovaci-system-gmail-api/) — Webotvůrci s.r.o. Dokument je anonymizovaný: adresy, domény i cesty jsou placeholdery.

> **Jak tenhle dokument použít:** Zkopíruj ho celý jako systémový/úvodní prompt pro svého coding agenta (Claude Code, Codex, Cursor…). Je psaný jako zadání + „lessons learned" z produkčního provozu podobného systému ve webové agentuře (Laravel + React, ~4 schránky, tisíce tiketů). Technologie si vyber vlastní — důležitá jsou pravidla a edge cases, ne framework. Sekce označené **⚠️ EDGE CASE** jsou věci, které nás v produkci reálně kously a stály nás opravný commit.

---

## 0. Role a cíl

Jsi senior backend/fullstack vývojář. Stavíš interní tiketovací systém, kde **každé e-mailové vlákno = jeden tiket**. E-maily se čtou i odesílají přes **Gmail API** (jeden Google Workspace účet, více send-as aliasů = více „schránek"). Uživatelé (agenti) odpovídají z webového UI, odpověď musí odejít tak, aby ji klient viděl jako normální e-mail ve stejném vlákně a aby jeho další odpověď zase dorazila do stejného tiketu.

Priority v tomto pořadí:
1. **Nikdy tiše neztratit e-mail** (příchozí ani odchozí). Raději duplicita jednou za čas než ztráta.
2. **Nikdy nepsat někomu, komu nemáme** (naše vlastní adresy, adresy formulářů, lidé, které klient z vlákna vyhodil).
3. Správné vláknování v Gmailu i Outlooku.
4. Až potom UX.

---

## 1. Klíčová architektonická rozhodnutí

| Rozhodnutí | Volba | Proč |
|---|---|---|
| Gmail OAuth | **Oddělený OAuth klient od loginu** | Login OAuth (identita) a Gmail OAuth (offline refresh token, `gmail.modify`) mají jiné scopy i životní cyklus. Nemíchat. |
| Gmail účet | Jeden účet, více send-as aliasů | Každý alias = jedna schránka v aplikaci. Aliasy se tahají z `users/me/settings/sendAs`. |
| Příjem | **Polling každých 5 min** (ne Pub/Sub push) | Jednodušší, stačí pro interní podporu. Push přidej až když budeš potřebovat < 1 min latenci. |
| Co pollovat | **Vždy jen `label:{štítek-schránky} is:unread`** | Nikdy nečíst celý inbox. Mailbox bez štítku se přeskočí a zaloguje. Štítky přiřazují Gmail filtry (nastaví admin v Gmailu podle To/alias). |
| Po zpracování | **Archivovat** (odebrat `INBOX` + `UNREAD`), štítek nechat | Inbox v Gmailu zůstane čistý, zpráva je pořád dohledatelná přes štítek, dedup podle `gmail_message_id` brání opakovanému zpracování. |
| Odesílání | `messages/send` s `threadId` | E-mail se objeví v Sent složce Gmailu a Gmail ho zařadí do vlákna. |
| Dedup | `UNIQUE` na `ticket_messages.gmail_message_id` | `exists()` guard nestačí (viz race níže). |
| Vláknování | `gmail_thread_id` primárně, `In-Reply-To`/`References` jako fallback | Gmail thread ID je spolehlivější, ale pokrývá jen zprávy, které Gmail sám spojil. |
| SLA | Předpočítané deadliny na tiketu (`first_response_due_at`, `resolution_due_at`) | Levnější než počítat při každém čtení; breach hlídá cron každou minutu. |
| Přístup | Per-schránka (`ticket_mailbox_user` pivot) | Kdo nemá schránku, tiket nevidí ani nehledá. |
| AI | Draft → lidská kontrola → odeslání | AI nikdy neodesílá přímo. |
| Tokeny | Šifrované v DB (`encrypted` cast) | Refresh token = trvalý přístup ke schránce. |

---

## 2. Google Cloud + OAuth

### Setup
1. Google Cloud Console → **APIs & Services → Library → Gmail API → Enable**.
2. **OAuth consent screen** — Internal (Workspace) nebo External. Scopy:
   - `https://www.googleapis.com/auth/gmail.modify` (čtení, odesílání, labely)
   - `https://www.googleapis.com/auth/gmail.settings.basic` (send-as aliasy)
3. **Credentials → OAuth 2.0 Client ID → Web application**, redirect URI přesně `https://tvoje-domena/api/tickets/gmail/callback` (+ localhost varianta pro dev).
4. ENV: `GMAIL_CLIENT_ID`, `GMAIL_CLIENT_SECRET`, `GMAIL_REDIRECT_URI`.

### Flow
```
1. Owner klikne "Připojit Gmail" → GET /api/tickets/gmail/auth-url (auth. endpoint)
   → backend vygeneruje state (random 40 znaků), uloží do CACHE (ne session!)
     s user_id a TTL 10 min, vrátí URL na accounts.google.com/o/oauth2/v2/auth s:
     response_type=code, access_type=offline, prompt=consent, scope=..., state=...
2. Frontend přesměruje na URL.
3. Google → callback GET /api/tickets/gmail/callback?code=&state=  (VEŘEJNÁ routa, bez auth)
   → Cache::pull(state) – musí existovat, jinak redirect ?error=invalid_state
   → POST oauth2.googleapis.com/token (grant_type=authorization_code)
   → GET gmail/v1/users/me/profile → emailAddress
   → uložit GmailAccount (singleton) – access_token, refresh_token, token_expires_at, scopes
   → redirect na frontend /settings/mailboxes?connected=1
```

**⚠️ EDGE CASE – state v cache, ne v session.** Callback z Googlu přichází bez Sanctum/Bearer tokenu a mimo SPA session. Když jsme state drželi v session, callback ho neviděl. Cache s TTL + `pull()` (jednorázové) to řeší a zároveň brání replay.

**⚠️ EDGE CASE – `refresh_token` přijde jen napoprvé.** Google vrací `refresh_token` pouze při prvním consentu (nebo s `prompt=consent`). Při re-autorizaci existujícího účtu **nepřepisuj refresh_token prázdnou hodnotou** — updatuj ho jen když v odpovědi je. Když vytváříš účet poprvé a refresh_token chybí, odmítni (`?error=no_refresh_token`), jinak budeš mít účet, který za hodinu přestane fungovat.

**⚠️ EDGE CASE – token refresh s neúplnou odpovědí.** Google občas vrátí HTTP 200 bez `access_token` (intermitentní chyba). Bez guardu jsme přepsali platný token v DB `null`em a další request selhal na „Invalid token" → nutná ruční reautorizace. Validuj `access_token` v odpovědi a při jeho absenci vyhoď výjimku, DB nesahej.

**Expirace:** považuj token za expirovaný **5 minut před** `token_expires_at` (žádný request nesmí běžet s tokenem, který vyprší uprostřed).

**Send-as aliasy:** endpoint `GET /settings/sendAs`. V UI pro tvorbu schránky nabízej jen aliasy, které ještě žádná schránka nepoužívá. Aliasy jsou dostupné všem přihlášeným (reply editor je potřebuje pro dropdown „Od:"), správa propojení jen ownerovi.

---

## 3. Datový 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, ne 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` je dlouhý.** Gmail attachment ID má běžně 200–400+ znaků. `VARCHAR(255)` ti ho ořízne/odmítne → přílohy se tiše nestáhnou. Použij `TEXT`.

**⚠️ EDGE CASE – index na `message_id_header`.** Fallback vláknování dělá `WHERE message_id_header IN (...)`. Bez indexu je to full scan při každém příchozím e-mailu.

**Číslo tiketu:** v Postgresu vlastní `SEQUENCE`, ne `MAX()+1` (race). Ukazuj uživatelům jeden identifikátor (my `id`), ne dva.

**`requester_email_normalized`:** vždy lowercase, pro hledání a párování klienta. E-mailové adresy porovnávej **vždy case-insensitive** — všude, kde se objeví `in_array`, používej lowercase obou stran.

---

## 4. Příjem e-mailů (polling → tiket)

### Pipeline
```
cron každých 5 min (withoutOverlapping!) → pollAll()
  pro každý aktivní mailbox s aktivním Gmail účtem:
    - mailbox bez gmail_label_name → skip + warning (nikdy nečíst celý inbox)
    - listMessages("is:unread label:{label}") s paginací, strop 500 zpráv/poll
    - pro každou zprávu: processMessage()
    - při 429 → zastavit tento mailbox (zbytek dojede příští cron), když zbývá >10 zpráv → Slack alert
    - uložit last_polled_at

processMessage(mailbox, gmailMessageId):
  1. Dedup: existuje ticket_messages.gmail_message_id? → archivovat v Gmailu, 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 + rekurzivní extractBody()
  4. isAutoReply()? → archivovat, skip
  5. isSentByUs()? (From je některá z našich schránek) → archivovat, skip
  6. Tělo přes attachmentId? → dostáhnout
  7. stripHtmlWrapper(body_html), resolveInlineImages(cid: → data:URI)
  8. body_stripped = stripQuotedReply(text)
  9. DB transakce: findExistingTicket() ? addInboundMessage() : createFromEmail()
     catch UNIQUE violation na gmail_message_id → race, skip jako duplikát
  10. Po commitu: stáhnout přílohy, nastavit attachments_processed_at
  11. archiveMessage() (removeLabelIds: [UNREAD, INBOX])
```

**⚠️ EDGE CASE – race mezi cronem a ručním „Synchronizovat teď".** Dva pollery zpracují stejnou zprávu, oba projdou `exists()` guardem, oba vytvoří tiket. Řešení: `UNIQUE(gmail_message_id)` + insert v transakci + catch constraint violation → považovat za duplikát. Ruční sync navíc drž za 60s cooldownem (cache klíč) a spouštěj ho na pozadí přes frontu, ne v requestu.

**⚠️ EDGE CASE – tělo zprávy není inline.** Gmail API někdy nevrátí `body.data`, ale `body.attachmentId` i pro `text/html`/`text/plain` (větší těla). Musíš si ID zapamatovat a dotáhnout přes `messages/{id}/attachments/{attId}`. Jinak vznikne prázdný tiket.

**⚠️ EDGE CASE – NDR/bounce vytváří ghost tikety.** `Auto-Submitted` header pokryje out-of-office, ale bounce od mailer-daemonů často header nemá. Detekuj i podle předmětu (`mail delivery failed`, `undelivered mail returned`, `delivery status notification`, `failure notice`, `returned mail`, česky `doručení selhalo`, `nedoručeno`) a podle odesílatele (`mailer-daemon@`, `postmaster@`, `noreply@`, `no-reply@`, `bounce@`, `bounces@`). Hodnota `Auto-Submitted: no` NENÍ auto-reply.

**⚠️ EDGE CASE – naše vlastní odchozí e-maily v inboxu.** Když někdo odpoví klientovi přímo z Gmailu (mimo aplikaci) a Gmail filtr zprávu oštítkuje, poll ji vidí. Skip podle `From ∈ naše schránky`. (Odpovědi odeslané aplikací mají `gmail_message_id` uložený, takže je zachytí dedup.)

**⚠️ EDGE CASE – `References` s 1000 ID.** Dlouhá forwardovaná vlákna mají stovky Message-ID. `whereIn` s tisícem hodnot je pomalý a zbytečný — ber **posledních 20** (nejnovější jsou nejpravděpodobnější match).

**⚠️ EDGE CASE – Reply-To rovné From.** Když `Reply-To == From`, ignoruj ho (ulož null). Jinak ti logika „Reply-To má přednost" zbytečně komplikuje adresaci.

**⚠️ EDGE CASE – Date header.** Parsuj s try/catch a fallbackem na `now()`; převeď do svého timezone. Neplatné Date hlavičky existují.

### Parsování MIME (extractBody, rekurzivně)

Pravidla, která jsme museli postupně přidat:
- Filename bývá v `payload.filename`, **ale někdy jen v `Content-Disposition: attachment; filename="..."`** nebo RFC 5987 `filename*=UTF-8''...` (URL-encoded) → dekóduj.
- **Příloha je:** (a) má filename + attachmentId; (b) má filename + inline data a není `text/html`/`text/plain`; (c) `Content-Disposition: attachment` i bez filename (doplň `attachment`); (d) má attachmentId, není inline obrázek a MIME není tělo (`text/html`, `text/plain`, `multipart/*`).
- **Inline obrázek** = má `Content-ID` + `image/*` MIME. Ulož mapu `cid → attachmentId`, po parsování stáhni a nahraď `cid:xxx` (i `CID:`) za `data:image/...;base64,...` v `body_html`. Prohlížeč `cid:` nezobrazí. (Ano, zvětší to DB řádek; alternativa je servírovat obrázky přes vlastní endpoint. Pozor: tohle jsou synchronní Gmail API cally per obrázek — u newsletterů s 20 obrázky to poll zpomalí; do budoucna přesunout do jobu.)
- Malé přílohy mohou přijít **inline v `body.data`** bez attachmentId → dekóduj base64url (`-_` → `+/`) se **strict** módem; `false` = poškozená data, záznam nevytvářej.
- Loguj MIME strom (`mimeType [filename] (data inline|attachmentId, size)`) na debug úrovni — při ladění „proč chybí příloha" to ušetří hodiny.
- **Strip `<!DOCTYPE>`, `<html>`, `<head>…</head>`, `<body>`** z `body_html` — budeš ho vkládat do vlastního iframe/wrapperu a nested HTML dokument rozbije rendering.
- `stripQuotedReply`: odstraň řádky začínající `>`, bloky `On … wrote:` a česky `Dne … napsal(a):`. Výsledek ulož do `body_stripped` (náhled v seznamu, AI kontext). Originál nikdy nemaž.

---

## 5. Vláknování (threading)

### Příchozí → existující tiket
1. `tickets.gmail_thread_id == message.threadId` → match.
2. Fallback: `In-Reply-To` + posledních 20 z `References` → `ticket_messages.message_id_header IN (...)` → tiket té zprávy.
3. Nic → nový tiket.

### Odchozí odpověď — hlavičky
```
Message-ID:  <random32hex@tvoje-domena.cz>     (ulož do message_id_header!)
In-Reply-To: <Message-ID POSLEDNÍ zprávy vlákna – bez ohledu na směr>
References:  <References té zprávy> + ' ' + <její Message-ID>
+ Gmail API parametr threadId = ticket.gmail_thread_id
```

**⚠️ EDGE CASE – Outlook trhá vlákno.** Původně jsme stavěli `In-Reply-To` jen z poslední **příchozí** zprávy. Když agent odpověděl dvakrát po sobě (klient mezitím nepsal), druhá odpověď odkazovala na starou zprávu klienta a naše první odpověď v řetězci chyběla. Gmail to tolerantně sloučí, Outlook ne → klient viděl dvě vlákna. Rodič musí být **poslední zpráva bez ohledu na směr** a `References` = rodičovské `References` + rodičovo `Message-ID` (rodič už nese celý řetězec, takže vznikne kompletní RFC 5322 chain).

**⚠️ EDGE CASE – ORM default ordering.** Měli jsme vztah `messages()` s výchozím `orderBy('sent_at' ASC)`. `->orderByDesc('sent_at')->first()` se k tomu jen **přidal** a vrátil NEJSTARŠÍ zprávu → špatné `In-Reply-To` i špatný příjemce. Když máš default ordering na relaci, u každého dotazu na „poslední zprávu" ho explicitně resetuj (`reorder()`). Řaď vždy `sent_at DESC, id DESC` (dvě zprávy ve stejné sekundě).

**⚠️ EDGE CASE – Gmail `threadId` u compose.** U nově založeného odchozího e-mailu (compose) vezmi `threadId` z odpovědi `messages/send` a ulož na tiket — jinak klientova odpověď nenajde tiket přes primární cestu.

**⚠️ EDGE CASE – Gmail zařadí do vlákna jen při shodném Subject.** Když posíláš do `threadId` e-mail s jiným předmětem (např. `Fwd:`), Gmail request odmítne. Přeposílání proto posílej **bez** `threadId` jako samostatný e-mail.

**Header injection:** všechny hodnoty do hlaviček (Subject, From name, filename, In-Reply-To) projdou funkcí, která **nejdřív odstraní `\r`, `\n`, `\0`** a teprve pak případně RFC 2047 zakóduje (`=?UTF-8?B?...?=` jen pro non-ASCII). Předmět teče z cizího e-mailu; `"...\r\nBcc: attacker@..."` by jinak přidal hlavičku do e-mailu, který odesíláš ty.

---

## 6. Komu jde odpověď (nejvíc bugů za celý projekt)

Tohle je oblast, kde jsme udělali **pět** opravných commitů. Zaveď tři jasně oddělené pojmy a každý počítej na jednom místě:

### 6.1 `replyRecipientEmail(ticket)` — do pole **To**
```
pro příchozí zprávy od nejnovější (limit 20):
   kandidát = zpráva.reply_to  (pokud je)  JINAK zpráva.from_email
   pokud kandidát ∈ našeAdresy(tiket) → další zpráva
   jinak → vrať kandidáta
fallback: ticket.requester_email
```

**⚠️ EDGE CASE – poptávka z webového formuláře.** WordPress/Shoptet formulář pošle notifikaci `From: wordpress@nase-domena.cz`, `Reply-To: klient@firma.cz`. První odpověď šla správně na Reply-To. Jakmile ale klient odpověděl sám (jeho zpráva už Reply-To nemá), fallback na `requester_email` (= adresa formuláře, která nemá schránku) poslal další odpovědi do prázdna a klient zůstal jen v kopii. → Proto pořadí *Reply-To poslední příchozí → její From → starší příchozí → requester*.

**⚠️ EDGE CASE – Reply-To stíní From jen u své vlastní zprávy.** První verze plošně vyloučila From každé zprávy, která kdy měla Reply-To, přes celý tiket. Když stejná adresa napsala znovu bez Reply-To, přeskočili jsme ji a odpověděli na starší Reply-To — klidně úplně jinému člověku. Pro **To** platí stínění jen v rámci jedné zprávy. Pro **CC** (viz níže) ticket-wide, protože tam je riziko opačné (nejhůř někdo chybí).

### 6.2 `requesterEmail(ticket)` — **identita** klienta (párování s CRM, předvyplnění zakázky)
= `Reply-To ?: From` **PRVNÍ** příchozí zprávy. Schválně ne `replyRecipientEmail`: ten ukazuje na toho, kdo psal naposledy, a to může být kolega z kopie. Identitu tiketu drží ten, kdo ho založil. Při auto-linku na klienta zkus nejdřív Reply-To, pak From, pak firemní doménu (ne freemail — gmail.com/seznam.cz doménové párování vypni).

### 6.3 `ccFromLastMessage(ticket)` — prefill **CC** pro příští odpověď
```
poslední zpráva vlákna (jakýkoli směr):
  outbound → její cc_emails minus replyRecipient
  inbound  → externalParticipants(To + CC + From) minus:
               našeAdresy(tiket), replyRecipient, Reply-To té zprávy a jím stíněný From,
               ticket-wide stíněné From adresy (formuláře)
```

**⚠️ EDGE CASE – CC se kumulovalo.** Napíše A s B v kopii, odpovíme, pak odpoví B a A vědomě vynechá. Naše další odpověď šla zase na A+B, protože `cc_emails` na tiketu jsme jen přidávali. **Kopie musí zrcadlit poslední zprávu vlákna** — přesně jako Reply-All v mailovém klientu. Kdo byl vyhozen, nesmí se tam vracet; kdo byl přidán, je tam hned.

**⚠️ EDGE CASE – poslední zpráva je naše.** Agent u odpovědi ručně upraví CC. Přepočet, který koukal jen na příchozí zprávy, by jeho volbu zahodil. Když je poslední zpráva outbound, platí CC, které agent reálně použil. `sendReply()` proto ukládá použité CC na tiket.

**⚠️ EDGE CASE – odesílatel z kopie.** Když odpoví kolega klienta (byl v CC), má v „To" nás a původního klienta v CC. Do `externalParticipants` proto patří i `From` zprávy — jinak by při naší odpovědi (To = původní klient) kolega z vlákna vypadl.

**⚠️ EDGE CASE – naše send-as aliasy nejsou v tabulce schránek.** Agent odpověděl z aliasu, klient dal Reply-All, a náš alias se přilepil do CC tiketu. `našeAdresy(tiket)` = adresy schránek **+ `from_email` všech odchozích zpráv tiketu** (použitý alias je v DB, žádné volání Gmail API v cestě zpracování).

**⚠️ EDGE CASE – klient dvakrát.** Starší tikety měly klienta v `cc_emails` (z doby, kdy jsme ho brali jako účastníka). Před odesláním vždy: `CC minus To`, `BCC minus (To + CC)`, case-insensitive, dedup.

**⚠️ EDGE CASE – souběžný zápis CC.** Cron + ruční sync zpracují dvě zprávy jednoho vlákna současně, obě čtou stejné stale CC, pozdější update zahodí adresy té první. Přepočet CC dělej pod **row lockem** (`SELECT … FOR UPDATE`) a z nejnovější zprávy **v DB**, ne z právě zpracovávaného payloadu.

**⚠️ EDGE CASE – cache našich adres.** Seznam adres schránek jsme cachovali 5 min. Po přidání nové schránky se její adresa 5 minut lepila do CC. Cache invaliduj při každém uložení/smazání schránky.

### 6.4 Varování v UI
Pokud **poslední příchozí** zpráva měla externí příjemce, které agent do CC nedal, vrať po odeslání `warning: original_had_multiple_recipients` se seznamem — UI ukáže toast. (První verze koukala na první zprávu vlákna, takže lidé přidaní v průběhu konverzace varování nespustili.)

### 6.5 Frontend — prefill CC
- Prefill klíčuj **`ticketId + recipient + join(cc_emails)`**, ne jen ID tiketu: refetch se stejným CC ruční úpravy nepřepíše, ale změna kopie příchozí zprávou se ukáže hned.
- Při přechodu na jiný tiket **resetuj celý stav editoru** (CC, BCC, přílohy, next status). Pokud se komponenta neremountuje (detail se kreslí z cache seznamu), zůstanou tam data z předchozího tiketu. Pozor na React StrictMode — ref, který hlídá „prefill už proběhl", resetuj spolu se stavem.
- Ukazuj vedle editoru **„Komu: …"**, ať agent vidí, kam odpověď jde, a tlačítko na zkopírování adresy.
- Validace e-mailu přísnější než HTML5: vyžaduj TLD ≥ 2 znaky. Vstup do chip inputu splituj na `,`, `;` (Outlook) i whitespace.

---

## 7. Odesílání odpovědi

### Sestavení RFC 822
```
From: =?UTF-8?B?...?= <alias@domena.cz>
To: <replyRecipient>
Cc: …            (jen když neprázdné)
Bcc: …
Subject: Re: <subject bez existujícího "Re: ">
MIME-Version: 1.0
Message-ID / In-Reply-To / References   (viz §5)

Tělo (RFC 2387):
  s inline obrázky + přílohami: multipart/mixed > multipart/related > multipart/alternative
  s inline obrázky:             multipart/related > multipart/alternative
  s přílohami:                  multipart/mixed > multipart/alternative
  jinak:                        multipart/alternative (text/plain + text/html, quoted-printable)
Inline obrázek: Content-ID: <cid>, Content-Disposition: inline, base64 + chunk_split
Příloha:        Content-Disposition: attachment; filename="RFC2047", base64 + chunk_split
```
Payload pro API: `raw = base64url(rfc822)` bez paddingu, + `threadId`.

### Obsah odpovědi
1. Text agenta (HTML z editoru).
2. **Podpis**: vlastní podpis uživatele má přednost před výchozím podpisem schránky. Šablona s proměnnými (`{{jmeno}}`, `{{email}}`, `{{telefon}}`, `{{funkce}}`); hodnoty escapuj; pokud je šablona HTML, sanitizuj (strip `script/style/iframe/object/embed/form`, `on*=` atributy, `javascript:` v href/src).
3. **Inline obrázky**: frontend před odesláním vytáhne `<img src="data:…">` z HTML do `File` objektů s CID (`inline_images[]` + `inline_image_cids[]` ve FormData) a v HTML nahradí `src="cid:…"`. Backend udělá totéž ještě jednou jako defense-in-depth (fast-path: když HTML neobsahuje `data:image`, regex přeskoč).
4. **Do DB** ulož `body_html` s obrázky **obnovenými zpět na data-URI** (prohlížeč `cid:` nezobrazí) — ale **bez** citované historie.
5. **Citovaná historie** (jen do odeslaného MIME, ne do DB) — viz níže.

**⚠️ EDGE CASE – 49MB e-mail a OOM (nejdražší bug).** První verze citovala do každé odpovědi **celé vlákno** z plného `body_html`. Klientův mail klient nám naši citaci poslal zpět, my ji uložili v plné délce a příště ocitovali znovu → každá výměna velikost ~zdvojnásobila. Po 26 zprávách 49 MB, `memory exhausted`. Pravidla:
   - Cituj **jen poslední zprávu vlákna** (dělá to tak Gmail i Outlook; ta sama nese starší citace, růst je lineární).
   - Když je poslední zpráva **naše odchozí** (agent odpovídá podruhé v řadě), přibal i **poslední příchozí** — odchozí zprávy mají v DB jen napsaný text bez citace, takže samotná citace naší odpovědi by neobsahovala dotaz klienta.
   - **Rozpočet 200 kB na celou citaci** (HTML i text zvlášť); ořez přes `mb_strcut` (nikdy uprostřed UTF-8 znaku) + `[…zkráceno…]`.
   - `<img src="data:…">` v citaci nahraď placeholderem `[vložený obrázek]`.
   - Zprávy načítej **po jedné cíleným dotazem**, ne `->get()` nad celým vláknem.

**⚠️ EDGE CASE – Gmail 5MB JSON limit vs. inline screenshoty.** Base64 obrázky v `body_html` snadno překročí limit JSON endpointu (5 MB po base64) i nginx buffery → opaque 500 bez záznamu v Sentry. Řešení: extrakce do CID příloh (výše) + **nad ~3,5 MB syrových dat posílej přes upload endpoint** `upload/gmail/v1/users/me/messages/send?uploadType=multipart` (limit 35 MB). Multipart tělo skládej do **streamu** (`php://temp`), ne do stringu — u 20MB zprávy by ti konkatenace + HTTP klient držely 3 kopie v paměti. Velikost rozhoduj z `strlen(raw)`, ne z `strlen(json_encode(payload))` (to vyrobí další kopii o třetinu větší jen kvůli změření).

**⚠️ EDGE CASE – tvrdý strop 25 MB.** Gmail odmítne cokoli nad 25 MB. Zkontroluj velikost **před** sestavením payloadu a vyhoď vlastní `MessageTooLargeException` → HTTP 413 s lidskou hláškou („E-mail má X MB, limit je 25 MB, pošlete odkazem"). Nehlásit do Sentry — není to chyba aplikace.

**⚠️ EDGE CASE – limit příloh 17 MB, ne 18.** Gmail měří celou zprávu **po** base64 (+33 %) a `chunk_split` (+2,6 %). 18 MB dat = 24,7 MB v MIME + tělo + citace → přes limit. Inline obrázky **počítej do limitu spolu s přílohami** (původně neměly žádnou kontrolu a byly druhou cestou k OOM).

### Po odeslání — konzistence DB
```
sendResult = gmail.send(raw, threadId)
if (!sendResult.id) → RuntimeException "Gmail nevrátil ID; e-mail MOHL odejít, ověřte v Gmailu"
DB transakce:
  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 = použité CC
catch → Log::critical + Slack "ORPHANED EMAIL" + výjimka "Neodesílejte znovu, Gmail ID: …"
přílohy uložit lokálně MIMO transakci (selhání nesmí shodit už odeslaný e-mail)
volitelně: next_status (výchozí 'resolved'), assigned_user_id (rozlišuj "nezasláno" od "explicitně null")
```

**⚠️ EDGE CASE – e-mail odešel, DB zápis selhal.** Bez ošetření agent vidí chybu, klikne znovu, klient dostane duplikát. Proto: neúplná odpověď Gmailu = výjimka s varováním; DB selhání po odeslání = critical log + Slack + explicitní hláška „neodesílejte znovu".

**Další pravidla odesílání:**
- Odpověď na `closed` tiket zamítni (nejdřív reopen).
- Rate limit na `/reply` a `/compose` (my 30/min/uživatel) — chrání Gmail kvótu i před zneužitím.
- Hlášky chyb v produkci **neodhalují `$e->getMessage()`** (může obsahovat tokeny/cesty); loguj plně, uživateli generickou hlášku.
- `Ctrl+Enter` odešle; tlačítko Odeslat s tooltipem proč je disabled.

---

## 8. Přílohy

### Příchozí
- Blocklist **přípon** (`exe bat cmd scr pif com vbs vbe js jse wsf wsh ps1 msi dll svg svgz`) a **MIME** (`application/x-msdownload`, `x-sh`, …). Gmail je stejně zablokuje na druhé straně.
- **SVG nikdy** — ani jako přílohu, ani do náhledu (XSS přes `<script>` v SVG).
- Max velikost 25 MB/soubor; větší přeskoč s logem.
- Záznam vytvoř, stáhni obsah; když stažení selže a máš `gmail_attachment_id`, **záznam nech** (re-download on demand); když inline data nejdou dekódovat a re-download není možný, **záznam smaž** (jinak download endpoint padá na chybějícím souboru).
- `storeContent()` pod try/catch — plný disk vytvoří záznam bez souboru.
- Storage path: `ticket-attachments/YYYY/MM/{message_id}/{id}_{sanitized_filename}`.
- **Purge** lokálních kopií Gmail příloh po 90 dnech (cron), nikdy upload příloh. Download endpoint umí re-stáhnout z Gmailu podle `gmail_attachment_id`.
- Po dokončení nastav `ticket_messages.attachments_processed_at` — jediný spolehlivý signál „přílohy jsou hotové" (počty nesedí, protože blokované/velké se do DB nezapíšou).

### Odchozí (upload z UI)
- Stejný blocklist + **magic bytes** check (`finfo`): deklarovaný `image/*` musí být detekován jako obrázek, `application/pdf` jako PDF; Office formáty (docx/xlsx/pptx) jsou uvnitř ZIP → tolerovat.
- Ověř `getRealPath()` a načti obsah **před** vytvořením DB záznamu — u dlouhého requestu (odesílání trvá vteřiny) může framework tmp soubor uklidit.

**⚠️ EDGE CASE – tiché zahození přílohy.** Kolegyně přiložila `.js`, frontend ho zablokoval, toast zmizel dřív, než si všimla, e-mail odešel bez přílohy. Backend soubor nikdy nedostal → žádný alert. Řešení: **perzistentní** červené upozornění v editoru (ne toast), tooltip se seznamem zakázaných přípon, backend při odmítnutí loguje + posílá Slack.

**⚠️ EDGE CASE – PHP/nginx limity.** `upload_max_filesize`/`post_max_size` nastav na 25M+; **PHP-FPM pool config bez `[www]` hlavičky se tiše ignoruje** a běží defaulty 2M/8M. Nginx `client_max_body_size 25M` pro `/api/*` + větší `fastcgi_buffers` (velké PHP odpovědi jinak dají 500).

---

## 9. Gmail API klient — robustnost

```
requestWithRetry(method, endpoint):
  ensureTokenValid()  (refresh 5 min před expirací)
  throttle 100 ms mezi requesty
  loop max 4 retry:
    200 → return
    401 (poprvé) → refreshAccessToken(), retry
    429 → Retry-After header (cap 120 s) nebo exponential backoff 0.5/1/2/4 s + 0–30 % jitter
    403 reason ∈ {rateLimitExceeded, userRateLimitExceeded} → backoff; dailyLimitExceeded → throw hned; jiný 403 → throw (permission)
    5xx → backoff
    jiný → throw
```
- `listAllMessages` s paginací a stropem (500), překročení zaloguj — tiché oříznutí vypadá jako „vše zpracováno".
- `modifyLabels(add[], remove[])` v jednom volání (přesun mezi schránkami).
- `archiveMessage` = jedno volání s `removeLabelIds: [UNREAD, INBOX]`.
- Neloguj `response.body()` chybových odpovědí bez sanitizace (mohou obsahovat tokeny).
- Testovací endpoint `users/me/profile` pro „Otestovat spojení" v nastavení.

---

## 10. Stavy, reopen, SLA

- Stavy: `open` → `pending` (čeká na klienta) → `on_hold` → `resolved` → `closed`. Přechody uvolni (z každého do každého kromě sebe sama) — striktní stavový automat lidi jen otravoval.
- **Auto-reopen:** příchozí zpráva na tiket v `pending`/`resolved`/`closed` → `open`, vynuluj `resolved_at`/`closed_at`, systémový komentář „znovu otevřen příchozím e-mailem". (`pending` jsme původně zapomněli — klient odpověděl a tiket zůstal v „Čeká".)
- Výchozí stav po odeslání odpovědi: `resolved` (dropdown v editoru; heuristika „končí otazníkem → pending" je hezký follow-up).
- Každou změnu stavu/priority/přiřazení/schránky zapiš jako **systémový komentář** — je to tvůj audit trail.
- SLA: policy per schránka s fallbackem na globální; hodiny per priorita; volitelně jen pracovní doba (Po–Pá 9–17, iterace po dnech s max počtem kroků). Deadliny předpočítej při vzniku a přepočítej při změně priority (jen když ještě nebylo odpovězeno). Cron každou minutu označí breach a pošle Slack. `first_responded_at` nastavuj jen při první odpovědi; přeposlání (forward) se **nepočítá** jako odpověď.

---

## 11. Automatizace a přeposílání (forward)

Pravidla: trigger (`ticket_created`, `ticket_replied`, `status_changed`, …), podmínky (AND/OR; subject contains/regex, doména odesílatele, schránka, priorita, tag), akce (assign, set_priority, set_status, add_tag, set_sla, slack_alert, add_note, assign_to_agent, **forward_to_email**). Regex od uživatele: délka ≤ 200, `@preg_match` v try/catch, timeout.

- Vyhodnocení dispatchuj **`afterCommit`** — tiket a zpráva vznikají v transakci; job by jinak četl nezacommitovaná data.
- Event `TicketCreated` **nese zprávu**, se kterou tiket vznikl. Když jsme ji dohledávali jako „poslední příchozí", při zdržené frontě mezitím přistála odpověď a pravidlo přeposlalo ji místo původního e-mailu.

**Forward** = samostatný job (ne inline v akci), protože:
- `executeActions()` polyká výjimky (aby jedna rozbitá akce nezastavila ostatní) → výpadek Gmailu by se tiše zahodil a e-mail u jednorázového triggeru zmizel nadobro.
- Stažení příloh + odeslání se nevejde do timeoutu jobu pro vyhodnocení pravidel.
- Přílohy se ukládají až po commitu → job na `attachments_processed_at` **počká** (release 15 s, max 8×; pak pošle raději bez příloh než vůbec).

**⚠️ EDGE CASE – deduplikace vs. zabitý worker.** Záznam v `ticket_forwards` vzniká **před** odesláním (UNIQUE chrání souběh dvou workerů), ale worker zabitý timeoutem ho neuklidil a unique index blokoval přeposlání napořád. Řešení: `sent_at` je jediný důkaz doručení; rezervace bez `sent_at` po 10 min propadá. **A ještě:** retry téhož jobu přijde po `retry_after` (90 s) — hluboko uvnitř 10min okna — a vlastní rezervaci vyhodnotil jako „posílá to někdo jiný". Proto `lease_owner = UUID jobu` (napříč pokusy stejné): vlastní rezervaci převezmi, cizí čerstvou respektuj, cizí prošlou uvolni.

**⚠️ EDGE CASE – timeout jobu < `retry_after` fronty.** S timeoutem 180 s a `retry_after` 90 s převzal běžící job druhý worker, narazil na čerstvou rezervaci, přeskočil odeslání a job z fronty odstranil — kdyby první worker umřel, e-mail se ztratí. Timeout jobu **musí být menší** než `retry_after`.

- `tries` vyšší než retry budget (release při čekání na přílohy se počítá jako pokus), skutečné chyby hlídej `maxExceptions`.
- **Smyčka:** zakázat forward na vlastní schránku (nový tiket → stejné pravidlo → …). Kontroluj v DB, ne v cache.
- Hlavičky forwardu: `Auto-Submitted: auto-forwarded` (RFC 3834 — protistrana ani náš vlastní poll na to nereaguje), `X-Ticket: {id}`, `Subject: Fwd: …`, bez `threadId`.
- Forward se **nezapisuje** jako outbound zpráva (posunul by `first_responded_at`/SLA); stopa je `ticket_forwards` + systémový komentář.
- Strop příloh u forwardu nižší (10 MB) — celý MIME v paměti workeru s krátkým timeoutem.
- Vadná konfigurace pravidla (neplatná adresa, vlastní schránka) → `InvalidArgumentException` → **neopakovat**, jen zalogovat.

---

## 12. Přesun tiketu mezi schránkami

Gmail filtr občas zařadí e-mail špatně. Akce „Přesunout do schránky": změň `ticket_mailbox_id` a **best-effort** přeštítkuj všechny Gmail zprávy tiketu (`modifyLabels(add nový, remove starý)`) — jen když jsou obě schránky pod stejným Gmail účtem a mají `gmail_label_id`. Selhání Gmail API přesun v DB nezhodí, jen se zapíše do systémového komentáře. Při přesunu zkontroluj, že přiřazený uživatel má do cílové schránky přístup (jinak reassign/unassign).

---

## 13. Frontend — zobrazení e-mailu a editor

- **Tělo e-mailu renderuj v `<iframe srcDoc>` se `sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts"` — bez `allow-same-origin`** (jinak sandbox nic neizoluje). Obsah předtím prožeň DOMPurify. `<base target="_blank">` pro otevírání odkazů. Auto-výšku řeš skriptem uvnitř iframe přes `postMessage` s ID zprávy. Vlastní CSS uvnitř (bílé pozadí, `max-width:100%` obrázky, sbalení `.gmail_quote`).
- Autolink URL dělej **před** sanitizací nebo tak, aby nemohl re-injektnout HTML.
- Editor je `contentEditable`; před odesláním DOMPurify + `extractInlineImages`.

**⚠️ EDGE CASE – vložený obrázek z jiné webové aplikace.** Obrázek zkopírovaný z Freelo/Notion apod. je ve schránce jako HTML s `blob:https://cizi-domena/…` (nenačte se z tvého originu) + jako binárka. Prohlížeč sáhne po HTML → prázdný rámeček v editoru i v e-mailu. Řešení: v `onPaste` má **binárka přednost** — vlož ji jako `data:` URI. Schémata `blob:`, `webkit-fake-url:`, `file:` považuj za mrtvá. Retina screenshoty zmenši na max ~1600 px šířky (e-mail se čte v 600px sloupci).

- Chip input pro CC/BCC s našeptávačem (adresy z historie tiketů), navigace klávesnicí.
- Seznam zpráv polluj (30 s) s **ETag/304** — fingerprint = COUNT + MAX(updated_at) přes zprávy, komentáře, přílohy i autory (přejmenování uživatele musí ETag zneplatnit). Polling pozastav, když je karta na pozadí.
- Presence („Kolega prohlíží tento tiket"): heartbeat POST každých 20 s, dotaz na viewery každých 10 s, viewer = `last_seen_at > now − 45 s`, DELETE při odchodu.
- Koncept odpovědi ukládej do localStorage per tiket.
- Mobil: tabulka inboxu `table-layout: fixed` + `word-break` v předmětu, reply editor jako bottom sheet.

---

## 14. Bezpečnost a oprávnění

- Role: owner (vše), PM (schránky, ke kterým má přístup), developer (odpovídá jen na **přiřazené** tikety ve svých schránkách), client (nic).
- Full-text search omez na přístupné schránky — vždy.
- Správa Gmail propojení a schránek: jen owner.
- Tokeny šifrované, nikdy v logu/odpovědi.
- Download příloh přes signed/query token: ověřuj i scope, ne jen existenci tokenu.
- Interní poznámky (`type=note`) se nikdy nedostanou do odeslaného e-mailu ani citace — drž je v jiné tabulce než zprávy.

---

## 15. Provoz

- Fronta: samostatná queue `tickets` (`retry_after` 90 s, vlastní worker). Jobs: `ProcessInboundEmail` (tries 3, backoff 30/120/300, timeout 60), `EvaluateAutomations`, `ForwardMessage` (viz §11).
- Cron: `poll-gmail` každých 5 min `withoutOverlapping(10)`; `check-sla` každou minutu; `purge-attachments` denně.
- Alerting do Slacku: definitivní selhání jobu, orphaned e-mail, rate limit s >10 nezpracovanými zprávami, odmítnutá příloha, forward selhal po všech pokusech.
- Logy: každá zpracovaná zpráva (subject, délky těl, počty příloh/inline obrázků), MIME strom na debug.
- Backfill příkazy pro data z doby před opravou (u nás `tickets:backfill-cc` s `--dry-run`) — počítej s tím, že logiku CC/příjemce budeš měnit a staré tikety je potřeba dorovnat.

---

## 16. Checklist scénářů, které musí projít testy

Příjem:
- [ ] Nová zpráva → nový tiket, SLA deadliny, auto-přiřazení ze schránky, auto-link klienta.
- [ ] Odpověď se stejným `threadId` → přidá se ke stávajícímu tiketu.
- [ ] Odpověď bez `threadId` match, ale s `In-Reply-To` na naši odchozí zprávu → stejný tiket.
- [ ] Stejná zpráva zpracovaná dvakrát (cron + ruční sync) → jedna DB řádka.
- [ ] Out-of-office, `Auto-Submitted`, NDR „Mail delivery failed", `mailer-daemon@` → žádný tiket, zpráva archivována.
- [ ] Zpráva od naší vlastní schránky → skip.
- [ ] Tělo jen přes `attachmentId` → tělo dotaženo.
- [ ] Inline obrázek `cid:` → data URI v `body_html`.
- [ ] Příloha s filename jen v `Content-Disposition`, RFC 5987 filename.
- [ ] `.exe`/`.svg` příloha → zablokována, ostatní uloženy.
- [ ] `References` s 500 ID → nezpomalí, vezme posledních 20.
- [ ] Příchozí na `pending`/`resolved`/`closed` → reopen + systémový komentář.
- [ ] Reply-To == From → ignorováno.

Adresace:
- [ ] Formulář (`From: wordpress@nas`, `Reply-To: klient`) → To = klient; po klientově vlastní odpovědi To = klient dál; adresa formuláře nikdy v To ani CC.
- [ ] A píše s B v CC → CC prefill = B. B odpoví bez A → CC prefill prázdné (A ne).
- [ ] Kolega z CC odpoví → původní klient zůstane v CC.
- [ ] Agent odpoví z aliasu, klient Reply-All → alias se nedostane do CC.
- [ ] Klient v `ticket.cc_emails` i jako příjemce → v odeslaném e-mailu jen jednou.
- [ ] Identita tiketu (párování klienta) = první odesílatel, i když naposledy psal kolega.

Odesílání:
- [ ] `In-Reply-To` = poslední zpráva vlákna (i naše), `References` kompletní řetězec.
- [ ] Druhá odpověď v řadě → citace obsahuje naši odpověď + poslední dotaz klienta.
- [ ] Citace nikdy > 200 kB; data-URI obrázky v citaci nahrazeny placeholderem.
- [ ] Předmět s `\r\nBcc:` → hlavička neinjektována.
- [ ] Non-ASCII předmět/jméno/filename → RFC 2047.
- [ ] Inline obrázek z editoru → CID část v MIME, v DB data-URI, e-mail < limit.
- [ ] 17 MB příloh OK, 18 MB → 422; 26 MB zpráva → 413 s hláškou.
- [ ] Gmail vrátí 200 bez `id` → výjimka, DB nezapsána, uživatel varován.
- [ ] DB selže po odeslání → critical log + Slack + hláška „neodesílejte znovu".
- [ ] Odpověď na `closed` → odmítnuto.
- [ ] `next_status` a `assigned_user_id` (včetně explicitního null) po odeslání.

Automatizace/forward:
- [ ] Pravidlo na `ticket_created` přepošle **původní** zprávu, i když mezitím přišla další.
- [ ] Forward na vlastní schránku → odmítnuto, job se neopakuje.
- [ ] Zabitý worker → retry převezme vlastní rezervaci (lease_owner) a odešle.
- [ ] Cizí rezervace < 10 min → skip; > 10 min → uvolnit a odeslat.
- [ ] Přílohy ještě nestažené → job počká; po 8 pokusech pošle bez nich.
- [ ] Forward nenastaví `first_responded_at`.

OAuth/klient:
- [ ] Callback s neplatným/expirovaným state → chyba, nic neuloženo.
- [ ] Re-autorizace bez `refresh_token` v odpovědi → starý refresh token zachován.
- [ ] Refresh vrátí 200 bez `access_token` → výjimka, DB token nedotčen.
- [ ] 429 s `Retry-After` → čeká; 403 `dailyLimitExceeded` → hned výjimka.

---

## 17. Co bychom dnes udělali jinak (známé dluhy)

- **Gateway rozhraní pro poštu** (`MailGateway`) hned od začátku — teď je Gmail klient volaný napřímo ze servisní vrstvy a unit testy odesílání jsou těžké.
- Inline obrázky a přílohy stahovat **asynchronně v jobu**, ne v pollu (newsletter s 20 obrázky = 20 synchronních API callů).
- Stránkovat timeline zpráv v detailu (100+ zpráv = MB payload).
- Blocklist přípon a limity velikostí servírovat frontendu z **jednoho config endpointu** místo ručního synchronizování dvou seznamů.
- Oddělit „sestavení MIME" od „odeslání" a od „zápisu do DB" do tří tříd — `sendReply()` a `compose()` sdílejí 80 % kódu.
- Zvážit Gmail push (Pub/Sub) místo pollingu, pokud potřebujete latenci < 5 min.
- Webhooky z automatizací (pro vývojáře, kteří žijí v terminálu/Slacku, ne v UI).

---

*Sepsáno podle produkčního systému „Paluba" (Laravel 12 + React 19, Gmail API, PostgreSQL), stav srpen 2026. Všechny edge cases výše jsou reálné incidenty nebo nálezy z code review, ne hypotézy.*
