Reaching people
Mail and push are both slow, both fail, and neither degrades quietly — which is why both belong in a job and both ship with a memory implementation to assert on.
Email
Templates in two parsers on purpose, attachments that stream instead of loading, and why sending belongs in a job with an idempotency key — a retry with no guard is a user with five identical emails. See Mailer.
go
package main
import (
"context"
"io"
"strings"
core "gitlab.finema.co/finema/idin-core/v2"
)
// --- Example 2: email -------------------------------------------------------
//
// core.Mailer(ctx), for the same reason as core.Requester: sending mail is
// something the code goes and does, not a capability the request has. A
// NotificationService that knows nothing about this framework can still send one,
// because the function takes a plain context.Context.
//
// Mail does not degrade quietly either. With no EMAIL_* configuration every call
// fails with MAILER_DISABLED — because a password reset that was silently
// dropped is a user locked out with nothing in the logs to say why.
// exampleTemplates builds the template set in code. A real service embeds a
// directory instead:
//
// //go:embed templates/email
// var emailFS embed.FS
// tpl, err := core.NewMailTemplates(emailFS, core.MailTemplateOptions{Root: "templates/email"})
//
// where "welcome.html" and "welcome.txt" are the two bodies of one template
// named "welcome". Everything parses into one set, so a layout defined in one
// file is usable from every other.
func exampleTemplates() *core.MailTemplates {
tpl := core.NewMailTemplateSet(map[string]any{"upper": strings.ToUpper})
// The HTML body is parsed by html/template and the text body by text/template
// — deliberately different sets. A display name containing "<" must not be
// able to rewrite the markup around it, and a plain-text body must not be
// mangled by escaping it does not need.
_ = tpl.Add("welcome",
`<h1>สวัสดี {{.Name}}</h1><p>ขอบคุณที่สมัครใช้งาน</p>`,
"สวัสดี {{.Name}} — ขอบคุณที่สมัครใช้งาน",
)
_ = tpl.Add("export-ready",
`<p>รายงานของคุณพร้อมแล้ว <a href="{{.URL}}">ดาวน์โหลด</a></p>`,
"รายงานของคุณพร้อมแล้ว: {{.URL}}",
)
return tpl
}
// sendWelcome renders a template and sends it. SendTemplate only fills the
// bodies the caller left empty, so setting HTML by hand still wins.
func sendWelcome(ctx context.Context, to, name string) core.IError {
return core.Mailer(ctx).SendTemplate(core.EmailMessage{
// the named form quotes a display name that needs quoting — otherwise a
// name containing a comma silently becomes two recipients
ToAddresses: []core.EmailAddress{{Name: name, Address: to}},
Subject: "ยินดีต้อนรับ",
ReplyTo: "[email protected]",
Headers: map[string]string{"List-Unsubscribe": "<https://example.com/u/abc>"},
}, "welcome", map[string]any{"Name": name})
}
// sendReceipt attaches files two ways. Content holds the bytes; Reader streams
// them, read once at send time — so an S3 object larger than memory can be
// attached without ever being whole in the process.
//
// Embeds are inline parts the HTML references as cid:<name>, which is why an
// empty ContentID falls back to the filename.
func sendReceipt(ctx context.Context, to string, pdf []byte, logo io.Reader) core.IError {
return core.Mailer(ctx).Send(core.EmailMessage{
To: []string{to},
Subject: "ใบเสร็จของคุณ",
HTML: `<p>ใบเสร็จแนบมาแล้ว <img src="cid:logo.png"></p>`,
// send both bodies when you can: HTML for clients that render it, text
// for those that do not — and for the spam filter, which counts an
// HTML-only message as a small strike
Text: "ใบเสร็จแนบมาแล้ว",
Attachments: []core.Attachment{
{Name: "invoice.pdf", Content: pdf, ContentType: "application/pdf"},
},
Embeds: []core.Attachment{
{Name: "logo.png", Reader: logo},
},
})
}
// mailJob is where sending belongs. SMTP is slow and it fails, and neither of
// those should be the user's problem: a signup must not fail because the mail
// server is down, and nobody should watch a spinner while a relay thinks.
//
// A retried job with no guard is a user with five identical emails, so the job
// carries an idempotency key — one send per user per event, however many times
// the run is retried or replayed.
func registerMailJobs(reg *core.JobRegistry) {
_ = reg.Register(core.JobDef{
Name: "mail.send-welcome",
Description: "send the welcome email out of band",
MaxAttempts: 5,
}, func(c core.ICronjobContext) error {
// c is an IContext, so core.Mailer(c) binds the run: cancelling the run
// cancels the delivery it is waiting on
return sendWelcome(c, "[email protected]", "สมชาย")
})
}
// previewWelcome renders without sending — for a preview route, or a test that
// asserts on the wording rather than on the fact that a method was called.
func previewWelcome(ctx context.Context, name string) (string, string, core.IError) {
return core.Mailer(ctx).Render("welcome", map[string]any{"Name": name})
}
// Testing: NewMemoryMailer records messages instead of delivering them, and runs
// the same validation the real mailer does — so a message an SMTP server would
// have rejected fails the test instead of passing it.
//
// m := core.NewMemoryMailer(exampleTemplates())
// app, _ := core.NewApp(env, core.WithMailer(m))
//
// require.NoError(t, sendWelcome(app.NewContext(context.Background()), "[email protected]", "สมชาย"))
//
// sent := core.SentMail(m)
// require.Len(t, sent, 1)
// // assert the wording, not that a method was called
// assert.Contains(t, sent[0].HTML, "สวัสดี สมชาย")
// core.ResetMail(m)
//
// It is also what dev should use, so nothing escapes to a real inbox by accident.
func sentSubjects(m core.IMailer) []string {
sent := core.SentMail(m)
out := make([]string, 0, len(sent))
for _, msg := range sent {
out = append(out, msg.Subject)
}
return out
}Push notifications
Notification versus data, topic versus token, and the distinction that matters in a batch: a failed call is an error, a failed token is a result — and a dead one should be deleted the moment you learn of it. See Push.
go
package main
import (
"context"
"errors"
"time"
core "gitlab.finema.co/finema/idin-core/v2"
)
// --- Example 3: push notifications ------------------------------------------
//
// core.Pusher(ctx) — a function taking a context.Context, for the same reason as
// Mailer and Requester. With no FIREBASE_CREDENTIAL every call fails with
// PUSH_DISABLED rather than returning nil.
// notifyShipped sends to one device. Title/Body are what the device *shows*;
// Data is what the app *receives*.
//
// Note what is not in the payload: nothing sensitive. A notification is drawn on
// a lock screen and passes through Google's and Apple's infrastructure, so send
// an id and let the app fetch the real thing behind the user's session.
func notifyShipped(ctx context.Context, token, orderID string) core.IError {
badge := 1
return core.Pusher(ctx).Send(token, core.PushMessage{
Title: "ออเดอร์ถูกจัดส่งแล้ว",
Body: "พัสดุของคุณออกจากคลังแล้ว",
Data: map[string]string{"order_id": orderID},
// High priority wakes the device immediately. Reserve it for something a
// person is actually waiting for — providers throttle a sender that marks
// everything high, and then the messages that matter arrive late too.
Priority: core.PushPriorityHigh,
Sound: "ping.caf",
Badge: &badge, // a pointer, because 0 means "clear the badge"
ChannelID: "orders",
// after five minutes this notification is a lie, so do not deliver it
TTL: 5 * time.Minute,
// a device coming back online gets the latest state of this order, not a
// backlog of every step it missed
CollapseKey: "order-" + orderID,
})
}
// syncSilently sends data with no notification block. The system draws nothing
// and the app is simply woken to sync — which is why the framework sets
// content-available for iOS automatically; without it iOS drops the message.
func syncSilently(ctx context.Context, token string) core.IError {
return core.Pusher(ctx).Send(token, core.PushMessage{
Data: map[string]string{"action": "sync"},
})
}
// broadcastToDevices sends to many tokens and cleans up as it goes.
//
// The distinction that matters: a returned error means the *call* failed, so
// nothing in that batch was attempted. A token failing is not an error — it is a
// result, and it lives in BatchResult. Batching to the provider's limit of 500 is
// handled inside, so a caller with ten thousand tokens does not have to know.
func broadcastToDevices(ctx context.Context, tokens []string, msg core.PushMessage) ([]string, core.IError) {
res, err := core.Pusher(ctx).SendMulticast(tokens, msg)
if err != nil {
return nil, err
}
// Dead tokens — the app was uninstalled, or the token was rotated. They will
// never work again, and a store full of them makes every broadcast slower and
// every "delivered" number a lie. Delete them the moment you learn.
dead := res.UnregisteredTokens()
// per token, the same fact is available without knowing anything about the
// provider's error types
for _, r := range res.Results {
if !r.Success && errors.Is(r.Error, core.ErrPushUnregistered) {
_ = r.Token // repo.DeleteToken(r.Token)
}
}
return dead, nil
}
// announceToTopic is the other addressing mode. A topic is right for broad news
// — the provider does the fan-out and you do not keep a token list. It is wrong
// for anything personal: a topic cannot be cancelled for one person, and you
// cannot see who is on it.
func announceToTopic(ctx context.Context, topic string, msg core.PushMessage) core.IError {
p := core.Pusher(ctx)
if err := p.SendToTopic(topic, msg); err != nil {
return err
}
// conditions combine topics without a second send
return p.SendToCondition("'"+topic+"' in topics && 'th' in topics", msg)
}
// manageSubscriptions batches to the provider's topic limit of 1000 internally.
func manageSubscriptions(ctx context.Context, topic string, tokens []string) core.IError {
_, err := core.Pusher(ctx).Subscribe(topic, tokens...)
return err
}
// validatePayload asks the provider whether a message is well-formed without
// delivering it. Worth doing whenever the payload shape changes: a malformed
// payload fails silently on the user's device, not on our server, so nothing in
// our logs would ever mention it.
func validatePayload(ctx context.Context, token string, msg core.PushMessage) core.IError {
return core.Pusher(ctx).Validate(token, msg)
}
// registerPushJobs puts the fan-out in a job. Sending to tens of thousands of
// people is not a request's work, and it needs bounded retries.
func registerPushJobs(reg *core.JobRegistry) {
_ = reg.Register(core.JobDef{
Name: "push.broadcast",
Description: "send an announcement to every registered device",
Timeout: 30 * time.Minute,
MaxAttempts: 3,
}, func(c core.ICronjobContext) error {
dead, err := broadcastToDevices(c, []string{"tok-1", "tok-2"}, core.PushMessage{
Title: "มีอัปเดตใหม่", Body: "เปิดแอปเพื่อดูรายละเอียด",
})
if err != nil {
return err
}
c.Log().Info("broadcast finished", "dead_tokens", len(dead))
return nil
})
}
// Testing: NewMemoryPusher records deliveries instead of making them. Assert on
// the payload — that is what the user sees; that a method was called is not.
//
// p := core.NewMemoryPusher()
// app, _ := core.NewApp(env, core.WithPusher(p))
//
// require.NoError(t, notifyShipped(app.NewContext(context.Background()), "tok-1", "o-1"))
//
// sent := core.SentPushes(p)
// require.Len(t, sent, 1)
// assert.Equal(t, []string{"tok-1"}, sent[0].Tokens)
// assert.Equal(t, "o-1", sent[0].Message.Data["order_id"])
// assert.Equal(t, []string{"tok-1"}, core.PushTopicTokens(p, "news"))
// core.ResetPushes(p)
func sentTitles(p core.IPusher) []string {
sent := core.SentPushes(p)
out := make([]string, 0, len(sent))
for _, s := range sent {
out = append(out, s.Message.Title)
}
return out
}