mailinfra

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 22 Imported by: 0

README

MailInfra SDK для Go

Официальный клиент MailInfra — email API для разработчиков.

go get github.com/g7AzaZLO/mailinfra-go
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New() // ключ из MAILINFRA_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:    "hello@mail.example.ru",
		To:      []string{"user@example.com"},
		Subject: "Добро пожаловать!",
		HTML:    "<p>Привет!</p>",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(result.ID, result.Status)
}

Требования: Go 1.22+. Внешних зависимостей нет — только стандартная библиотека, поэтому SDK ничего не тянет в ваш go.sum.

Пакет называется mailinfra, хотя путь импорта заканчивается на mailinfra-go. Если линтер ругается на неочевидное имя — импортируйте явно: import mailinfra "github.com/g7AzaZLO/mailinfra-go".

Что клиент делает за вас

Возможность Подробности
Идемпотентность На каждую отправку — и письма, и батча — ставится Idempotency-Key, и он переиспользуется при повторах — обрыв сети не превращается в два письма
Повторы 429, 408/425, 5xx и сетевые сбои повторяются с экспоненциальным backoff и half-jitter
Rate limit Retry-After уважается как есть; слишком долгий (>60 с) не блокирует поток, а приходит как Error.RetryAfter
Типизированные ошибки errors.Is по ~30 часовым ошибкам, errors.As до *mailinfra.Error с RequestID и Details
Локальная валидация Лимиты получателей, тегов, темы, вложений, Metadata и TTL проверяются до сетевого вызова; совпадение со спекой проверяет тест
Отложенная отправка SendAt в письме, Cancel и Reschedule до отправки
Пагинация ListAll обходит все письма выборки, сам следуя за курсором
Вебхуки ConstructWebhookEvent — проверка HMAC-подписи (с окном ротации секрета) и разбор события

Аутентификация

mailinfra.New(mailinfra.WithAPIKey("mi_live_…"))              // явно
mailinfra.New()                                                // из MAILINFRA_API_KEY
mailinfra.New(mailinfra.WithRequireEnvironment(mailinfra.EnvironmentLive)) // упасть на тестовом ключе

mi_live_… отправляет письма по-настоящему. mi_test_… — sandbox: письмо сохраняется со статусом SIMULATED, не доставляется и не расходует месячный объём плана. Окружение доступно как client.Environment().

База API меняется через WithBaseURL или переменную MAILINFRA_BASE_URL.

Client потокобезопасен: держите один на приложение.

Отправка

Имена полей письма совпадают с API по смыслу, а в JSON уходят ровно как в спеке (from, reply_to, content_base64).

По шаблону
result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:     "hello@mail.example.ru",
	To:       []string{"user@example.com"},
	Template: "order-confirmed",
	Variables: map[string]any{
		"name":  "Аня",
		"order": "A-1024",
	},
})
С вложением
invoice, err := os.ReadFile("invoice.pdf")
if err != nil {
	return err
}

result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:    "billing@mail.example.ru",
	To:      []string{"user@example.com"},
	Subject: "Счёт",
	Text:    "Счёт во вложении.",
	Attachments: []mailinfra.Attachment{{
		Filename:    "invoice.pdf",
		Content:     invoice, // SDK закодирует в base64
		ContentType: "application/pdf",
	}},
})

Если base64 уже готов — положите его в ContentBase64 вместо Content.

Inline-картинки

Вложение с ContentID встраивается в письмо, а не прикладывается файлом; HTML ссылается на него как cid:<ContentID>:

logo, err := os.ReadFile("logo.png")
if err != nil {
	return err
}

result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:    "hello@mail.example.ru",
	To:      []string{"user@example.com"},
	Subject: "Добро пожаловать!",
	HTML:    `<img src="cid:logo" alt="Логотип"><p>Привет!</p>`,
	Attachments: []mailinfra.Attachment{{
		Filename:    "logo.png",
		Content:     logo,
		ContentType: "image/png",
		ContentID:   "logo",
	}},
})
Metadata

Свои пары «ключ — значение» (номер заказа, id пользователя) на доставку не влияют, а возвращаются в Get и List (поле Metadata письма) и в каждом вебхуке письма (event.EmailMetadata()) — по ним событие связывается с вашей системой:

result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:     "billing@mail.example.ru",
	To:       []string{"user@example.com"},
	Subject:  "Счёт",
	Text:     "Счёт во вложении.",
	Metadata: map[string]string{"order_id": "A-1024", "user_id": "42"},
})

До 20 ключей длиной до 40 символов из латиницы, цифр, _, - и .; значения — строки до 500 символов без NUL; весь объект — до 4096 байт JSON. SDK проверяет это до сетевого вызова (ErrInvalidRequest с кодом invalid_request).

Отложенная отправка
result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:    "hello@mail.example.ru",
	To:      []string{"user@example.com"},
	Subject: "Напоминание об оплате",
	Text:    "Заказ A-1024 ждёт оплаты.",
	SendAt:  time.Now().Add(24 * time.Hour),
})
// result.Status == mailinfra.EmailStatusScheduled

// Пользователь оплатил — напоминание не нужно.
canceled, err := client.Emails.Cancel(ctx, result.ID) // canceled.Status == CANCELED

// Или перенести на другое время.
moved, err := client.Emails.Reschedule(ctx, result.ID, time.Now().Add(48*time.Hour))

SendAt уходит в API в RFC 3339 с часовым поясом. Горизонт — 7 дней от приёма письма (дальше — ErrSendAtTooFar), и при переносе он тоже считается от приёма, а не от переноса. Время в прошлом — отправить сразу. Отменить или перенести можно только письмо в статусе SCHEDULED; ушедшее в очередь, отправленное или уже отменённое отвечает ErrEmailNotScheduled (текущий статус — в Details["status"]). Отмена возвращает резерв месячного объёма; обеим операциям нужен ключ с правом отправки.

Срок жизни письма
result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:    "auth@mail.example.ru",
	To:      []string{"user@example.com"},
	Subject: "Код входа",
	Text:    "Ваш код: 4821",
	TTL:     10 * time.Minute,
})

TTL — от mailinfra.MinTTL (минута) до mailinfra.MaxTTL (сутки), в целых секундах; отсчёт от SendAt или от приёма. Не дошедшее за это время письмо не отправляется и не повторяется: статус FAILED с причиной expired или отказ с категорией BounceDeliveryExpired. Для кодов входа и других писем, которые поздно уже не нужны.

Свой ключ идемпотентности
result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:           "hello@mail.example.ru",
	To:             []string{"user@example.com"},
	Subject:        "Заказ A-1024 оплачен",
	HTML:           "<p>Спасибо!</p>",
	IdempotencyKey: "order-A-1024-paid", // повтор вернёт то же письмо
})

if result.Replayed {
	log.Println("письмо уже было отправлено раньше, дубля нет")
}

Без IdempotencyKey клиент генерирует UUID сам — на каждый вызов новый. Отправить вообще без ключа можно через WithoutIdempotencyKey: true, но тогда бэкенду нечем распознать повтор, и SDK перестанет ретраить этот запрос после 5xx и таймаутов. Бэкенд хранит ключ 24 часа: повтор с тем же ключом позже создаст новое письмо.

Пакетная отправка
batch, err := client.Emails.SendBatch(ctx, []mailinfra.SendParams{
	{From: "hello@mail.example.ru", To: []string{"a@example.com"}, Subject: "Раз", Text: "1"},
	{From: "hello@mail.example.ru", To: []string{"b@example.com"}, Subject: "Два", Text: "2"},
})
if err != nil {
	return err
}

// Батч возвращает 202, даже если часть писем не принята.
for _, item := range batch.Results {
	if item.Error != nil {
		log.Printf("письмо #%d отклонено: %s", item.Index, item.Error.Code)
	}
}

До 100 писем за раз. Idempotency-Key у батча один на запрос, и SDK ставит его сам, как у одиночной отправки: бэкенд выводит из него ключ каждого письма, поэтому повтор после 5xx, таймаута и обрыва соединения не дублирует уже принятые письма, а отклонённые пробует принять заново. Свой ключ — опция WithBatchIdempotencyKey:

batch, err := client.Emails.SendBatch(ctx, emails,
	mailinfra.WithBatchIdempotencyKey("digest-2026-10-28")) // до 252 символов

if batch.Replayed {
	log.Println("батч уже был принят раньше, дублей нет")
}

Replayed истинно, когда все принятые письма батча оказались повтором (ответ 200 с Idempotent-Replayed: true). Ключ у отдельного письма батча (SendParams.IdempotencyKey) не задаётся — SDK вернёт ошибку. Без ключа (WithoutBatchIdempotencyKey() или WithAutoIdempotency(false)) батч повторяется только на 429: лимитёр отказывает до приёма писем, а повтор после 5xx мог бы отправить часть писем дважды.

Чтение

email, err := client.Emails.Get(ctx, emailID)

page, err := client.Emails.List(ctx, mailinfra.ListParams{
	Status: mailinfra.EmailStatusBounced,
	Tag:    "order",
	From:   time.Now().Add(-24 * time.Hour),
	Limit:  50,
})
// page.Data — письма, page.HasMore и page.NextCursor — следующая страница

events, err := client.Emails.Events(ctx, emailID)

From и To в ListParams — это интервал по времени создания письма, а не адреса; адреса фильтруются полями FromEmail и ToEmail. Время уходит в API в UTC.

