Help us shape the future of Go! Take the 2026 Go Developer Survey

announcer

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 18 Imported by: 0

README

announcer-go

Go SDK for Announcer — send transactional email from your own domain, DKIM-signed.

No dependencies outside the standard library.

go get github.com/ZeldaIV/announcer-go

Send an email

package main

import (
	"context"
	"log"

	"github.com/ZeldaIV/announcer-go"
)

func main() {
	client, err := announcer.New("") // reads ANNOUNCER_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	sent, err := client.Send(context.Background(), &announcer.SendEmailRequest{
		From:    "Acme <billing@acme.com>",
		To:      announcer.Address("customer@example.com"),
		Subject: "Your receipt",
		Text:    "Thanks for your order.",
		HTML:    "<p>Thanks for your order.</p>",
	})
	if err != nil {
		log.Fatal(err)
	}

	log.Printf("sent %s", sent.ID)
}

That's the whole integration. Pass the key to New explicitly if you would rather not use the environment.

To, Cc and Bcc are announcer.Addresses, whose underlying type is []string — so announcer.Address("one@example.com") and []string{"a@example.com", "b@example.com"} both assign directly. See Several recipients.

Before your first send

You need a registered, verified sending domain — Announcer will not let you send From: a domain you have not proved you control.

domain, err := client.Domains.Create(ctx, "acme.com")
if err != nil {
	log.Fatal(err)
}

for _, r := range domain.DNS {
	fmt.Printf("%s  %s  %s\n", r.Type, r.Name, r.Value)
}
// TXT  mail._domainkey.acme.com  v=DKIM1; k=rsa; p=MIIBIjANBg...

// Publish that record, wait for DNS, then:
_, err = client.Domains.Verify(ctx, domain.ID)

One TXT record is the entire ask. SPF and MX stay on Announcer's own bounce domain, so your root domain's DNS is untouched.

What the SDK does for you

Retries are safe. Every send carries an Idempotency-Key, generated per call when you leave IdempotencyKey empty. A timeout, a 500, or a 429 gets retried with exponential backoff and jitter — and because the key travels with the retry, the API recognises it as the same operation instead of sending twice. Retries respect your context.Context: cancel it and the loop stops.

Set the key yourself to extend that guarantee across process restarts:

sent, err := client.Send(ctx, &announcer.SendEmailRequest{
	From:           "billing@acme.com",
	To:             "customer@example.com",
	Subject:        "Your receipt",
	Text:           "Thanks!",
	IdempotencyKey: fmt.Sprintf("receipt-%d", order.ID), // mails exactly once, ever
})
if err != nil {
	log.Fatal(err)
}
if sent.IdempotentReplay {
	// Already sent earlier. Nothing went out a second time.
}

Errors work the way Go errors work. Branch with errors.Is, reach the details with errors.As:

sent, err := client.Send(ctx, msg)
switch {
case err == nil:
	// ...
case errors.Is(err, announcer.ErrSuppressedRecipient):
	// They hard-bounced or complained before. Don't retry; mark them inactive.
	deactivate(msg.To)
case errors.Is(err, announcer.ErrRateLimit):
	var apiErr *announcer.Error
	errors.As(err, &apiErr)
	time.Sleep(apiErr.RetryAfter)
case errors.Is(err, announcer.ErrPermission):
	// Domain not registered, not verified, or this key is send-scoped.
default:
	return err
}

The full set: ErrValidation (see Error.Errors for the per-field messages), ErrAuthentication, ErrPermission, ErrNotFound, ErrConflict, ErrUnprocessable, ErrSuppressedRecipient, ErrRateLimit, ErrServer, ErrConnection, ErrSignatureVerification. A suppressed recipient matches both ErrSuppressedRecipient and the broader ErrUnprocessable.

Field names make sense. The API calls them header_from and recipient; the SDK calls them From and To, matching what you used to send and what the webhook payload says, and AttachmentCount says how many files a message carried. Timestamps are time.Time, nullable ones are *time.Time, and the booleans you want are methods: domain.Verified(), key.Revoked(), endpoint.Disabled().

Attachments

Pass the file's bytes; encoding/json does the base64.

pdf, err := os.ReadFile("invoice.pdf")
if err != nil {
    return err
}
_, err = client.Send(ctx, &announcer.SendEmailRequest{
    From:        "billing@acme.com",
    To:          announcer.Address("customer@example.com"),
    Subject:     "Invoice 1042",
    Text:        "Your invoice is attached.",
    Attachments: []announcer.Attachment{{Filename: "invoice-1042.pdf", Content: pdf}},
})

At most 20 files and 10 MB per message, text and HTML included. Add a ContentID to show an image inline with <img src="cid:logo">. Attachments are sent and never stored, and executables are refused.

Several recipients

