Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions client/www/app/docs/self-hosting/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,34 @@ 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
```

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
Expand Down
9 changes: 5 additions & 4 deletions self-hosting/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions self-hosting/docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
2 changes: 2 additions & 0 deletions self-hosting/docker-compose.with-caddy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
2 changes: 2 additions & 0 deletions self-hosting/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
2 changes: 2 additions & 0 deletions self-hosting/swarm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
10 changes: 9 additions & 1 deletion server/src/instant/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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")
Expand All @@ -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))))

Expand Down
2 changes: 2 additions & 0 deletions server/src/instant/config_edn.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -87,6 +88,7 @@
::next-database-cluster-id
::postmark-token
::sendgrid-token
::resend-token
::postmark-account-token
::secret-discord-token
::stripe-secret
Expand Down
13 changes: 11 additions & 2 deletions server/src/instant/email_router.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?)
Expand Down
79 changes: 79 additions & 0 deletions server/src/instant/resend.clj
Original file line number Diff line number Diff line change
@@ -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)})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(catch Exception e
(throw-send-error! e to)))))))
5 changes: 4 additions & 1 deletion server/test/instant/config_edn_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)))))

Expand Down
Loading