Mail Relay
Outgoing mail for Pocker instances that haven't set up SMTP.
Stock PocketBase falls back to the host's sendmail when SMTP is off. Under Pocker that would run sendmail on the
shared host for every tenant. With this extension:
- SMTP on in an instance's Settings → Mail: the app sends the mail itself. Bring your own SMTP always wins.
- SMTP off: the mail goes through your relay as
<instance>@<Domain>, within per-instance limits.sendmailnever runs.
Install · Configure · Use · Integrations · Example
Install
Mail Relay is not in the pocketone binary. Add it to the extensions of your own Pocker build, with your sending
domain and relay:
srv.Extensions = append(srv.Extensions, mailrelay.New(mailrelay.Config{
Domain: "mail.example.com",
Upstream: mailrelay.SMTPUpstream{Addr: "smtp.provider.com:587", Username: "…", Password: "…"},
}))
The module is github.com/pockethost/pocketone/ext/multisite-mailrelay. It works the same in pocker.Provider{Extensions: ...}.
It also registers on a single app, but a single app can set up its own SMTP and doesn't need a relay.
Configure
The relay (Go)
Domain and Upstream are required; the host doesn't start without them.
Domain: every relayed message is From<local part>@Domain.Upstream: what delivers the mail.SMTPUpstreamships here:Addr(host:port), optionalUsernameandPassword(PLAIN auth, only over TLS or to localhost),ImplicitTLSfor port 465 (otherwise STARTTLS when the server offers it), andTimeout(default 30 s). It dials with its own dialer, so Pocker's egress rules for tenants (public addresses only) don't block a relay on a private address. Anything withSend(ctx, from, to, raw)works, for example an Amazon SESSendRawEmailsender.LocalPart: picks an instance's address from its id. The default is the first label of the id, lowercased:blog.localhost→blog.- Limits:
PerMinute(default 20) andDaily(default 200) per instance,MaxRecipients(To + Cc + Bcc, default 5) andMaxSize(encoded message, default 1 MiB) per message.
Defaults for every instance (pocketone.yaml)
mailrelay: { per_minute: 20, daily: 200 }
Limits set in Go win over this section. Each instance can override it (see Per-instance settings).
Use
Once it's in, there's nothing to run. Each instance's mail is relayed whenever its SMTP is off.
- Settings → Mail in an instance's dashboard shows its superusers the relay: the address it sends as, its limits and how many messages it sent today. When sending is off for the instance, it says so and points to the SMTP settings.
- The instance's log gets one line per message, with masked addresses:
mail from blog@… to a***@x.org accepted. Refusals and upstream failures are logged too. - A refused message returns an error to whoever triggered the mail, such as a password-reset request: sending off, too many recipients, too large, or the limit reached.
Integrations
Works with
| Extension | How they fit |
|---|---|
| Pocker | The host it's built for. Every instance gets the relay, its own address and its own limits. |
| Mail Trap | In development, point SMTPUpstream at the trap's SMTP port and every relayed message lands in one inbox, tagged with the instance. The example below does this. |
| Bulk Mail | Campaigns go through PocketBase's mailer by default, so with SMTP off they are relayed and count against the instance's limits. |
Per-instance settings (pocker)
Each instance can override the defaults in its extensions.mailrelay settings, from instances.yaml or your instance
source (for example, per plan):
instances:
- id: free-plan-app
extensions: { mailrelay: { send: false } } # refuse its mail unless it uses its own SMTP
- id: big-app
extensions: { mailrelay: { per_minute: 60, daily: 2000 } }
A change to these settings restarts the instance's app on its next request.
Use send: false, not enabled: false, to stop an instance sending. enabled: false turns the extension off for that
instance, and with SMTP off PocketBase then falls back to the host's sendmail. Don't set it under Pocker.
Building on Mail Relay
- Your own upstream. Implement
mailrelay.Upstream(Send(ctx, from, to, raw)) to deliver through an API. - Your own hooks first. The relay runs after the app's own
onMailerSendhooks, so they can still change or drop a message. - Go API.
(*mailrelay.Extension).Address(id)returns the From address an instance sends as. - REST API (superusers):
GET /api/mailrelayreturns the instance's relay status:active,send,address,perMinute,daily,sentToday.
Example
example/main.go runs a Pocker host whose relay sends to the dev mail trap, so every instance's
mail lands in one inbox at http://127.0.0.1:8025. Run go run ./example --dev from this folder, then trigger a
password reset on any instance:
curl -X POST http://alpha.localhost:8090/api/collections/users/request-password-reset -d [email protected]
Log in to an instance's dashboard as [email protected] / password to see the relay on Settings → Mail. --listen
changes the instances' port; PB_MAILTRAP_HTTP and PB_MAILTRAP_SMTP change the trap's.
// Command example is a Pocker build with mailrelay, relaying into PocketOne's dev mail trap: every instance
// without its own SMTP sends as <instance>@mail.localhost, and all of it lands in one inbox.
//
// go run ./example --dev
//
// Then trigger mail on any instance, for example a password reset (each instance has the test user below):
//
// curl -X POST http://alpha.localhost:8090/api/collections/users/request-password-reset -d [email protected]
// curl -X POST http://beta.localhost:8090/api/collections/users/request-password-reset -d [email protected]
//
// and read it in PocketOne's dev mail trap at http://127.0.0.1:8025 (filter by instance; PB_MAILTRAP_HTTP and
// PB_MAILTRAP_SMTP change its ports). Settings → Mail in each dashboard shows the relay.
//
// Logins (ext/devusers, local testing only): dashboard [email protected] / password, app user [email protected] / password.
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"ganigeorgiev.com/pocketbase/pocketbase/core"
"github.com/pockethost/pocketone/ext/devusers"
"github.com/pockethost/pocketone/ext/mailtrap"
"github.com/pockethost/pocketone/ext/multisite-mailrelay"
"github.com/pockethost/pocketone/ext/multisite-provider-pocker"
)
func main() {
dev := flag.Bool("dev", false, "serve any hostname (alpha.localhost, beta.localhost, ...); otherwise instances.yaml is required")
listen := flag.String("listen", "", "HTTP address for the instances (default :8090)")
flag.Parse()
cfg := pocker.DefaultConfig()
cfg.Dev = cfg.Dev || *dev
if *listen != "" {
cfg.Listen = *listen
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// The dev mail trap (ext/mailtrap) stands in for the operator's real relay (SES, a provider's SMTP, ...)
inbox, err := mailtrap.Default("mailrelay example: the relay sends to the trap")
if err != nil {
log.Fatal(err)
}
srv := &pocker.Server{
Config: cfg,
Extensions: []pocker.Extension{
mailrelay.New(mailrelay.Config{
Domain: "mail.localhost",
Upstream: mailrelay.SMTPUpstream{Addr: inbox.SMTPAddr},
}),
},
// local testing: every instance gets superuser [email protected] and user [email protected], password "password"
SetupApp: func(_ pocker.Instance, app core.App) error { return devusers.Ensure(app) },
}
if err := srv.Run(ctx); err != nil {
log.Fatal(err)
}
}
How it works
- The extension binds PocketBase's
OnMailerSendon every instance, last, after the app's own hooks. No core change. - When SMTP is off it doesn't call
e.Next(), so PocketBase'ssendmailfallback never runs. It relays the message in-process instead. - Nothing is written to the instance's settings. A backup restored somewhere else has no relay credentials in it, and falls back to stock PocketBase behavior.
For each message:
- Check it's allowed. May this instance send (
send)? Are there no more than 5 recipients (To + Cc + Bcc)? Is the encoded size no more than 1 MiB? - Count it. Each message counts once against the instance's limits, however many recipients it has. The day resets at midnight UTC. Counts are kept in memory.
- Set the sender. The From address becomes
<local part>@<Domain>. The display name is kept, minus anything that looks like an address or a link. The app's own sender address becomesReply-To, unless it's PocketBase's@example.complaceholder. - Build and send. Bcc stays out of the headers. An
X-Pocker-Instanceheader is added, then the message goes to theUpstream. - Log it to the instance's own log.
License
MIT
Questions and comments
Comments are a GitHub Discussions thread; sign in with GitHub to post.