To, Cc and Bcc each take one address or many. Everything in To and Cc is one email whose recipients see each other; Bcc recipients see nobody, not even each other:

sent, err := client.Send(ctx, &announcer.SendEmailRequest{
	From:    "billing@acme.com",
	To:      []string{"customer@example.com", "partner@example.com"},
	Cc:      announcer.Address("accounting@acme.com"),
	Bcc:     announcer.Address("archive@acme.com"),
	ReplyTo: announcer.Address("support@acme.com"),
	Subject: "Your receipt",
	Text:    "Thanks!",
})

At most 50 addresses across the three. ReplyTo is a header only — it costs nothing and cannot bounce.

Recipients are the billable unit. That call counts four against your quota, not one. It is also what keeps MonthlyHardCap meaningful: otherwise a leaked key could send fifty times your ceiling by padding the slice.

One email, or many?

For anything list-shaped — a newsletter, a digest, a fan-out — you want SendMany, not a slice:

results, err := client.Emails.SendMany(ctx,
	[]string{"a@example.com", "b@example.com", "c@example.com"},
	&announcer.SendEmailRequest{
		From:    "news@acme.com",
		Subject: "September update",
		HTML:    body,
	},
	nil, // or &announcer.SendManyOptions{StopOnError: true}
)

for _, r := range results {
	if !r.OK() {
		log.Printf("%s failed: %v", r.To, r.Err)
	}
}
To: []string{a, b} SendMany([]string{a, b}, …)
Emails sent one two
Do they see each other? yes, in To: no
API requests one two
Idempotency key one one each, derived
One address fails the send reports it the others are unaffected

Each SendMany recipient gets its own derived idempotency key, and your request struct is left untouched.

Suppressed recipients

A recipient on your suppression list is dropped and the rest still goes out:

sent, err := client.Send(ctx, &announcer.SendEmailRequest{
	From:    "billing@acme.com",
	To:      []string{"good@example.com", "bounced-before@example.com"},
	Subject: "Your receipt",
	Text:    "Thanks!",
})

sent.Recipients // 1 — what actually went out and what you were billed
sent.Suppressed // ["bounced-before@example.com"]

ErrSuppressedRecipient is returned only when every recipient is suppressed (or every To recipient — a message with no visible primary recipient is refused rather than sent). Error.Suppressed names them all.

Webhooks

Register an endpoint, store the secret, verify every delivery:

endpoint, err := client.Webhooks.Create(ctx, "https://acme.com/hooks/announcer")
log.Println(endpoint.Secret) // whsec_... — shown once, store it now
func announcerWebhook(w http.ResponseWriter, r *http.Request) {
	// The raw bytes are what was signed. Decoding and re-encoding reorders
	// keys and the signature will not match.
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "", http.StatusBadRequest)
		return
	}

	event, err := announcer.VerifyWebhook(
		body,
		r.Header.Get("X-Announcer-Signature"),
		os.Getenv("ANNOUNCER_WEBHOOK_SECRET"),
	)
	if err != nil {
		http.Error(w, "", http.StatusBadRequest)
		return
	}

	switch event.Event {
	case announcer.EventDelivered:
		markDelivered(event.Message.ID)
	case announcer.EventBounced, announcer.EventComplained:
		deactivate(event.Message.To)
	}

	w.WriteHeader(http.StatusOK)
}

Verification checks the HMAC and the timestamp, rejecting anything more than five minutes old so a captured delivery cannot be replayed at you. Tune it with announcer.WithTolerance(d).

Events: EventSent, EventDelivered, EventBounced, EventComplained, EventSuppressed.

API reference

Client
func New(apiKey string, opts ...Option) (*Client, error)
Option Default
WithBaseURL(url) ANNOUNCER_BASE_URL, then https://mail.misralo.com
WithTimeout(d) 30s, per attempt
WithMaxRetries(n) 2 extra attempts
WithHTTPClient(hc) &http.Client{Timeout: 30s}
WithUserAgent(ua) appended to the SDK's own — name your app
WithHeader(k, v) —

An empty apiKey reads ANNOUNCER_API_KEY. New errors only when no key is found anywhere.

Methods

Every call takes a context.Context first.