Все письма выборки

List отдаёт одну страницу, новые письма сначала. Следующая — тот же вызов с Cursor: page.NextCursor, пока page.HasMore. Обойти выборку целиком проще через ListAll — за курсором он следит сам и запрашивает страницы по мере чтения:

pager := client.Emails.ListAll(mailinfra.ListParams{
	Status: mailinfra.EmailStatusBounced,
	Limit:  100, // размер страницы, а не потолок обхода
})
for pager.Next(ctx) {
	email := pager.Email()
	log.Println(email.ID, email.Metadata["order_id"])
}
if err := pager.Err(); err != nil {
	return err
}

Интерфейс — как у bufio.Scanner: Next подгружает страницы, Err после цикла говорит, кончились письма или запрос страницы не удался. Курсор непрозрачен: сохранить его и продолжить обход позже можно, собирать или менять — нет.

Ошибки

Ветвиться по причине отказа надо через errors.Is, а метаданные для логов брать через errors.As:

result, err := client.Emails.Send(ctx, params)
switch {
case err == nil:
	// принято

case errors.Is(err, mailinfra.ErrDomainNotVerified):
	// домен отправителя ещё не подтверждён

case errors.Is(err, mailinfra.ErrRateLimit):
	var apiErr *mailinfra.Error
	errors.As(err, &apiErr)
	log.Printf("лимит, повторить через %s", apiErr.RetryAfter)

case errors.Is(err, mailinfra.ErrUnprocessable):
	// вся категория 422 разом
	var apiErr *mailinfra.Error
	errors.As(err, &apiErr)
	for _, field := range apiErr.Fields() {
		log.Printf("%s: %s", field.Field, field.Message)
	}

default:
	return err
}

Три уровня ловли:

  • *mailinfra.Error через errors.As — всё, что вернул API, вместе с RequestID;
  • категория статуса: ErrBadRequest, ErrAuthentication, ErrPermissionDenied, ErrNotFound, ErrConflict, ErrUnprocessable, ErrRateLimit, ErrServer, ErrConnection, ErrTimeout;
  • конкретная причина: ErrDomainNotVerified, ErrDomainNotAttached, ErrAllRecipientsSuppressed, ErrTemplateRender, ErrTemplateVariableMissing, ErrReservedEmailHeader, ErrSandboxRecipientNotAllowed, ErrSandboxTrialExhausted, ErrIdempotencyInFlight, ErrLegalDocumentsNotAccepted, ErrEmailNotScheduled, ErrSendAtTooFar, ErrSendAtInvalid, ErrIPNotAllowed (ключ использован с IP-адреса вне его белого списка).

Конкретная причина всегда ловится и своей категорией: errors.Is(err, mailinfra.ErrUnprocessable) истинно и для ErrDomainNotVerified. Неизвестный код с бэкенда деградирует до категории по статусу — новый код не ломает старый SDK.

Отмена контекста приходит как context.Canceled / context.DeadlineExceeded, а не как ошибка SDK: повторять её нечего.

Настройка повторов

client, err := mailinfra.New(
	mailinfra.WithMaxRetries(4),
	mailinfra.WithRetryPolicy(mailinfra.RetryPolicy{
		MaxRetries:     4,
		InitialBackoff: 200 * time.Millisecond,
		MaxBackoff:     5 * time.Second,
		MaxRetryAfter:  30 * time.Second, // дольше — не ждём, отдаём ошибку
	}),
)

Незаполненные поля RetryPolicy заменяются значениями по умолчанию, поэтому менять можно только нужное (незаданный MaxRetries — тоже 2 повтора). WithMaxRetries(0) после WithRetryPolicy отключает повторы.

Таймаут одной попытки — WithTimeout; по умолчанию 30 с. Свой *http.Client (прокси, пул, mTLS) подставляется через WithHTTPClient, и тогда SDK не ставит собственный дедлайн, пока вы не попросили его явно.

Вебхуки

func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "", http.StatusBadRequest)
		return
	}

	event, err := mailinfra.ConstructWebhookEvent(
		body,
		os.Getenv("MAILINFRA_WEBHOOK_SECRET"),
		r.Header.Get(mailinfra.WebhookSignatureHeader),
	)
	if err != nil {
		// Причина — в поле Reason: invalid_secret, malformed_header, too_old,
		// signature_mismatch, invalid_json, invalid_payload.
		var signatureErr *mailinfra.WebhookSignatureError
		if errors.As(err, &signatureErr) {
			log.Printf("подпись отклонена: %s", signatureErr.Reason)
		}
		http.Error(w, "", http.StatusBadRequest)
		return
	}

	switch event.Type {
	case "email.bounced":
		log.Println("отбито:", event.EmailID(), "заказ:", event.EmailMetadata()["order_id"])
	case "email.delivered":
		log.Println("доставлено:", event.EmailID())
	}
	w.WriteHeader(http.StatusOK)
}

Тело обязательно брать до разбора JSON: пересериализованный JSON даст другие байты и подпись не сойдётся. Подписи старше 5 минут отклоняются (WithWebhookMaxAge меняет окно, 0 отключает проверку).

Типы событий письма — email. плюс значение EmailEventType: email.delivered, email.bounced, email.scheduled, email.canceled и другие. event.EmailMetadata() возвращает Metadata письма из data.email.metadata.

Во время ротации секрета MailInfra ставит в заголовок две подписи — t=<unix>,v1=<новым секретом>,v1=<прежним>, — и SDK принимает доставку, если совпала любая. Поэтому обработчик со старым секретом продолжает работать, пока вы не переложили новый в MAILINFRA_WEBHOOK_SECRET.

Что пока не покрыто

v1 покрывает публичный API писем (/v1/emails*) — всё, что доступно по API-ключу проекта. Управление доменами, шаблонами, списком подавления, вебхуками и ключами — это Management API на сессионном токене пользователя; для машинной интеграции у него пока нет подходящего credential, поэтому в SDK этих ресурсов нет. Следите за CHANGELOG.

Ссылки

Лицензия

MIT

Documentation

Overview

Package mailinfra — официальный Go SDK MailInfra.

Быстрый старт:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New() // ключ из MAILINFRA_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:    "hello@mail.example.ru",
		To:      []string{"user@example.com"},
		Subject: "Добро пожаловать!",
		HTML:    "<p>Привет!</p>",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(result.ID, result.Status)
}

Что клиент делает за вас:

  • ставит Idempotency-Key на каждую отправку — и письма, и батча — и переиспользует его при повторах: сбой сети не превращается в два письма;
  • повторяет 429/5xx/таймауты с экспоненциальным backoff и уважает Retry-After;
  • отдаёт ошибки API часовыми значениями для errors.Is (ErrRateLimit, ErrDomainNotVerified, …) и структурой *Error для errors.As;
  • проверяет ограничения письма (в том числе Metadata и TTL) локально до сетевого вызова;
  • обходит все письма выборки по курсору (ListAll);
  • проверяет подписи вебхуков (ConstructWebhookEvent), в том числе во время ротации секрета.

Покрытие v1 — публичный API писем (/v1/emails*). Управление доменами, шаблонами, вебхуками и ключами — Management API на сессионном токене, оно в SDK пока не входит.

Ошибки

Ветвиться по причине отказа надо через errors.Is, а метаданные для логов брать через errors.As:

result, err := client.Emails.Send(ctx, params)
switch {
case err == nil:
	// принято
case errors.Is(err, mailinfra.ErrDomainNotVerified):
	// домен отправителя ещё не подтверждён
case errors.Is(err, mailinfra.ErrRateLimit):
	var apiErr *mailinfra.Error
	errors.As(err, &apiErr)
	// apiErr.RetryAfter — сколько просил подождать сервер
default:
	return err
}

Отложенная отправка

Письмо с SendParams.SendAt получает статус SCHEDULED и уходит в заданный момент (не дальше 7 дней от приёма; время в прошлом — сразу). До отправки его можно отменить или перенести; TTL ограничивает срок жизни письма, а Metadata возвращается в Get, List и каждом вебхуке письма:

result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:     "hello@mail.example.ru",
	To:       []string{"user@example.com"},
	Subject:  "Напоминание об оплате",
	Text:     "Заказ A-1024 ждёт оплаты.",
	Metadata: map[string]string{"order_id": "A-1024"},
	SendAt:   time.Now().Add(24 * time.Hour),
	TTL:      2 * time.Hour,
})
…
// Напоминание больше не нужно…
_, err = client.Emails.Cancel(ctx, result.ID)
// …или нужно, но позже.
_, err = client.Emails.Reschedule(ctx, result.ID, time.Now().Add(48*time.Hour))

На письмо, которое уже не запланировано, обе операции отвечают ErrEmailNotScheduled.

Inline-картинка — вложение с Attachment.ContentID, на которое HTML ссылается как cid:<ContentID>.

Пагинация

List отдаёт одну страницу писем (EmailPage) с курсором следующей. Обойти выборку целиком проще через ListAll — он сам запрашивает страницы по курсору:

pager := client.Emails.ListAll(mailinfra.ListParams{Status: mailinfra.EmailStatusBounced})
for pager.Next(ctx) {
	email := pager.Email()
	…
}
if err := pager.Err(); err != nil {
	return err
}

Пакетная отправка

