Áttekintés
A mail önálló Go modul (github.com/gp-system/mail, +mail/smtp, +mail/mjml): egy Mailer interfész, egy fluens üzenetépítő, és egy sor törzs-típus (plain text, HTML, Go template, MJML). Bármely Go programban működik, kittel vagy anélkül. A shop mintaalkalmazásban a sendOrderConfirmation listener a workerben fut, és MJML-ből renderelt rendelés-visszaigazolót küld. Az üzleti kód (a listener) csak a mail.Mailer interfészt látja; hogy mögötte SMTP van, log-kimenet vagy egy tesztbeli memória-mailer, azt a bekötés dönti el.
Telepítés
go get github.com/gp-system/mail@v0.1.0 # a kit is ezt a taget használja
go get github.com/gp-system/mail@latest
A gyökérmodulnak nincs saját függősége az errs-en túl. A két opcionális alcsomag saját lábnyomot hoz, és egy bináris csak azért fizet, amelyiket ténylegesen importálja:
| Import-út | Mit ad hozzá |
|---|---|
github.com/gp-system/mail | a Mailer interfészt, a Message-t, a törzs-típusokat, a dev mailereket |
github.com/gp-system/mail/smtp | SMTP-küldést, a wneessen/go-mail-en keresztül |
github.com/gp-system/mail/mjml | MJML-ből HTML-fordítást, a Boostport/mjml-go-n (egy WASM runtime) keresztül |
Miért interfész a Mailer
A kit alapelve a „centralizálj, ne absztrahálj": a mail a storage melletti másik kivétel, ahol az interfész szándékos:
- A tényleges küldést a
mail/smtpalcsomag végzi, az MJML-fordítást amail/mjml. Amailgyökércsomag egyiket sem importálja. A service-réteged SMTP-kliens és WASM-runtime nélkül fordul. - Tesztben a
mail.NewMemory()egy sorral váltja ki a drivert, így a küldött üzenetek assertálhatók anélkül, hogy valódi SMTP-kapcsolat kellene.
Az interfész
import "github.com/gp-system/mail"
type Mailer interface {
Send(ctx context.Context, msg *Message) error
}
A Send szinkron és először Validate()-et hív: címzett nélkül ErrNoRecipient, törzs nélkül ErrNoBody jön vissza.
A Message felépítése
A Message-t fluensen építed; a konstruálás sosem hibázik, a hiányosságok küldéskor derülnek ki:
msg := mail.NewMessage().
WithFromNamed("Shop", "noreply@acme.test"). // elhagyható: a driver default feladója lép be
WithTo("user@example.com").
WithSubject("Rendelésed visszaigazolása").
WithText("Köszönjük a rendelést!")
A builder teljes felülete:
| Metódus | Mit csinál |
|---|---|
WithFrom(addr) / WithFromNamed(name, addr) | feladó; ha kihagyod, a driver konfigurált defaultja (MAIL_FROM_ADDRESS/MAIL_FROM_NAME) érvényesül |
WithTo(addrs...) / WithToNamed(name, addr) | To címzett(ek) |
WithCc(addrs...) / WithCcNamed(name, addr) | Cc címzett(ek) |
WithBcc(addrs...) / WithBccNamed(name, addr) | Bcc címzett(ek) |
WithReplyTo(addr) | Reply-To |
WithSubject(s) | tárgy |
WithHeader(key, value) | egyedi fejléc (pl. List-Unsubscribe) |
WithText(s) / WithHTML(s) | rövidítés a WithBody(mail.Text(s)) / WithBody(mail.HTML(s)) hívásokra |
WithBody(b mail.Body) | tetszőleges törzs (az utolsó hívás nyer) |
WithAttachment(filename, data, contentType) | csatolmány memóriából (contentType üresen: a driver kitalálja) |
WithAttachmentReader(filename, r, contentType) | streamelt csatolmány (az r egyszer, küldéskor olvasódik ki) |
WithEmbed(filename, data, contentType) | inline erőforrás, HTML-ből cid:<filename>-ként hivatkozva |
Levéltörzs-típusok
Minden törzs a mail.Body interfészt implementálja (Render(ctx) (Content, error); a driverek a send-span belsejében renderelnek, így a template-hiba is trace-elve van):
// 1. plain text
msg.WithText("Kész a fiókod.")
// 2. nyers HTML, opcionális text-alternatívával
msg.WithBody(mail.HTML("<p>Kész a fiókod.</p>").WithText("Kész a fiókod."))
// 3. html/template + text/template alternatíva ugyanabból az fs.FS-ből
//go:embed templates
var tmpls embed.FS
msg.WithBody(mail.Template(tmpls, "templates/invoice.html.tmpl", inv).
WithTextTemplate("templates/invoice.txt.tmpl"))
// 4. előre parse-olt template-ek, saját cache mellett
msg.WithBody(mail.Parsed(htmlTpl, textTpl, data))
A mail.Template WithFuncs(template.FuncMap)-pel bővíthető parse előtt. MJML-forrású HTML-hez lásd az MJML oldalt: a lefordított mjml.Body is mail.Body, ugyanúgy illeszkedik a WithBody-ba.
.WithText()/.WithTextTemplate()-tel, a HTML-only levél HTML-only marad.Dev mailerek: memory, log és discard
mem := mail.NewMemory() // renderel és eltárol, nincs valódi küldés
mailer := mail.NewLog(slog.Default()) // renderel és logol küldés helyett (dev)
mailer = mail.NewDiscard() // renderel (a template-hiba így is kiderül), majd eldob
A Memory minden üzenetet lerenderel és []mail.SentMessage-ként tárol (Messages(), Reset()); konkurens használatra biztonságos. A Log driver Info szinten csak a tárgyat, címzett-darabszámot és renderelt méretet írja; a teljes törzset csak Debug szinten. PII alapból nem kerül logba. A Discard az, amit az smtp.NewIfConfigured ad vissza üres MAIL_HOST esetén: továbbra is validál és renderel (egy hibás template ugyanúgy elbukik, mint egy valódi driverrel), csak épp sosem küld vagy logol; a legcsendesebb "a mail ki van kapcsolva" állapot, szemben a Log "ki van kapcsolva, de szólok róla" viselkedésével.
A konkrét SMTP driverért lásd az SMTP oldalt.
A shopban: a sendOrderConfirmation listener
A worker-ben futó listener az orderPlaced eventre iratkozik fel, és a Dependencies-ből kapott mail.Mailer-rel küld; a driverről nem tud:
import (
"github.com/acme/shop/internal/modules/shop"
shopevents "github.com/acme/shop/internal/modules/shop/events"
)
//go:embed templates
var tmpls embed.FS
func sendOrderConfirmation(deps shop.Dependencies) func(context.Context, shopevents.OrderPlaced) error {
return func(ctx context.Context, ev shopevents.OrderPlaced) error {
return deps.Mailer.Send(ctx, mail.NewMessage().
WithTo(ev.CustomerEmail).
WithSubject("Rendelésed visszaigazolása: "+ev.OrderID).
WithBody(mjml.Template(tmpls, "templates/order_confirmation.mjml.tmpl", ev).
WithTextTemplate("templates/order_confirmation.txt.tmpl")))
}
}
A regisztráció a listeners/register.go-ban él, a mail nevű queue-ra irányítva (add listener shop orderPlaced sendOrderConfirmation --queue mail generálta):
func Register(reg *events.Registry, deps shop.Dependencies) {
events.Listen(reg, "sendOrderConfirmation", sendOrderConfirmation(deps), queue.OnQueue("mail"))
}
A kézbesítés legalább-egyszeres, ezért a listener legyen idempotens: az events.MetaFromContext(ctx)-ből kiolvasott Meta.ID stabil idempotencia-kulcs a retryk között.
A teszt: Memory mailer
func TestSendOrderConfirmation(t *testing.T) {
mem := mail.NewMemory()
handle := sendOrderConfirmation(shop.Dependencies{Mailer: mem})
err := handle(t.Context(), shopevents.OrderPlaced{
OrderID: "o-1001", CustomerEmail: "user@example.com",
})
require.NoError(t, err)
sent := mem.Messages()
require.Len(t, sent, 1)
require.Equal(t, "user@example.com", sent[0].To[0].Address)
require.Contains(t, sent[0].Content.HTML, "o-1001") // a Memory renderel is
}
Mivel a Memory ténylegesen renderel, a teszt a template-hibákat is elkapja: egy elgépelt mező a Content ellenőrzésén bukik, nem élesben.
Queue-zott (aszinkron) küldés
A Mailer szinkron marad; az aszinkron küldés a fenti mintát követi: az esemény megy a queue-ba az outboxon át, és a listener küldi a levelet a workerben, retry-kkal. Nincs külön beépített „mail queue": az events rendszer az.
Hova kerül a tartalom: add mail
A fenti sendOrderConfirmation példa közvetlenül a listenerből hívja a deps.Mailer.Send-et: ez egyetlen, egyszerű üzenetre jó. Amint egy modul több, összetartozó értesítést küld, vagy az üzenet összeállítása (több címzett, számolt subject, több sablon) megér egy külön nevet és tesztet, generálj inkább egy notifiert:
gpsystem add mail <modul> <név>
Ez ugyanaz a szétválasztás, amit a kit mindenhol követ: a transzport infrastruktúra, a tartalom a modul üzleti logikája (lásd Projektstruktúra: mi élhet egy modulon belül és a közös kód döntési létráját). Az add mail legenerálja az internal/modules/<modul>/mail/<név>.go fájlt: egy payload structot, egy <Név>Notifier-t, ami egy injektált mail.Mailer-t köt össze a modul saját sablonjaival, és bekötve egy Mailer mail.Mailer mezőt a modul Dependencies-ébe, minden belépési pontban egyszer megkonstruálva, smtp.NewIfConfigured/MustNewIfConfigured-dal. Lásd az add mail referenciát a pontos generált alakért.
Kidolgozott példa: a contact-email flow
A minta egy konkrét, végigvitt változata: egy webes űrlap, ami egyszerre értesíti az admin postafiókot és a kérelmezőt:
POST /web/contact: a web surface service-e elmenti a beküldést, és egyContactSubmittedeventet dob az outboxon át, ugyanabban a tranzakcióban.- A
cmd/workeroutbox-relay-je kézbesíti az eventet asendContactEmaillistenernek. - A listener meghívja az
internal/modules/contact/mail.Notifier.Notify-t, ami mindkét sablont lerendereli (egy admin-értesítést és egy kérelmező-visszaigazolást), és mindkettőt elküldi az injektáltmail.Mailer-en át. - Ez a
mail.Mailerélesben SMTP, devben (ha aMAIL_HOSTüres)mail.NewDiscard(), a notifier saját tesztjeiben pedigmail.NewMemory()(lásd fent).
package mail
//go:embed templates/*.tmpl
var templatesFS embed.FS
type ContactNotification struct {
Name, Email string
// ... az űrlap többi mezője
}
type Notifier interface {
Notify(ctx context.Context, n ContactNotification) error
}
func New(m kitmail.Mailer, cfg Config) Notifier {
return ¬ifier{mailer: m, adminTo: cfg.To}
}
func (n *notifier) Notify(ctx context.Context, p ContactNotification) error {
adminErr := n.sendAdminNotification(ctx, p) // -> Config.To
requesterErr := n.sendRequesterConfirmation(ctx, p) // -> p.Email
return errors.Join(adminErr, requesterErr)
}
func sendContactEmail(deps contact.Dependencies) func(context.Context, contactevents.ContactSubmitted) error {
notifier := contactmail.New(deps.Mailer, deps.Mail)
return func(ctx context.Context, ev contactevents.ContactSubmitted) error {
return notifier.Notify(ctx, contactmail.ContactNotification{Name: ev.Name, Email: ev.Email /* ... */})
}
}
Mind a címzettkonfiguráció (Config.To, modul-tartalom), mind a közös SMTP-config (smtp.Config, kit-szintű transzport) ugyanazon a MAIL_ prefixen komponálódik a platform configban: a MAIL_TO és a MAIL_HOST csak két mező egy env-névtér alatt, ütközés nélkül:
type Config struct {
// ...
Mail smtp.Config `envPrefix:"MAIL_"` // MAIL_HOST, MAIL_FROM_ADDRESS, ...
ContactMail contactmail.Config `envPrefix:"MAIL_"` // MAIL_TO
}
add mail nem generálja le kézzel a fenti sendContactEmail-ben látott bekötést: a generátor a notifiert, a sablonjait és a Dependencies/config bekötést állítja elő; a payload mezők, a subject-sor és a hívás egy service-ből vagy listenerből üzleti logika, amit te írsz meg, ugyanúgy, ahogy a service/service.go is minimális seamként generálódik.Ha egy modul tartalma egy recipiensnek szól, nem csak egy puszta címnek, főleg ha többre van szükség mint mail (perzisztált inbox, élő push), lásd a Notification oldalt és az add notification-t helyette; a két generátor összeadódik, és egy notification ToMail-je ugyanazokat a mail.Message/mail.Template buildereket használja, mint amiket itt láttál.
Kapcsolódó oldalak
- SMTP: az SMTP driver, konfigurációja és a
NewIfConfigureddev/prod kapcsoló. - MJML: MJML template-ek fordítása responsive HTML-lé.
- Notification: recipiensnek címzett tartalom, akár több csatornán, mail-lel is.
- Architektúra: hol helyezkedik el a
maila többi önálló modul között.