Call Does
client.Send(ctx, msg) Shorthand for Emails.Send.
client.Usage(ctx) Quota consumption plus a 14-day sending series.
Emails.Send(ctx, msg) Sends one email. To/Cc/Bcc take one address or many; Attachments take the file's bytes.
Emails.SendMany(ctx, to, msg, opts) Separate emails, one per recipient.
Emails.List(ctx, opts) Send history.
Emails.Events(ctx, id) A message's audit trail.
Domains.Create(ctx, domain) Registers a domain, returns the DNS record.
Domains.List(ctx) Every domain on the account.
Domains.DNS(ctx, id) The records again, for a domain you already registered.
Domains.Verify(ctx, id) Resolves DNS and checks the published key.
Domains.Delete(ctx, id) Removes the domain and its signing key.
APIKeys.Create(ctx, name, scope) Issues a key. Secret shown once.
APIKeys.List(ctx) Every key, without secrets.
APIKeys.Revoke(ctx, id) Revokes a key; history survives.
Webhooks.Create(ctx, url) Registers an endpoint. Max 2 active.
Webhooks.List(ctx) Every endpoint.
Webhooks.Delete(ctx, id) Disables an endpoint.
VerifyWebhook(body, header, secret, ...) Verifies a delivery.
Suppressions.List(ctx, limit) Addresses that bounced or complained.

Domains, APIKeys and Webhooks need a full-scoped key. Everything else works with a send key too — give integrations send.

Scopes

Mint a send-scoped key for anything that only sends mail:

key, err := client.APIKeys.Create(ctx, "production-worker", announcer.ScopeSend)

A leaked send key cannot register domains, mint successor keys, or touch billing. It is the difference between an incident and a catastrophe.

Contributing

go test ./...   # no network; everything runs against httptest
go vet ./...
gofmt -l .

License

MIT

Documentation

Overview

Package announcer is the Go SDK for Announcer — transactional email sent from your own domain, DKIM-signed.

Create one client and reuse it; it is safe for concurrent use and holds a connection pool.

client, err := announcer.New("") // reads ANNOUNCER_API_KEY
if err != nil {
    log.Fatal(err)
}

sent, err := client.Send(ctx, &announcer.SendEmailRequest{
    From:    "Acme <billing@acme.com>",
    To:      announcer.Address("customer@example.com"),
    Subject: "Your receipt",
    Text:    "Thanks for your order.",
})

Files go in Attachments as their bytes; see Attachment.

Every send carries an Idempotency-Key, generated per call when you do not supply one, so the SDK's automatic retries can never send twice.

Index

Constants

View Source
const (
	StatusQueued     = "queued"
	StatusSent       = "sent"
	StatusDelivered  = "delivered"
	StatusBounced    = "bounced"
	StatusComplained = "complained"
	StatusFailed     = "failed"
	StatusSuppressed = "suppressed"
)

Message statuses. bounced, failed and suppressed are terminal.

View Source
const (
	EventSent       = "sent"
	EventDelivered  = "delivered"
	EventBounced    = "bounced"
	EventComplained = "complained"
	EventSuppressed = "suppressed"
)

Webhook event types — the transitions that produce a delivery.

View Source
const (
	ScopeFull = "full"
	ScopeSend = "send"
)

API key scopes. Give integrations ScopeSend: a leaked send key cannot register domains, mint successor keys, or reach billing.

View Source
const DefaultBaseURL = "https://mail.misralo.com"

DefaultBaseURL is where the hosted API lives.

View Source
const DefaultTolerance = 5 * time.Minute

DefaultTolerance is how far apart a delivery's timestamp and our clock may be before VerifyWebhook rejects it as a possible replay.

View Source
const Version = "0.2.1"

Version is this SDK's version, reported in the User-Agent.

Variables

View Source
var (
	// ErrAuthentication is a 401: the key is missing, malformed, or revoked.
	ErrAuthentication = newKind("authentication failed")

	// ErrPermission is a 403: the key is valid but not allowed to do this.
	// In practice a send-scoped key touching the management surface, or a
	// From: domain that is unregistered or unverified.
	ErrPermission = newKind("permission denied")

	// ErrNotFound is a 404: no such resource, or it belongs to another account.
	ErrNotFound = newKind("not found")

	// ErrConflict is a 409: a duplicate domain, or a send with this
	// Idempotency-Key still in flight. The in-flight case is retried
	// automatically first.
	ErrConflict = newKind("conflict")

	// ErrValidation is a 400. See Error.Errors for the per-field messages.
	ErrValidation = newKind("validation failed")

	// ErrUnprocessable is a 422: well-formed, but it cannot be carried out.
	ErrUnprocessable = newKind("unprocessable")

	// ErrSuppressedRecipient is a 422 from a send: the recipient hard-bounced
	// or complained before. Do not retry.
	ErrSuppressedRecipient = newKind("recipient is suppressed")

	// ErrRateLimit is a 429. See Error.RetryAfter.
	ErrRateLimit = newKind("rate limited")

	// ErrServer is a 5xx. Retried automatically before you see it.
	ErrServer = newKind("server error")

	// ErrConnection means the request never got an answer: DNS, TCP, TLS, or
	// the client-side timeout. Error.Unwrap also yields the underlying cause.
	ErrConnection = newKind("connection failed")

	// ErrSignatureVerification means a webhook's X-Announcer-Signature did not
	// check out. Treat the delivery as hostile.
	ErrSignatureVerification = newKind("webhook signature verification failed")
)

