diff --git a/client/www/app/docs/self-hosting/page.md b/client/www/app/docs/self-hosting/page.md index de0671ae05..b1e20a8991 100644 --- a/client/www/app/docs/self-hosting/page.md +++ b/client/www/app/docs/self-hosting/page.md @@ -71,8 +71,8 @@ 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: +If only one of `POSTMARK_TOKEN`, `SENDGRID_TOKEN`, or `RESEND_TOKEN` is set, Instant uses that +provider automatically. If you set multiple, choose one explicitly with: ```shell INSTANT_EMAIL_PROVIDER=sendgrid @@ -80,6 +80,25 @@ INSTANT_EMAIL_PROVIDER=sendgrid Restart the backend and try logging in to the dashboard to confirm delivery. +### Configure email with Resend + +You can also use [Resend](https://resend.com/) to send emails. Create an API key, +verify your domain in Resend, and set: + +```shell +RESEND_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 +``` + +You can also set `INSTANT_EMAIL_PROVIDER=resend` to explicitly select Resend. +Restart the backend and try logging in 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/self-hosting/.env.example b/self-hosting/.env.example index 2c64f5d576..3eac1df577 100644 --- a/self-hosting/.env.example +++ b/self-hosting/.env.example @@ -42,15 +42,16 @@ INSTANT_SUPERUSER_EMAIL= INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_ID= INSTANT_DASHBOARD_GOOGLE_OAUTH_CLIENT_SECRET= -# 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 +# Email. Provide a Postmark, SendGrid, or Resend token to send login magic codes. +# If none 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 +# Set exactly one token, or set multiple and pick one with INSTANT_EMAIL_PROVIDER +# ("postmark", "sendgrid", or "resend"). Make sure the *_EMAIL_SENDER_EMAIL addresses # below are verified senders/domains with your chosen provider. POSTMARK_TOKEN= SENDGRID_TOKEN= +RESEND_TOKEN= INSTANT_EMAIL_PROVIDER= INSTANT_EMAIL_REPLY_TO=hello@example.com INSTANT_DASHBOARD_EMAIL_SENDER_NAME=Instant diff --git a/self-hosting/docker-compose.local.yml b/self-hosting/docker-compose.local.yml index 5043f29e55..d834705756 100644 --- a/self-hosting/docker-compose.local.yml +++ b/self-hosting/docker-compose.local.yml @@ -100,6 +100,8 @@ services: STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + RESEND_TOKEN: ${RESEND_TOKEN:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} diff --git a/self-hosting/docker-compose.with-caddy.yml b/self-hosting/docker-compose.with-caddy.yml index f23168e4fe..dda076c352 100644 --- a/self-hosting/docker-compose.with-caddy.yml +++ b/self-hosting/docker-compose.with-caddy.yml @@ -83,6 +83,8 @@ services: STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + RESEND_TOKEN: ${RESEND_TOKEN:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} diff --git a/self-hosting/docker-compose.yml b/self-hosting/docker-compose.yml index 0becfca693..ddb635b9b3 100644 --- a/self-hosting/docker-compose.yml +++ b/self-hosting/docker-compose.yml @@ -86,6 +86,8 @@ services: STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + RESEND_TOKEN: ${RESEND_TOKEN:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} diff --git a/self-hosting/swarm.yml b/self-hosting/swarm.yml index 796dfd3ac0..cc700e14d1 100644 --- a/self-hosting/swarm.yml +++ b/self-hosting/swarm.yml @@ -103,6 +103,8 @@ services: STRIPE_API_KEY: ${STRIPE_API_KEY:-} POSTMARK_TOKEN: ${POSTMARK_TOKEN:-} SENDGRID_TOKEN: ${SENDGRID_TOKEN:-} + RESEND_TOKEN: ${RESEND_TOKEN:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} INSTANT_EMAIL_PROVIDER: ${INSTANT_EMAIL_PROVIDER:-} INSTANT_EMAIL_REPLY_TO: ${INSTANT_EMAIL_REPLY_TO:-} INSTANT_DASHBOARD_EMAIL_SENDER_NAME: ${INSTANT_DASHBOARD_EMAIL_SENDER_NAME:-} diff --git a/server/src/instant/config.clj b/server/src/instant/config.clj index 108432c27e..1215b9a4c1 100644 --- a/server/src/instant/config.clj +++ b/server/src/instant/config.clj @@ -134,6 +134,11 @@ (or (some-> (System/getenv "SENDGRID_TOKEN") string/trim not-empty) (some-> @config-map :sendgrid-token crypt-util/secret-value))) +(defn resend-token [] + (or (some-> (System/getenv "RESEND_TOKEN") string/trim not-empty) + (some-> (System/getenv "RESEND_API_KEY") string/trim not-empty) + (some-> @config-map :resend-token crypt-util/secret-value))) + (defn postmark-account-token [] (or (System/getenv "POSTMARK_ACCOUNT_TOKEN") (some-> @config-map :postmark-account-token crypt-util/secret-value))) @@ -161,7 +166,7 @@ (defn email-provider "Explicit email-provider override for self-hosted deployments - (INSTANT_EMAIL_PROVIDER = \"postmark\" | \"sendgrid\"). Wins over token + (INSTANT_EMAIL_PROVIDER = \"postmark\" | \"sendgrid\" | \"resend\"). Wins over token auto-detection when set. nil when unset." [] (some-> (System/getenv "INSTANT_EMAIL_PROVIDER") @@ -170,6 +175,9 @@ not-empty keyword)) +(defn resend-send-enabled? [] + (not (string/blank? (resend-token)))) + (defn sendgrid-send-enabled? [] (not (string/blank? (sendgrid-token)))) diff --git a/server/src/instant/config_edn.clj b/server/src/instant/config_edn.clj index 100e0e8546..68a9f699a9 100644 --- a/server/src/instant/config_edn.clj +++ b/server/src/instant/config_edn.clj @@ -48,6 +48,7 @@ (s/def ::s3-bucket-name string?) (s/def ::postmark-token ::config-value) (s/def ::sendgrid-token ::config-value) +(s/def ::resend-token ::config-value) (s/def ::postmark-account-token ::config-value) (s/def ::secret-discord-token ::config-value) (s/def ::database-url ::config-value) @@ -87,6 +88,7 @@ ::next-database-cluster-id ::postmark-token ::sendgrid-token + ::resend-token ::postmark-account-token ::secret-discord-token ::stripe-secret diff --git a/server/src/instant/email_router.clj b/server/src/instant/email_router.clj index 5edd69edef..0620e0ac33 100644 --- a/server/src/instant/email_router.clj +++ b/server/src/instant/email_router.clj @@ -3,6 +3,7 @@ [instant.config :as config] [instant.flags :as flags] [instant.postmark :as postmark] + [instant.resend :as resend] [instant.sendgrid :as sendgrid])) (def sendgrid-froms @@ -11,14 +12,22 @@ (defn send-structured! [req] (cond - ;; Explicit provider override (self-hosted), wins even if both tokens - ;; are configured. + ;; Explicit provider override (self-hosted) + (= :resend (config/email-provider)) + (resend/send! req) + (= :sendgrid (config/email-provider)) (sendgrid/send! req) (= :postmark (config/email-provider)) (postmark/send-structured! req) + ;; Auto-detect: Resend configured and others not + (and (config/resend-send-enabled?) + (not (config/postmark-send-enabled?)) + (not (config/sendgrid-send-enabled?))) + (resend/send! req) + ;; Auto-detect: SendGrid configured and Postmark not — route through ;; SendGrid using the operator's own from-address. (and (config/sendgrid-send-enabled?) diff --git a/server/src/instant/resend.clj b/server/src/instant/resend.clj new file mode 100644 index 0000000000..7ca50e023e --- /dev/null +++ b/server/src/instant/resend.clj @@ -0,0 +1,79 @@ +(ns instant.resend + (:require + [clj-http.client :as clj-http] + [clojure.string :as string] + [instant.config :as config] + [instant.util.exception :as ex] + [instant.util.json :refer [->json <-json]] + [instant.util.tracer :as tracer])) + +(defn format-sender [from] + (cond + (string? from) from + (map? from) (if (:name from) + (str (:name from) " <" (:email from) ">") + (:email from)) + :else (str from))) + +(defn format-recipients [to] + (cond + (nil? to) [] + (string? to) [to] + (sequential? to) (mapv (fn [r] (if (map? r) (:email r) (str r))) to) + (map? to) [(:email to)] + :else [(str to)])) + +(defn error-detail + [e] + (try + (or (-> e ex-data :body (<-json true) :message) + (-> e ex-data :body (<-json true) :error :message)) + (catch Exception _ nil))) + +(defn throw-send-error! + [e to] + (tracer/add-data! {:attributes {:resend-status (-> e ex-data :status) + :resend-error (error-detail e)}}) + (ex/throw-email-send-failed! + "We weren't able to send the email." + {:recipient (first (format-recipients to))} + e)) + +(defn send! [{:keys [from to cc bcc subject html text reply-to]}] + (let [to-emails (format-recipients to) + from-str (format-sender from) + reply-to-email (when-let [rt (or reply-to (config/email-reply-to))] + (format-sender rt)) + body (cond-> {:from from-str + :to to-emails + :subject subject + :html html} + text (assoc :text text) + reply-to-email (assoc :reply_to reply-to-email) + cc (assoc :cc (format-recipients cc)) + bcc (assoc :bcc (format-recipients bcc)))] + + (if-not (config/resend-send-enabled?) + (tracer/with-span! {:name "resend/send-disabled" + :attributes {:to-count (count to-emails)}} + (tracer/record-info! + {:name "resend-disabled" + :attributes + {:msg + "Resend is disabled, add resend-token to config to enable"}})) + (tracer/with-span! + {:name "resend/send" + :attributes {:to-count (count to-emails)}} + (try + (clj-http/post + "https://api.resend.com/emails" + {:headers {"Authorization" (str "Bearer " (config/resend-token)) + "Content-Type" "application/json"} + :redirect-strategy :none + :unexceptional-status #(<= 200 % 299) + :conn-timeout 10000 + :socket-timeout 10000 + :connection-request-timeout 10000 + :body (->json body)}) + (catch Exception e + (throw-send-error! e to))))))) diff --git a/server/test/instant/config_edn_test.clj b/server/test/instant/config_edn_test.clj index 17cc51630b..d777afeba4 100644 --- a/server/test/instant/config_edn_test.clj +++ b/server/test/instant/config_edn_test.clj @@ -45,7 +45,9 @@ (is (= "INSTANT_CONFIG_S3_ENDPOINT" (config-edn/config-env-var-name :s3-endpoint))) (is (= "INSTANT_CONFIG_POSTMARK_TOKEN" - (config-edn/config-env-var-name :postmark-token)))) + (config-edn/config-env-var-name :postmark-token))) + (is (= "INSTANT_CONFIG_RESEND_TOKEN" + (config-edn/config-env-var-name :resend-token)))) (deftest env-overrides-obfuscate-secrets-only ;; With no INSTANT_CONFIG_* env vars set, there's nothing to override. @@ -54,6 +56,7 @@ (let [overridable? #(some? (#'config-edn/overridable-key-type %))] (is (overridable? :s3-endpoint)) (is (overridable? :postmark-token)) + (is (overridable? :resend-token)) (is (not (overridable? :google-oauth-client))) (is (not (overridable? :aead-keyset)))))