mail

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: AGPL-3.0 Imports: 31 Imported by: 0

Documentation

Overview

Package mail integrates an outbound e-mail provider as a server-registered Atlas connector: a BPMN mail connector task sends a model-authored message through a configured provider via the job path (ADR-0079), mirroring how the clio package delegates an append to a registry-managed endpoint (ADR-0036). The integration inherits the job protocol's durability and non-blocking properties (ADR-0007):

  • A connector task creates a job carrying the reserved compiler.MailJobType. The processor never performs the outbound send itself, so it stays allocation-free (invariant I1) and free of any SMTP dependency.
  • The in-process Handler — a job worker — pulls those jobs, sends the message off the processor goroutine and after fsync (invariant I2, never inside applyToState / I4), and completes the job, which drives the token onward.
  • The provider host and credentials live in a server-side Registry keyed by connector name, so a model refers to a provider by name only and never carries a host or a secret (ADR-0036/0041). Only the message (recipients, subject, body) is authored in the model, like a REST task's endpoint (ADR-0067).

The first provider is SMTP (SMTPClient), which reaches Google, Microsoft 365, and any standards-compliant server via its submission endpoint; native Gmail / Microsoft Graph API providers are additive behind the same Client seam (ADR-0079).

Delivery is at-least-once (a crash between "the provider accepted the message" and "job completed" replays the send); every message carries the job key as its RFC 5322 Message-ID so a provider or downstream de-duplicator can recognize a replayed send rather than delivering it twice.

Index

Constants

View Source
const (
	ProviderSMTP      = "smtp"
	ProviderGmail     = "gmail"
	ProviderMicrosoft = "microsoft"
)

Provider identifiers for a managed mail connector. SMTP (the default) reaches any submission server; Gmail and Microsoft are the native provider APIs (ADR-0079/0081). ProviderPreview, declared beside its outbox, is the fourth: it frames a message like the others and delivers it in-server instead of sending it (ADR-0150).

View Source
const ProviderPreview = "preview"

ProviderPreview is the zero-configuration mail provider: a connector that frames every message exactly like a real one and then delivers it to an in-server Outbox instead of the internet (ADR-0150).

It exists for the first message someone ever sends from a process. Every other provider asks for a submission host, or an OAuth app registration and a refresh token in the vault, *before* the author can see whether their subject line renders — so the first run of a mail task fails on infrastructure the author was not yet thinking about. Preview removes that ordering: model the message, run it, read it, and configure a real provider once there is something worth delivering.

It is a rehearsal, not a bypass. The message is framed by the same buildRFC822 the SMTP and Gmail providers send, and a message that a real provider would refuse — no sender, no recipients — is refused here too, with the same shape of error. What a preview run proves about a message therefore stays true after the switch to real sending; only the transport changes.

Variables

This section is empty.

Functions

func Handler

func Handler(store state.Reader, lookup ProcessLookup, reg *Registry) job.Handler

Handler builds a job handler that performs an outbound mail connector task. Register it with a job.Runner for the reserved compiler.MailJobTypeIndex; the runner then pulls activatable mail jobs, and for each the handler resolves the task's connector/recipients/subject/body from the compiled process — evaluating any FEEL field over the variables the task sees, up its scope chain (the fx toggle, ADR-0067/0068) — resolves the named connector's provider client from reg, and sends the message keyed by the job key so an at-least-once retry de-duplicates (ADR-0079). Returning an error leaves the job pending (retry, then an incident, ADR-0061); the runner completes it only on success.

func NormalizeSMTPEndpoint added in v0.3.0

func NormalizeSMTPEndpoint(endpoint string) (string, error)

NormalizeSMTPEndpoint canonicalizes an operator-written SMTP submission endpoint into the "host:port" form net/smtp requires, or explains why it cannot.

