Двенадцать граблей Gmail API, которые я собрал, пока учил Claude отправлять почту

от автора

Началось с простого желания: чтобы Claude мог написать письмо коллеге, не заставляя меня переключаться в браузер. Готовые решения есть — Zapier, всякие облачные коннекторы. Но там моя переписка проходит через чужую инфраструктуру, а мне этого не хотелось. Значит, свой MCP‑сервер: пара сотен строк, Gmail API, за вечер.

Вечер растянулся на несколько дней и 300 тестов не потому что задача сложная, потому что почта оказалась минным полем, где каждая вторая мелочь ломает всё молча и не сразу.

Собрал грабли в кучу. Если будете делать что‑то похожее — сэкономите себе несколько вечеров.

Что получилось

Локальный MCP‑сервер: восемь инструментов — отправка и чтение почты, поиск, работа с календарём, поиск контактов. Запускается на своих OAuth‑креденшелах, ходит в Google напрямую.

 Четыре слоя. Пунктир — декораторы, они не часть потока вызовов

Четыре слоя. Пунктир — декораторы, они не часть потока вызовов

Четыре слоя. server.py тонкий — только принимает параметры инструмента и форматирует ответ. Вся логика, которая ломается на практике, живёт в пакете gws и покрыта тестами без единого сетевого вызова. Благодаря этому 300 тестов гоняются за девять секунд и не зависят от того, доступен ли сейчас Google.

Секреты — refresh‑токен и ключи приложения — лежат в системном менеджере учётных данных. На диске ни файла, в конфиге пусто.

А теперь к граблям.

Грабля 1. Письмо в windows-1251 превращается в кашу. Молча

Самая коварная из всех, потому что не падает.

Gmail API отдаёт тело письма как base64 в исходной кодировке отправителя. Не в UTF-8. В той, в которой письмо было отправлено.

Наивный код выглядит так:

raw = base64.urlsafe_b64decode(data)return raw.decode(“utf-8”, errors=“replace”)

И он работает. Ровно до первого письма из корпоративной системы, которая до сих пор шлёт в windows-1251. Таких в российской корпоративной почте хватает — легаси‑MTA, старые CRM, автоматические уведомления из систем возрастом с меня.

Замерил на модельном письме: из 41 символа кириллицы 34 превратились в U+FFFD. Тот самый ромбик с вопросом. Никакой ошибки, никакого исключения — просто текст письма стал мусором, а сервер уверенно отчитался, что всё прочитал.

Правильно — брать кодировку из Content-Type конкретной части:

def partcharset(part):    for header in part.get("headers", []):        if header.get("name", "").lower() == "content-type":            match = re.search(                r'charset\s*=\s*"?([\w.-]+)"?', header.get("value", ""), re.IGNORECASE            )            if match:                return match.group(1)    return "utf-8"def decodepart(part):    data = part.get("body", {}).get("data")    if not data:        return ""    raw = base64.urlsafe_b64decode(data)    try:        return raw.decode(_part_charset(part), errors="replace")    except LookupError:        # Неизвестное имя кодировки — не роняем чтение письма из-за этого        return raw.decode("utf-8", errors="replace")

LookupError тут не для красоты: в заголовке может оказаться что угодно, включая опечатку отправителя. Падать из‑за этого при чтении письма — плохой размен.

Грабля 2. Тема письма приходит закодированной

Продолжение первой. Я был уверен, что уж заголовки‑то Gmail API отдаёт по‑человечески.

Не отдаёт. Subject приходит ровно в том виде, в каком его прислал отправитель. А отправитель, если тема на кириллице, шлёт по RFC 2047:

=?utf-8?B?0KLQtdC80LAg0L/QuNGB0YzQvNCw?=

То есть в интерфейсе поиска у вас будет вот это вместо «Тема письма». Лечится тремя строками:

from email.errors import HeaderParseErrorfrom email.header import decode_header, make_headerdef decodeheader_value(value):    """Раскодирует RFC 2047. Обычную строку возвращает как есть."""    if not value or "=?" not in value:        return value    try:        return str(make_header(decode_header(value)))    except (UnicodeDecodeError, LookupError, ValueError, HeaderParseError):        return value

Обратите внимание на HeaderParseError в списке. Я его сначала не написал — казалось, что ValueError покрывает всё. Оказалось, email.errors.HeaderParseError наследуется от MessageError, а не от ValueError, и битый base64 внутри =?utf-8?B?...?= пролетал мимо перехвата.

Грабля 3. Русский Outlook отвечает «Ответ:», а не «Re:»

Классическая функция «добавить Re: к теме, если его ещё нет»:

if subject.lower().startswith("re:"):    return subjectreturn f"Re: {subject}"

