Конфигурация
Три способа построить клиент, все опции и то, что он отклоняет до отправки запроса.
Опции
require "openemail" OpenEmail.init(api_key: ENV.fetch("OPENEMAIL_API_KEY"))OpenEmail.me.ping pinned = OpenEmail::Client.new(api_key: ENV.fetch("OPENEMAIL_API_KEY"), base_url: "https://api.openemail.uk")quick = OpenEmail::Client.new(ENV.fetch("OPENEMAIL_API_KEY"))billing = OpenEmail.create_client(api_key: ENV.fetch("BILLING_API_KEY")) p pinned.mode, quick.mode, billing.mode| Точка входа | Что вы получаете |
|---|---|
| OpenEmail.init(...) | Настраивает общий клиент и возвращает его. С этого момента OpenEmail.client и есть этот клиент в каждом файле и в каждом потоке, а всё, что вы не указали, читается из окружения. |
| OpenEmail.client, OpenEmail.emails, OpenEmail.threads и все остальные пространства имён | Общий клиент и короткие пути к его пространствам имён. Если обратиться к нему до init, он соберёт себя при первом вызове из OPENEMAIL_API_KEY и OPENEMAIL_BASE_URL. |
| OpenEmail.reset_client | Сбрасывает общий клиент, так что следующий вызов соберёт новый из окружения. |
| OpenEmail.create_client(...) | Отдельный клиент с тем же запасным чтением из окружения: для второго ключа рядом с общим или для клиента, которого ваш собственный код хранит и передаёт дальше. |
| OpenEmail::Client.new(...) or OpenEmail::Client.new(api_key) | Отдельный клиент, собранный ровно из того, что вы передали. Он не читает окружение, поэтому ему нужен api_key: или access_token:. OpenEmail.new делает то же самое. |
OpenEmail.init( api_key: ENV.fetch("OPENEMAIL_API_KEY"), base_url: "https://api.openemail.uk", timeout: 30, max_retries: 2, adapter: OpenEmail::NetHttpAdapter.new(max_idle: 8, keep_alive_timeout: 2), headers: {"X-Team" => "billing"}, user_agent: "billing-service/1.4", disable_update_notice: true)| Опция | По умолчанию | Примечания |
|---|---|---|
| api_key: | OPENEMAIL_API_KEY | init, create_client и общий клиент читают его из окружения. Должен начинаться с oe_live_ или oe_test_. Его можно передать и первым аргументом, но не обоими способами сразу. |
| access_token: | OPENEMAIL_ACCESS_TOKEN | Токен доступа OAuth или любой объект, который отвечает на call и возвращает токен. См. раздел «Токены доступа OAuth» ниже. Передавайте ключ или токен, но никогда оба сразу. |
| base_url: | https://api.openemail.uk | Или OPENEMAIL_BASE_URL. Завершающие слэши обрезаются, а init и create_client добавляют https:// перед голым хостом или http:// перед хостом на этой машине: localhost, адресом 127.x.x.x или ::1. Учётные данные никогда не отправляются по обычному http ни на какой другой хост, а 0.0.0.0 или [::] выбрасывают исключение при сборке клиента, потому что это адреса, на которых слушает сервер, а не адреса для отправки запросов. |
| timeout: | 30 | Секунды на попытку, а не на вызов. С адаптером по умолчанию это время охватывает подключение и чтение всего тела ответа, а не только заголовков. 0 отключает таймаут. files.upload ждёт не меньше 600 секунд, если вы не передадите timeout: в этом вызове. |
| max_retries: | 2 | Дополнительные попытки после первой для вызовов, которые безопасно повторять. Задаётся на клиенте, а не для отдельного вызова. 0 отключает повторы. |
| adapter: | OpenEmail::NetHttpAdapter.new | HTTP-слой. По умолчанию он держит до 8 простаивающих соединений на хост по 2 секунды каждое, а max_idle: и keep_alive_timeout: это меняют. Его место может занять любой объект, который отвечает на call(request): так тест работает без сети. |
| headers: | {} | Отправляются с каждым запросом. |
| user_agent: | openemail-ruby/<version> | Отправляются с каждым запросом. |
| disable_update_notice: | false | Пропускает проверку новой версии на RubyGems, которая выполняется один раз за процесс. Проверка запускается, только когда стандартный вывод является терминалом, и OPENEMAIL_DISABLE_UPDATE_NOTICE тоже её отключает. |
Переменные окружения
| Переменная | Что делает |
|---|---|
| OPENEMAIL_API_KEY | Ключ, который используют init, create_client и общий клиент, если вы не передали ни api_key:, ни access_token:. |
| OPENEMAIL_ACCESS_TOKEN | Токен доступа OAuth. Читается, только если вы не передали ни ключ, ни токен и OPENEMAIL_API_KEY не задан, так что ключ в окружении имеет приоритет. |
| OPENEMAIL_BASE_URL | Базовый URL, если вы его не передали. К голому хосту вроде localhost:2222 добавляется схема. |
| OPENEMAIL_DISABLE_UPDATE_NOTICE | Любое непустое значение отключает уведомление об обновлении для всех клиентов в процессе. |
| HTTPS_PROXY и NO_PROXY или https_proxy и no_proxy | Прокси, через который подключается адаптер по умолчанию, и хосты, к которым он подключается напрямую. См. раздел «Прокси» ниже. |
OpenEmail::Client.new не читает ни одну из первых трёх, поэтому клиент, собранный таким способом, никогда случайно не подхватит ключ из окружения. Переменная, которая задана, но пуста, считается незаданной.
Что он отклоняет до отправки
Эти случаи выбрасывают ArgumentError из той строки, где было неверное значение, а не всплывают непонятным сбоем при первой отправке. Сообщение говорит, что было не так и что передать вместо этого, и никогда не повторяет учётные данные.
| Отклонено | Почему |
|---|---|
| Нет никаких учётных данных | Не передан ни api_key:, ни access_token:, а для init и create_client не задана и ни одна из переменных, так что аутентифицироваться нечем. Выбрасывается при сборке клиента. |
| Ключ и токен одновременно | Каждый запрос несёт одни учётные данные, поэтому клиент не может понять, что вы имели в виду. Ключ, переданный и первым аргументом, и как api_key:, отклоняется по той же причине. |
| Cookie сессии, токен сессии или ключ от другого сервиса | Здесь аутентифицируют только oe_live_ и oe_test_, и API говорит то же самое. Проверяется только префикс и ничего больше, поэтому отозванный ключ всё равно отклонит уже сам API, с ошибкой OpenEmail::AuthenticationError. |
| base_url:, который не является URL с http или https или содержит имя пользователя или пароль | Ни до чего другого достучаться нельзя, а учётным данным место в api_key: или access_token:, а не в URL. Выбрасывается при сборке клиента. |
| Учётные данные по обычному http на хост, который находится не на этой машине | Выбрасывается вызовом ещё до того, как что-либо отправлено. Используйте базовый URL с https. |
| timeout:, который не является числом секунд или отрицателен | Передайте секунды или 0, чтобы обойтись без таймаута. Выбрасывается при сборке клиента. |
| Имя заголовка, которое не является токеном, или перевод строки в значении заголовка | Проверяется в headers:, user_agent: и idempotency_key:, потому что перевод строки начал бы второй заголовок. |
| Пустой идентификатор или идентификатор из одних точек в любом методе | Выбрасывается при вызове метода. Сегмент пути из точек удаляется любым парсером URL, так что запрос попал бы на другой эндпоинт. Идентификатор, который не является корректным UTF-8, тоже отклоняется. |
| Тело запроса, которое не является Hash | Передайте именованные аргументы или один Hash. Любой объект, который отвечает на to_hash, тоже считается Hash. |
Параметра test_mode: нет и не будет. Схема ключа является частью учётных данных, а не подсказкой, поэтому режим является свойством ключа. client.mode читает префикс, "live" или "test", и ничего не решает.
Один клиент, несколько ключей
Соберите клиент один раз и разделяйте его. Новый клиент на каждый запрос впустую выбрасывает свои открытые соединения, а никакая часть его состояния не привязана к вызывающему. После сборки клиент заморожен, и его безопасно использовать из многих потоков одновременно, поэтому процессу Puma или Sidekiq нужен только один, а после форка дочерний процесс открывает собственные соединения.
Для случая, который иначе потребовал бы по клиенту на ключ, например задания, отправляющего от имени нескольких рабочих пространств, передайте api_key: в вызов. Он заменяет заголовок Authorization для этого запроса и ничего не оставляет на клиенте.
message = {from: "[email protected]", to: "[email protected]", subject: "Your invoice", text: "Attached."}workspace_key = ENV.fetch("OPENEMAIL_API_KEY") client.emails.send(message) client.emails.send(message, api_key: workspace_key) client.threads.list(folder: "inbox", api_key: workspace_key)client.webhooks.list(api_key: workspace_key)Каждый метод вне temp_mail принимает его как именованный аргумент, рядом с фильтрами списка, а методы temp_mail вместо него принимают inbox_token:. Он проверяется до отправки запроса по тому же правилу, что использует клиент, поэтому опечатка выбрасывает ArgumentError про api_key, переданный в этот вызов, а не 401 про учётные данные, которые потом ещё придётся искать. Повторный вызов сохраняет ключ, который ему дали.
client.mode описывает ключ, с которым клиент был СОБРАН, и не следует за переопределением. Когда один клиент обслуживает несколько ключей, единого режима, о котором можно сообщить, нет, поэтому определяйте его по ключу, который вы передали. client.inspect показывает режим и базовый URL, но никогда не ключ.
Эндпоинты, которые не оборачивает ни один метод
client.raw является транспортом, через который проходит каждый метод. client.raw.request вызывает путь, который пока не оборачивает ни один метод, применяя учётные данные клиента, базовый URL, таймаут и политику повторов, и возвращает разобранное тело так же, как это делает метод.
ping = client.raw.request("/ping") label = client.raw.request("/labels", method: :post, body: {name: "Invoices"}) p ping[:ok], label[:id]| Именованный аргумент | Что делает |
|---|---|
| method: | :get, если не указано иное: :post, :put, :patch или :delete. |
| query: | Hash параметров запроса. nil и пустые значения опускаются, Array или Set склеиваются через запятую, а Time отправляется как момент времени в формате ISO 8601. |
| body: | Hash, который отправляется как JSON. |
| raw: и content_type: | Байты, которые отправляются как есть, в виде двоичной String, IO или Pathname, с типом application/octet-stream, если вы не укажете другой. |
| accept: и binary: | При accept:, отличном от JSON, тело возвращается как текст, а binary: true возвращает его как двоичную String. |
| idempotent: и idempotency_key: | idempotent: true добавляет Idempotency-Key, который генерируется, если вы не передали свой. |
| repeatable: | Повторяется ли вызов после сбоя. Повторяется только GET, если вы не передадите repeatable: true. |
| api_key: и timeout: | Тот же ключ для отдельного вызова и таймаут в секундах только для этого вызова. |
Путь должен начинаться с одного /, а путь, итоговый URL которого вышел бы за пределы источника (origin) базового URL, выбрасывает ArgumentError ещё до отправки, поэтому учётные данные никогда не попадают на другой хост.
Одноразовые ящики
OpenEmail.create_temp_mail собирает клиент для одноразовых ящиков, который не несёт API-ключа и не читает его из окружения. Он создаёт ящики анонимно, а каждое чтение отправляет токен ящика, который вернул create, или более новый, который вернул extend: либо в каждом вызове как inbox_token:, либо один раз как OpenEmail.create_temp_mail(inbox_token:).
temp_mail = OpenEmail.create_temp_mail inbox = temp_mail.createpage = temp_mail.list_messages(inbox[:id], inbox_token: inbox[:token]) p page.items.size, page.expires_atcreate_temp_mail принимает base_url:, adapter:, max_retries:, timeout:, user_agent:, headers: и disable_update_notice:, как любой клиент, и читает OPENEMAIL_BASE_URL, если вы не передали базовый URL.
Токены доступа OAuth
Приложение, которое человек подключил через OAuth, например инструмент командной строки или агент, держит токен доступа вместо API-ключа. Передайте его как access_token:: либо сам токен, либо любой объект, который отвечает на call и возвращает его, например lambda или Method. Он вызывается один раз на каждый вызов, а повторы этого вызова переиспользуют то, что он вернул, поэтому обновляйте токен внутри него, когда срок действия подходит к концу, и клиент никогда не придётся пересобирать.
tokens = {current: "token-from-your-oauth-flow"} oauth_client = OpenEmail::Client.new(access_token: -> { tokens.fetch(:current) }) me = oauth_client.me.get puts me[:clientId], me[:expiresAt] if me[:object] == "oauth_token"| Случай | Что происходит |
|---|---|
| api_key: и access_token: вместе или ни одного из них | Клиент выбрасывает ArgumentError при сборке. Если нет ни того, ни другого, сообщение называет OPENEMAIL_API_KEY и OPENEMAIL_ACCESS_TOKEN. |
| Значение, которое не является токеном | Токен содержит от 1 до 512 символов и не начинается с oe_: именно это проверяет OpenEmail.access_token?. String, не прошедшая проверку, выбрасывает исключение при сборке клиента, а вызываемый объект, который вернул такую строку, выбрасывает ArgumentError из вызова ещё до отправки. |
| OPENEMAIL_ACCESS_TOKEN | Читается init, create_client и общим клиентом, если вы не передали ни ключ, ни токен и OPENEMAIL_API_KEY не задан, так что ключ в окружении имеет приоритет. |
| Вызываемый объект, который выбрасывает исключение | Вызов выбрасывает эту ошибку без изменений, и ничего не отправляется. |
| api_key: для отдельного вызова | Заменяет токен для этого одного запроса, а вызываемый объект не вызывается. |
| client.mode | С токеном всегда "live". |
| OpenEmail.create_temp_mail | Не отправляет никаких учётных данных, что бы ни было в окружении. |
| me.get и me.ping | Для токена get отвечает с object, равным oauth_token, с id и roleId, равными nil, с clientId подключённого приложения и с expiresAt, моментом, когда истекает согласие человека на это приложение. ping отвечает с kind, равным oauth, с keyId, равным nil, и с clientId. Проверяйте object или kind, прежде чем читать id или keyId. |
Токен действует от имени человека и читает его почту так же, как может он сам, поэтому храните его на сервере, как ключ.
Коды подтверждения
Перед чувствительным изменением, например удалением домена или изменением вебхука, API запрашивает у токена доступа код подтверждения, который веб-приложение запросило бы у человека. Вызов выбрасывает OpenEmail::PermissionError, ошибку 403, у которой step_up_required? равно true, и ничего не изменилось. Запросите код, проверьте тот, который даст вам человек, затем повторите вызов. У API-ключа код никогда не запрашивается.
domain_id = "b3e1f0a4-6c2d-4e8a-9f17-2d5c8a0b4e6f" begin client.domains.delete(domain_id)rescue OpenEmail::ApiError => error raise unless error.step_up_required? challenge = client.security.begin_step_up if challenge[:method] == "email" puts "Enter the code we emailed to #{challenge[:sentTo]}" else puts "Enter the code from your authenticator app, or a backup code" end client.security.verify_step_up(code: $stdin.gets.to_s.strip) client.domains.delete(domain_id)end| Метод | Что делает |
|---|---|
| security.step_up_status | Подтверждено ли приложение прямо сейчас (elevated, elevatedUntil), как будет проверяться следующий код (method, email или totp) и minutes, длина окна. Ничего не отправляет и не сообщает о паузе. |
| security.begin_step_up | Открывает проверку. С email шестизначный код уходит на адрес, с которым человек входит в систему, а sentTo показывает этот адрес в замаскированном виде. С totp человек берёт код из приложения-аутентификатора или использует резервный код. Проверка, которая ещё открыта и у которой остались попытки, переиспользуется, если вы не передадите resend: true, а заблокированная или истёкшая заменяется обычным вызовом. Каждое приложение может открыть 5 проверок в час и 20 за 24 часа для каждого человека, а следующая выбрасывает 429 step_up_throttled. |
| security.verify_step_up(code:) | Проверяет код и разблокирует чувствительные изменения для этого приложения на 60 минут, до elevatedUntil, через REST и через инструменты MCP, которые вносят те же изменения. После 10 неверных кодов за 24 часа от этого приложения или 20 от всех приложений человека вместе этот вызов и begin_step_up выбрасывают 429 step_up_locked с сообщением, в котором сказано, когда подтверждение снова станет доступно. |
Клиент никогда сам не запрашивает код и не повторяет вызов, и ни один из трёх методов не повторяется автоматически, потому что повтор после потерянного ответа мог бы отправить второе письмо или потратить вторую попытку. Им не нужна область, а API-ключ, вызвавший один из них, получает 400 step_up_not_applicable. OpenEmail::STEP_UP_ERROR_CODES перечисляет все причины, по которым подтверждение может не пройти, а страница ошибок API говорит, что делать в каждом случае.
Уведомление об обновлении
Когда на RubyGems есть более новая версия гема, клиент сообщает об этом один раз за процесс в стандартный поток ошибок строкой вроде ℹ openemail 0.0.2 is available, you are on 0.0.1., за которой следует страница гема. Проверка запускается при сборке первого клиента в фоновом потоке с таймаутом в две секунды и только когда стандартный вывод является терминалом, а неудачная попытка связаться с RubyGems игнорируется.
Проверка идёт через адаптер клиента, поэтому тестовый адаптер может увидеть запрос к RubyGems, когда тесты запускаются в терминале. Собирайте тестовые клиенты с disable_update_notice: true или задайте OPENEMAIL_DISABLE_UPDATE_NOTICE.
Прокси
Адаптер по умолчанию находит прокси с помощью встроенного в Ruby URI#find_proxy, поэтому следует тем же правилам, что и остальная стандартная библиотека: https_proxy или HTTPS_PROXY называет прокси, а no_proxy или NO_PROXY перечисляет хосты, к которым подключение идёт напрямую. Имя пользователя и пароль из URL прокси отправляются самому прокси, а к серверу на этой машине подключение через прокси никогда не идёт.
Соединения используют TLS 1.2 или новее и проверяют сертификат сервера, поэтому для прокси, который инспектирует TLS, его центр сертификации должен быть доверенным для OpenSSL на этой машине.
Тестирование без сети
adapter: заменяет HTTP-слой. Это любой объект, который отвечает на call(request), включая lambda, и возвращает OpenEmail::HttpResponse с status, headers и body. Запрос является OpenEmail::HttpRequest с method, url, headers, body и timeout, поэтому тест может проверить, что именно ушло бы по сети.
requests = [] adapter = lambda do |request| requests << request OpenEmail::HttpResponse.new( status: 200, headers: {"content-type" => "application/json"}, body: JSON.generate({id: "msg_test", status: "sent", replayed: false}) )end test_client = OpenEmail::Client.new(api_key: "oe_test_fake", adapter:, max_retries: 0, disable_update_notice: true) sent = test_client.emails.send(from: "[email protected]", to: "[email protected]", subject: "Hi", text: "Hello") p sent[:status], requests.first.method, requests.first.url, requests.first.headers["Idempotency-Key"]p requests.firstПри выводе запроса его заголовок Authorization показывается как [redacted], поэтому ключ никогда не попадает в журнал тестов.
- Верните статус вне диапазона 2xx с конвертом ошибки API в теле, например
{"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, чтобы получить соответствующий подклассOpenEmail::ApiError. - Выбросьте из
callисключениеTimeout::ErrorилиNet::ReadTimeout, который является его разновидностью, чтобы получитьOpenEmail::NetworkError, у которогоtimeout?равно true. Любой другой StandardError, напримерErrno::ECONNREFUSED, становитсяNetworkError, у которогоtimeout?равно false. NameError,TypeErrorиArgumentError, выброшенные внутри адаптера, считаются ошибками в нём самом. Они выбрасываются без изменений и никогда не повторяются.
Собирайте тестовый клиент с max_retries: 0, когда программируете сбои. Иначе вызов, который безопасно повторять, при статусе, допускающем повтор, или при сетевом сбое выполняется трижды, с настоящими паузами между попытками.