This exists because the shapes a human writes for a mail server — "mx1.example.ch", "smtp://mx1.example.ch", "smtps://mx1.example.ch/" — are all obviously *meant* as a submission endpoint, but only one of them dials. Passing an endpoint without a port straight through fails deep inside the send with "missing port in address", which surfaces as an incident on a parked token hours after someone configured the connector — the failure is real, but it arrives at the wrong time, to the wrong person, in the wrong words. Normalizing at the boundary turns three of those into a working connector and the rest into a message at the moment of typing.

The rules, in order: an optional "smtp://" or "smtps://" scheme is consumed (smtps selects implicit TLS, hence port 465); a path, query or fragment is dropped, so a pasted URL works; a bare IPv6 address is bracketed; and a missing port becomes the submission default. Host and port are then checked, so what comes back either dials or is rejected here. The returned error is a complete sentence, suitable for showing to whoever typed the endpoint.

func Probe added in v0.3.0

func Probe(ctx context.Context, c Client) error

Probe checks a client as far as it can be checked without sending anything (ADR-0150). A nil error means the configuration works: the server answered, the credential was accepted, the connector is ready to carry a message.

func Run added in v0.3.0

func Run(ctx context.Context, j Job, reg *Registry) error

Run sends a resolved job through the caller's own registry. It is the whole of the worker's half, and the in-process path calls it too, so there is one definition of what a resolved mail task means rather than two that drift.

The connector lookup comes first: an unconfigured name is the more actionable of the two failures a job can carry here, and reporting it ahead of an empty recipient list keeps the message an operator sees pointed at the fix.

Types

type Client

type Client interface {
	Send(ctx context.Context, m Message) error
}

Client sends a Message through one configured mail provider. It is an interface so the worker is testable without a live server and so a connector name binds to exactly one provider (SMTP today; a native Gmail / Graph provider is additive).

func NewProviderClient

func NewProviderClient(cfg ProviderConfig) (Client, error)

NewProviderClient builds the mail client for a managed connector, dispatching on its provider. SMTP is the default; Gmail and Microsoft Graph parse the credential bundle and build an OAuth token source. A misconfigured connector returns an error so the caller can skip it (its tasks park) rather than sending wrongly. This is the single place a new provider is added.

type Connector

type Connector struct {
	Endpoint string
	Username string
	Password string
	From     string
	// ImplicitTLS opens the connection with a TLS handshake instead of upgrading a
	// plaintext one with STARTTLS. [NewSMTPClient] derives it from the endpoint's
	// port (465, the RFC 8314 submissions port), which is what an "smtps://" endpoint
	// normalizes to; a caller can also set it outright.
	ImplicitTLS bool
}

Connector is the server-side configuration of one SMTP mail provider: the submission Endpoint ("host:port"), the auth Username and Password (the Password is the resolved secret — an app password or account password — held only at call time, never persisted, I6), and the default From address a task that authors no sender falls back to.

type GmailClient

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