Sentinel errors for errors.Is. Every API failure returns an *Error that unwraps to exactly one of these (a suppressed recipient unwraps to both ErrSuppressedRecipient and ErrUnprocessable), so callers can branch on the kind without type-asserting:

if errors.Is(err, announcer.ErrSuppressedRecipient) {
    // they bounced or complained before; stop mailing them
}

Use errors.As to reach the details:

var apiErr *announcer.Error
if errors.As(err, &apiErr) {
    log.Printf("status=%d request=%s", apiErr.StatusCode, apiErr.RequestID)
}

Functions

This section is empty.

Types

type APIKey

type APIKey struct {
	ID   string `json:"id"`
	Name string `json:"name"`

	// Prefix is the first few characters, for telling keys apart in a list.
	Prefix string `json:"prefix"`

	Scope      string     `json:"scope"`
	CreatedAt  time.Time  `json:"created_at"`
	LastUsedAt *time.Time `json:"last_used_at"`
	RevokedAt  *time.Time `json:"revoked_at"`
}

APIKey is a key, minus the secret.

func (APIKey) Revoked

func (k APIKey) Revoked() bool

Revoked reports whether the key has been revoked.

type APIKeysService

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

APIKeysService covers issuing and revoking API keys. Needs a full-scoped key.

func (*APIKeysService) Create

func (s *APIKeysService) Create(ctx context.Context, name, scope string) (*CreatedAPIKey, error)

Create issues a key. The secret is in the response and nowhere else — the API stores only its hash.

Pass ScopeSend for anything that only sends mail: a leaked send key cannot register domains, mint more keys, or reach billing. An empty scope means ScopeFull.

func (*APIKeysService) List

func (s *APIKeysService) List(ctx context.Context) ([]APIKey, error)

List returns every key on the account. Secrets are never included.

func (*APIKeysService) Revoke

func (s *APIKeysService) Revoke(ctx context.Context, keyID string) error

Revoke revokes a key. The row stays, so LastUsedAt remains auditable.

type Addresses

type Addresses []string

Addresses is one or more email addresses.

Its underlying type is []string, so a slice literal assigns directly:

To: []string{"a@example.com", "b@example.com"},

For the common single-address case, Address reads better:

To: announcer.Address("a@example.com"),

func Address

func Address(address string) Addresses

Address wraps one address, for the common case.

func AddressList

func AddressList(addresses ...string) Addresses

AddressList wraps several addresses.

func (Addresses) First

func (a Addresses) First() string

First returns the first address, or "" when there are none.

type Attachment added in v0.2.0

type Attachment struct {
	// Filename is the name the recipient sees, with its extension.
	Filename string `json:"filename"`

	// Content is the file's bytes. encoding/json writes a []byte as base64,
	// which is what the API takes, so pass the bytes as they are.
	Content []byte `json:"content"`

	// ContentType is the media type. Guessed from Filename when empty.
	ContentType string `json:"content_type,omitempty"`

	// ContentID makes this an inline image: the HTML shows it as
	// <img src="cid:logo"> when this is "logo".
	ContentID string `json:"content_id,omitempty"`
}

Attachment is a file to attach, or an image for the HTML to show inline.

Attachments are sent and never stored: not their contents, and not their names.

type BatchSendResult

type BatchSendResult struct {
	To string

	// Result is set when Err is nil.
	Result *SentEmail

	// Err is set when the send failed. Inspect it with errors.Is/errors.As.
	Err error
}

BatchSendResult is one recipient's outcome from EmailsService.SendMany.

func (BatchSendResult) OK

func (r BatchSendResult) OK() bool

OK reports whether this recipient's send succeeded.

type Client

type Client struct {

	// Emails covers sending and send history. Works with either key scope.
	Emails *EmailsService

	// Domains covers registering and verifying sending domains. Needs a
	// full-scoped key.
	Domains *DomainsService

	// APIKeys covers issuing and revoking keys. Needs a full-scoped key.
	APIKeys *APIKeysService

	// Webhooks covers endpoints and delivery verification. Needs a
	// full-scoped key for the endpoint calls.
	Webhooks *WebhooksService

	// Suppressions lists addresses that hard-bounced or complained.
	Suppressions *SuppressionsService
	// contains filtered or unexported fields
}

Client talks to the Announcer API. Build one with New.

func New

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

New builds a client.

Pass an empty apiKey to read ANNOUNCER_API_KEY from the environment; the base URL likewise falls back to ANNOUNCER_BASE_URL and then DefaultBaseURL. It returns an error only when no key can be found.

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL reports the API root this client talks to.

func (*Client) Send

func (c *Client) Send(ctx context.Context, req *SendEmailRequest) (*SentEmail, error)

Send is shorthand for Emails.Send — the one call most integrations make.