SendBatch ставит один Idempotency-Key на батч, и бэкенд выводит из него ключ каждого письма: повтор после сбоя не дублирует уже принятые письма. Свой ключ — WithBatchIdempotencyKey, отказ от ключа — WithoutBatchIdempotencyKey; признак идемпотентного повтора — BatchResult.Replayed.

Вебхуки

Во время ротации секрета в заголовке X-MailInfra-Signature две подписи v1 — новым секретом и прежним; ConstructWebhookEvent и VerifyWebhookSignature принимают доставку, если совпала любая из них.

Потокобезопасность

Client можно держать один на приложение и вызывать из нескольких горутин: состояние запроса в нём не хранится.

Index

Examples

Constants

View Source
const (
	// APIKeyEnvVar — переменная окружения с API-ключом проекта.
	APIKeyEnvVar = "MAILINFRA_API_KEY"
	// BaseURLEnvVar — переменная окружения с базой API.
	BaseURLEnvVar = "MAILINFRA_BASE_URL"
)

Переменные окружения, из которых клиент берёт настройки, если их не передали явно.

View Source
const (
	// MaxRecipients — потолок адресов в каждом из полей To, CC и BCC.
	MaxRecipients = 50
	// MaxReplyTo — потолок адресов в Reply-To.
	MaxReplyTo = 10
	// MaxTags — потолок тегов письма.
	MaxTags = 10
	// MaxSubjectLength — потолок длины темы.
	MaxSubjectLength = 998
	// MaxFromNameLength — потолок длины отображаемого имени отправителя.
	MaxFromNameLength = 200
	// MaxTemplateLength — потолок длины слага шаблона.
	MaxTemplateLength = 100
	// MaxAttachments — потолок числа вложений в письме.
	MaxAttachments = 10
	// MaxAttachmentBase64Length — потолок длины base64-содержимого вложения.
	MaxAttachmentBase64Length = 14348938
	// MaxFilenameLength — потолок длины имени файла вложения.
	MaxFilenameLength = 255
	// MaxContentTypeLength — потолок длины MIME-типа вложения.
	MaxContentTypeLength = 255
	// MaxContentIDLength — потолок длины Content-ID inline-вложения: 255
	// символов и пара внешних угловых скобок.
	MaxContentIDLength = 257
	// MaxBatchSize — потолок числа писем в одном батче.
	MaxBatchSize = 100
	// IdempotencyKeyMaxLength — потолок длины Idempotency-Key (бэкенд
	// отклоняет длиннее с 422).
	IdempotencyKeyMaxLength = 255
	// BatchIdempotencyKeyMaxLength — потолок длины Idempotency-Key батча: из
	// 255 символов ключа письма бэкенд берёт до трёх под номер письма в батче.
	BatchIdempotencyKeyMaxLength = 252
)

Ограничения запроса, зафиксированные в https://docs.mailinfra.ru/openapi/mailinfra-public.json.

Проверяем их на клиенте, чтобы не тратить запрос: API вернул бы 422, но батч с вложениями уезжает целиком (до ~25 МиБ на письмо) только чтобы вернуться с ошибкой. Совпадение констант со спекой проверяет spec_parity_test.go.

View Source
const (
	// MinTTL — самый короткий срок жизни письма.
	MinTTL = time.Minute
	// MaxTTL — самый длинный срок жизни письма.
	MaxTTL = 24 * time.Hour
)

Срок жизни письма (SendParams.TTL) — тоже из спеки.

View Source
const (
	// MaxMetadataKeys — потолок числа ключей в Metadata.
	MaxMetadataKeys = 20
	// MaxMetadataKeyLength — потолок длины ключа Metadata.
	MaxMetadataKeyLength = 40
	// MaxMetadataValueLength — потолок длины значения Metadata в символах.
	MaxMetadataValueLength = 500
	// MaxMetadataBytes — потолок размера Metadata целиком: байты UTF-8 его
	// JSON-представления.
	MaxMetadataBytes = 4096
)

Ограничения metadata письма.

View Source
const (
	// WebhookSignatureHeader — заголовок с подписью доставки.
	WebhookSignatureHeader = "X-MailInfra-Signature"
	// WebhookEventIDHeader — заголовок с идентификатором события; удобен для
	// дедупликации доставок на вашей стороне.
	WebhookEventIDHeader = "X-MailInfra-Event-Id"
	// DefaultWebhookMaxAge — допустимый возраст подписи.
	//
	// Бэкенд отклоняет подписи старше 5 минут (SIGNATURE_MAX_AGE_SEC), держим
	// то же окно, чтобы поведение получателя совпадало с ожиданиями отправителя.
	DefaultWebhookMaxAge = 5 * time.Minute
)
View Source
const (
	// WebhookReasonInvalidSecret — secret пуст.
	WebhookReasonInvalidSecret = "invalid_secret"
	// WebhookReasonMalformedHeader — заголовок подписи не разобран.
	WebhookReasonMalformedHeader = "malformed_header"
	// WebhookReasonTooOld — подпись старше допустимого возраста (возможен replay).
	WebhookReasonTooOld = "too_old"
	// WebhookReasonSignatureMismatch — подпись не совпала с телом.
	WebhookReasonSignatureMismatch = "signature_mismatch"
	// WebhookReasonInvalidJSON — тело не является JSON-объектом.
	WebhookReasonInvalidJSON = "invalid_json"
	// WebhookReasonInvalidPayload — событие пришло не по контракту.
	WebhookReasonInvalidPayload = "invalid_payload"
)

Причины отказа (поле Reason у WebhookSignatureError).

View Source
const DefaultBaseURL = "https://app.mailinfra.ru/v1"

DefaultBaseURL — продакшен-база Public API.

View Source
const DefaultTimeout = 30 * time.Second

DefaultTimeout — таймаут одного запроса, если вызывающий не задал свой *http.Client.

View Source
const Version = "0.2.0"

Version — версия SDK; уезжает в User-Agent каждого запроса.

Тег релиза в github.com/g7AzaZLO/mailinfra-go берётся отсюда: его ставит sdk/scripts/release-mirror.sh в монорепозитории MailInfra.

Variables

View Source
var (
	// ErrConnection — не удалось получить ответ: DNS, TCP, TLS, обрыв соединения.
	ErrConnection = errors.New("mailinfra: не удалось соединиться с API")
	// ErrTimeout — запрос не успел уложиться в таймаут. Вкладывается в
	// ErrConnection: ответа не было в обоих случаях.
	ErrTimeout = errors.New("mailinfra: истёк таймаут запроса")
	// ErrBadRequest — 400, запрос некорректен.
	ErrBadRequest = errors.New("mailinfra: некорректный запрос")
	// ErrAuthentication — 401, API-ключ отсутствует, невалиден или отозван.
	ErrAuthentication = errors.New("mailinfra: ошибка аутентификации")
	// ErrPermissionDenied — 403, у ключа нет прав на операцию: ключ выпущен с
	// правами READ_ONLY (запрещены отправка, отмена и перенос) или
	// SENDING_ONLY (запрещено чтение), либо запрос пришёл с IP-адреса вне
	// белого списка ключа (ErrIPNotAllowed).
	ErrPermissionDenied = errors.New("mailinfra: недостаточно прав")
	// ErrNotFound — 404, ресурс не найден или принадлежит другому проекту.
	ErrNotFound = errors.New("mailinfra: ресурс не найден")
	// ErrConflict — 409, конфликт состояния.
	ErrConflict = errors.New("mailinfra: конфликт состояния")
	// ErrUnprocessable — 422, не прошла валидация полей.
	ErrUnprocessable = errors.New("mailinfra: запрос не прошёл валидацию")
	// ErrRateLimit — 429, исчерпан лимит отправки: месячный объём или
	// дневной потолок организации.
	ErrRateLimit = errors.New("mailinfra: превышен лимит запросов")
	// ErrServer — 5xx, ошибка на стороне MailInfra. Такие запросы SDK ретраит сам.
	ErrServer = errors.New("mailinfra: ошибка на стороне API")
	// ErrAPI — ответ по статусу, для которого отдельной категории нет.
	ErrAPI = errors.New("mailinfra: ошибка API")
)

Часовые ошибки категорий — по HTTP-статусу ответа.

