Lodní deník podporyTL;DR: Nejdřív jsme si postavili vlastní ERP, protože jsme potřebovali řídit čísla, projekty a lidi z jednoho místa. Pak do něj přibyly tikety – každé e-mailové vlákno je jeden tiket, čte se i odesílá přes Gmail API. Největší výhoda není tiketovací systém sám o sobě (těch je spousta), ale to, že vedle tiketu vidíte, jestli klient dluží, jedním klikem ho přidáte do CRM a z e-mailu necháte agenta založit celý projekt. A ušetří nám to přes 70 000 Kč ročně, které jsme do té doby platili za hotový tiketovací systém. Cestou nás kouslo několik věcí, o kterých dokumentace Gmailu mlčí. Sepsali jsme je – a na konci článku je kompletní zadání ke stažení, ze kterého si podobný systém postavíte s coding agentem.
Proč vlastní ERP a proč do něj tikety
Jsme malá agentura – Shoptet Premium e-shopy, doplňky, AI na míru. S rostoucím počtem projektů jsme přestali stíhat mít přehled: kolik hodin jsme na čem odpracovali, co je vyfakturované, co klient ještě nezaplatil, kdo je vytížený a kdo má volno. Tak vznikla Paluba – náš interní systém, který si tahá úkoly a výkazy z Freela, hlídá rozpočty projektů, generuje měsíční reporty a fakturuje.
Klientská komunikace ale pořád žila mimo. Měli jsme na ni český tiketovací systém – poctivý nástroj, ale nám nevyhovoval: neznal naše projekty ani faktury, takže se stejně přeposílalo „kdo tohle řeší?" a hledalo se v několika oknech najednou. Tikety jsme si postavili vlastní proto, aby e-mail bydlel vedle všeho ostatního. V praxi to dnes vypadá takhle:
- Otevřu tiket a hned vidím, jakého klienta se týká, jaké má projekty a jestli nám dluží – nemusím nic hledat ve fakturaci.
- Z poptávky, která přišla e-mailem, ji jedním klikem přes AI založím jako lead v CRM. Jméno, firma, co chtějí – to všechno si AI vytáhne z vlákna sama.
- Z tiketu nechám agenta založit celý projekt včetně úkolů, a to ve chvíli, kdy klient odsouhlasí nabídku.
- AI agenti připravují návrhy odpovědí, ale nikdy neodesílají sami. Draft → lidská kontrola → odeslání.
Tohle žádný hotový helpdesk neumí, protože nezná naše faktury ani projekty. Integrace na jednom místě je celý důvod, proč to stavět vlastními silami.
Kolik to ušetří
Počítali jsme to i penězi. Za předchozí tiketovací systém jsme platili zhruba 12 × 490 Kč bez DPH měsíčně, tedy přes 70 000 Kč ročně – a to jen za licence, bez integrací na fakturaci a CRM, které by nám stejně nikdo nenabídl. Větší položka je ale efektivita: AI připraví návrh odpovědi, MCP dá agentům přístup k datům, integrace ušetří ruční přepisování poptávek do CRM a zakládání projektů. To jsou hodiny týdně, které se dřív ztrácely v přepínání mezi pěti nástroji.
Technicky je to Laravel 12 + React 19, PostgreSQL, jeden Google Workspace účet s více send-as aliasy (každý alias = jedna schránka v aplikaci), příchozí pošta se stahuje pollingem každých pět minut a vlákno se drží přes Gmail threadId s fallbackem na In-Reply-To/References. Většina kódu vznikla ve dvojici s AI coding agentem – proto jsou i lekce níže sepsané tak, aby je agent pochopil.
Co nás v produkci kouslo
Obecných tutoriálů „jak na Gmail API" jsou stovky. Tohle jsou věci, na které v nich nenarazíte, protože se projeví až s reálnými klienty, reálnými poštovními klienty a tisíci tiketů. U každé: co se stalo, proč, a jaké pravidlo z toho plyne.
1. Komu vlastně odpovídáš?
Nejvíc opravných commitů za celý projekt. Zní to triviálně – odpovídáš tomu, kdo napsal. Jenže:
Poptávka z webového formuláře přijde jako 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 „původního odesílatele" poslal další odpovědi na adresu formuláře – do prázdna. Klient zůstal jen v kopii a divil se, proč mu nikdo neodpovídá.
Kopie se kumulovaly. Napsal klient A s kolegou B v kopii. Odpověděli jsme. Pak odpověděl B a A vědomě vynechal. Naše další odpověď šla zase na A i B, protože jsme kopie na tiketu jen přidávali.
Náš vlastní alias se přilepil do CC. Kolega odpověděl ze send-as aliasu, klient dal Reply-All, a náš alias se na tiketu objevil jako externí účastník.
Pravidla, ke kterým jsme došli: příjemce se hledá od nejnovější příchozí zprávy zpět (Reply-To, jinak From), a přeskakuje všechno, co je „naše" – včetně aliasů použitých v odchozích zprávách. Kopie zrcadlí poslední zprávu vlákna, přesně jako Reply-All v mailovém klientu: kdo byl vyhozen, nesmí se vracet; kdo byl přidán, je tam hned. A identita tiketu (kdo je klient pro párování s CRM) je ten, kdo ho založil – ne ten, kdo psal naposledy, protože to může být kolega z kopie.
2. Outlook trhá vlákno, Gmail to tolerantně schová
Původně jsme In-Reply-To stavěli z poslední příchozí zprávy. Když kolega 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 sloučil, Outlook ne – klient viděl dvě vlákna a odpovídal do toho špatného.
Rodičem odchozí zprávy musí být poslední zpráva bez ohledu na směr a References = rodičovské References plus rodičovo Message-ID. Bonus z téhle opravy: ORM mělo na relaci výchozí řazení sent_at ASC, ke kterému se orderByDesc jen přidal – „poslední zpráva" byla ve skutečnosti ta nejstarší. Když má relace výchozí řazení, u každého dotazu na poslední zprávu ho explicitně resetujte.
3. Jak nám odpověď narostla do 49 MB
Nejdražší bug. První verze citovala do každé odpovědi celé vlákno v plné HTML podobě. Klientův mailový 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 zhruba zdvojnásobila. Po 26 zprávách měl e-mail 49 MB a PHP spadlo na vyčerpanou paměť.
Dnes citujeme jen poslední zprávu vlákna (ta sama nese starší citace, růst je lineární – dělá to tak Gmail i Outlook), celá citace má rozpočet 200 kB, obrázky vložené jako data: URI v citaci nahrazujeme placeholderem a zprávy načítáme po jedné cíleným dotazem, ne celé vlákno najednou.
S tím souvisí limity Gmailu: JSON endpoint pro odeslání bere 5 MB, takže vložený screenshot v base64 ho lehce překročí a výsledkem je neprůhledná pětistovka. Nad ~3,5 MB posíláme přes upload endpoint (limit 35 MB) a multipart tělo skládáme do streamu, ne do stringu. Tvrdý strop 25 MB kontrolujeme před sestavením payloadu a vracíme lidskou hlášku („E-mail má 31 MB, limit je 25 MB, pošlete odkazem"). A limit příloh máme 17 MB, ne 18 – Gmail měří zprávu po base64 (+33 %).
4. Token, který se sám smazal
Google občas vrátí při obnově tokenu HTTP 200 bez access_token. Bez guardu jsme přepsali platný token v databázi hodnotou null, další request selhal na „Invalid token" a schránka přestala fungovat, dokud ji někdo ručně nepřipojil znovu. Stejný typ chyby: refresh_token Google vrací jen při prvním souhlasu – při re-autorizaci ho nesmíte přepsat prázdnou hodnotou.
Pravidlo: odpověď na obnovu tokenu validujte a při chybějícím tokenu do databáze nesahejte. A token považujte za expirovaný pět minut před skutečnou expirací – žádný request nesmí běžet s tokenem, který vyprší uprostřed.
5. Dva pollery, dva tikety
Cron stahuje poštu každých pět minut. Kolega mezitím klikl na „Synchronizovat teď". Oba prošli kontrolou „tahle zpráva ještě neexistuje", oba vytvořili tiket. exists() guard proti souběhu nestačí – jediná spolehlivá ochrana je UNIQUE index na ID zprávy a zachycení porušení constraintu jako „duplikát, přeskoč". Ruční synchronizaci jsme navíc dali 60s cooldown a spouštíme ji na pozadí přes frontu.
6. Prázdný tiket a příloha, která se nikdy nestáhla
Gmail API u větších těl nevrátí body.data, ale body.attachmentId – i pro obyčejný text/html. Kdo s tím nepočítá, má prázdný tiket. A ID přílohy v Gmailu má klidně 300+ znaků; ve VARCHAR(255) se ořízne a přílohy se tiše nestáhnou. Prostě TEXT.
Stejná kategorie: bounce od mailer-daemona často nemá hlavičku Auto-Submitted, takže vám z každého nedoručení vznikne ghost tiket. Detekujte i podle předmětu („Mail delivery failed", „doručení selhalo") a odesílatele (mailer-daemon@, postmaster@, noreply@).
7. Příloha, která tiše zmizela
Kolegyně přiložila k odpovědi soubor .js. Frontend ho správně zablokoval (spustitelné přílohy Gmail stejně odmítne), ale toast zmizel dřív, než si ho všimla, a e-mail odešel bez přílohy. Backend soubor nikdy nedostal, takže ani nemohl nic zalogovat.
Pravidlo, které se hodí nad rámec tiketů: destruktivní upozornění nesmí být toast. Perzistentní červený box v editoru, tooltip se seznamem zakázaných přípon a backend, který odmítnutí zaloguje a pošle do Slacku.
8. E-mail odešel, zápis do databáze ne
Gmail zprávu odeslal, pak selhal zápis do databáze. Bez ošetření kolega vidí chybu, klikne znovu a klient dostane duplikát. Proto: neúplná odpověď Gmailu (200 bez ID) je výjimka s varováním „e-mail MOHL odejít, ověřte v Gmailu"; selhání databáze po odeslání je critical log, Slack alert a explicitní hláška „Neodesílejte znovu, Gmail ID: …". Priorita číslo jedna celého systému je nikdy tiše neztratit e-mail – raději jeden duplikát za čas než ztráta.
Co bychom dnes udělali jinak
- Rozhraní pro poštu od začátku. Gmail klient voláme přímo ze servisní vrstvy a unit testy odesílání jsou proto těžké.
- Přílohy a inline obrázky stahovat asynchronně. Newsletter s dvaceti obrázky = dvacet synchronních API callů uprostřed pollu.
- Oddělit sestavení MIME, odeslání a zápis do DB do tří tříd. Odpověď a nová zpráva dnes sdílejí 80 % kódu.
- Push notifikace přes Pub/Sub místo pollingu, pokud byste potřebovali latenci pod pět minut. Pro interní podporu nám pět minut stačí.
Zadání pro vašeho coding agenta
Všechno výše – a dalších zhruba čtyřicet edge cases, které se do článku nevešly (forwardy s lease lockem, přesun tiketu mezi schránkami, SLA, automatizace, bezpečnost hlaviček, sandbox pro zobrazení e-mailu) – jsme sepsali do jednoho dokumentu. Není to tutoriál, je to zadání psané pro coding agenta: architektonická rozhodnutí, minimální datový model, pipeline příjmu a odeslání, pravidla adresace a checklist scénářů, které musí projít testy.
Zkopírujte ho jako úvodní prompt do Claude Code, Codexu nebo Cursoru a technologie si vyberte vlastní. Důležitá jsou pravidla, ne framework.
Stáhnout zadání: prompt-tiketovaci-system-gmail.md
Dokument je anonymizovaný – žádné reálné adresy, klienti ani čísla tiketů. Všechny edge cases v něm jsou skutečné incidenty nebo nálezy z code review, ne hypotézy.
Chcete něco podobného?
Tiketovací systém je pro nás jen jedna dlaždice. Pointa je v tom, že e-mail, fakturace, CRM, projekty a AI agenti sdílejí jedna data. Pokud řešíte podobný problém – firma roste a přehled se drolí do pěti nástrojů – ozvěte se. Stavíme interní systémy a AI aplikace na míru a rádi ukážeme, jak to máme postavené u sebe.