func (*Client) Usage

func (c *Client) Usage(ctx context.Context) (*Usage, error)

Usage reports consumption against this account's limits, plus a 14-day sending series for charting.

type CreatedAPIKey

type CreatedAPIKey struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Prefix string `json:"prefix"`
	Scope  string `json:"scope"`

	// Key is the full secret. Only ever present here.
	Key string `json:"key"`
}

CreatedAPIKey is a newly minted key. Key is shown once and never again — the API stores only its hash.

type CreatedDomain

type CreatedDomain struct {
	ID       string `json:"id"`
	Domain   string `json:"domain"`
	Selector string `json:"selector"`
	Verified bool   `json:"verified"`

	// DNS holds every record to publish. Publish them all, then call Verify.
	DNS []DNSRecord `json:"dns"`
}

CreatedDomain is a freshly registered domain, including the record to publish.

type CreatedWebhookEndpoint

type CreatedWebhookEndpoint struct {
	ID  string `json:"id"`
	URL string `json:"url"`

	// Secret is the whsec_... value to pass to VerifyWebhook.
	Secret string `json:"secret"`
}

CreatedWebhookEndpoint is a newly registered endpoint. Secret is shown once.

type DNSRecord

type DNSRecord struct {
	Type string `json:"type"`

	// Name is the record name, e.g. mail._domainkey.acme.com.
	Name string `json:"name"`

	// Value is the record value, e.g. v=DKIM1; k=rsa; p=MIIBIjANBg...
	Value string `json:"value"`

	// Purpose is why the record exists, in a sentence you can show a user.
	Purpose string `json:"purpose"`
}

DNSRecord is a record the domain owner has to publish.

type Domain

type Domain struct {
	ID     string `json:"id"`
	Domain string `json:"domain"`

	// Selector is the DKIM selector. Always "mail"; the API ignores
	// client-supplied selectors.
	Selector string `json:"selector"`

	// VerifiedAt is when verification first succeeded, or nil.
	VerifiedAt *time.Time `json:"verified_at"`

	CreatedAt time.Time `json:"created_at"`
}

Domain is a sending domain, as returned by DomainsService.List.

func (Domain) Verified

func (d Domain) Verified() bool

Verified reports whether the DKIM record has been seen in DNS and matched.

type DomainDNS

type DomainDNS struct {
	ID       string      `json:"id"`
	Domain   string      `json:"domain"`
	Verified bool        `json:"verified"`
	DNS      []DNSRecord `json:"dns"`
}

DomainDNS is the record set for an existing domain, re-derived from the stored public key.

type DomainVerification

type DomainVerification struct {
	ID       string `json:"id"`
	Verified bool   `json:"verified"`
}

DomainVerification is the outcome of a verification attempt.

type DomainsService

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

DomainsService covers registering and verifying sending domains. Every call here needs a full-scoped key.

func (*DomainsService) Create

func (s *DomainsService) Create(ctx context.Context, domain string) (*CreatedDomain, error)

Create registers a sending domain and returns the DNS record to publish.

Registration generates an RSA keypair, so the API rate-limits it hard — roughly one a minute. Publish every record in CreatedDomain.DNS, then call Verify. A domain that is already registered fails with ErrConflict.

func (*DomainsService) DNS

func (s *DomainsService) DNS(ctx context.Context, domainID string) (*DomainDNS, error)

DNS returns the records for a domain, re-derived from the stored public key — so "what was I supposed to publish?" stays answerable after registration.

func (*DomainsService) Delete

func (s *DomainsService) Delete(ctx context.Context, domainID string) error

Delete removes the domain and its signing key. Send history survives; sending from the domain stops immediately.

func (*DomainsService) List

func (s *DomainsService) List(ctx context.Context) ([]Domain, error)

List returns every domain on the account, newest first.

func (*DomainsService) Verify

func (s *DomainsService) Verify(ctx context.Context, domainID string) (*DomainVerification, error)

Verify resolves the DKIM record and compares it to the key we issued.

This is a real DNS lookup, not a self-report: it only succeeds on an actual match. A missing or mismatched record fails with ErrUnprocessable. DNS propagation takes minutes to hours — retry rather than re-registering.

type EmailsService

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

EmailsService covers sending mail and reading send history. Both work with either key scope.

func (*EmailsService) Events

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

Events returns the audit trail for one message: every status transition, oldest first.

func (*EmailsService) List

func (s *EmailsService) List(ctx context.Context, opts *ListMessagesOptions) ([]Message, error)

List returns send history, newest first. A nil opts asks for the API's defaults.

func (*EmailsService) Send

Send sends one email.