View Source
var (
	// ErrDomainNotVerified — домен отправителя ещё не подтверждён (DNS-записи не сошлись).
	ErrDomainNotVerified = errors.New("mailinfra: домен не подтверждён")
	// ErrDomainNotAttached — домен не привязан к проекту, которому принадлежит ключ.
	ErrDomainNotAttached = errors.New("mailinfra: домен не привязан к проекту")
	// ErrAllRecipientsSuppressed — все получатели в списке подавления, отправлять некому.
	ErrAllRecipientsSuppressed = errors.New("mailinfra: все получатели в списке подавления")
	// ErrSandboxRecipientNotAllowed — пробное письмо с sandbox-домена (LIVE-ключ) уходит только владельцу ключа.
	ErrSandboxRecipientNotAllowed = errors.New("mailinfra: пробное письмо с sandbox-домена — только владельцу ключа")
	// ErrSandboxTrialExhausted — пробная sandbox-отправка уже израсходована; нужен свой домен.
	ErrSandboxTrialExhausted = errors.New("mailinfra: пробная sandbox-отправка израсходована")
	// ErrTemplateRender — шаблон не отрендерился.
	ErrTemplateRender = errors.New("mailinfra: шаблон не отрендерился")
	// ErrTemplateVariableMissing — в variables не хватает переменной, которую требует шаблон.
	ErrTemplateVariableMissing = errors.New("mailinfra: не хватает переменной шаблона")
	// ErrReservedEmailHeader — попытка переопределить системный заголовок письма.
	ErrReservedEmailHeader = errors.New("mailinfra: системный заголовок письма нельзя переопределить")
	// ErrIdempotencyInFlight — запрос с таким Idempotency-Key ещё выполняется, повторите позже.
	ErrIdempotencyInFlight = errors.New("mailinfra: запрос с этим Idempotency-Key ещё выполняется")
	// ErrLegalDocumentsNotAccepted — не приняты обязательные юридические документы;
	// список ждущих согласия — в Details["pending"].
	ErrLegalDocumentsNotAccepted = errors.New("mailinfra: не приняты юридические документы")
	// ErrEmailNotScheduled — письмо уже не запланировано (отправлено,
	// отправляется или отменено), поэтому отменить или перенести его нельзя;
	// текущий статус — в Details["status"].
	ErrEmailNotScheduled = errors.New("mailinfra: письмо уже не запланировано")
	// ErrSendAtTooFar — время отправки дальше 7 дней от приёма письма; горизонт
	// в днях — в Details["max_days"].
	ErrSendAtTooFar = errors.New("mailinfra: время отправки дальше допустимого горизонта")
	// ErrSendAtInvalid — время отправки без часового пояса.
	ErrSendAtInvalid = errors.New("mailinfra: время отправки без часового пояса")
	// ErrIPNotAllowed — API-ключ использован с IP-адреса вне его белого списка.
	ErrIPNotAllowed = errors.New("mailinfra: API-ключ нельзя использовать с этого IP-адреса")
)

Часовые ошибки конкретных причин — по `error.code` из ответа.

View Source
var (
	// ErrInvalidRequest — параметры вызова не проходят ограничения спеки.
	ErrInvalidRequest = errors.New("mailinfra: некорректные параметры запроса")
	// ErrMalformedResponse — ответ успешен, но не по контракту.
	ErrMalformedResponse = errors.New("mailinfra: ответ API не по контракту")
)

Часовые ошибки клиентской валидации — запрос до сети не доходит.

View Source
var ErrWebhookSignature = errors.New("mailinfra: подпись вебхука не прошла проверку")

ErrWebhookSignature — часовая ошибка для errors.Is: подпись не прошла проверку. Конкретная причина — в поле Reason у *WebhookSignatureError.

Functions

func SignWebhookPayload

func SignWebhookPayload(body []byte, secret string, timestamp int64) string

SignWebhookPayload считает hex-подпись тела — та же функция, что на бэкенде.

Нужна в первую очередь тестам и локальной отладке обработчика: позволяет собрать корректный X-MailInfra-Signature без реального вебхука.

func VerifyWebhookSignature

func VerifyWebhookSignature(body []byte, secret, signatureHeader string, opts ...WebhookOption) bool

VerifyWebhookSignature проверяет подпись, не возвращая ошибки.

Удобно, когда причина отказа неважна и нужен только ответ «пускать или нет». Если причина нужна для логов — берите ConstructWebhookEvent.

Types

type Attachment

type Attachment struct {
	// Filename — имя файла в письме.
	Filename string
	// Content — сырые байты файла.
	Content []byte
	// ContentBase64 — готовая base64-строка (альтернатива Content).
	ContentBase64 string
	// ContentType — MIME-тип; пустая строка означает application/octet-stream.
	ContentType string
	// ContentID — Content-ID inline-картинки (до 255 символов, внешние угловые
	// скобки допустимы); пустая строка — обычное вложение.
	ContentID string
}

Attachment — вложение письма.

Передайте Content сырыми байтами, и SDK сам закодирует их в base64; если base64 уже готов — положите его в ContentBase64. Указывать оба поля нельзя.

С ContentID вложение становится inline-картинкой: оно встраивается в письмо, а не прикладывается файлом, и HTML ссылается на него как <img src="cid:<ContentID>">.

type BatchOption added in v0.2.0

type BatchOption func(*batchOptions)

BatchOption — настройка одного вызова SendBatch.

func WithBatchIdempotencyKey added in v0.2.0

func WithBatchIdempotencyKey(key string) BatchOption

WithBatchIdempotencyKey задаёт свой Idempotency-Key батча вместо сгенерированного SDK — например, привязанный к бизнес-событию, чтобы и повторный вызов SendBatch не дублировал уже принятые письма. Не длиннее BatchIdempotencyKeyMaxLength.

func WithoutBatchIdempotencyKey added in v0.2.0

func WithoutBatchIdempotencyKey() BatchOption

WithoutBatchIdempotencyKey отправляет батч вообще без Idempotency-Key.

Осознанный отказ от защиты: повтор после 5xx, таймаута или обрыва соединения мог бы отправить часть писем дважды, поэтому SDK повторяет такой батч только на 429.

type BatchResult added in v0.2.0

type BatchResult struct {
	BatchSendResponseData

	// Replayed — true, если это идемпотентный повтор: все принятые письма
	// батча уже были приняты предыдущим запросом с тем же Idempotency-Key,
	// повторной отправки не произошло (ответ 200 + заголовок
	// Idempotent-Replayed).
	Replayed bool
	// IdempotencyKey — ключ, с которым ушёл батч: сгенерированный SDK или
	// переданный через WithBatchIdempotencyKey; пустая строка — батч ушёл без
	// ключа. Повтор батча с этим ключом не дублирует уже принятые письма.
	IdempotencyKey string
	// RequestID — идентификатор запроса для обращений в поддержку.
	RequestID string
}

BatchResult — результат SendBatch: ответ API вместе с метаданными запроса.

type BatchSendError

type BatchSendError struct {
	StatusCode int            `json:"status_code"`
	Code       string         `json:"code"`
	Message    string         `json:"message"`
	Details    map[string]any `json:"details"`
}

BatchSendError — ошибка одного письма внутри батча.

type BatchSendResponseData

type BatchSendResponseData struct {
	Results       []BatchSendResult `json:"results"`
	AcceptedCount int               `json:"accepted_count"`
	FailedCount   int               `json:"failed_count"`
}

BatchSendResponseData — результат пакетной отправки.

type BatchSendResult

type BatchSendResult struct {
	// Index — позиция письма в исходном списке.
	Index int `json:"index"`
	// ID — идентификатор принятого письма; nil, если письмо отклонено.
	ID *string `json:"id"`
	// Status — статус принятого письма; nil, если письмо отклонено.
	Status *EmailStatus `json:"status"`
	// SkippedRecipients — отброшенные получатели с причиной в значении.
	SkippedRecipients []map[string]string `json:"skipped_recipients"`
	// Error — причина отказа; nil, если письмо принято.
	Error *BatchSendError `json:"error"`
}

BatchSendResult — результат одного письма внутри батча.

type BounceCategory

type BounceCategory string

BounceCategory — причина недоставки.

const (
	BounceInvalidRecipient    BounceCategory = "invalid-recipient"
	BounceMailboxFull         BounceCategory = "mailbox-full"
	BounceMessageTooLarge     BounceCategory = "message-too-large"
	BounceContentSpamBlock    BounceCategory = "content-spam-block"
	BouncePolicyRejection     BounceCategory = "policy-rejection"
	BounceReputationBlocklist BounceCategory = "reputation-blocklist"
	BounceGreylisting         BounceCategory = "greylisting"
	BounceDNSMXFailure        BounceCategory = "dns-mx-failure"
	BounceConnectionError     BounceCategory = "connection-error"
	BounceRateLimited         BounceCategory = "rate-limited"
	BounceAuthFailure         BounceCategory = "auth-failure"
	// BounceDeliveryExpired — письмо не доставлено за SendParams.TTL и больше
	// не повторяется.
	BounceDeliveryExpired BounceCategory = "delivery-expired"
	BounceUnknown         BounceCategory = "unknown"
)

Значения BounceCategory.

type BounceType

type BounceType string

BounceType — характер недоставки: постоянная или временная.

const (
	// BounceHard — постоянный отказ: адрес в suppression после первого же случая.
	BounceHard BounceType = "hard"
	// BounceSoft — временный отказ: имеет смысл повторить позже.
	BounceSoft BounceType = "soft"
)

Значения BounceType.

type Client

type Client struct {
	// Emails — письма: Send, SendBatch, Get, List, ListAll, Events, Cancel,
	// Reschedule.
	Emails *EmailsService
	// contains filtered or unexported fields
}

Client — клиент Public API MailInfra.

Создаётся через New и безопасен для параллельного использования: состояние запроса в нём не хранится.

client, err := mailinfra.New() // ключ из MAILINFRA_API_KEY
if err != nil {
	return err
}
result, err := client.Emails.Send(ctx, mailinfra.SendParams{
	From:    "hello@mail.example.ru",
	To:      []string{"user@example.com"},
	Subject: "Привет",
	HTML:    "<p>Привет!</p>",
})
Example (Send)
package main

import (
	"context"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New() // ключ из MAILINFRA_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:    "hello@mail.example.ru",
		To:      []string{"user@example.com"},
		Subject: "Добро пожаловать!",
		HTML:    "<p>Привет!</p>",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(result.ID, result.Status)
}

func New

func New(opts ...Option) (*Client, error)

New создаёт клиент.

