App

Mailers And Storage

Render mail, deliver messages, store files, and generate media variants through app-owned services.

By Guillermo Alvarez - Published - Updated

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.