sent, err := client.Emails.Send(ctx, &announcer.SendEmailRequest{
    From:    "Acme <billing@acme.com>",
    To:      []string{"customer@example.com", "partner@example.com"},
    Cc:      announcer.Address("accounting@acme.com"),
    ReplyTo: announcer.Address("support@acme.com"),
    Subject: "Your receipt",
    Text:    "Thanks!",
})

To and Cc go out as one email whose recipients see each other; Bcc recipients see nobody. At most 50 addresses across the three.

Attachments take the file's bytes:

pdf, _ := os.ReadFile("invoice.pdf")
sent, err := client.Emails.Send(ctx, &announcer.SendEmailRequest{
    From:        "billing@acme.com",
    To:          announcer.Address("customer@example.com"),
    Subject:     "Invoice 1042",
    Text:        "Your invoice is attached.",
    Attachments: []announcer.Attachment{{Filename: "invoice-1042.pdf", Content: pdf}},
})

An Idempotency-Key is generated when SendEmailRequest.IdempotencyKey is empty, so the SDK's automatic retries can never send twice. Set it yourself — an order id, a job id — to extend that guarantee across process restarts.

A recipient on the account's suppression list is dropped and reported in SentEmail.Suppressed; the rest of the message still goes out. Only when every recipient is suppressed does this return an error.

Failures worth branching on: ErrPermission (the From domain is not registered or not verified), ErrSuppressedRecipient (every recipient bounced or complained before), ErrRateLimit (a limit or a quota; see Error.RetryAfter — quota counts recipients, so one call can consume several).

func (*EmailsService) SendMany

func (s *EmailsService) SendMany(
	ctx context.Context,
	recipients []string,
	msg *SendEmailRequest,
	opts *SendManyOptions,
) ([]BatchSendResult, error)

SendMany sends the same message to several recipients as separate emails, one API call each, and returns a result per recipient in the order given.

This is not the same as putting several addresses in SendEmailRequest.To:

  • To: []string{a, b} is one email. A and B see each other in the To header, it costs one request, and one bounce marks one message.
  • SendMany([]string{a, b}, …) is two emails. Neither knows the other exists, each gets its own idempotency key and its own bounce, and one failure leaves the other untouched.

Use this one for anything list-shaped — a newsletter, a digest, a fan-out. A Cc on msg is copied on every message, so a three-recipient batch sends the Cc three copies; that is usually not what you want.

The returned error is non-nil only when the context was cancelled; the results gathered up to that point are still returned. Per-recipient failures live in BatchSendResult.Err.

type Error

