A report-ready email can start as plain text and later gain HTML or an attached
file without changing how you submit it. Build those choices into a
mail.Message with mail.NewMessage. Recipients and body are required inputs;
the construction callback sets optional sender, subject, reply-to, and attachments.
Examples import arkoren.dev/core/service/mail and assume a
configured mailer. Send each completed value
with mailer.Send(ctx, message) as shown in Mail Overview.
These are focused composition variants, not consecutive declarations to paste
into one scope.
Message Inputs
Use application data that already identifies an authorized recipient and the content they may receive. Mailbox syntax validation does not authorize sending a private report:
type ReportEmail struct {
Recipient mail.Address
Subject string
Text string
HTML string
}
notice := ReportEmail{
Recipient: mail.Address{Name: "Ada", Email: "ada@example.com"},
Subject: "Your weekly report",
Text: "Your weekly report is ready in Acme.",
HTML: "<p>Your weekly report is ready in Acme.</p>",
}
Start with the text representation and optional subject:
message := mail.NewMessage(mail.Recipients{
To: []mail.Address{notice.Recipient},
}, mail.Body{Text: notice.Text}, func(letter *mail.MessageBuilder) {
letter.Subject(notice.Subject)
})
err := message.Err()
Err() reports the first construction error, wrapping mail.ErrInvalidMessage,
without requiring a sender that the mailer can supply. At least one recipient
across all groups and one nonempty body representation are required. Neither
can be repaired by the optional builder. The zero message is invalid.
Subject may be empty, but CR or LF is rejected. Each mail.Address requires
a bare mailbox in Email; Name is an optional display name. Address and display
name line breaks are rejected to prevent header injection. No DNS lookup or
recipient-ownership check is performed.
Recipient Groups
When support should see a copy while an audit recipient stays out of visible headers, select the groups explicitly:
recipients := mail.Recipients{
To: []mail.Address{notice.Recipient},
CC: []mail.Address{{Email: "support@acme.test"}},
BCC: []mail.Address{{Email: "audit@acme.test"}},
}
message := mail.NewMessage(recipients, mail.Body{Text: notice.Text})
CC is visible to recipients; BCC is not placed in visible delivery headers.
BCC-only mail is valid. This boundary does not conceal addresses from the
provider, your application, or the Log driver, whose summary
includes BCC. Treat recipient lists as sensitive data.
Body Alternatives
If your template produces HTML, provide plain text too for clients that cannot render it. Render and escape untrusted values before construction; Arkoren does not render templates or sanitize HTML:
message := mail.NewMessage(mail.Recipients{
To: []mail.Address{notice.Recipient},
}, mail.Body{Text: notice.Text, HTML: notice.HTML})
These are two representations of one email, not two submissions. Text-only and HTML-only messages are also valid. Arkoren does not derive one representation from the other; getters return exactly the supplied strings.
File Attachments
If the report is small enough for your provider’s encoded-message limit, attach
its already-loaded bytes. Here reportPDF is an application-owned []byte
containing the authorized report:
message := mail.NewMessage(mail.Recipients{
To: []mail.Address{notice.Recipient},
}, mail.Body{Text: "Your weekly report is attached."}, func(letter *mail.MessageBuilder) {
letter.Attachments(mail.Attachment{
Filename: "weekly-report.pdf",
ContentType: "application/pdf",
Data: reportPDF,
})
})
Filename, content type, and nonempty unencoded data are all required. Empty
ContentType does not select application/octet-stream. Omitted disposition
becomes mail.AttachmentDispositionAttachment, a downloadable file.
Attachments replaces the whole list; pass all files in one call rather than
calling it repeatedly to append.
Construction copies attachment records and bytes, so later changes to
reportPDF cannot change the message. That copying and encoding consume memory;
for large reports, use file storage and send an
authorized signed URL instead. Keep confidential
files out of diagnostic dumps even after construction.
Media types can include parameters such as text/plain; charset=UTF-8.
SMTP and SES preserve parameters through MIME encoding, except that Filename
supplies the name parameter as well as the disposition filename. Resend and
Postmark forward media types to their APIs; Mailgun’s SDK selects them instead.
Check the compatibility matrix before relying
on identical wire representations.
Inline Images
To embed a logo rather than offer it only as a download, give the inline
attachment a bare content ID and reference it through a matching HTML cid: URL.
Here logoPNG contains an already-loaded image:
message := mail.NewMessage(mail.Recipients{
To: []mail.Address{notice.Recipient},
}, mail.Body{HTML: `<p>Your report is ready.</p><img src="cid:logo.png">`},
func(letter *mail.MessageBuilder) {
letter.Attachments(mail.Attachment{
Filename: "logo.png",
ContentType: "image/png",
Data: logoPNG,
Disposition: mail.AttachmentDispositionInline,
ContentID: "logo.png",
})
})
An inline part requires ContentID; a regular attachment must omit it. Content
IDs cannot contain CR, LF, spaces, or angle brackets. Supply the bare ID, not
the cid: URL or a bracketed header value.
Using the filename as the ID also meets Mailgun’s restriction.
Postmark adds cid: only in its native payload; your
message keeps the bare value. Driver acceptance does not guarantee that a
particular mail client displays the image.
Immutable Values
A published message has getters, not mutable fields or setters. Read
From(), To(), CC(), BCC(), ReplyTo(), Subject(), TextBody(),
HTMLBody(), or Attachments(); construct another message to change content.
Recipient lists are captured before callbacks. Optional lists and attachment
bytes are copied when set and again at publication. Slice getters return
independent copies, including each attachment’s bytes.
With a message that has at least one attachment, preparing an export copy does not rewrite the original message:
files := message.Attachments()
files[0].Filename = "download.pdf"
Only files changes; sending message still uses its original filename and bytes.
Retaining the construction draft cannot change the published value either.
Callbacks run once, synchronously in order on a shared draft, even when required inputs are invalid. Optional setters replace previous values, but the first construction error remains sticky after replacement. Fix invalid input by constructing a new message, not by overwriting a bad setting later. Supplied nil callbacks and callback panics propagate; expanding a nil or empty callback slice supplies no edits.
Validate() checks a complete message including the sender, without applying
defaults. An omitted sender can pass Err() but fail Validate() until the
mailer completes it. Direct driver sends and MIME encoding require the complete
form. An explicitly invalid MessageBuilder.From is a construction error, not
a request to fall back to a sender default.
MIME Encoding
SMTP and SES encode MIME automatically. If an integration needs raw bytes, construct a message with an explicit valid sender and pass a modern RFC 5322 Message-ID; this function does not apply mailer defaults:
contents, err := mail.EncodeMIME(message, "<weekly-report-42@acme.test>")
On success, contents is newly owned sensitive message data. BCC is excluded
from visible headers. Text and HTML bodies are quoted-printable encoded, and
related multipart containers declare their actual root media type: text/plain,
text/html, or multipart/alternative. This also holds when regular attachments
place that container inside multipart/mixed.
Validation alone does not guarantee encoding. Unformattable metadata or any
physical line longer than 998 bytes excluding CRLF returns an error wrapping
mail.ErrInvalidMessage and no output. Existing address and encoded-word folding
is retained; oversized plain headers and attachment metadata are rejected, not
truncated or rewritten to fit. SMTP and SES stop before submission on these errors.
The supplied Message-ID must be unfolded and uncommented, with angle brackets
around a dot-atom left side, @, and a dot-atom or bracketed no-fold-literal
right side. Dot-atoms contain nonempty ASCII components separated by single
dots. Empty IDs, whitespace, comments, controls, obsolete quoted forms, and
malformed syntax fail with mail.ErrInvalidMessage and no bytes. Accepted IDs
are preserved verbatim, including case and punctuation.
To generate an ID, call mail.NewMessageID("mail.acme.test"). It validates the
domain with the same right-side syntax and returns an empty ID with
mail.ErrInvalidMessage for malformed input. Valid domain bytes are preserved,
except raw IPv6 is wrapped as [IPv6:address]. Neither function checks DNS or
domain ownership; random-ID generation can also return an entropy-source error.
EncodeMIME checks the physical-line limit separately.
For every function and option, see the
mail API reference.