Fluent email composition and pluggable delivery for GoForj packages and apps.
go get github.com/goforj/mailpackage 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())
// 1Bcc 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.comCc 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.comFrom 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.comMessage 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)
// WelcomeReplyTo 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.comTo 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))
// 1New 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)
// trueAttach 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.txtAttachFile 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.txtHTML 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_123Metadata 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_123Subject 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)
// WelcomeTag 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])
// welcomeText 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 worldWithDefaultFrom 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)
// trueWithDefaultHeader 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"])
// goforjWithDefaultMetadata 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_123WithDefaultReplyTo 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.comWithDefaultTag 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])
// transactionalSend 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)
// trueBuild 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.comSend 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())
// 1Send 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\""))
// trueNew 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\""))
// trueWithBodies 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\""))
// trueWithNow 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"))
// trueSend 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)
// falseNew 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)
// trueError 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.txtAttachmentFromPath 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.txtClone 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)
// WelcomeValidate 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)
// trueSend 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)
// falseNew creates a Postmark mail driver from the given config.
driver, _ := mailpostmark.New(mailpostmark.Config{
ServerToken: "pm_test_token",
})
fmt.Println(driver != nil)
// trueError 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)
// falseNew creates a Resend mail driver from the given config.
driver, _ := mailresend.New(mailresend.Config{
APIKey: "re_test_key",
})
fmt.Println(driver != nil)
// trueError 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)
// falseNew 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)
// trueSend 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)
// falseNew 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)
// truegmail:
driver, _ := mailsmtp.New(mailsmtp.Config{
Host: "smtp.gmail.com",
Port: 587,
Username: "you@gmail.com",
Password: "gmail-app-password",
})
fmt.Println(driver != nil)
// trueRender 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"))
// trueSend 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)
// falseNew creates a SendGrid mail driver from the given config.
driver, _ := mailsendgrid.New(mailsendgrid.Config{
APIKey: "SG.test_key",
})
fmt.Println(driver != nil)
// trueError 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)
// WelcomeMessages 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()))
// 1Reset 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())
// 0Send 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())
// 1SentCount 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())
// 1SetError 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)
// trueNew 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())
// 1go run ./docs/examplegen/main.gogo run ./docs/readme/main.gogo run ./docs/readme/testcounts/main.go./docs/watcher.sh