Без WithAPIKey ключ берётся из переменной окружения MAILINFRA_API_KEY. Ошибка возвращается сразу, если ключа нет или его префикс не распознан: упасть при создании клиента лучше, чем получить 401 на первой отправке.

func (*Client) AutoIdempotency

func (c *Client) AutoIdempotency() bool

AutoIdempotency сообщает, ставит ли клиент Idempotency-Key сам.

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL возвращает базу API без завершающего слеша.

func (*Client) Environment

func (c *Client) Environment() Environment

Environment возвращает окружение ключа, распознанное по его префиксу.

func (*Client) RetryPolicy

func (c *Client) RetryPolicy() RetryPolicy

RetryPolicy возвращает действующую политику повторов.

type DkimSigningStatus

type DkimSigningStatus string

DkimSigningStatus — что известно про DKIM-подпись письма.

const (
	DkimExpected  DkimSigningStatus = "expected"
	DkimConfirmed DkimSigningStatus = "confirmed"
	DkimFailed    DkimSigningStatus = "failed"
	DkimNotSigned DkimSigningStatus = "not_signed"
)

Значения DkimSigningStatus.

type EmailDetail

type EmailDetail struct {
	ID                string             `json:"id"`
	ProjectID         string             `json:"project_id"`
	FromEmail         string             `json:"from_email"`
	FromName          *string            `json:"from_name"`
	ToEmails          []string           `json:"to_emails"`
	CCEmails          []string           `json:"cc_emails"`
	BCCEmails         []string           `json:"bcc_emails"`
	ReplyTo           []string           `json:"reply_to"`
	Subject           string             `json:"subject"`
	HTMLBody          *string            `json:"html_body"`
	TextBody          *string            `json:"text_body"`
	Headers           map[string]string  `json:"headers"`
	Status            EmailStatus        `json:"status"`
	Source            EmailSource        `json:"source"`
	Tags              []string           `json:"tags"`
	Metadata          map[string]string  `json:"metadata"`
	CreatedAt         time.Time          `json:"created_at"`
	ScheduledAt       *time.Time         `json:"scheduled_at"`
	ExpiresAt         *time.Time         `json:"expires_at"`
	SentAt            *time.Time         `json:"sent_at"`
	DeliveredAt       *time.Time         `json:"delivered_at"`
	FailedAt          *time.Time         `json:"failed_at"`
	MessageID         *string            `json:"message_id"`
	BounceType        *BounceType        `json:"bounce_type"`
	BounceCategory    *BounceCategory    `json:"bounce_category"`
	SuppressionReason *SuppressionReason `json:"suppression_reason"`
	FinalSMTPCode     *int               `json:"final_smtp_code"`
	FinalSMTPEnhanced *string            `json:"final_smtp_enhanced"`
	FinalSMTPText     *string            `json:"final_smtp_text"`
	SPFDomain         *string            `json:"spf_domain"`
	SPFAligned        *bool              `json:"spf_aligned"`
	SPFAuthorizedIP   *bool              `json:"spf_authorized_ip"`
	DkimD             *string            `json:"dkim_d"`
	DkimSelector      *string            `json:"dkim_selector"`
	DkimAligned       *bool              `json:"dkim_aligned"`
	DkimSigningStatus *DkimSigningStatus `json:"dkim_signing_status"`
	DmarcAligned      *bool              `json:"dmarc_aligned"`
}

EmailDetail — письмо с телом, заголовками и диагностикой доставки.

Metadata, ScheduledAt и ExpiresAt — как у EmailListItem.

type EmailEvent

type EmailEvent struct {
	ID        string         `json:"id"`
	Type      EmailEventType `json:"type"`
	Data      map[string]any `json:"data"`
	Timestamp time.Time      `json:"timestamp"`
	IPAddress *string        `json:"ip_address"`
	UserAgent *string        `json:"user_agent"`
}

EmailEvent — событие жизненного цикла письма.

type EmailEventType

type EmailEventType string

EmailEventType — тип события жизненного цикла письма.

const (
	// EmailEventScheduled — письмо запланировано на SendAt; повторяется при
	// переносе.
	EmailEventScheduled     EmailEventType = "scheduled"
	EmailEventQueued        EmailEventType = "queued"
	EmailEventSending       EmailEventType = "sending"
	EmailEventSent          EmailEventType = "sent"
	EmailEventDelivered     EmailEventType = "delivered"
	EmailEventSMTPConnected EmailEventType = "smtp_connected"
	EmailEventOpened        EmailEventType = "opened"
	EmailEventClicked       EmailEventType = "clicked"
	EmailEventBounced       EmailEventType = "bounced"
	EmailEventComplained    EmailEventType = "complained"
	EmailEventFailed        EmailEventType = "failed"
	EmailEventSimulated     EmailEventType = "simulated"
	// EmailEventDeferred — временный отказ сервера получателя, MTA повторит попытку.
	EmailEventDeferred EmailEventType = "deferred"
	// EmailEventCanceled — запланированное письмо отменено до отправки.
	EmailEventCanceled EmailEventType = "canceled"
)

Значения EmailEventType.

type EmailListItem

type EmailListItem struct {
	ID                string             `json:"id"`
	ProjectID         string             `json:"project_id"`
	FromEmail         string             `json:"from_email"`
	ToEmails          []string           `json:"to_emails"`
	Subject           string             `json:"subject"`
	Status            EmailStatus        `json:"status"`
	Source            EmailSource        `json:"source"`
	Tags              []string           `json:"tags"`
	Metadata          map[string]string  `json:"metadata"`
	CreatedAt         time.Time          `json:"created_at"`
	ScheduledAt       *time.Time         `json:"scheduled_at"`
	ExpiresAt         *time.Time         `json:"expires_at"`
	SentAt            *time.Time         `json:"sent_at"`
	DeliveredAt       *time.Time         `json:"delivered_at"`
	MessageID         *string            `json:"message_id"`
	BounceType        *BounceType        `json:"bounce_type"`
	BounceCategory    *BounceCategory    `json:"bounce_category"`
	SuppressionReason *SuppressionReason `json:"suppression_reason"`
	FinalSMTPCode     *int               `json:"final_smtp_code"`
	FinalSMTPEnhanced *string            `json:"final_smtp_enhanced"`
	FinalSMTPText     *string            `json:"final_smtp_text"`
	SPFDomain         *string            `json:"spf_domain"`
	SPFAligned        *bool              `json:"spf_aligned"`
	SPFAuthorizedIP   *bool              `json:"spf_authorized_ip"`
	DkimD             *string            `json:"dkim_d"`
	DkimSelector      *string            `json:"dkim_selector"`
	DkimAligned       *bool              `json:"dkim_aligned"`
	DkimSigningStatus *DkimSigningStatus `json:"dkim_signing_status"`
	DmarcAligned      *bool              `json:"dmarc_aligned"`
}

EmailListItem — письмо в списке.

Metadata — пары из SendParams.Metadata (пустой объект, если их не передавали). ScheduledAt — на когда запланирована отправка, nil у письма без SendAt. ExpiresAt — когда истекает SendParams.TTL, nil у письма без срока жизни.

type EmailPage added in v0.2.0

type EmailPage struct {
	// Data — письма страницы, новые сначала.
	Data []EmailListItem `json:"data"`
	// HasMore — есть ли письма после этой страницы.
	HasMore bool `json:"has_more"`
	// NextCursor — курсор следующей страницы для ListParams.Cursor; пустая
	// строка, если следующей страницы нет.
	NextCursor string `json:"next_cursor"`
}

EmailPage — страница писем: ответ GET /v1/emails.

type EmailPager added in v0.2.0

type EmailPager struct {
	// contains filtered or unexported fields
}

EmailPager обходит все письма выборки, сам запрашивая следующие страницы по курсору. Создаётся через EmailsService.ListAll:

pager := client.Emails.ListAll(mailinfra.ListParams{Status: mailinfra.EmailStatusBounced})
for pager.Next(ctx) {
	email := pager.Email()
	log.Println(email.ID, email.Subject)
}
if err := pager.Err(); err != nil {
	return err
}

Каждая страница — отдельный запрос со своими повторами; ошибка любой из них останавливает обход и возвращается из Err. Pager не безопасен для параллельного использования: одна горутина — один обход.

func (*EmailPager) Email added in v0.2.0

func (p *EmailPager) Email() EmailListItem

Email возвращает письмо, на которое перешёл последний успешный Next.

func (*EmailPager) Err added in v0.2.0

func (p *EmailPager) Err() error

Err возвращает ошибку, остановившую обход, или nil, если письма просто кончились.

func (*EmailPager) Next added in v0.2.0

func (p *EmailPager) Next(ctx context.Context) bool

Next переходит к следующему письму и сообщает, есть ли оно. Когда письма текущей страницы кончаются, Next запрашивает следующую с контекстом ctx.

false означает конец обхода: письма кончились или запрос страницы не удался — что именно, скажет Err.

type EmailScheduleData added in v0.2.0

type EmailScheduleData struct {
	// ID — идентификатор письма.
	ID string `json:"id"`
	// Status — статус после операции: CANCELED после отмены, SCHEDULED после
	// переноса (и тогда, когда новое время уже наступило: письмо уйдёт с
	// ближайшим выпуском запланированных).
	Status EmailStatus `json:"status"`
	// ScheduledAt — время отправки: после переноса — новое (момент переноса,
	// если переданное уже наступило), после отмены — то, на которое письмо
	// было запланировано.
	ScheduledAt *time.Time `json:"scheduled_at"`
}

