Sending

Maps

Build a Mapbox Static Images API URL, fetch the bytes, and keep the access token out of the email and out of your errors.

The ::map component (used by the job.offered example's driver email) needs a static map image URL. The mapbox package builds that URL and fetches the image; it never renders anything into the template itself — you fetch the bytes at send time, upload them to your own CDN, and put the CDN URL in ::map{src}.

The Static Images API URL carries your Mapbox access token in its query string. Never hot-link it or put it directly in an email. See Upload to your own CDN below.

Configure Static

type Point struct{ Lng, Lat float64 }

type Static struct {
    Token   string
    Style   string
    Width   int
    Height  int
    Padding int

    RouteColor   string // route line stroke
    PickupColor  string // pickup pin
    DropoffColor string // drop-off pin
}

Width, Height and Padding default to 600, 240 and 80 pixels at their zero value (1280px is the maximum for either dimension). Style is owner/id.

The three colours are required fields (D34): each must be six hex digits without #, or URL and Fetch fail with mapbox: {name} colour is required or must be 6 hex digits without '#'. The mapbox package carries no default brand colours — SPEC's literal #0b4740/#c84a0e pins are superseded by this rule, since the library itself must stay brand-neutral. examples/quiet.Mapbox supplies the Quiet brand's own colours:

examples/quiet/mapbox.go
// Mapbox returns the Static map builder for the T4 band in the Quiet
// colours: the pickup pin in the accent orange, the drop-off pin and the
// route in the action teal, both read from Theme.
func Mapbox(token, style string) mapbox.Static {
    l := Theme().Light
    hex := func(c string) string { return strings.TrimPrefix(c, "#") }
    return mapbox.Static{
        Token:        token,
        Style:        style,
        RouteColor:   hex(string(l.Action)), // "0B4740"
        PickupColor:  hex(string(l.Accent)), // "C84A0E"
        DropoffColor: hex(string(l.Action)), // "0B4740"
    }
}

Write the equivalent for your own brand: read the two theme colours you want for the pins and route, strip their #, and pass them through.

Build the URL

func (s Static) URL(pickup, dropoff Point, route []Point) (string, error)

When route is empty, the line runs directly from pickup to dropoff; pass your own points (e.g. a decoded polyline) to draw the actual path instead. Every point is validated: finite, longitude in -180, 180, latitude in -90, 90.

main.go
package main

import (
    "fmt"

    "github.com/sulv-io/lttr/mapbox"
)

func main() {
    m := mapbox.Static{
        Token:        "pk.example-token",
        Style:        "caribhubs/quiet-map",
        RouteColor:   "0B4740",
        PickupColor:  "C84A0E",
        DropoffColor: "0B4740",
    }

    pickup := mapbox.Point{Lng: -62.72, Lat: 17.30}
    dropoff := mapbox.Point{Lng: -62.70, Lat: 17.28}

    u, err := m.URL(pickup, dropoff, nil)
    if err != nil {
        panic(err)
    }
    fmt.Println(u)
}

prints a URL of the shape:

URL
https://api.mapbox.com/styles/v1/caribhubs/quiet-map/static/geojson(%7B%22type%22...),pin-s+C84A0E(-62.72000,17.30000),pin-s+0B4740(-62.70000,17.28000)/auto/600x240@2x?padding=80&access_token=pk.example-token

The overlay list is, in order: a path-escaped GeoJSON LineString feature ("stroke": "#{RouteColor}", "stroke-width": 4) for the route, then pin-s+{PickupColor}(lng,lat) for the pickup, then pin-s+{DropoffColor}(lng,lat) for the drop-off — coordinates to 5 decimal places. No error URL returns contains the token — errors name only what was wrong (a colour, a style, a point), never the request.

Fetch the image bytes

func (s Static) Fetch(ctx context.Context, c *http.Client, pickup, dropoff Point, route []Point) ([]byte, error)

Fetch builds the URL, issues the GET, and returns the body. Three things make it safe to call from a send path:

  • The response body is capped at 8 MiB. A larger body is an error (mapbox: response body exceeds 8 MiB) rather than an unbounded read.
  • A non-200 response names only the status code — mapbox: unexpected status 404 — never the URL, so a logged error can't leak the token.
  • Transport errors are sanitised too. net/http wraps client errors in *url.Error, whose Error() string embeds the request URL; Fetch unwraps to the inner error before returning, so a DNS failure or a timeout doesn't print the token either.

Upload to your own CDN

The Static Images API URL is short-lived working state, not something to persist or hand to a recipient. The intended flow:

  1. Call Fetch at send time (or ahead of time, in a batch job) to get the PNG bytes.
  2. Upload those bytes to your own object storage / CDN.
  3. Put only the CDN URL — never the Mapbox URL — in ::map{src="{{map_url}}"} (see the job.offered example in Modules).
main.go
package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/sulv-io/lttr/mapbox"
)

func main() {
    m := mapbox.Static{
        Token: "pk.example-token", Style: "caribhubs/quiet-map",
        RouteColor: "0B4740", PickupColor: "C84A0E", DropoffColor: "0B4740",
    }
    pickup := mapbox.Point{Lng: -62.72, Lat: 17.30}
    dropoff := mapbox.Point{Lng: -62.70, Lat: 17.28}

    img, err := m.Fetch(context.Background(), http.DefaultClient, pickup, dropoff, nil)
    if err != nil {
        fmt.Println("fetch failed:", err) // safe to log: never contains the token
        return
    }
    mapURL := uploadToCDN(img) // your own storage; not part of lttr
    fmt.Println("map_url =", mapURL)
}

func uploadToCDN(img []byte) string {
    // upload img to S3 / R2 / GCS / wherever your assets already live
    return "https://cdn.caribhubs.com/maps/ch-20417.png"
}

Next steps

  • Modules for the ::map component's frontmatter and attributes.
  • SMTP for delivering the message once the map image is in place.
Copyright © 2026