type Error struct {
	// StatusCode is the HTTP status, or 0 when the request never reached the API.
	StatusCode int

	// Message is the most useful sentence the API gave us.
	Message string

	// Title and Detail come from the RFC 9457 problem body, when there was one.
	Title  string
	Detail string

	// Errors holds per-field messages on a 400. Field names are verbatim.
	Errors map[string][]string

	// RetryAfter is set on a 429, from the Retry-After header.
	RetryAfter time.Duration

	// Recipient is set when a send was refused because the address is
	// suppressed. It is the first entry of Suppressed when the API named any,
	// and otherwise the address the SDK sent.
	Recipient string

	// Suppressed holds every address the API refused, from the RFC 9457
	// extension member on a suppression refusal. Prefer it over parsing
	// Message: it is there precisely so clients need not read the prose.
	Suppressed []string

	// RequestID is the response's x-request-id. Quote it in support.
	RequestID string

	// Raw is the error body exactly as the API sent it, for anything this
	// struct does not model.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

Error is the single error type returned for every API failure.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

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

Unwrap yields the sentinel kinds (and the transport cause, for connection failures) so errors.Is works against any of them.

type ListMessagesOptions

type ListMessagesOptions struct {
	// Limit is clamped to 1-200 by the API. Zero means the API default of 50.
	Limit int

	// Status filters by one of the Status* constants.
	Status string

	// Search matches a substring of the recipient address.
	Search string
}

ListMessagesOptions filters send history. Zero values are omitted.

type Message

type Message struct {
	ID        string  `json:"id"`
	MessageID *string `json:"message_id"`
	From      string  `json:"header_from"`

	// To is the primary recipient — the first To address. A message with Cc,
	// Bcc or several To addresses reports its first here and the total in
	// RecipientCount.
	To string `json:"recipient"`

	// RecipientCount is how many addresses the message went to, across To, Cc
	// and Bcc.
	RecipientCount int `json:"recipient_count"`

	// AttachmentCount is how many files were attached. Their names and
	// contents are not kept.
	AttachmentCount int `json:"attachment_count"`

	// ReplyTo is the Reply-To header that went out, if any.
	ReplyTo *string `json:"reply_to"`

	Subject *string `json:"subject"`

	// Status is the rolled-up status. One bounced recipient makes the whole
	// message bounced — it is the thing you have to act on.
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
}

Message is one row of send history.

The json tags are the API's own names; the Go field names are the ones the rest of this package uses. header_from and recipient are the database's vocabulary, while the webhook payload already says from/to — one vocabulary is worth the mapping.

type MessageEvent

type MessageEvent struct {
	ID    int64  `json:"id"`
	Event string `json:"event"`

	// Payload is event-specific data, left exactly as the API sent it — these
	// keys are tenant data.
	Payload map[string]any `json:"payload"`

	CreatedAt time.Time `json:"created_at"`
}

MessageEvent is one entry in a message's audit trail.

type Option

type Option func(*Client)

Option configures a Client.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL points the client at a different API root, such as a mock server in tests.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient supplies your own *http.Client (proxies, custom TLS, instrumentation). Its Timeout, if set, applies per attempt.

func WithHeader

func WithHeader(name, value string) Option

WithHeader adds a header to every request.

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries sets how many extra attempts follow a retryable failure. Default 2, so three attempts in all. Zero disables retries.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the per-attempt timeout. Default 30s.

func WithUserAgent

func WithUserAgent(ua string) Option

WithUserAgent appends your application's name to the SDK's own User-Agent.

type SendEmailRequest

type SendEmailRequest struct {
	// From is the sender. Its domain must be registered to this account.
	From string `json:"from"`

	// To holds the primary recipients. At least one is required.
	To Addresses `json:"to"`

	// Cc holds carbon copies, visible to every other recipient.
	Cc Addresses `json:"cc,omitempty"`

	// Bcc holds blind copies. They receive the message; nobody — including
	// the other blind copies — sees that they did.
	Bcc Addresses `json:"bcc,omitempty"`

	// ReplyTo is where replies should go. A header only: no delivery, nothing
	// billable, nothing that can bounce.
	ReplyTo Addresses `json:"reply_to,omitempty"`

	Subject string `json:"subject,omitempty"`

	// Text is the plain-text body. Supply it even alongside HTML — filters
	// like seeing both.
	Text string `json:"text,omitempty"`

	// HTML is the HTML body.
	HTML string `json:"html,omitempty"`

	// Attachments are files to attach, or images for the HTML to show
	// inline. At most 20, and 10 MB per message in all, Text and HTML
	// included. Executables (.exe, .js, .bat, ...) are refused, because Gmail
	// and most mail servers would refuse them anyway.
	Attachments []Attachment `json:"attachments,omitempty"`

	// IdempotencyKey makes the send exactly-once. Left empty, the SDK
	// generates one per call so its own retries cannot double-send; set it
	// yourself (an order id, a job id) to keep that guarantee across process
	// restarts. Not serialised — it travels as a header.
	IdempotencyKey string `json:"-"`
}

SendEmailRequest is one email to send.

Every address accepts either a bare address (billing@acme.com) or a display name (Acme Billing <billing@acme.com>). At least one of Text or HTML is required, and at most 50 addresses across To, Cc and Bcc combined.

To and Cc go out as one email whose recipients see each other; Bcc recipients see nobody. For separate emails that share nothing, use EmailsService.SendMany.

type SendManyOptions

type SendManyOptions struct {
	// StopOnError abandons the remaining recipients after the first failure.
	// Off by default: one suppressed address should not sink a batch.
	StopOnError bool
}

SendManyOptions tunes SendMany.

type SentEmail

type SentEmail struct {
	// ID is Announcer's id for the message. Use it with EmailsService.Events.
	ID string `json:"id"`

	// MessageID is the RFC 5322 Message-ID the MTA assigned.
	MessageID *string `json:"messageId"`

	Status string `json:"status"`

	// IdempotentReplay is true when this key had already been used: nothing
	// was sent a second time and these are the original send's details.
	IdempotentReplay bool `json:"idempotentReplay"`

	// Recipients is how many addresses the message went to, across To, Cc and
	// Bcc. This is the number billed and counted against quota.
	Recipients int `json:"recipients"`

	// Suppressed holds addresses dropped because they are on the account's
	// suppression list. Empty on a clean send — the rest of the message still
	// went out. Only when every recipient is suppressed does the send fail.
	Suppressed []string `json:"suppressed"`
}

SentEmail is the result of a successful send.

type Suppression

type Suppression struct {
	ID    string `json:"id"`
	Email string `json:"email"`

	// Reason is why it was suppressed, e.g. "hard bounce (5.1.1 user unknown)".
	Reason    string    `json:"reason"`
	CreatedAt time.Time `json:"created_at"`
}

Suppression is an address Announcer refuses to send to.

type SuppressionsService

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

SuppressionsService lists addresses that hard-bounced or complained. Works with either key scope.

func (*SuppressionsService) List

func (s *SuppressionsService) List(ctx context.Context, limit int) ([]Suppression, error)

List returns addresses Announcer refuses to send to, newest first. A limit of zero asks for the API's default of 50.

type Usage

type Usage struct {
	SentToday      int64  `json:"sentToday"`
	DailySendLimit int    `json:"dailySendLimit"`
	Domains        int64  `json:"domains"`
	MaxDomains     int    `json:"maxDomains"`
	Plan           string `json:"plan"`
	SentThisPeriod int64  `json:"sentThisPeriod"`

	PeriodStart time.Time `json:"periodStart"`

	// MonthlyIncludedMessages is nil on plans with no monthly accounting.
	MonthlyIncludedMessages *int `json:"monthlyIncludedMessages"`

	// MonthlyHardCap is the spend ceiling. Nil when uncapped.
	MonthlyHardCap *int `json:"monthlyHardCap"`

	// OverageMinorUnits is what the current period's overage would cost.
	OverageMinorUnits *int64 `json:"overageMinorUnits"`

	// Series is the last 14 days, oldest first. Days with no sends are
	// present as zeroes rather than missing.
	Series []UsagePoint `json:"series"`
}

Usage is consumption against this account's limits.

type UsagePoint

type UsagePoint struct {
	// Date is YYYY-MM-DD.
	Date      string `json:"date"`
	Sent      int64  `json:"sent"`
	Delivered int64  `json:"delivered"`

	// Failed is bounced + complained + failed. A subset of Sent, not
	// additional to it.
	Failed int64 `json:"failed"`
}

UsagePoint is one day of the 14-day sending series.

type VerifyOption

type VerifyOption func(*verifyConfig)

VerifyOption tunes VerifyWebhook.

func WithClock

func WithClock(now time.Time) VerifyOption

WithClock fixes the current time. For tests.

func WithTolerance

func WithTolerance(d time.Duration) VerifyOption

WithTolerance sets the clock-skew allowance. Zero disables the timestamp check entirely, which also disables replay protection — do that only if something else in your stack already provides it.

type WebhookEndpoint

type WebhookEndpoint struct {
	ID         string     `json:"id"`
	URL        string     `json:"url"`
	CreatedAt  time.Time  `json:"created_at"`
	DisabledAt *time.Time `json:"disabled_at"`
}

WebhookEndpoint is a registered endpoint.

func (WebhookEndpoint) Disabled

func (w WebhookEndpoint) Disabled() bool

Disabled reports whether the endpoint has been disabled.

type WebhookEvent

type WebhookEvent struct {
	// Event is one of the Event* constants.
	Event      string              `json:"event"`
	OccurredAt time.Time           `json:"occurredAt"`
	Message    WebhookEventMessage `json:"message"`

	// Detail is event-specific data, e.g. {"dsnStatus": "5.1.1 ..."} on a bounce.
	Detail map[string]any `json:"detail"`
}

WebhookEvent is a verified webhook delivery.

func VerifyWebhook

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

VerifyWebhook checks a webhook delivery and returns the parsed event.

Pass the raw request body — the exact bytes Announcer sent. Re-serialising a parsed struct reorders keys and changes whitespace, and the signature will not match:

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 := announcer.VerifyWebhook(
        body,
        r.Header.Get("X-Announcer-Signature"),
        os.Getenv("ANNOUNCER_WEBHOOK_SECRET"),
    )
    if err != nil {
        http.Error(w, "", http.StatusBadRequest)
        return
    }
    ...
}