EmailScheduleData — состояние планирования письма: ответ Cancel и Reschedule. Тела письма в нём нет.

type EmailSendResponseData

type EmailSendResponseData struct {
	// ID — идентификатор письма в MailInfra.
	ID string `json:"id"`
	// Status — статус сразу после приёма: QUEUED, SCHEDULED для письма с
	// SendAt в будущем либо SIMULATED для sandbox-ключа.
	Status EmailStatus `json:"status"`
	// SkippedRecipients — получатели, которых API отбросил (например, адрес в
	// списке подавления), с причиной в значении.
	SkippedRecipients []map[string]string `json:"skipped_recipients"`
}

EmailSendResponseData — данные успешной отправки.

type EmailSource

type EmailSource string

EmailSource — транспорт, через который письмо попало в отправку.

const (
	EmailSourceAPI      EmailSource = "API"
	EmailSourceSMTP     EmailSource = "SMTP"
	EmailSourceCampaign EmailSource = "CAMPAIGN"
)

Значения EmailSource.

type EmailStatus

type EmailStatus string

EmailStatus — статус письма.

const (
	// EmailStatusScheduled — письмо принято с SendParams.SendAt в будущем и
	// ждёт своего часа; до отправки его можно отменить (Cancel) или перенести
	// (Reschedule).
	EmailStatusScheduled EmailStatus = "SCHEDULED"
	// EmailStatusQueued — письмо принято и ждёт отправки.
	EmailStatusQueued EmailStatus = "QUEUED"
	// EmailStatusSending — письмо передаётся MTA.
	EmailStatusSending EmailStatus = "SENDING"
	// EmailStatusSent — письмо принято сервером получателя.
	EmailStatusSent EmailStatus = "SENT"
	// EmailStatusDelivered — доставка подтверждена.
	EmailStatusDelivered EmailStatus = "DELIVERED"
	// EmailStatusBounced — письмо отбито (см. BounceType и BounceCategory).
	EmailStatusBounced EmailStatus = "BOUNCED"
	// EmailStatusComplained — получатель пожаловался на спам.
	EmailStatusComplained EmailStatus = "COMPLAINED"
	// EmailStatusFailed — отправка не удалась.
	EmailStatusFailed EmailStatus = "FAILED"
	// EmailStatusSimulated — sandbox-отправка по ключу mi_test_…: письмо
	// сохранено, но не доставлено и не расходует месячный объём.
	EmailStatusSimulated EmailStatus = "SIMULATED"
	// EmailStatusCanceled — запланированное письмо отменено до отправки.
	EmailStatusCanceled EmailStatus = "CANCELED"
)

Значения EmailStatus.

type EmailsService

type EmailsService struct {
	// contains filtered or unexported fields
}

EmailsService — письма проекта. Доступен как client.Emails.

func (*EmailsService) Cancel added in v0.2.0

func (s *EmailsService) Cancel(ctx context.Context, emailID string) (*EmailScheduleData, error)

Cancel отменяет запланированное письмо (SendParams.SendAt) до отправки: письмо получает статус CANCELED, резерв месячного объёма возвращается.

Отменить можно только письмо в статусе SCHEDULED; на остальные API отвечает 409 с ErrEmailNotScheduled, текущий статус — в Details["status"]. Нужен ключ с правом отправки. SDK повторяет отмену после 5xx и таймаутов: если первая попытка успела отменить письмо, повтор вернёт ErrEmailNotScheduled со статусом CANCELED.

Example

Отменить можно только письмо, которое ещё не ушло: на остальные API отвечает ErrEmailNotScheduled.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	emailID := "018e1b7a-1111-7000-aaaa-111111111111" // result.ID отложенной отправки
	canceled, err := client.Emails.Cancel(context.Background(), emailID)
	switch {
	case err == nil:
		fmt.Println("отменено:", canceled.Status) // CANCELED
	case errors.Is(err, mailinfra.ErrEmailNotScheduled):
		var apiErr *mailinfra.Error
		errors.As(err, &apiErr)
		fmt.Println("письмо уже не запланировано, статус:", apiErr.Details["status"])
	default:
		log.Fatal(err)
	}
}

func (*EmailsService) Events

func (s *EmailsService) Events(ctx context.Context, emailID string) ([]EmailEvent, error)

Events возвращает события письма в хронологическом порядке.

func (*EmailsService) Get

func (s *EmailsService) Get(ctx context.Context, emailID string) (*EmailDetail, error)

Get возвращает письмо с телом, заголовками и диагностикой доставки.

func (*EmailsService) List

func (s *EmailsService) List(ctx context.Context, params ListParams) (*EmailPage, error)

List возвращает одну страницу писем проекта по фильтрам, новые сначала.

Следующая страница — тот же вызов с Cursor = page.NextCursor, пока page.HasMore. Обойти все письма выборки проще через ListAll: за курсором он следит сам.

Example

List отдаёт одну страницу; следующая — по NextCursor, пока HasMore.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	// From и To — интервал по времени создания письма, а не адреса.
	page, err := client.Emails.List(context.Background(), mailinfra.ListParams{
		Status: mailinfra.EmailStatusBounced,
		Tag:    "order",
		From:   time.Now().Add(-24 * time.Hour),
		Limit:  50,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("на первой странице:", len(page.Data), "ещё есть:", page.HasMore)
}

func (*EmailsService) ListAll added in v0.2.0

func (s *EmailsService) ListAll(params ListParams) *EmailPager

ListAll возвращает итератор по всем письмам, подходящим под фильтры params.

Сам по себе вызов в сеть не ходит: первая страница запрашивается при первом Next. params.Limit задаёт размер страницы, а не общее число писем; params.Cursor позволяет продолжить обход с сохранённого курсора.

Example

ListAll обходит все письма выборки и сам запрашивает следующие страницы.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	pager := client.Emails.ListAll(mailinfra.ListParams{
		Status: mailinfra.EmailStatusBounced,
		From:   time.Now().Add(-7 * 24 * time.Hour),
		Limit:  100, // размер страницы, а не потолок обхода
	})

	bounced := 0
	for pager.Next(ctx) {
		email := pager.Email()
		if email.Metadata["order_id"] != "" {
			bounced++
		}
	}
	if err := pager.Err(); err != nil {
		log.Fatal(err)
	}
	fmt.Println("отбитых писем с заказом за неделю:", bounced)
}

func (*EmailsService) Reschedule added in v0.2.0

func (s *EmailsService) Reschedule(ctx context.Context, emailID string, sendAt time.Time) (*EmailScheduleData, error)

Reschedule переносит запланированное письмо на sendAt.

Горизонт — 7 дней от приёма письма, а не от переноса: дальше API ответит ErrSendAtTooFar. Время в прошлом — отправить сейчас, с ближайшим выпуском запланированных писем. Срок жизни письма (TTL) переезжает вместе с ним. Как и у Cancel, письмо должно быть в статусе SCHEDULED (иначе ErrEmailNotScheduled), а ключ — с правом отправки.

Example

Перенос считает горизонт в 7 дней от приёма письма; время в прошлом — отправить сейчас.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	emailID := "018e1b7a-1111-7000-aaaa-111111111111"
	moved, err := client.Emails.Reschedule(context.Background(), emailID, time.Now().Add(48*time.Hour))
	if errors.Is(err, mailinfra.ErrSendAtTooFar) {
		fmt.Println("так далеко запланировать нельзя")
		return
	}
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("уйдёт в", moved.ScheduledAt)
}

func (*EmailsService) Send

func (s *EmailsService) Send(ctx context.Context, params SendParams) (*SendResult, error)

Send отправляет письмо.

Возвращает идентификатор, статус, отброшенных получателей, флаг идемпотентного повтора и использованный ключ идемпотентности.

Example (IdempotencyKey)

Ключ идемпотентности, привязанный к бизнес-событию, делает повторный вызов безопасным: второй раз письмо не уйдёт.

package main

import (
	"context"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:           "hello@mail.example.ru",
		To:             []string{"user@example.com"},
		Subject:        "Заказ A-1024 оплачен",
		HTML:           "<p>Спасибо!</p>",
		IdempotencyKey: "order-A-1024-paid",
	})
	if err != nil {
		log.Fatal(err)
	}
	if result.Replayed {
		fmt.Println("письмо уже было отправлено раньше, дубля нет")
	}
}
Example (Scheduled)

Отложенная отправка: письмо получит статус SCHEDULED и уйдёт в SendAt, а TTL не даст ему уйти, если до получателя оно не дошло вовремя. Metadata вернётся в каждом вебхуке письма.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:     "hello@mail.example.ru",
		To:       []string{"user@example.com"},
		Subject:  "Напоминание об оплате",
		Text:     "Заказ A-1024 ждёт оплаты.",
		Metadata: map[string]string{"order_id": "A-1024"},
		SendAt:   time.Now().Add(24 * time.Hour),
		TTL:      2 * time.Hour,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(result.ID, result.Status) // … SCHEDULED
}

func (*EmailsService) SendBatch

func (s *EmailsService) SendBatch(ctx context.Context, emails []SendParams, opts ...BatchOption) (*BatchResult, error)

SendBatch отправляет до 100 писем одним запросом.

