Fluent email composition and pluggable delivery for GoForj packages and apps.
Go Reference
License: MIT
CI
Go version
Latest tag
Codecov
Unit tests (executed count)
mail coverage
mailfake coverage
maillog coverage
mailmailgun coverage
mailpostmark coverage
mailresend coverage
mailsendgrid coverage
mailses coverage
mailsmtp coverage
go get github.com/goforj/mail
package main import ( "context" "log" "github.com/goforj/mail" "github.com/goforj/mail/mailsmtp" ) func main() { driver, err := mailsmtp.New(mailsmtp.Config{ Host: "smtp.example.com", Port: 587, Username: "smtp-user", Password: "smtp-password", }) if err != nil { log.Fatal(err) } mailer := mail.New( driver, mail.WithDefaultFrom("no-reply@example.com", "Example"), ) err = mailer.Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Send(context.Background()) if err != nil { log.Fatal(err) } }
Gmail does not need its own driver. Use mailsmtp with Gmail's SMTP host and an app password:
driver, err := mailsmtp.New(mailsmtp.Config{ Host: "smtp.gmail.com", Port: 587, Username: "you@gmail.com", Password: "gmail-app-password", })
Notes:
- Use a Google app password, not your normal account password.
587is the usual STARTTLS port. Use465withForceTLS: trueif you explicitly want implicit TLS.- Gmail is fine for personal or low-volume transactional sending, but a dedicated provider like Resend, Postmark, Mailgun, or SendGrid is usually a better production default.
| Driver | HTML/Text | Headers | Tags | Metadata | Attachments | Notes |
|---|---|---|---|---|---|---|
| mailsmtp | ✓ | ✓ | x | x | ✓ | Covers Gmail and other SMTP providers. |
| mailresend | ✓ | ✓ | ✓ | ✓ | ✓ | API-backed transactional delivery. |
| mailpostmark | ✓ | ✓ | ✓ | ✓ | ✓ | First tag is native; additional tags are mapped into metadata. |
| mailmailgun | ✓ | ✓ | ✓ | ✓ | ✓ | Uses Mailgun multipart message uploads. |
| mailsendgrid | ✓ | ✓ | ✓ | ✓ | ✓ | Maps tags to categories and metadata to custom args. |
| mailses | ✓ | ✓ | ✓ | ✓ | ✓ | Uses SES raw email with the same MIME rendering as SMTP. |
| maillog | ✓ | ✓ | x | x | ✓ | Local/dev inspection only; logs the composed message. |
| mailfake | ✓ | ✓ | ✓ | ✓ | ✓ | Test helper; captures the full portable message. |
Every message is validated before a bundled driver performs I/O. A deliverable message requires:
- a
Fromrecipient (either explicit or supplied byWithDefaultFrom); - at least one
To,Cc, orBccrecipient; - a subject and at least one text or HTML body;
- single-address recipient fields, safe custom headers, valid provider metadata keys, and valid attachment MIME metadata.
mail.New requires a driver and panics for a nil or typed-nil driver. This is a construction invariant: use mailfake.New() when a test needs a harmless driver. The zero-value MessageBuilder remains useful for standalone composition and Build; calling Send on an unbound builder returns mail.ErrMissingMailer.
Custom headers cannot replace envelope-owned fields such as From, To, Subject, Content-Type, or MIME-Version, and names must be unique without regard to case. Header, subject, attachment, and metadata validation rejects control characters before data reaches SMTP or multipart encoders.
API-backed drivers return an exported, provider-specific ResponseError for non-2xx responses. It exposes the HTTP status and a safe request ID when the provider supplies one. Provider response bodies are bounded and deliberately omitted from error strings so logs cannot accidentally capture credentials or message content.
mailsmtp uses opportunistic STARTTLS when the server advertises it. Set ForceTLS: true for implicit TLS, commonly used on port 465.
TLS defaults are explicit:
- certificate and hostname verification are enabled;
ServerNamedefaults toConfig.Host;- the minimum protocol version defaults to TLS 1.2;
- a supplied
TLSConfigis cloned during construction, so later caller mutation cannot change a running driver.
Use TLSConfig for a private CA or a stricter minimum version. Disabling verification is not recommended.
This quality pass deliberately tightens the pre-v1 contract:
Message.Validatenow returnsErrMissingFromwhen no sender remains after defaults;mail.New(nil)andmaillog.New(nil)now fail fast by panicking;- direct
mailfakeandmaillogsends now honor cancellation and use the same validation contract as network drivers; - malformed custom HTTP endpoints fail during driver construction instead of the first send;
- SMTP rejects negative or out-of-range ports, preserves body whitespace, safely encodes Unicode subjects and filenames, and emits quoted-printable text bodies;
- SendGrid headers and custom arguments are emitted only inside
personalizations, matching its API schema. - The root module and
mailsesare staged as a coordinated v0.3.0 release; publish the root tag beforemailses/v0.3.0so the SES module never depends on a local replacement. The checksum-staging sequence is documented in scripts/RELEASE.md.
GoForj-generated mailers already configure WithDefaultFrom, so their normal send shape is unchanged.
Generated from public API comments and examples.
Message starts a new fluent message builder bound to this mailer.
fake := mailfake.New() mailer := mail.New(fake, mail.WithDefaultFrom("no-reply@example.com", "Example")) _ = mailer.Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Send(context.Background()) fmt.Println(fake.SentCount()) // 1
Bcc appends one blind-carbon-copy recipient.
msg, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Bcc("audit@example.com", "Audit"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.Bcc[0].Email) // audit@example.com
Cc appends one carbon-copy recipient.
msg, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Cc("manager@example.com", "Manager"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.Cc[0].Email) // manager@example.com
From sets the from recipient.
msg, _ := mail.New(mailfake.New()).Message(). From("team@example.com", "Example Team"). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.From.Email) // team@example.com
Message returns the currently composed message without applying mailer defaults.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Message() fmt.Println(msg.Subject) // Welcome
ReplyTo appends one reply-to recipient.
msg, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). ReplyTo("support@example.com", "Support"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.ReplyTo[0].Email) // support@example.com
To appends one primary recipient.
msg, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(len(msg.To)) // 1
New creates a Mailer backed by the provided driver. New panics when driver is nil because a Mailer cannot deliver without its required collaborator.
fake := mailfake.New() mailer := mail.New(fake, mail.WithDefaultFrom("no-reply@example.com", "Example")) fmt.Println(mailer != nil) // true
Attach appends one in-memory attachment.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Attach("report.txt", "text/plain", []byte("hello world")). Message() fmt.Println(msg.Attachments[0].Filename) // report.txt
AttachFile loads one attachment from disk and appends it to the message.
_ = os.WriteFile("report.txt", []byte("hello world"), 0o644) defer os.Remove("report.txt") msg, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). AttachFile("report.txt"). Build() fmt.Println(msg.Attachments[0].Filename) // report.txt
HTML sets the HTML body.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). HTML("<p>hello world</p>"). Message() fmt.Println(msg.HTML) // <p>hello world</p>
Header sets or replaces one message header.
message, _ := mail.New(mailfake.New()).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Header("X-Request-ID", "req_123"). Tag("welcome"). Metadata("tenant_id", "tenant_123"). Build() fmt.Println(message.Headers["X-Request-ID"]) // req_123
Metadata sets one provider-facing metadata key/value pair.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Metadata("tenant_id", "tenant_123"). Message() fmt.Println(msg.Metadata["tenant_id"]) // tenant_123
Subject sets the message subject.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Message() fmt.Println(msg.Subject) // Welcome
Tag appends one provider-facing message tag.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Tag("welcome"). Message() fmt.Println(msg.Tags[0]) // welcome
Text sets the plain text body.
msg := mail.New(mailfake.New()).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Message() fmt.Println(msg.Text) // hello world
WithDefaultFrom configures the default from recipient applied when a message omits one.
mailer := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), ) fmt.Println(mailer != nil) // true
WithDefaultHeader configures a header applied when a message omits that header key.
msg, _ := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), mail.WithDefaultHeader("X-App", "goforj"), ).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.Headers["X-App"]) // goforj
WithDefaultMetadata configures metadata applied when a message omits that metadata key.
msg, _ := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), mail.WithDefaultMetadata("tenant_id", "tenant_123"), ).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.Metadata["tenant_id"]) // tenant_123
WithDefaultReplyTo configures the default reply-to recipients applied when a message omits them.
mailer := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), mail.WithDefaultReplyTo(mail.Recipient{Email: "support@example.com", Name: "Support"}), ) msg, _ := mailer.Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.ReplyTo[0].Email) // support@example.com
WithDefaultTag configures a tag prepended to every message sent by the mailer.
msg, _ := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), mail.WithDefaultTag("transactional"), ).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.Tags[0]) // transactional
Send validates the message, applies defaults, and delegates delivery to the driver.
mailer := mail.New(mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example")) err := mailer.Send(context.Background(), mail.Message{ To: []mail.Recipient{{Email: "alice@example.com", Name: "Alice"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // true
Build applies defaults, validates, and returns the composed message without sending it.
msg, _ := mail.New( mailfake.New(), mail.WithDefaultFrom("no-reply@example.com", "Example"), ).Message(). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Build() fmt.Println(msg.From.Email) // no-reply@example.com
Send delegates the composed message to the bound mailer.
fake := mailfake.New() _ = mail.New(fake).Message(). From("no-reply@example.com", "Example"). To("alice@example.com", "Alice"). Subject("Welcome"). Text("hello world"). Send(context.Background()) fmt.Println(fake.SentCount()) // 1
Send validates the message and writes one JSON log record.
var out bytes.Buffer _ = maillog.New(&out).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(strings.Contains(out.String(), "\"subject\":\"Welcome\"")) // true
New creates a log mail driver that writes one JSON record per sent message. New panics when writer is nil because output is the driver's required collaborator.
var out bytes.Buffer mailer := maillog.New(&out) _ = mail.New(mailer).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(strings.Contains(out.String(), "\"subject\":\"Welcome\"")) // true
WithBodies controls whether HTML and text bodies are included in log output.
var out bytes.Buffer mailer := maillog.New(&out, maillog.WithBodies(true)) _ = mail.New(mailer).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(strings.Contains(out.String(), "\"text\":\"hello world\"")) // true
WithNow overrides the timestamp source used by log entries.
var out bytes.Buffer mailer := maillog.New(&out, maillog.WithNow(func() time.Time { return time.Date(2026, time.April, 19, 0, 0, 0, 0, time.UTC) })) _ = mail.New(mailer).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(strings.Contains(out.String(), "2026-04-19T00:00:00Z")) // true
Send validates and transmits one message through Mailgun.
driver, _ := mailmailgun.New(mailmailgun.Config{ Domain: "mg.example.com", APIKey: "key-test", Endpoint: "http://127.0.0.1:1", }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates a Mailgun mail driver from the given config.
driver, _ := mailmailgun.New(mailmailgun.Config{ Domain: "mg.example.com", APIKey: "key-test", }) fmt.Println(driver != nil) // true
Error formats the provider status and safe correlation identifier without including response content.
AttachmentFromBytes creates one attachment from in-memory content.
attachment := mail.AttachmentFromBytes("report.txt", "text/plain", []byte("hello world")) fmt.Println(attachment.Filename) // report.txt
AttachmentFromPath loads one attachment from a local file path.
_ = os.WriteFile("report.txt", []byte("hello world"), 0o644) defer os.Remove("report.txt") attachment, _ := mail.AttachmentFromPath("report.txt") fmt.Println(attachment.Filename) // report.txt
Clone returns a copy of the message safe for reuse in tests and builders.
original := mail.Message{ To: []mail.Recipient{{Email: "alice@example.com", Name: "Alice"}}, Subject: "Welcome", Text: "hello world", } cloned := original.Clone() cloned.Subject = "Changed" fmt.Println(original.Subject) // Welcome
Validate checks that the message has valid recipients, subject, body, and headers.
err := (mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com", Name: "Example"}, To: []mail.Recipient{{Email: "alice@example.com", Name: "Alice"}}, Subject: "Welcome", Text: "hello world", }).Validate() fmt.Println(err == nil) // true
Send validates and transmits one message through Postmark.
driver, _ := mailpostmark.New(mailpostmark.Config{ ServerToken: "pm_test_token", Endpoint: "http://127.0.0.1:1", }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates a Postmark mail driver from the given config.
driver, _ := mailpostmark.New(mailpostmark.Config{ ServerToken: "pm_test_token", }) fmt.Println(driver != nil) // true
Error formats provider codes and a safe correlation identifier without including response content.
Send validates and transmits one message through Resend.
driver, _ := mailresend.New(mailresend.Config{ APIKey: "re_test_key", Endpoint: "http://127.0.0.1:1", }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates a Resend mail driver from the given config.
driver, _ := mailresend.New(mailresend.Config{ APIKey: "re_test_key", }) fmt.Println(driver != nil) // true
Error formats the provider status and safe correlation identifier without including response content.
Send validates and transmits one message through Amazon SES.
driver, _ := mailses.New(mailses.Config{ Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test", Endpoint: "http://127.0.0.1:1", }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates an Amazon SES mail driver from the given config.
driver, _ := mailses.New(mailses.Config{ Region: "us-east-1", AccessKeyID: "test", SecretAccessKey: "test", }) fmt.Println(driver != nil) // true
Send validates and transmits one message over SMTP.
driver, _ := mailsmtp.New(mailsmtp.Config{ Host: "smtp.example.com", Port: 587, }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates an SMTP mail driver from the given config. TLS defaults to the configured host for ServerName, a minimum of TLS 1.2, and normal certificate verification.
driver, _ := mailsmtp.New(mailsmtp.Config{ Host: "smtp.example.com", Port: 587, }) fmt.Println(driver != nil) // true
gmail:
driver, _ := mailsmtp.New(mailsmtp.Config{ Host: "smtp.gmail.com", Port: 587, Username: "you@gmail.com", Password: "gmail-app-password", }) fmt.Println(driver != nil) // true
Render turns one message into an RFC 822 style SMTP payload.
raw, _ := mailsmtp.Render(mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com", Name: "Example"}, To: []mail.Recipient{{Email: "alice@example.com", Name: "Alice"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(strings.Contains(string(raw), "Subject: Welcome")) // true
Send validates and transmits one message through SendGrid.
driver, _ := mailsendgrid.New(mailsendgrid.Config{ APIKey: "SG.test_key", Endpoint: "http://127.0.0.1:1", }) err := driver.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err == nil) // false
New creates a SendGrid mail driver from the given config.
driver, _ := mailsendgrid.New(mailsendgrid.Config{ APIKey: "SG.test_key", }) fmt.Println(driver != nil) // true
Error formats the provider status and safe correlation identifier without including response content.
Last returns the last recorded message when one exists.
fake := mailfake.New() _ = mail.New(fake).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) last, _ := fake.Last() fmt.Println(last.Subject) // Welcome
Messages returns a copy of every recorded message.
fake := mailfake.New() _ = mail.New(fake).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(len(fake.Messages())) // 1
Reset clears recorded messages and any configured send error.
fake := mailfake.New() _ = fake.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fake.Reset() fmt.Println(fake.SentCount()) // 0
Send validates and records the message, returning the configured delivery error when set.
fake := mailfake.New() _ = fake.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(fake.SentCount()) // 1
SentCount reports the number of recorded messages.
fake := mailfake.New() _ = fake.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(fake.SentCount()) // 1
SetError configures the error returned by future sends.
fake := mailfake.New() fake.SetError(errors.New("boom")) err := fake.Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(err != nil) // true
New creates an in-memory fake mail driver for tests.
fake := mailfake.New() _ = mail.New(fake).Send(context.Background(), mail.Message{ From: &mail.Recipient{Email: "no-reply@example.com"}, To: []mail.Recipient{{Email: "alice@example.com"}}, Subject: "Welcome", Text: "hello world", }) fmt.Println(fake.SentCount()) // 1
Use make test for root-module tests, make vet for static checks, and make generate to refresh generated documentation. Run make docs-watch to regenerate documentation as source files change. The docs, examples, and mailses directories are separate Go modules and can be tested from their own directories when changed.