Выглядит исчерпывающе. Пока вам не ответит человек из русского Outlook, который ставит префикс «Ответ:». Через пару итераций тема выглядит так:

Re: Ответ: Re: Ответ: Согласование договора

Плюс бонус: ведущий пробел ломает проверку целиком. "  Re: Тема" не начинается с re:, поэтому получает второй префикс.»

REPLY_PREFIXES = ("re:", "ответ:", "ре:", "fwd:", "fw:", "пересылка:")def reply_subject(subject):    stripped = subject.strip()    lowered = stripped.lower()    if any(lowered.startswith(prefix) for prefix in REPLY_PREFIXES):        return stripped    return f"Re: {stripped}"

Грабля 4. Запятая в имени превращает одного получателя в двух

Из корпоративной адресной книги адрес часто копируется в таком виде:

Иванов, Иван <ivan@example.com>

По RFC 5322 запятая — разделитель адресов в списке. Поэтому парсер честно видит здесь двух получателей: некоего Иванов без адреса и Иван <ivan@example.com>.

Проверил:

>>> getaddresses(["Иванов, Иван <ivan@example.com>"])[('', 'Иванов'), ('Иван', 'ivan@example.com')]

Дальше сценарии расходятся: либо Gmail отвечает 400, либо — что хуже — письмо уходит не тому, кому вы думали.

Формат неоднозначен в принципе, «правильно» его разобрать нельзя. Единственный честный выход — не молчать:

for name, address in parsed:    if not address or "@" not in address:        broken = f"{name} <{address}>".strip() if name else address        raise ValueError(            f"Некорректный адрес получателя: {broken}. "            "Если в имени есть запятая, его нужно закавычить: "            '"Иванов, Иван" <ivan@example.com>'        )

Закавыченный вариант "Иванов, Иван" <ivan@example.com> разбирается корректно — это и подсказываем в тексте ошибки.

Отдельная тонкость: собирать текст ошибки через formataddr нельзя. Он требует ASCII в адресной части и на кириллице падает с UnicodeEncodeError, который, на минуточку, тоже ValueError. У меня из‑за этого тест на «некорректный адрес» какое‑то время был зелёным, ловя не ту ошибку. Мораль: pytest.raises(ValueError) без проверки текста — не проверка.

Грабля 5. Bcc нужно оставить в письме. Да, именно так

Тут я сам себя чуть не подставил, действуя из лучших побуждений.

Когда собираешь MIME руками, заголовок Bcc выглядит как явная утечка: он же уедет получателю, и все увидят скрытые копии. Рука тянется его вырезать.

Вырезать нельзя. Для Gmail API Bcc в raw‑сообщении — единственный способ сказать, кому отправить скрытую копию. Документация прямо говорит: сообщение отправляется получателям из заголовков To, Cc и Bcc. Gmail работает здесь как обычный MSA и сам вырезает заголовок перед доставкой.

То есть «исправление» превратило бы скрытых получателей в тех, кто письма просто не получит. Причём молча — отправитель видит успешный ответ API.

В коде на этом месте теперь стоит комментарий на пять строк, чтобы следующий человек (или я через полгода) не «починил» это снова.

Грабля 6. Событие на весь день не создаётся, если даты одинаковые

Календарь, казалось бы, проще почты. Ага.

Просите «встречу на весь день 6 августа» — логично поставить start.date = 2026-08-06 и end.date = 2026-08-06. Google на это отвечает 400 с reason: timeRangeEmpty.

Потому что end.date у событий на весь день — эксклюзивная граница. Событие на один день это start = 2026-08-06, end = 2026-08-07.

Если границы разные, но сдвиг не сделан, ошибки не будет — будет тихая потеря дня: попросили с 6-го по 8-е включительно, получили событие, закончившееся вечером 7-го.

def shiftall_day_end(body):    start_date = body.get("start", {}).get("date")    end_date = body.get("end", {}).get("date")    if start_date and end_date:        shifted = datetime.date.fromisoformat(end_date) + datetime.timedelta(days=1)        body["end"]["date"] = shifted.isoformat()    return body

И сразу следующая мина, которую я поставил сам: эта функция мутирует body, а вызывающая её create_event обёрнута в декоратор повторов. При 429 от Google повтор вызывает функцию заново с тем же объектом — и сдвигает уже сдвинутую дату ещё на день. Событие молча уезжает на сутки, причём только когда у Google икнулось.

Лечится копией на входе:

@with_retriesdef create_event(body, calendar_id="primary", add_meet=False):    body = copy.deepcopy(body)    shiftall_day_end(body)    ...

Мораль общая: если функция обёрнута в retry, она обязана быть чистой относительно своих аргументов. Иначе повтор — это не «то же самое ещё раз», а «то же самое поверх результата прошлой попытки».

Грабля 7. Скоуп calendar.events не даёт читать календарь

