Перейти к основному содержимому

Go SDK

go get github.com/g7AzaZLO/mailinfra/sdk/go

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

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

import mailinfra "github.com/g7AzaZLO/mailinfra/sdk/go"

Первое письмо​

package main

import (
"context"
"fmt"
"log"

mailinfra "github.com/g7AzaZLO/mailinfra/sdk/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@ваш-домен.ru",
To: []string{"user@example.com"},
Subject: "Добро пожаловать!",
HTML: "<p>Привет!</p>",
Text: "Привет!",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(result.ID, result.Status)
}

Ключ можно передать явно: mailinfra.New(mailinfra.WithAPIKey("mi_live_…")). Тестовый ключ mi_test_… работает через тот же клиент: письмо сохраняется со статусом SIMULATED, не доставляется и не расходует месячный объём плана.

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

Защита от перепутанных ключей

mailinfra.New(mailinfra.WithRequireEnvironment(mailinfra.EnvironmentLive)) вернёт ошибку, если в конфигурации оказался тестовый ключ — и наоборот.

Отправка по шаблону​

result, err := client.Emails.Send(ctx, mailinfra.SendParams{
From: "no-reply@ваш-домен.ru",
To: []string{"anna@example.com", "boris@example.com"},
Template: "welcome", // тема берётся из шаблона
Variables: map[string]any{
"user_name": "Анна",
"app_name": "MyApp",
},
Tags: []string{"onboarding"},
})

Вложения​

invoice, err := os.ReadFile("/var/invoices/A-1024.pdf")
if err != nil {
return err
}

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

Если base64 уже готов — положите его в ContentBase64 вместо Content. До 10 вложений, суммарно до 25 МиБ вместе с письмом.

Идемпотентность​

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

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

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

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

batch, err := client.Emails.SendBatch(ctx, []mailinfra.SendParams{
{From: "hello@ваш-домен.ru", To: []string{"anna@example.com"}, Subject: "Раз", Text: "1"},
{From: "hello@ваш-домен.ru", To: []string{"boris@example.com"}, Subject: "Два", Text: "2"},
})
if err != nil {
return err
}

log.Printf("%d принято, %d отклонено", batch.AcceptedCount, batch.FailedCount)

for _, item := range batch.Results {
if item.Error != nil {
log.Printf("письмо #%d: %s", item.Index, item.Error.Code)
}
}

До 100 писем за раз. Ответ приходит с кодом 202, даже если часть писем отклонена, поэтому проверяйте каждый элемент, а не только err. Подробнее — пакетная отправка.

Чтение статусов​

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

bounced, err := client.Emails.List(ctx, mailinfra.ListParams{
Status: mailinfra.EmailStatusBounced,
Tag: "onboarding",
From: time.Now().Add(-24 * time.Hour),
Limit: 50,
})

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

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

Ошибки​

Ветвиться по причине отказа надо через 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):
var apiErr *mailinfra.Error
errors.As(err, &apiErr)
for _, field := range apiErr.Fields() {
log.Printf("%s: %s", field.Field, field.Message)
}

default:
return err
}

В Go нет иерархии классов исключений, поэтому её место занимают часовые ошибки: одно значение *mailinfra.Error отвечает true и на свою категорию (ErrUnprocessable), и на конкретную причину (ErrDomainNotVerified). Ловить можно на любом уровне:

  • всё сразу — errors.As(err, &apiErr), там же apiErr.RequestID для поддержки;
  • категория статуса — ErrBadRequest, ErrAuthentication, ErrPermissionDenied, ErrNotFound, ErrConflict, ErrUnprocessable, ErrRateLimit, ErrServer, ErrConnection, ErrTimeout;
  • конкретная причина — ErrDomainNotVerified, ErrDomainNotAttached, ErrAllRecipientsSuppressed, ErrTemplateRender, ErrIdempotencyInFlight и другие.

Незнакомый error.code с бэкенда деградирует до категории по статусу — новый код в API не ломает уже выпущенный SDK. Полный список кодов — ошибки и лимиты.

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

Повторы и таймауты​

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

Незаполненные поля RetryPolicy заменяются значениями по умолчанию, поэтому менять можно только нужное. WithMaxRetries(0) отключает повторы. Пакетная отправка и отправка без ключа идемпотентности повторяются только на 429: после 5xx или обрыва соединения повтор мог бы отправить часть писем второй раз.

Свой *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 {
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":
handleBounce(event.EmailID())
case "email.delivered":
handleDelivered(event.EmailID())
}
w.WriteHeader(http.StatusOK)
}

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

Ссылки​