From ce61284d3557b3403db8898f9e90a13bf0aa623a Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Thu, 13 Aug 2026 10:52:56 -0700 Subject: [PATCH 1/2] allow self-hosted to use sendgrid as the email provider --- client/www/app/docs/self-hosting/aws/page.md | 6 ++-- .../www/app/docs/self-hosting/migrate/page.md | 2 +- client/www/app/docs/self-hosting/page.md | 29 +++++++++++++++++-- client/www/app/docs/self-hosting/vps/page.md | 4 +-- self-hosting/.env.example | 10 +++++-- self-hosting/docker-compose.local.yml | 2 ++ self-hosting/docker-compose.with-caddy.yml | 2 ++ self-hosting/docker-compose.yml | 2 ++ self-hosting/swarm.yml | 2 ++ server/src/instant/config.clj | 16 ++++++++-- server/src/instant/email_router.clj | 24 +++++++++++++-- server/src/instant/sendgrid.clj | 11 +++---- 12 files changed, 91 insertions(+), 19 deletions(-) diff --git a/client/www/app/docs/self-hosting/aws/page.md b/client/www/app/docs/self-hosting/aws/page.md index 73f900cb91..6d98f2b271 100644 --- a/client/www/app/docs/self-hosting/aws/page.md +++ b/client/www/app/docs/self-hosting/aws/page.md @@ -220,9 +220,9 @@ The load balancer should only send traffic to backends where `/health/system` returns `{"wal":"ok"}`. Open the dashboard and create an app to check queries, writes, realtime updates, and file uploads. -Until Postmark is configured, login codes are written to the backend logs. For -an ECS deployment using CloudWatch Logs, tail the log group configured on the -backend task definition: +Until an email provider is configured, login codes are written to the backend +logs. For an ECS deployment using CloudWatch Logs, tail the log group configured +on the backend task definition: ```shell {% showCopy=true %} aws logs tail /your/backend/log-group \ diff --git a/client/www/app/docs/self-hosting/migrate/page.md b/client/www/app/docs/self-hosting/migrate/page.md index 931c914e7d..f162e4d6f7 100644 --- a/client/www/app/docs/self-hosting/migrate/page.md +++ b/client/www/app/docs/self-hosting/migrate/page.md @@ -20,7 +20,7 @@ create an app, query it, and write data. Before restoring your app: -- [Configure Postmark](/docs/self-hosting#configure-email-with-postmark) so magic code emails work. +- [Configure an email provider](/docs/self-hosting#configure-email-with-postmark) so magic code emails work. - [Restrict dashboard signups](/docs/self-hosting#restrict-dashboard-signups) and [disable temporary apps](/docs/self-hosting#temporary-apps) to prevent unwanted app creation. - Configure webhooks for your self-hosted app if your app uses them. diff --git a/client/www/app/docs/self-hosting/page.md b/client/www/app/docs/self-hosting/page.md index 6ebc7429ef..abfb315876 100644 --- a/client/www/app/docs/self-hosting/page.md +++ b/client/www/app/docs/self-hosting/page.md @@ -23,8 +23,8 @@ startup. For side projects the expected cost is at least $30/month. For serious ## Operating Once you've got Instant running, you may want to change some default settings. At -the very least you should be sure to configure Postmark so magic code emails can -actually be delivered. +the very least you should be sure to configure an email provider so magic code +emails can actually be delivered. ### Configure email with Postmark @@ -47,6 +47,31 @@ INSTANT_TEAM_EMAIL_SENDER_EMAIL=teams@example.com Restart the backend and try logging in to the dashboard. If all goes right, you should get an email delivered! +### Configure email with SendGrid + +You can use [SendGrid](https://sendgrid.com/) instead of Postmark. Create an API +key, verify your sender addresses (or authenticate your domain), and set: + +```shell +SENDGRID_TOKEN=replace-with-your-api-key +INSTANT_EMAIL_REPLY_TO=hello@example.com +INSTANT_DASHBOARD_EMAIL_SENDER_NAME=Instant +INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL=verify@example.com +INSTANT_APP_EMAIL_SENDER_NAME=Instant +INSTANT_APP_EMAIL_SENDER_EMAIL=verify@example.com +INSTANT_TEAM_EMAIL_SENDER_NAME=Instant +INSTANT_TEAM_EMAIL_SENDER_EMAIL=teams@example.com +``` + +If only one of `POSTMARK_TOKEN` or `SENDGRID_TOKEN` is set, Instant uses that +provider automatically. If you set both, choose one explicitly with: + +```shell +INSTANT_EMAIL_PROVIDER=sendgrid +``` + +Restart the backend and try logging in to the dashboard to confirm delivery. + ### Configure Google dashboard login The dashboard also allows for login via Google. To enable this, you'll need to create a Web application OAuth client in the diff --git a/client/www/app/docs/self-hosting/vps/page.md b/client/www/app/docs/self-hosting/vps/page.md index 2ba374cda2..f81b23bb4c 100644 --- a/client/www/app/docs/self-hosting/vps/page.md +++ b/client/www/app/docs/self-hosting/vps/page.md @@ -120,8 +120,8 @@ curl -fsS https://api.myinstant.com/health/system A healthy backend returns `{"wal":"ok"}`. Open the dashboard and create an app to check queries, writes, and file uploads. -Until Postmark is configured, login codes are written to the backend logs. Tail -them with: +Until an email provider is configured, login codes are written to the backend +logs. Tail them with: ```sh {% showCopy=true %} sudo docker compose -f docker-compose.with-caddy.yml --env-file .env logs --follow server diff --git a/self-hosting/.env.example b/self-hosting/.env.example index 82f4b8acaf..2c64f5d576 100644 --- a/self-hosting/.env.example +++ b/self-hosting/.env.example @@ -42,10 +42,16 @@ INSTANT_SUPERUSER_EMAIL= INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_ID= INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET= -# Email. Provide the Postmark token to send login magic codes. -# If omitted, one-time login codes will be printed to stdout +# Email. Provide a Postmark or SendGrid token to send login magic codes. +# If neither is set, one-time login codes will be printed to stdout # from the server container. +# +# Set exactly one token, or set both and pick one with INSTANT_EMAIL_PROVIDER +# ("postmark" or "sendgrid"). Make sure the *_EMAIL_SENDER_EMAIL addresses +# below are verified senders/domains with your chosen provider. POSTMARK_TOKEN= +SENDGRID_TOKEN= +INSTANT_EMAIL_PROVIDER= INSTANT_EMAIL_REPLY_TO=hello@example.com INSTANT_DASHBOARD_EMAIL_SENDER_NAME=Instant INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL=verify@example.com diff --git a/self-hosting/docker-compose.local.yml b/self-hosting/docker-compose.local.yml index de56b10557..5043f29e55 100644 --- a/self-hosting/docker-compose.local.yml +++ b/self-hosting/docker-compose.local.yml @@ -99,6 +99,8 @@ services: INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET: ${INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET:-} STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} + SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL: ${INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL:-} diff --git a/self-hosting/docker-compose.with-caddy.yml b/self-hosting/docker-compose.with-caddy.yml index b2c6dd870f..f23168e4fe 100644 --- a/self-hosting/docker-compose.with-caddy.yml +++ b/self-hosting/docker-compose.with-caddy.yml @@ -82,6 +82,8 @@ services: INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET: ${INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET:-} STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} + SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL: ${INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL:-} diff --git a/self-hosting/docker-compose.yml b/self-hosting/docker-compose.yml index 21125038d2..0becfca693 100644 --- a/self-hosting/docker-compose.yml +++ b/self-hosting/docker-compose.yml @@ -85,6 +85,8 @@ services: INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET: ${INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET:-} STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} + SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL: ${INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL:-} diff --git a/self-hosting/swarm.yml b/self-hosting/swarm.yml index be74e65bf3..796dfd3ac0 100644 --- a/self-hosting/swarm.yml +++ b/self-hosting/swarm.yml @@ -102,6 +102,8 @@ services: INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET: ${INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET:-} STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} + SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL: ${INSTANT_DASHBOARD_EMAIL_SENDER_EMAIL:-} diff --git a/server/src/instant/config.clj b/server/src/instant/config.clj index 4d1f6ecd83..cb8038821e 100644 --- a/server/src/instant/config.clj +++ b/server/src/instant/config.clj @@ -131,7 +131,8 @@ (some-> @config-map :postmark-token crypt-util/secret-value))) (defn sendgrid-token [] - (some-> @config-map :sendgrid-token crypt-util/secret-value)) + (or (System/getenv "SENDGRID_TOKEN") + (some-> @config-map :sendgrid-token crypt-util/secret-value))) (defn postmark-account-token [] (or (System/getenv "POSTMARK_ACCOUNT_TOKEN") @@ -158,7 +159,18 @@ :email (or (System/getenv "INSTANT_TEAM_EMAIL_SENDER_EMAIL") "teams@pm.instantdb.com")}) -(defn sendgrid-send-disabled? [] +(defn email-provider + "Explicit email-provider override for self-hosted deployments + (INSTANT_EMAIL_PROVIDER = \"postmark\" | \"sendgrid\"). Wins over token + auto-detection when set. nil when unset." + [] + (some-> (System/getenv "INSTANT_EMAIL_PROVIDER") + string/trim + string/lower-case + not-empty + keyword)) + +(defn sendgrid-send-enabled? [] (not (string/blank? (sendgrid-token)))) (defn postmark-send-enabled? [] diff --git a/server/src/instant/email_router.clj b/server/src/instant/email_router.clj index 787a49994b..5edd69edef 100644 --- a/server/src/instant/email_router.clj +++ b/server/src/instant/email_router.clj @@ -1,5 +1,6 @@ (ns instant.email-router (:require + [instant.config :as config] [instant.flags :as flags] [instant.postmark :as postmark] [instant.sendgrid :as sendgrid])) @@ -9,7 +10,26 @@ "verify@dash-pm.instantdb.com" "verify@auth-sg.instantdb.com"}) (defn send-structured! [req] - (if (and (flags/send-with-sendgrid?) - (contains? sendgrid-froms (-> req :from :email))) + (cond + ;; Explicit provider override (self-hosted), wins even if both tokens + ;; are configured. + (= :sendgrid (config/email-provider)) + (sendgrid/send! req) + + (= :postmark (config/email-provider)) + (postmark/send-structured! req) + + ;; Auto-detect: SendGrid configured and Postmark not — route through + ;; SendGrid using the operator's own from-address. + (and (config/sendgrid-send-enabled?) + (not (config/postmark-send-enabled?))) + (sendgrid/send! req) + + ;; Hosted: gated by the feature flag, and only for the known Instant + ;; from-addresses that have a SendGrid equivalent. + (and (flags/send-with-sendgrid?) + (contains? sendgrid-froms (-> req :from :email))) (sendgrid/send! (update-in req [:from :email] sendgrid-froms)) + + :else (postmark/send-structured! req))) diff --git a/server/src/instant/sendgrid.clj b/server/src/instant/sendgrid.clj index 8e21a70ceb..03e1321b64 100644 --- a/server/src/instant/sendgrid.clj +++ b/server/src/instant/sendgrid.clj @@ -7,16 +7,17 @@ [instant.postmark :as postmark])) (defn send! [{:keys [from to cc bcc subject html reply-to]}] - (let [body {:personalizations [{:to - - to :cc cc :bcc bcc}] + (let [personalization (cond-> {:to to} + cc (assoc :cc cc) + bcc (assoc :bcc bcc)) + body {:personalizations [personalization] :from from - :reply_to {:email (or reply-to "hello@instantdb.com")} + :reply_to {:email (or reply-to (config/email-reply-to))} :subject subject :content [{:type "text/html" :value html}]}] - (if-not (config/sendgrid-send-disabled?) + (if-not (config/sendgrid-send-enabled?) (tracer/with-span! {:name "sendgrid/send-disabled" :attributes body} (tracer/record-info! From e4a88e8fb699040d12e5c2b16392b0a4fde7390a Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Thu, 13 Aug 2026 11:10:22 -0700 Subject: [PATCH 2/2] filter empty --- server/src/instant/config.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/instant/config.clj b/server/src/instant/config.clj index cb8038821e..38e51b27c9 100644 --- a/server/src/instant/config.clj +++ b/server/src/instant/config.clj @@ -131,7 +131,7 @@ (some-> @config-map :postmark-token crypt-util/secret-value))) (defn sendgrid-token [] - (or (System/getenv "SENDGRID_TOKEN") + (or (some-> (System/getenv "SENDGRID_TOKEN") string/trim not-empty) (some-> @config-map :sendgrid-token crypt-util/secret-value))) (defn postmark-account-token []