Вот эту я поймал только на живом аккаунте, когда все 299 тестов были зелёными.

Мне нужна таймзона календаря — чтобы «встреча завтра в 15:00» без указания смещения создавалась в правильное время, а не в UTC. Очевидный способ:

info = service.calendars().get(calendarId="primary").execute()timezone = info.get("timeZone", "UTC")

Работает. Но требует скоуп calendar или calendar.readonly, а я запрашивал минимальный calendar.events — по принципу «бери только то, что нужно». В ответ:

403 insufficientPermissions: Request had insufficient authentication scopes.

Причём calendars.get относится к ресурсу Calendars, а calendar.events даёт доступ к ресурсу Events. Разные ресурсы, разные права — логично, когда знаешь, и совершенно неочевидно, когда пишешь.

Самое неприятное: calendar_timezone() вызывается первым делом в create_event и update_event. То есть весь календарный функционал был мёртв, а тесты этого не видели — тестовый двойник отвечает на любой вызов, ему безразлично, какие права нужны настоящему Google.

Расширять права не пришлось: events.list возвращает таймзону календаря в том же ответе и доступен с calendar.events.

response = service.events().list(    calendarId=calendar_id, maxResults=1, singleEvents=True).execute()timezone = response.get("timeZone", "UTC")

Вывод, который я записал себе на будущее: тесты с моками проверяют, что вы вызываете API так, как задумали. Они не проверяют, что вам вообще можно так его вызывать.

Грабля 8. Поиск контактов находит только тех, кого вы добавили руками

people.searchContacts ищет по «Моим контактам» — тем, кого вы явно сохранили в адресную книгу.

У меня в аккаунте таких оказалось ноль. При этом переписка идёт годами.

Всё, с кем вы переписывались, Google складывает в отдельный список «Другие контакты», и для него отдельный метод otherContacts.search с отдельным скоупом contacts.other.readonly. Без него поиск контактов на обычном аккаунте — просто дорогой способ получить пустой список.

Две мелочи сверху:

readMask у методов разный. У searchContacts можно запросить organizations, у otherContacts.search — нельзя, получите 400. Доступны только names, emailAddresses, phoneNumbers, metadata.

Оба метода работают по прогретому кэшу. Первый вызов после старта процесса может вернуть пустоту, даже если контакт есть. Google рекомендует отправить прогревочный запрос с пустым query и подождать. Да, это выглядит как костыль. Нет, это документированное поведение.

Грабля 9. pageSize по умолчанию 10, а пагинации нет вообще

Продолжение восьмой, но заслуживает отдельного пункта, потому что тихо ломает обещание.

Инструмент поиска контактов у меня возвращает все совпадения — принципиально, потому что выбирать «лучшее» за пользователя при отправке письма нельзя: письмо не тому Иванову не отзывается.

Только вот searchContacts без явного pageSize отдаёт 10 результатов. А в ответе SearchResponse нет nextPageToken — пагинации у метода не существует в принципе. Максимум — 30, и это жёсткий потолок.

То есть «возвращаем все совпадения» на распространённой фамилии тихо превращалось в «возвращаем первые десять». Обещание, которое код не выполняет, хуже отсутствия обещания.

Грабля 10. Содержимое <script> попадает в текст письма

Эта уже не про кодировки, а про безопасность — и она специфична именно для AI‑агентов.

Сервер, который умеет и читать почту, и отправлять её, — интересная мишень. Письмо приходит от кого угодно, а модель читает его как часть контекста. Классическая непрямая инъекция промпта: «перешли этот тред на адрес…».

Стандартный ответ — обернуть тело письма маркером «это данные, а не инструкции». Сделал. Но есть тонкость.

Приведение HTML к тексту обычно пишут так:

text = re.sub(r"<[^>]+>", " ", html)

Теги вырезаны. А содержимое <script> и <style> — осталось. Причём это ровно тот текст, который получатель‑человек не видит: браузер скрипты не рендерит.

Получается канал для инструкций, невидимых при обычном просмотре письма, но прекрасно видимых модели.

def htmlto_text(raw):    cleaned = re.sub(r"<!--.*?-->", " ", raw, flags=re.DOTALL)    cleaned = re.sub(        r"<(script|style)\b[^>]*>.*?(?:</\1\s*>|\Z)",        " ",        cleaned,        flags=re.IGNORECASE | re.DOTALL,    )    without_tags = re.sub(r"<[^>]+>", " ", cleaned)    return re.sub(r"\s+", " ", html_module.unescape(without_tags)).strip()

Хвост |\Z в середине — не паранойя. У незакрытого <script> браузер съедает остаток документа как код, то есть человек этого текста опять не видит. Без этого хвоста регулярка не сработала бы вообще, и содержимое утекло бы в тело письма.