Errors match ErrSignatureVerification. The timestamp is inside the MAC, so the staleness check is what actually stops a captured delivery being replayed at you later.

type WebhookEventMessage

type WebhookEventMessage struct {
	ID      string  `json:"id"`
	From    string  `json:"from"`
	To      string  `json:"to"`
	Subject *string `json:"subject"`
	Status  string  `json:"status"`
}

WebhookEventMessage is the message summary carried on a webhook delivery.

type WebhooksService

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

WebhooksService covers webhook endpoints. Registering needs a full-scoped key; verifying a delivery needs nothing but the secret.

func (*WebhooksService) Create

func (s *WebhooksService) Create(ctx context.Context, endpointURL string) (*CreatedWebhookEndpoint, error)

Create registers an endpoint. Maximum two active per account.

The returned Secret crosses the wire exactly once — store it now and pass it to VerifyWebhook on every delivery.

func (*WebhooksService) Delete

func (s *WebhooksService) Delete(ctx context.Context, endpointID string) error

Delete disables an endpoint. Pending deliveries stop; history stays auditable.

func (*WebhooksService) List

List returns every endpoint on the account, including disabled ones.

func (*WebhooksService) Verify

func (s *WebhooksService) Verify(body []byte, signatureHeader, secret string, opts ...VerifyOption) (*WebhookEvent, error)

Verify checks a delivery's signature and returns the parsed event. It is a method for discoverability; VerifyWebhook is the same function and needs no client.

Jump to

Keyboard shortcuts

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