App
Mailers And Storage
Render mail, deliver messages, store files, and generate media variants through app-owned services.
App-owned services
Mail and stored files are application services. GoLazy provides small packages for rendering, delivery boundaries, object storage, file catalogs, and generated media variants, but the app decides which services to initialize and how controllers use them.
Wire those services in init/context.go, then expose them through typed context
helpers just like any other dependency.
Mailer setup
golazy.dev/lazymailer renders mail through the same view renderer used by
controllers. Delivery is a separate interface, so SMTP, memory inboxes, and
provider-specific transports can be swapped without changing templates.
func Context(ctx context.Context) context.Context {
registry := lazymailer.NewRegistry("default", map[string]lazymailer.Delivery{
"default": lazymailer.SMTPDelivery{
Addr: os.Getenv("SMTP_ADDR"),
Auth: smtp.PlainAuth(
"",
os.Getenv("SMTP_USER"),
os.Getenv("SMTP_PASSWORD"),
os.Getenv("SMTP_HOST"),
),
},
})
mailer, err := lazymailer.New(ctx, registry)
if err != nil {
panic(err)
}
return lazymailer.WithContext(ctx, mailer)
}
Use lazymailer.MemoryDelivery in tests or development when you want to assert
which messages would be sent.
Application mailer types
Put mailer types under app/mailers. A concrete mailer embeds or stores a
lazymailer.Base, sets template variables, and calls Mail.
package notices
type NoticeMailer struct {
base lazymailer.Base
}
func New(ctx context.Context) (*NoticeMailer, error) {
base, err := lazymailer.NewBase(ctx, "notice_mailer", lazymailer.Defaults{
From: lazymailer.MustParseAddress("GoLazy <hello@example.com>"),
Delivery: "default",
Layout: "mailer",
})
if err != nil {
return nil, err
}
return &NoticeMailer{base: base}, nil
}
func (m *NoticeMailer) Welcome(to lazymailer.Address, name string) error {
m.base.Set("name", name)
return m.base.Mail(lazymailer.Options{
Action: "welcome",
To: []lazymailer.Address{to},
Subject: "Welcome",
})
}
The controller should call an app operation such as Welcome; it should not
build MIME messages or know which delivery transport is active.
Mailer views
Mailer templates live next to normal views. The controller name passed to
NewBase selects the view directory, and the action selects the template name.
app/views/layouts/mailer.text.tpl
app/views/layouts/mailer.html.tpl
app/views/notice_mailer/welcome.text.tpl
app/views/notice_mailer/welcome.html.tpl
When both text and HTML templates exist, GoLazy builds a multipart alternative message. If only one format exists, the message uses that body.
Object storage
golazy.dev/lazystorage is the byte-storage layer. Its smallest interface can
open objects by key, and optional interfaces add writes, deletes, listing, URLs,
and watches.
storage := lazystorage.NewFilesystem("storage/objects")
info, remaining, err := storage.Put(
ctx,
"avatars/ada.txt",
strings.NewReader("hello"),
lazystorage.ContentType{Value: "text/plain"},
)
Object keys use io/fs path rules: no leading slash, no .., and no empty
path. Storage implementations consume the options they understand and return
the remaining options so higher-level services can compose cleanly.
S3-compatible storage
Use golazy.dev/lazystorage/s3 when an application stores files in an
S3-compatible service such as SeaweedFS:
storage := s3.New(
s3.WithEndpoint(os.Getenv("ASSETS_S3_ENDPOINT")),
s3.WithRegion(os.Getenv("ASSETS_S3_REGION")),
s3.WithBucket(os.Getenv("ASSETS_S3_BUCKET")),
s3.WithCredentials(
os.Getenv("ASSETS_S3_ACCESS_KEY_ID"),
os.Getenv("ASSETS_S3_SECRET_ACCESS_KEY"),
),
s3.WithPublicBaseURL(os.Getenv("ASSETS_PUBLIC_URL")),
)
The public base URL can point at a different domain or port from the S3 API, such as an ingress or SeaweedFS filer path that serves stored objects.
File catalog
golazy.dev/lazyfiles adds logical file records on top of storage. Use it when
the app needs stable file ids, metadata, migration-friendly storage locations,
or fallback application URLs.
repo, err := lazyfiles.NewLogRepository("storage/files.log.jsonl")
if err != nil {
panic(err)
}
files := &lazyfiles.Files{
Repository: repo,
Storages: map[string]lazystorage.Storage{
"local": lazystorage.NewFilesystem("storage/objects"),
},
DefaultStorage: "local",
RoutePrefix: "/files",
SigningKey: []byte(os.Getenv("FILE_SIGNING_KEY")),
}
file, _, err := files.Put(
ctx,
upload,
lazyfiles.Filename{Name: "avatar.txt"},
lazystorage.ContentType{Value: "text/plain"},
)
The returned lazyfiles.File is metadata. The bytes live in the configured
storage backend.
File URLs
Ask the file service for URLs instead of building paths by hand.
href, _, err := files.URL(
ctx,
file.ID,
lazystorage.ExpiresIn{Duration: 15 * time.Minute},
)
If the active storage implements lazystorage.URLer, lazyfiles returns that
storage URL. Otherwise it returns a signed fallback route under RoutePrefix.
When fallback URLs are enabled, construct the file service where App can pass
it both to context and to lazyapp.Config.Middlewares:
func App() *lazyapp.App {
files := newFileService()
return lazyapp.New(lazyapp.Config{
Name: "my_app",
Drawer: Draw,
Public: app.Public,
Views: app.Views,
Context: func(ctx context.Context) context.Context {
ctx = Context(ctx)
return fileservice.WithContext(ctx, files)
},
Middlewares: []lazydispatch.Middleware{
lazydispatch.MiddlewareFunc(files.Handler),
},
})
}
Use a stable SigningKey in production when fallback URLs should hide raw file
ids or expire.
Generated media variants
golazy.dev/lazymedia tracks generated representations of source files. It
does not own storage. Instead, it depends on a small file-store interface that
can open source files, store generated outputs, and return URLs.
Adapt your app's file service to that interface. The adapter is also the right place to translate media options into file-catalog options:
type MediaFiles struct {
Files *lazyfiles.Files
}
func (m MediaFiles) Open(
ctx context.Context,
id string,
options ...any,
) (io.ReadCloser, lazymedia.File, []any, error) {
body, file, remaining, err := m.Files.Open(ctx, id, options...)
return body, mediaFile(file), remaining, err
}
func (m MediaFiles) Put(
ctx context.Context,
body io.Reader,
options ...any,
) (lazymedia.File, []any, error) {
if name, remaining, ok := lazystorage.Take[lazymedia.OutputFilename](options); ok {
options = append(remaining, lazyfiles.Filename{Name: name.Name})
}
file, remaining, err := m.Files.Put(ctx, body, options...)
return mediaFile(file), remaining, err
}
func (m MediaFiles) URL(
ctx context.Context,
id string,
options ...any,
) (string, []any, error) {
return m.Files.URL(ctx, id, options...)
}
func mediaFile(file lazyfiles.File) lazymedia.File {
return lazymedia.File{
ID: file.ID,
Filename: file.Filename,
ContentType: file.ContentType,
Size: file.Size,
Metadata: file.Metadata,
}
}
Then compose the media service with a repository and processor:
variants, err := lazymedia.NewLogRepository("storage/variants.log.jsonl")
if err != nil {
panic(err)
}
media := &lazymedia.Media{
Files: MediaFiles{Files: files},
Repository: variants,
Processor: lazymedia.ProcessorFunc(func(
ctx context.Context,
source lazymedia.Source,
request lazymedia.Request,
options ...any,
) (lazymedia.Result, []any, error) {
body, err := io.ReadAll(source.Body)
if err != nil {
return lazymedia.Result{}, options, err
}
return lazymedia.Result{
Body: bytes.NewReader(body),
ContentType: source.File.ContentType,
Filename: request.VariantKey + "-" + source.File.Filename,
}, options, nil
}),
}
url, _, err := media.URL(ctx, lazymedia.Request{
SourceFileID: file.ID,
VariantKey: "preview",
})
The first request generates and records the variant. Later requests reuse the
ready output unless you pass lazymedia.Regenerate{}.
Service boundaries
Keep the layers separate:
- Mailer types render messages and call a delivery boundary.
- Storage backends read and write object bytes.
- File catalogs assign logical ids and decide where bytes live.
- Media services generate derived files and remember the output relationship.
- Controllers call product-level services and return HTTP responses.
That separation keeps tests small and lets applications replace SMTP providers, storage backends, repositories, or media processors without rewriting controllers.