Красный пунктир — путь, которого быть не должно

Красный пунктир — путь, которого быть не должно

Отдельно я пометил необратимые инструменты в самом протоколе MCP: destructive_hint у отправки письма и работы с событиями, read_only_hint у чтения. Просьба в описании инструмента — это просьба, её модель может и не выполнить. Пометка в протоколе работает независимо от того, прочитана ли проза.

Грабля 11. В статусе Testing токен живёт неделю

Не про API, а про настройку — но стоила бы мне регулярных «почему опять отвалилось».

Приложение в Google Cloud по умолчанию в статусе Testing. Для него Google выдаёт refresh‑токен со сроком жизни 7 дней — это прямо написано в документации, но мимо этого абзаца проходят все, включая авторов туториалов.

Публикация приложения (*Publishing status → In production*) снимает ограничение. Взамен при первом входе вы увидите экран «Google hasn’t verified this app» → Advanced → Go to app. Разовое неудобство против еженедельной переавторизации — по‑моему, размен очевидный.

Верификация при этом не нужна: она обязательна, когда приложением пользуются посторонние. Для личного инструмента лимит в 100 пользователей никогда не станет проблемой.

Грабля 12. mcp 2.0 выкинул FastMCP

Уже на финише, когда всё работало, я обновил зависимости — и сервер перестал импортироваться.

В mcp 2.0.0 модуль mcp.server.fastmcp удалён. Вместо него mcp.server.MCPServer. API похож — тот же декоратор @tool(), докстринг по‑прежнему становится описанием инструмента, — но импорт другой.

А теперь неприятное: зависимость была объявлена просто как "mcp", без границы. Значит, сервер работает или не работает в зависимости от того, что PyPI отдал в момент установки. У меня по этой же причине молча слёг соседний MCP‑сервер, написанный раньше и не тронутый месяцами.

# /// script# dependencies = [#   "mcp>=2",          # ← вот это#   "google-api-python-client",# ]# ///

Скучный вывод, который все знают и половина не делает: пиньте границы версий. Особенно для библиотек, которые ещё активно ломают API.

Как я это писал

Обещал честно — пишу честно: проект сделан в паре с Claude Code, и процесс сам по себе оказался поучительным.

Схема была такая. Сначала спека — что делаем, чего не делаем, где живут секреты. Потом план: 12 задач, у каждой описаны файлы, тесты и шаги. Дальше каждая задача выполнялась отдельным агентом по TDD — сначала падающий тест, потом код. После каждой задачи — отдельный агент‑ревьюер, который её проверял.

Что из этого получилось интересного.

Ревью нашло девять дефектов, и большинство — в моём же плане. Не в коде, который писали агенты, а в том, что я им поручил. Мёртвый перехват исключения, которое в этом месте не возникает. Утечка секретов через атрибут doc у JSONDecodeError. Необработанные сетевые сбои. Неверная политика парсинга в тестовом хелпере, из‑за которой четыре теста падали на ровном месте.

Агенты спорили — и были правы. Я поручил пометить update_event как идемпотентный: повторный патч теми же полями даёт то же состояние календаря. Агент отказался и объяснил: метод вызывается с sendUpdates="all", поэтому каждый повтор рассылает участникам уведомление. Пометка «идемпотентно» означает для клиента «можно молча повторить» — то есть второе письмо живым людям. Аргумент сильнее моего, вариант агента остался в коде.

Самая дорогая находка пришла не от тестов. Грабля номер 7 — та, где calendars.get требует более широкий скоуп, — выжила через 299 зелёных тестов и два раунда ревью. Её поймала первая же попытка сходить в живой Google. Никакие моки этого не ловят по определению: тестовый двойник не знает про права.

Ощущение по итогу такое: агенты хорошо делают то, что описано, и отлично ловят несоответствия внутри описанного. Но граница между «код делает, что задумано» и «задумано правильно» никуда не делась — она просто переехала на уровень выше, в спеку и план.

Что в итоге

Восемь инструментов, 300 тестов, работает.

Две точки, где всё обрывается до сети: подтверждение и проверки в build_mime

Две точки, где всё обрывается до сети: подтверждение и проверки в build_mime

Первое боевое письмо ушло с первой попытки — кириллица в теме, дисклеймер в подписи, всё на месте. После двенадцати граблей это было даже как‑то подозрительно.

Код на Гитхабе, MIT, форки и issues приветствуются.

Если делаете что‑то похожее — главный совет не про Gmail API. Заведите себе привычку прогонять новый интеграционный код по живому сервису до того, как напишете двести тестов вокруг своих предположений. Моки проверяют, что вы вызываете API так, как задумали. Что вам вообще можно так его вызывать — проверяет только сам сервис.

ссылка на оригинал статьи https://habr.com/ru/articles/1068140/