Актуализировано 2026-09-30. Gmail используется для доставки подготовленной и разрешённой Application и read-only сверки DSN/bounce/ответов. LLM, crawler, MCP-клиент и пользовательский HTTP payload не получают access/refresh token и не формируют raw MIME.
Реальные credentials не нужны для разработки и CI: там используется fake Gmail provider с тем же контрактом и реальной проверкой статусов/idempotency.
Публичный интерфейс sender принимает только:
application_id
Сервер по этому ID загружает и повторно проверяет:
- Application и актуальный policy decision;
- verified EmployerContact/recipient;
- subject/body из сохранённой Application;
- selected verified Resume и его storage key/hash;
- отсутствие предыдущей отправки/
delivery_unknown; - дневной лимит, pause и emergency switch;
- idempotency key.
Запрещены параметры recipient, arbitrary MIME, attachment path или произвольный файл. Это правило действует для REST, MCP, Celery task и внутренних вызовов.
В отдельном Google Cloud project:
- включите Gmail API;
- настройте OAuth consent screen и применимый тип публикации;
- добавьте только необходимых test users, пока приложение в testing;
- создайте OAuth 2.0 client подходящего типа для server-side web flow;
- добавьте точный HTTPS redirect URI production deployment;
- храните client secret в secret manager, не в репозитории.
Redirect URI должен полностью совпадать по scheme/host/path с callback, который
показывает конфигурация job-agent. Не копируйте примерный домен из документа.
Для локальной разработки используйте отдельный OAuth client и только разрешённый
localhost callback; production client не должен разрешать лишние origins/redirects.
Обычный вход /auth/google/login и регистрация /auth/google/register
используют отдельный GoogleIdentityService: только openid и email.
Они не запрашивают Gmail consent и не заменяют сохранённую почтовую credential.
Тот же callback /api/v1/oauth/gmail/callback различает provider/actor
одноразового запроса. /admin/auth/google — legacy redirect на общий вход.
Gmail подключается отдельным действием из настроек. Для доставки и мониторинга:
https://www.googleapis.com/auth/gmail.send
https://www.googleapis.com/auth/gmail.readonly
gmail.readonly нужен reconciler для DSN/bounce и ответов в известных threads;
он не разрешает менять или удалять письма. API-only подключение с двумя Gmail
scopes не доказывает identity mailbox. Legacy операторский connect path может
также запросить openid/email для проверки admin allowlist; это отдельное явное
действие подключения, а не обычный вход.
Текущие browser routes и хранение сессий описаны в accounts-panel.md и google-oauth-sessions.md. Список scopes Google: официальная документация.
Точные имена переменных находятся в .env.example. Обычно требуются:
- OAuth client ID;
- OAuth client secret;
- точный JSON allowlist
GOOGLE_ADMIN_EMAILS; - точный redirect/public base URL;
- ключ шифрования token storage;
- provider mode (
fakeв тестах, Gmail только при явном production выборе); - независимый real-send/emergency switch.
Не передавайте refresh token через .env, MCP или форму вручную. Он появляется в
OAuth callback, проверяется и сохраняется зашифрованно. .env имеет права 0600
либо заменяется secret manager.
Общий механизм для identity и Gmail flows:
- Сервер создаёт случайные state и browser binding, server-side PKCE verifier, actor и account binding. В БД сохраняются хеши state/binding и зашифрованный verifier; срок запроса — 10 минут.
- Браузер направляется на Google; callback принимает code/state только на настроенном URL и с правильным binding cookie.
- Условный UPDATE атомарно погашает state и очищает verifier до обмена code. Сбой provider не позволяет повторно использовать уже погашенный запрос.
- Identity flow проверяет подпись, issuer, audience, expiry, nonce и verified email ID token; затем определяет admin либо активный зарегистрированный user. Gmail flow проверяет account/actor и необходимые Gmail scopes.
- Gmail refresh token сохраняется зашифрованно в credential соответствующего аккаунта. Обновление не затрагивает Gmail другого владельца.
- Audit хранит результат и безопасный error code; code, state, verifier, binding, tokens и ciphertext не возвращаются и не логируются.
Binding cookie — HttpOnly/SameSite=Lax, ограничен callback path и Secure при
HTTPS. Callback очищает cookie. Утраченный binding требует нового flow.
Gmail connect намеренно запрашивает offline consent; обычный identity login не
должен выпускать новый Gmail token. Источник параметров — app/email/oauth.py
и app/auth/google.py.
API endpoints требуют операторский Bearer credential, кроме callback:
GET /api/v1/oauth/gmail/start— отдельное Gmail authorization;GET /api/v1/oauth/gmail/callback— общий callback identity/Gmail;GET /api/v1/oauth/gmail/status— safe status bootstrap admin credential;DELETE /api/v1/oauth/gmail— local disconnect bootstrap admin credential и незавершённых Gmail requests. Это не tenant API с произвольным account ID.
Пользователь подключает почту через GET /app/gmail/connect после проверки
активного account session; POST /app/gmail/disconnect требует session-bound
CSRF. Owner/account ID определяется сервером. Администратор отключает свою
credential через POST /admin/oauth/gmail/disconnect с CSRF. Существующий
операторский consent path доступен через authenticated API start и защищённый
admin-сессией /admin/oauth/gmail/connect. Кнопка в настройках использует
этот маршрут: запрашиваются openid, email, Gmail send/read-only, явный
consent и выбор аккаунта. Callback проверяет allowlist identity, nonce,
PKCE и привязку к браузеру. Legacy /admin/auth/google?consent=1 ведёт к тому же
подключению; без consent legacy URL по-прежнему открывает обычный identity login.
Для чужого владельца UI направляет оператора к аккаунтам, не подменяет его Gmail.
Local disconnect не отзывает grant у Google. Для полного отзыва владелец удаляет доступ приложения в Google Account. Logout из JobHunter не disconnect Gmail. Ни один endpoint не принимает recipient, MIME или произвольный refresh token.
Refresh token хранится только в зашифрованном поле БД:
- Fernet обеспечивает конфиденциальность и проверку целостности;
- каждый ciphertext использует собственную случайность;
- ключ производен от одного
TOKEN_ENCRYPTION_KEY; - associated data, key ID/version и key ring отсутствуют;
- master key находится вне БД/backup;
- расшифрование доступно только Gmail provider process path.
Нельзя хешировать refresh token вместо шифрования: для обновления access token нужно исходное значение. Не возвращайте ciphertext через API/MCP: он всё равно является чувствительным материалом.
После финальной policy-проверки сервер:
- берёт recipient из verified EmployerContact;
- берёт subject/body из Application;
- читает только выбранный Resume через безопасный storage abstraction;
- проверяет active/verified, SHA-256, размер и MIME;
- формирует MIME локально с безопасным filename;
- кодирует сообщение согласно Gmail API;
- вызывает
users.messages.send(userId="me", body={"raw": ...})с ограниченными timeouts/retries; - сохраняет Gmail message id/thread id и sanitized response.
Путь файла никогда не формируется из текста вакансии, оригинального filename или MCP payload. Полный MIME/резюме не попадает в лог.
Формат base64url MIME и метод отправки описаны в официальном руководстве Gmail.
Gmail API не заменяет идемпотентность домена. Перед provider call выполняются:
- атомарный compare-and-set статуса Application;
- distributed/database lock по application ID;
- уникальное ограничение idempotency key/EmailDelivery attempt;
- проверка отправок CanonicalJob;
- резервирование дневного лимита.
Celery redelivery и двойной клик видят существующую попытку и не отправляют второе сообщение. Успешный response сохраняется до освобождения lock.
Retry ограничен по количеству и использует задержки 15 минут, 1 час и 6 часов. Он допустим при однозначной временной ошибке provider или подтверждённом временном DSN; при неизвестном исходе отправки повтор запрещён.
- 429/некоторые 5xx: уважать
Retry-After, ограниченно повторять; - refresh access token: выполнять внутри provider, refresh token не логировать;
- permanent refresh rejection (revoked/invalid grant): потребовать reconnect только у затронутого аккаунта; temporary refresh failures допускают bounded retry, не помечают grant отозванным и не продвигают mailbox cursor;
- invalid recipient/payload:
failed, не бесконечный retry; - timeout/connection loss после возможной передачи:
delivery_unknown.
delivery_unknown автоматически не повторяется. Оператор сверяет Gmail Sent и
сохранённые IDs. Это важнее риска пропустить одно письмо, чем отправить дубль.
После provider acceptance reconciler каждые пять минут читает Gmail history с
durable cursor. Он сначала связывает DSN по исходному RFC Message-ID, затем допускает
только однозначный thread или recipient/time fallback. Permanent bounce инвалидирует
конкретный EmployerContact, сохраняет факт первоначальной отправки и освобождает
application slot работодателя; он не suppress-ит работодателя. Transient bounce
получает ограниченный retry schedule, причём каждый retry повторно проверяет employer
relationship policy. Обычный входящий ответ в известном thread создаёт
employer_replied и замораживает новые отклики этому работодателю.
Существующий token, выданный только с gmail.send, показывает
delivery_ready=false/monitoring_ready=false и останавливает новые отправки до
повторного OAuth consent. Без gmail.readonly задача безопасно завершается без
чтения mailbox, а sender не создаёт письма без обязательного post-send monitoring.
Read-only исторический отчёт запускается через email-delivery-audit; он не меняет
application, delivery или contact. Employer backfill сначала запускайте командами
employer-identity-audit и employer-relationship-audit, затем отдельно
employer-backfill --apply и employer-remediate --apply после проверки dry-run.
Храните:
- application ID и provider;
- recipient, уже разрешённый contact policy;
- provider message/thread ID;
- статус и timestamps;
- номер логической попытки/idempotency key;
- sanitized error/response без токенов и полного message body.
Не считайте наличие Celery success достаточным доказательством доставки; доменный статус опирается на provider result.
- Оставьте server-side real send выключенным.
- Прогоните локальный браузерный roundtrip с fake token exchange. Он проверяет
Securecookie, state и callback, но не заменяет настоящий Google consent. - Создайте отдельный staging OAuth client и тестовый Google account с точным HTTPS callback. Не используйте production client или mailbox.
- В чистом Playwright browser context пройдите реальный Google consent, callback и
вход в панель. Проверьте, что выданы оба Gmail scope,
delivery_ready=true,monitoring_ready=true, а read-only mailbox audit может получить профиль и сообщения. Не включайте отправку писем. - Отключите локальное staging-подключение и повторите пункт 4 ещё два раза в новых чистых browser contexts. Все три последовательных прохода должны завершиться успешно.
- Проверьте audit и зашифрованное хранение без вывода ciphertext/token. Тестовые Application и токены удаляйте согласно retention, сохраняя audit trail.
Автоматические тесты никогда не используют реальный Gmail.
- Настройте отдельный production OAuth client и HTTPS callback.
- Проверьте consent/verification requirements Google.
- На экране Google consent вручную проверьте ожидаемый аккаунт; при admin login сервер проверяет подписанный ID token и точное совпадение email с allowlist. После входа отдельно подключите Gmail по соответствующему connect flow (для admin UI учитывайте описанное ограничение); одна identity-сессия не предоставляет доступ к отправке или чтению почты.
- Настройте verified resumes/contacts и консервативную policy.
- Проверьте глобальную паузу.
- Явно включите real Gmail provider/server-side switch.
- Явно включите пользовательский auto-send только после проверки по
auto-send-policy.md. - Контролируйте первую отправку и дневной отчёт.
Для отключения:
- поставьте глобальную паузу;
- отключите real-send switch;
- вызовите
DELETE /api/v1/oauth/gmailс privileged Bearer credential; это удалит локальный token и незавершённые OAuth requests, но не удалённый Google grant; - отзовите доступ приложения в Google account/security controls;
- зафиксируйте AuditEvent;
- проверьте queued/sending/delivery_unknown Applications.
При reconnect не меняйте исторические EmailDelivery. Текущая схема обновляет Gmail credential соответствующего аккаунта и её timestamps; отдельной key version в записи нет. Reconnect state и durable mailbox cursor также изолированы по владельцу.
Текущая реализация не имеет key ring, key ID или команды re-encryption. Простая
замена TOKEN_ENCRYPTION_KEY делает существующий refresh token нечитаемым.
Штатная процедура при компрометации или обязательной замене:
- включить global pause и server-side real-send kill switch;
- отозвать доступ приложения в Google account;
- сохранить необходимый audit и проверенный backup отдельно от ключа;
- заменить
TOKEN_ENCRYPTION_KEYв secret manager и перезапустить приложение; - заново пройти OAuth с проверкой выбранного аккаунта;
- выполнить refresh/dry-run или контролируемую staging-проверку без автоматической массовой отправки;
- снять ограничения только после проверки.
Бесшовная ротация возможна лишь после отдельной реализации versioned ciphertext, key ring и транзакционной миграции; этот runbook не утверждает, что они уже есть.
- scope ограничен
gmail.sendиgmail.readonly; - callback — точный HTTPS URL;
- state непрозрачный и короткоживущий, в БД хранится только его хеш;
- state атомарно одноразовый, привязан к browser cookie/actor, PKCE verifier зашифрован server-side и удаляется до token exchange;
- token не появляется в браузере/API/MCP/логах;
- refresh token защищён Fernet, единственный ключ находится вне БД;
- выбранный Google account проверен оператором; admin login подтвердил подписанный ID token и email allowlist;
- sender принимает только application ID;
- recipient и Resume выбирает сервер;
- финальная policy/limit/pause проверяется перед вызовом;
- двойной запуск идемпотентен;
-
delivery_unknownне retry-ится; - CI использует fake provider; реальный staging OAuth прошёл три раза в чистых Playwright contexts без отправки писем;
- real send по умолчанию выключен;
- revoke/reconnect и incident runbook проверены.