GmailClient sends mail through the Gmail API (ADR-0093). It posts a base64url-encoded RFC 5322 message to /users/me/messages/send with a bearer token from its TokenSource; "me" resolves to the authenticated user (the impersonated subject under a service account, or the refresh token's user). It frames the message with the same MIME builder the SMTP client uses.

func NewGmailClient

func NewGmailClient(tokens TokenSource, baseURL, sender string) *GmailClient

NewGmailClient builds a Gmail mail client. baseURL defaults to the Gmail v1 API when empty; sender is the default From address a task without one falls back to.

func (*GmailClient) Probe added in v0.3.0

func (c *GmailClient) Probe(ctx context.Context) error

Probe acquires an access token, which is exactly the step that fails when a Gmail refresh token has been revoked or has expired — the failure that otherwise appears as "invalid_grant" on a parked token days after the credential was configured, on an OAuth client left in Testing publishing status. A cached, still-valid token answers without a round trip, which is the same thing a send would do.

func (*GmailClient) Send

func (c *GmailClient) Send(ctx context.Context, m Message) error

type GraphClient

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

GraphClient sends mail through the Microsoft Graph sendMail API (ADR-0093). It posts a structured message to /users/{mailbox}/sendMail with a bearer token from its TokenSource; the mailbox is the message's From or the connector's default sender. It reaches Microsoft 365 mailboxes with an app-only or refresh-token grant.

func NewGraphClient

func NewGraphClient(tokens TokenSource, baseURL, sender string) *GraphClient

NewGraphClient builds a Graph mail client. baseURL defaults to the Graph v1.0 API when empty; sender is the default mailbox to send as.

func (*GraphClient) Probe added in v0.3.0

func (c *GraphClient) Probe(ctx context.Context) error

Probe acquires an access token from the Graph token endpoint — a wrong tenant, client id, or expired client secret fails here rather than at the first send.

func (*GraphClient) Send

func (c *GraphClient) Send(ctx context.Context, m Message) error

type Job added in v0.3.0

type Job struct {
	// Connector names the worker's own configured provider. It is a name and not an
	// endpoint on purpose — an endpoint would be half a credential.
	Connector string   `json:"connector"`
	From      string   `json:"from,omitempty"`
	To        []string `json:"to"`
	Cc        []string `json:"cc,omitempty"`
	Bcc       []string `json:"bcc,omitempty"`
	Subject   string   `json:"subject,omitempty"`
	Body      string   `json:"body,omitempty"`
	HTML      string   `json:"html,omitempty"`
	// MessageID is the job key, so a message resent after a lease elapsed is
	// identifiable as the same one rather than looking like a second mail.
	MessageID string `json:"messageId,omitempty"`
}

Job is a mail task with everything already evaluated: the message, and the name of the connector that will carry it. It is what travels with a leased job.

Every field here is model-authored or instance-derived. None of it is a secret, and that is a property of the type rather than of the code that fills it in: there is nowhere in a Job to put a password.

func Resolve added in v0.3.0

func Resolve(store state.Reader, cp *compiler.CompiledProcess, detail *compiler.ConnectorTaskDetail, ei *model.ElementInstanceValue, elementInstanceKey, jobKey uint64) (Job, error)

Resolve turns a compiled mail connector task into a Job: the authored fields evaluated against the scope's variables. It is engine work by necessity — FEEL is compiled at deploy (ADR-0008/0015) and the scope lives in the store.

It deliberately does not validate that there is a recipient. That check belongs with the send, after the connector lookup, so an operator with both an unconfigured connector and an empty recipient list hears about the configuration first — that being the one they can act on.

type Message

type Message struct {
	From    string
	To      []string
	Cc      []string
	Bcc     []string
	Subject string
	Body    string
	// HTML is the optional HTML body (ADR-0079, amended). When set alongside Body the
	// message goes out as multipart/alternative — the plain text for clients that
	// cannot or will not render HTML, the markup for those that can; alone it is a
	// plain text/html message. Empty leaves the message exactly as it was before HTML
	// bodies existed: one text/plain part.
	HTML      string
	MessageID string
}

Message is one e-mail an outbound mail connector task sends. To is the required recipient list; Cc and Bcc are optional. From overrides the provider's default sender when set. MessageID is deterministic (the job key), so an at-least-once retry carries the same RFC 5322 Message-ID and can be de-duplicated rather than delivered twice.

type Outbox added in v0.3.0

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

Outbox is the bounded, newest-last mailbox every preview connector on a server delivers into — the "Outbox" view in Operations reads it.

It holds its own mutex, which is deliberate and is the one piece of shared state in this package that needs one: a mail worker writes it off the run loop and after fsync (I2/I3), while an HTTP handler reads it on a request goroutine, so neither the run-loop single-writer discipline nor a lock-free registry swap applies. It is explicitly *not* durable state — no event, no log, nothing replayed (I4/I6) — so a restart empties it, which is the honest behavior for a preview of something that was never sent.

func NewOutbox added in v0.3.0

func NewOutbox(capacity int) *Outbox

NewOutbox creates an outbox holding at most the newest capacity messages; a capacity below one falls back to the default.

func (*Outbox) Add added in v0.3.0

Add stamps a message with the next sequence number and the current time, stores it, and returns the stored copy. The oldest message is dropped once the outbox is full.

func (*Outbox) Clear added in v0.3.0

func (o *Outbox) Clear()

Clear empties the outbox, keeping the sequence counter so a message a reader already saw never has its number reused. The drop count starts over with the contents: after a clear there is no older message to have been dropped.

func (*Outbox) Deliver added in v0.3.0

func (o *Outbox) Deliver(m OutboxMessage) error

Deliver stores a message, satisfying Sink. It cannot fail: the outbox is this process's own memory, and the error exists for the sinks that reach another one.

func (*Outbox) Len added in v0.3.0

func (o *Outbox) Len() int

Len is how many messages the outbox currently holds.

func (*Outbox) Messages added in v0.3.0

func (o *Outbox) Messages(limit int) ([]OutboxMessage, bool)

Messages returns the newest limit messages, newest first, and whether older ones were left behind — by this limit, or earlier by the capacity bound. A limit below one returns everything the outbox holds.

type OutboxMessage added in v0.3.0

type OutboxMessage struct {
	Seq       uint64   `json:"seq"`
	Connector string   `json:"connector"`
	At        int64    `json:"at"` // unix nanoseconds the outbox accepted the message
	From      string   `json:"from"`
	To        []string `json:"to"`
	Cc        []string `json:"cc,omitempty"`
	Bcc       []string `json:"bcc,omitempty"`
	Subject   string   `json:"subject"`
	Body      string   `json:"body,omitempty"`
	HTML      string   `json:"html,omitempty"`
	MessageID string   `json:"messageId,omitempty"`
	Raw       string   `json:"raw"`
}

OutboxMessage is one message a preview connector delivered: the addressing and bodies the model authored, plus the framed RFC 5322 bytes that would have gone out. Raw is what makes a preview run worth reading — headers, MIME structure and encoding are the parts an author cannot check by re-reading their own model.

type PreviewClient added in v0.3.0

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

PreviewClient is the Client for ProviderPreview: it frames the message and delivers it to a sink.

func NewPreviewClient added in v0.3.0

func NewPreviewClient(outbox Sink, connector, sender string) *PreviewClient

NewPreviewClient binds a preview connector to the sink it delivers into.

func (*PreviewClient) Probe added in v0.3.0

func (c *PreviewClient) Probe(context.Context) error

Probe confirms the preview connector has an outbox to deliver into. There is nothing to dial and no credential to present, which is the whole point of the provider — so this succeeds, and says so, rather than pretending to check a network that is not involved.

func (*PreviewClient) Send added in v0.3.0

func (c *PreviewClient) Send(ctx context.Context, m Message) error

Send frames m and appends it to the outbox. The sender and recipient checks are the ones a real provider applies, so a message that previews cleanly is a message that can be sent (see ProviderPreview).

type Prober added in v0.3.0

type Prober interface {
	Probe(ctx context.Context) error
}

Prober is a Client that can check its own configuration without delivering a message. Every provider in this package implements it, each answering the question its own configuration actually raises: SMTP opens the session a send opens (connect, TLS, AUTH), a native provider acquires an access token, and preview confirms it has somewhere to deliver. It is a separate interface rather than a method on Client so that "can this be checked?" stays an honest question — a provider that genuinely cannot be checked short of sending says so instead of returning a hollow success.

type ProcessLookup

type ProcessLookup func(defKey uint64) *compiler.CompiledProcess

ProcessLookup resolves a process-definition key to its compiled process. The worker uses it to find the connector name and message fields a mail job belongs to, so one handler serves every deployed process.

type ProviderConfig

type ProviderConfig struct {
	Provider string
	Endpoint string
	Sender   string
	Secret   string
	Name     string
	Outbox   Sink
}

ProviderConfig is the per-connector data the server resolves before building a client: the provider, an optional endpoint override, the default sender, and the resolved Secret — an SMTP password, or (for a native provider) the OAuth credential JSON bundle held in the vault under the connector's credentialsRef (ADR-0093). The secret lives only here at build time, never in a model or an event (I6).

Name and Outbox serve the preview provider (ADR-0150), which delivers into the server's outbox under the connector's own name; every other provider ignores them. Outbox is a Sink rather than the concrete outbox because a mail worker runs in another process and delivers back over the wire (ADR-0168).

type Registry

type Registry = clientreg.Registry[Client]

Registry resolves a connector name to the Client for this kind. Connectors are registered at the server from managed configuration (endpoint plus credentials), so a model refers to a connector by name only (ADR-0036/0041).

It is the shared clientreg.Registry, which also carries *why* a configured connector is missing from it — the difference between "never configured" and "configured and broken", which is what a parked token has to be able to say (ADR-0158).

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates an empty connector registry.

type SMTPClient

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

SMTPClient sends a Message over SMTP (the submission endpoint of any standards compliant provider, including Google and Microsoft 365). It authenticates with the connector's username/password when a username is configured, and frames the message as a UTF-8 MIME e-mail (text, HTML, or both — see buildRFC822).

func NewSMTPClient

func NewSMTPClient(conn Connector) *SMTPClient

NewSMTPClient builds an SMTP mail client for a configured connector, backed by the [submit] transport: net/smtp's SendMail flow under the shared connector call budget (ADR-0149), extended to reach an implicit-TLS submissions server, which SendMail cannot (ADR-0150).

func (*SMTPClient) Probe added in v0.3.0

func (c *SMTPClient) Probe(ctx context.Context) error

Probe opens the session a send would open — connect, TLS, authenticate — and hangs up without a message (ADR-0150). It is what the connector form's check button calls: the failures it catches (a host that does not resolve, a port nothing listens on, a credential the server rejects) are exactly the ones that otherwise surface much later as an incident on a parked token.

func (*SMTPClient) Send

func (c *SMTPClient) Send(ctx context.Context, m Message) error

Send frames m as a UTF-8 MIME e-mail and submits it to the connector's SMTP endpoint. The sender is the message's From, or the connector's default From when the task authored none; a message with no sender and no default is a configuration error. Recipients are the union of To, Cc and Bcc (the SMTP envelope); Bcc addresses are delivered but never written into a header. A missing recipient or a send failure returns an error so the job stays pending and is retried (at-least-once).

type Sink added in v0.3.0

type Sink interface {
	// Deliver stores a message, or reports why it could not be stored. The in-process
	// outbox never fails; a sink that has to reach another process can, and a preview
	// task whose message did not arrive must fail rather than report a send nobody
	// can find.
	Deliver(OutboxMessage) error
}

Sink is where a preview connector delivers. Outbox is the one that lives in the server's own memory, and for as long as mail was an in-process connector it was the only one there could be.

It is an interface because mail now runs on a worker (ADR-0168), and a preview connector that framed its message in another process and appended it to *that* process's memory would have previewed into a window nobody can open. A worker binds a sink that hands the message back to the engine's outbox instead, so where the framing happened stops being visible to the person reading Operations › Outbox — which is the whole promise of the preview provider (ADR-0150).

type TokenSource

type TokenSource = oauth2.TokenSource

TokenSource yields a valid OAuth2 bearer access token for a provider API. The mechanism — caching, refresh timing, the token exchange — is the shared oauth2 package's; what stays here is this connector's policy: which grants it accepts, what its credential bundle looks like, and the one grant nobody else has.

Jump to

Keyboard shortcuts

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