Батч возвращает 202, даже если часть писем не принята: проверяйте Error у каждого элемента Results, а не только отсутствие ошибки вызова.

Idempotency-Key SDK ставит сам, один на вызов (свой — WithBatchIdempotencyKey): бэкенд выводит из него ключ каждого письма, поэтому повтор после 5xx, таймаута и обрыва соединения не дублирует уже принятые письма, а отклонённые пробует принять заново. Без ключа (WithoutBatchIdempotencyKey, WithAutoIdempotency(false)) батч повторяется только на 429 — повтор мог бы отправить часть писем дважды.

Example

Батч возвращает 202, даже если часть писем не принята: проверять надо каждый элемент, а не только отсутствие ошибки вызова.

package main

import (
	"context"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	batch, err := client.Emails.SendBatch(context.Background(), []mailinfra.SendParams{
		{From: "hello@mail.example.ru", To: []string{"a@example.com"}, Subject: "Раз", Text: "1"},
		{From: "hello@mail.example.ru", To: []string{"b@example.com"}, Subject: "Два", Text: "2"},
	})
	if err != nil {
		log.Fatal(err)
	}

	for _, item := range batch.Results {
		if item.Error != nil {
			fmt.Printf("письмо #%d отклонено: %s\n", item.Index, item.Error.Code)
		}
	}
}
Example (IdempotencyKey)

Ключ батча, привязанный к бизнес-событию, делает безопасным и повторный вызов: уже принятые письма второй раз не уйдут, а отклонённые батч попробует принять заново.

package main

import (
	"context"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	batch, err := client.Emails.SendBatch(context.Background(), []mailinfra.SendParams{
		{From: "hello@mail.example.ru", To: []string{"a@example.com"}, Subject: "Раз", Text: "1"},
		{From: "hello@mail.example.ru", To: []string{"b@example.com"}, Subject: "Два", Text: "2"},
	}, mailinfra.WithBatchIdempotencyKey("digest-2026-10-28"))
	if err != nil {
		log.Fatal(err)
	}
	if batch.Replayed {
		fmt.Println("батч уже был принят раньше, дублей нет")
	}
}

type Environment

type Environment string

Environment — окружение API-ключа.

const (
	// EnvironmentLive — рабочий ключ mi_live_…: письма уходят получателям.
	EnvironmentLive Environment = "live"
	// EnvironmentTest — sandbox-ключ mi_test_…: письмо сохраняется со статусом
	// SIMULATED, не доставляется и не расходует месячный объём.
	EnvironmentTest Environment = "test"
)

Значения Environment.

type Error

type Error struct {
	// Message — человеко-читаемое сообщение от API (на русском).
	Message string
	// StatusCode — HTTP-статус ответа; 0 для сетевых ошибок и клиентской валидации.
	StatusCode int
	// Code — машинный код ошибки (error.code).
	Code string
	// Details — безопасные структурированные детали (error.details).
	Details map[string]any
	// RequestID — идентификатор запроса для поддержки (он же заголовок X-Request-ID).
	RequestID string
	// RetryAfter — сколько просил подождать сервер; значимо только вместе с
	// HasRetryAfter.
	RetryAfter time.Duration
	// HasRetryAfter — был ли в ответе разобранный заголовок Retry-After.
	HasRetryAfter bool
	// contains filtered or unexported fields
}

Error — ошибка вызова MailInfra: и ответ API, и транспортный сбой, и отказ клиентской валидации.

Ловить её целиком удобно через errors.As, а ветвиться — через errors.Is по часовым ошибкам (см. описание пакета).

Example

Ветвиться по причине отказа надо через errors.Is, а метаданные для логов брать через errors.As.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	client, err := mailinfra.New()
	if err != nil {
		log.Fatal(err)
	}

	_, err = client.Emails.Send(context.Background(), mailinfra.SendParams{
		From:    "hello@mail.example.ru",
		To:      []string{"user@example.com"},
		Subject: "Привет",
		Text:    "Привет!",
	})

	switch {
	case err == nil:
		fmt.Println("принято")

	case errors.Is(err, mailinfra.ErrDomainNotVerified):
		fmt.Println("домен отправителя ещё не подтверждён")

	case errors.Is(err, mailinfra.ErrRateLimit):
		var apiErr *mailinfra.Error
		errors.As(err, &apiErr)
		fmt.Println("лимит, повторить через", apiErr.RetryAfter)

	case errors.Is(err, mailinfra.ErrUnprocessable):
		var apiErr *mailinfra.Error
		errors.As(err, &apiErr)
		for _, field := range apiErr.Fields() {
			fmt.Printf("%s: %s\n", field.Field, field.Message)
		}

	default:
		log.Fatal(err)
	}
}

func (*Error) Error

func (e *Error) Error() string

Error возвращает текст ошибки с машинным кодом, статусом и request_id — всем, что нужно для обращения в поддержку.

func (*Error) Fields

func (e *Error) Fields() []ValidationField

Fields возвращает пер-полевые причины отказа валидации (422 с кодом request_validation_failed). Пустой срез, если детализации нет.

func (*Error) Unwrap

func (e *Error) Unwrap() []error

Unwrap отдаёт часовые ошибки, по которым работает errors.Is: категорию статуса и, если код известен, конкретную причину.

type ListParams

type ListParams struct {
	// Status — фильтр по статусу.
	Status EmailStatus
	// FromEmail — фильтр по отправителю.
	FromEmail string
	// ToEmail — фильтр по получателю.
	ToEmail string
	// Tag — фильтр по тегу.
	Tag string
	// From — начало интервала по времени создания.
	From time.Time
	// To — конец интервала по времени создания.
	To time.Time
	// Limit — размер страницы, 1..100 (по умолчанию 25 на стороне API).
	Limit int
	// Cursor — EmailPage.NextCursor предыдущей страницы; пустая строка —
	// первая страница. Курсор непрозрачен: собирать или менять его не нужно.
	Cursor string
}

ListParams — фильтры выборки писем. Нулевое значение поля означает «без фильтра».

type Option

type Option func(*options)

Option — настройка клиента, передаётся в New.

func WithAPIKey

func WithAPIKey(apiKey string) Option

WithAPIKey задаёт API-ключ проекта. По умолчанию ключ берётся из MAILINFRA_API_KEY.

func WithAutoIdempotency

func WithAutoIdempotency(enabled bool) Option

WithAutoIdempotency включает или выключает автоматический Idempotency-Key на отправке письма и батча. По умолчанию включён.

Выключать стоит только если ключ вы генерируете сами: без него бэкенду нечем распознать повтор, и SDK перестаёт ретраить отправку после 5xx и таймаутов.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL задаёт базу API. По умолчанию — MAILINFRA_BASE_URL либо DefaultBaseURL.

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient подставляет свой *http.Client — прокси, пул соединений, mTLS.

Таймаут такого клиента SDK не перетирает: без явного WithTimeout собственный дедлайн не ставится вовсе, и запрос ограничивают только ваши настройки.

func WithMaxRetries

func WithMaxRetries(maxRetries int) Option

WithMaxRetries задаёт число повторов сверх первой попытки. 0 — без повторов.

func WithRequireEnvironment

func WithRequireEnvironment(environment Environment) Option

WithRequireEnvironment требует ключ нужного окружения: New вернёт ошибку, если префикс ключа не совпал.

Страхует от самой дорогой ошибки конфигурации — тестовый ключ в проде не отправит ни одного письма, а рабочий в тестах отправит настоящие.

func WithRetryPolicy

func WithRetryPolicy(policy RetryPolicy) Option

WithRetryPolicy задаёт политику повторов. Незаполненные поля заменяются значениями по умолчанию — и MaxRetries тоже: нулевой MaxRetries в политике значит «не задан» (по умолчанию 2 повтора), а не «без повторов». Отключить повторы — WithMaxRetries(0) после WithRetryPolicy или отрицательный MaxRetries в самой политике.

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout задаёт таймаут одной попытки. 0 — не ставить собственный дедлайн (полезно, если временем управляет ваш http.Client или контекст вызова).

По умолчанию — DefaultTimeout для своего клиента и 0 для переданного через WithHTTPClient.

func WithUserAgentSuffix

func WithUserAgentSuffix(suffix string) Option

WithUserAgentSuffix добавляет к User-Agent имя вашей интеграции.

type RetryPolicy

type RetryPolicy struct {
	// MaxRetries — сколько раз повторить сверх первой попытки. Как и у
	// остальных полей, 0 в WithRetryPolicy — «не задан» (по умолчанию 2);
	// отрицательное значение — повторов нет. Проще отключить повторы через
	// WithMaxRetries(0).
	MaxRetries int
	// InitialBackoff — базовая задержка первой паузы.
	InitialBackoff time.Duration
	// BackoffFactor — множитель роста задержки между попытками.
	BackoffFactor float64
	// MaxBackoff — потолок собственной задержки.
	MaxBackoff time.Duration
	// MaxRetryAfter — порог доверия к `Retry-After`. Если сервер просит
	// подождать дольше — SDK не спит, а отдаёт ошибку с ErrRateLimit, чтобы
	// вызывающий сам решил, ждать или деградировать.
	MaxRetryAfter time.Duration
	// RetryableStatuses — статусы, повторяемые для идемпотентных запросов.
	// nil — набор по умолчанию.
	RetryableStatuses []int
	// RetryableStatusesNonIdempotent — то же для неидемпотентных. nil —
	// набор по умолчанию.
	RetryableStatusesNonIdempotent []int
}

