mail

Áttekintés

Email-küldés: fluent Message builder, MJML template-ek, memória-mailer a tesztekhez.

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-útMit ad hozzá
github.com/gp-system/maila Mailer interfészt, a Message-t, a törzs-típusokat, a dev mailereket
github.com/gp-system/mail/smtpSMTP-küldést, a wneessen/go-mail-en keresztül
github.com/gp-system/mail/mjmlMJML-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/smtp alcsomag végzi, az MJML-fordítást a mail/mjml. A mail gyö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ódusMit 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.

Nincs automatikus text/plain-generálás HTML-ből vagy MJML-ből: ha kell text-alternatíva, add meg explicit .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:

internal/modules/shop/listeners/send_order_confirmation.go
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):

internal/modules/shop/listeners/register.go
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> <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:

  1. POST /web/contact: a web surface service-e elmenti a beküldést, és egy ContactSubmitted eventet dob az outboxon át, ugyanabban a tranzakcióban.
  2. A cmd/worker outbox-relay-je kézbesíti az eventet a sendContactEmail listenernek.
  3. 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ált mail.Mailer-en át.
  4. Ez a mail.Mailer élesben SMTP, devben (ha a MAIL_HOST üres) mail.NewDiscard(), a notifier saját tesztjeiben pedig mail.NewMemory() (lásd fent).
internal/modules/contact/mail/notifier.go
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 &notifier{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)
}
internal/modules/contact/listeners/sendcontactemail.go
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:

internal/platform/config/config.go
type Config struct {
    // ...
    Mail        smtp.Config        `envPrefix:"MAIL_"` // MAIL_HOST, MAIL_FROM_ADDRESS, ...
    ContactMail contactmail.Config `envPrefix:"MAIL_"` // MAIL_TO
}
Az 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 NewIfConfigured dev/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 mail a többi önálló modul között.
Copyright © 2026