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
- func Handler(store state.Reader, lookup ProcessLookup, reg *Registry) job.Handler
- func NormalizeSMTPEndpoint(endpoint string) (string, error)
- func Probe(ctx context.Context, c Client) error
- func Run(ctx context.Context, j Job, reg *Registry) error
- type Client
- type Connector
- type GmailClient
- type GraphClient
- type Job
- type Message
- type Outbox
- type OutboxMessage
- type PreviewClient
- type Prober
- type ProcessLookup
- type ProviderConfig
- type Registry
- type SMTPClient
- type Sink
- type TokenSource
Constants ¶
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).
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 ¶
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
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
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
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 ¶
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.
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.
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
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
func (o *Outbox) Add(m OutboxMessage) OutboxMessage
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) 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
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 ¶
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).
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.