RetryPolicy — настройки повторов.

Нулевое значение полей означает «взять дефолт», поэтому частичная настройка через WithRetryPolicy не обнуляет остальное.

func DefaultRetryPolicy

func DefaultRetryPolicy() RetryPolicy

DefaultRetryPolicy возвращает политику повторов по умолчанию: две попытки сверх первой, backoff 0.5s → 1s → … с потолком 8s, доверие к `Retry-After` до минуты.

type SendParams

type SendParams struct {
	// From — адрес отправителя на подтверждённом домене.
	From string
	// FromName — отображаемое имя отправителя.
	FromName string
	// To — получатели, минимум один.
	To []string
	// CC — копия.
	CC []string
	// BCC — скрытая копия.
	BCC []string
	// ReplyTo — адреса для ответа.
	ReplyTo []string
	// Subject — тема; обязательна, если письмо шлётся без шаблона.
	Subject string
	// HTML — HTML-часть письма.
	HTML string
	// Text — текстовая часть письма.
	Text string
	// Headers — дополнительные заголовки письма (системные запрещены).
	Headers map[string]string
	// Tags — теги для фильтрации в дашборде и вебхуках.
	Tags []string
	// Metadata — ваши пары «ключ — значение» (номер заказа, id пользователя).
	// На доставку не влияют, а возвращаются в Get и List (поле Metadata
	// письма) и в каждом вебхуке письма (WebhookEvent.EmailMetadata).
	//
	// До MaxMetadataKeys ключей длиной до MaxMetadataKeyLength из латиницы,
	// цифр, '_', '-' и '.'; значения — до MaxMetadataValueLength символов без
	// NUL; весь объект — до MaxMetadataBytes байт JSON.
	Metadata map[string]string
	// Template — слаг шаблона вместо HTML/Text.
	Template string
	// Variables — переменные шаблона.
	Variables map[string]any
	// Attachments — вложения; inline-картинки — вложения с ContentID.
	Attachments []Attachment
	// SendAt — отложенная отправка: письмо получит статус SCHEDULED и уйдёт в
	// этот момент, а до того его можно отменить (Cancel) или перенести
	// (Reschedule). Не дальше 7 дней от приёма письма — иначе API ответит
	// ErrSendAtTooFar; время в прошлом — отправить сразу. Нулевое значение —
	// отправить сразу.
	SendAt time.Time
	// TTL — срок жизни письма от MinTTL до MaxTTL в целых секундах, отсчёт от
	// SendAt или от приёма. Не дошедшее за это время письмо не отправляется и
	// не повторяется: статус FAILED с причиной expired или отказ с категорией
	// BounceDeliveryExpired. Для кодов входа и других писем, которые поздно уже
	// не нужны. 0 — без срока жизни.
	TTL time.Duration

	// IdempotencyKey — свой ключ идемпотентности. Пустая строка означает
	// «сгенерировать», если у клиента включён автоматический ключ.
	//
	// В SendBatch не задаётся: ключ там один на весь батч
	// (WithBatchIdempotencyKey).
	IdempotencyKey string
	// WithoutIdempotencyKey отправляет письмо вообще без Idempotency-Key.
	//
	// Осознанный отказ от защиты: бэкенду будет нечем распознать повтор, и SDK
	// перестанет ретраить этот запрос после 5xx и таймаутов. В SendBatch не
	// действует — там это WithoutBatchIdempotencyKey.
	WithoutIdempotencyKey bool
}

SendParams — параметры отправки письма.

Обязательны только From и To; остальное зависит от того, шлёте вы готовое тело (Subject + HTML/Text) или шаблон (Template + Variables).

type SendResult

type SendResult struct {
	EmailSendResponseData

	// Replayed — true, если это идемпотентный повтор: письмо было принято
	// предыдущим запросом с тем же Idempotency-Key, повторной отправки не
	// произошло (ответ 200 + заголовок Idempotent-Replayed).
	Replayed bool
	// IdempotencyKey — ключ, с которым ушёл запрос: сгенерированный SDK или
	// переданный вызывающим. Стоит залогировать: с ним повтор гарантированно
	// не создаст второе письмо.
	IdempotencyKey string
	// RequestID — идентификатор запроса для обращений в поддержку.
	RequestID string
}

SendResult — результат отправки одного письма.

type SuppressionReason

type SuppressionReason string

SuppressionReason — причина попадания адреса в список подавления.

const (
	SuppressionHardBounce  SuppressionReason = "HARD_BOUNCE"
	SuppressionComplaint   SuppressionReason = "COMPLAINT"
	SuppressionUnsubscribe SuppressionReason = "UNSUBSCRIBE"
	SuppressionManual      SuppressionReason = "MANUAL"
)

Значения SuppressionReason.

type ValidationField

type ValidationField struct {
	Field   string
	Message string
	Code    string
}

ValidationField — пер-полевая причина отказа валидации из Details["fields"].

type WebhookEvent

type WebhookEvent struct {
	// ID — идентификатор события: evt_<uuid> у событий письма,
	// evt_deliverability_<hex> у deliverability.*, evt_test_ping у теста.
	ID string `json:"id"`
	// Type — тип: email.<событие> у событий письма (например email.bounced,
	// email.scheduled, email.canceled — значения EmailEventType с префиксом
	// email.), deliverability.<инцидент> как есть (например
	// deliverability.dns_degraded), test.ping у тестовой отправки из дашборда.
	Type string `json:"type"`
	// CreatedAt — момент события на стороне MailInfra.
	CreatedAt time.Time `json:"created_at"`
	// Data — полезная нагрузка. У событий письма Data["email"] — письмо (в том
	// числе его metadata), Data["event"] — специфика события (MX, IP,
	// bounce-детали, URL клика).
	// У deliverability.* письма нет: domain_id, domain, severity, title,
	// details инцидента.
	Data map[string]any `json:"data"`
}

WebhookEvent — разобранное событие вебхука.

func ConstructWebhookEvent

func ConstructWebhookEvent(body []byte, secret, signatureHeader string, opts ...WebhookOption) (*WebhookEvent, error)

ConstructWebhookEvent проверяет подпись и возвращает типизированное событие.

Это рекомендуемый способ: тело нельзя разобрать раньше, чем проверена подпись, иначе обработчик доверяет неаутентифицированным данным.

Ошибка всегда *WebhookSignatureError; причина — в поле Reason.

Example

Тело обязательно брать до разбора JSON: пересериализованный JSON даст другие байты и подпись не сойдётся.

package main

import (
	"errors"
	"io"
	"log"
	"net/http"
	"os"

	mailinfra "github.com/g7AzaZLO/mailinfra-go"
)

func main() {
	http.HandleFunc("/mailinfra/events", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "", http.StatusBadRequest)
			return
		}

		event, err := mailinfra.ConstructWebhookEvent(
			body,
			os.Getenv("MAILINFRA_WEBHOOK_SECRET"),
			r.Header.Get(mailinfra.WebhookSignatureHeader),
		)
		if err != nil {
			var signatureErr *mailinfra.WebhookSignatureError
			if errors.As(err, &signatureErr) {
				log.Printf("подпись отклонена: %s", signatureErr.Reason)
			}
			http.Error(w, "", http.StatusBadRequest)
			return
		}

		switch event.Type {
		case "email.bounced":
			log.Println("отбито:", event.EmailID(), "заказ:", event.EmailMetadata()["order_id"])
		case "email.delivered":
			log.Println("доставлено:", event.EmailID())
		}
		w.WriteHeader(http.StatusOK)
	})
}

func (*WebhookEvent) Email

func (e *WebhookEvent) Email() map[string]any

Email возвращает письмо, к которому относится событие, или nil для событий без письма (deliverability.*, test.ping).

func (*WebhookEvent) EmailID

func (e *WebhookEvent) EmailID() string

EmailID возвращает идентификатор письма или пустую строку, если события письма нет.

func (*WebhookEvent) EmailMetadata added in v0.2.0

func (e *WebhookEvent) EmailMetadata() map[string]string

EmailMetadata возвращает metadata письма — пары из SendParams.Metadata, по которым событие связывается с вашей системой. nil, если у события нет письма; пустой map, если metadata письму не задавали.

func (*WebhookEvent) IsTest

func (e *WebhookEvent) IsTest() bool

IsTest сообщает, тестовое ли это событие («Отправить тест» в дашборде).

type WebhookOption

type WebhookOption func(*webhookOptions)

WebhookOption — настройка проверки подписи.

func WithWebhookMaxAge

func WithWebhookMaxAge(maxAge time.Duration) WebhookOption

WithWebhookMaxAge задаёт допустимый возраст подписи; защита от replay. Нулевое значение отключает проверку времени.

type WebhookSignatureError

type WebhookSignatureError struct {
	// Message — человеко-читаемое описание.
	Message string
	// Reason — машинная причина: одна из констант WebhookReason*.
	Reason string
}

WebhookSignatureError — подпись не прошла проверку.

func (*WebhookSignatureError) Error

func (e *WebhookSignatureError) Error() string

Error возвращает описание вместе с машинной причиной.

func (*WebhookSignatureError) Unwrap

func (e *WebhookSignatureError) Unwrap() error

Unwrap отдаёт часовую ошибку, по которой работает errors.Is.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL