resend_rs/
config.rs

1#[cfg(not(feature = "blocking"))]
2use governor::{
3    Quota, RateLimiter,
4    clock::MonotonicClock,
5    middleware::NoOpMiddleware,
6    state::{InMemoryState, NotKeyed},
7};
8#[cfg(feature = "blocking")]
9use reqwest::blocking::{Client, RequestBuilder, Response};
10#[cfg(not(feature = "blocking"))]
11use reqwest::{Client, RequestBuilder, Response};
12use reqwest::{Method, Url};
13use reqwest::{StatusCode, header::USER_AGENT};
14use std::{env, fmt};
15#[cfg(not(feature = "blocking"))]
16use std::{num::NonZeroU32, sync::Arc, time::Duration};
17
18use crate::{Error, Result, error::types::ErrorResponse};
19
20#[cfg(doc)]
21use crate::Resend;
22
23/// Convenience builder for [`Config`].
24///
25/// This requires from you to set the API key ([`ConfigBuilder::new`]), but also
26/// makes it possible to set a `reqwest` http client with your custom configuration
27/// (see also [`Resend::with_client`]) as well as an override for the Resend's
28/// base url to send requests to.
29///
30/// ```no_run
31/// # use resend_rs::ConfigBuilder;
32/// let http_client = reqwest::Client::builder()
33///      .timeout(std::time::Duration::from_secs(10))
34///      .build()
35///      .unwrap();
36///
37/// // Make sure to not store secrets in code, instead consider using crates like `dotenvy`
38/// // or `secrecy`.
39/// let _config = ConfigBuilder::new("re_...")
40///     // this can be your proxy's url (if any) or a test server url which
41///     // is intercepting request and allows to inspect them later on
42///     .base_url("http://wiremock:35353".parse().unwrap())
43///     .client(http_client)
44///     .build();
45/// ```
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct ConfigBuilder {
49    api_key: String,
50    base_url: Option<Url>,
51    client: Option<Client>,
52}
53
54impl ConfigBuilder {
55    /// Create new [`ConfigBuilder`] with `api_key` set.
56    pub fn new<S>(api_key: S) -> Self
57    where
58        S: Into<String>,
59    {
60        Self {
61            api_key: api_key.into(),
62            base_url: None,
63            client: None,
64        }
65    }
66
67    /// Set a custom Resend's base url.
68    ///
69    /// This can be your proxy's url (if any) or a test server url which
70    /// intercepting request and allows to inspect them later on.
71    ///
72    /// If not provided here, the `RESEND_BASE_URL` environment variable will be
73    /// used. If that is not not provided either - a [default] url will be used.
74    ///
75    /// [default]: https://resend.com/docs/api-reference/introduction#base-url
76    #[must_use]
77    pub fn base_url(mut self, url: Url) -> Self {
78        self.base_url = Some(url);
79        self
80    }
81
82    /// Set custom http client.
83    #[must_use]
84    pub fn client(mut self, client: Client) -> Self {
85        self.client = Some(client);
86        self
87    }
88
89    /// Builder's terminal method producing [`Config`].
90    pub fn build(self) -> Config {
91        Config::new(self.api_key, self.client.unwrap_or_default(), self.base_url)
92    }
93}
94
95/// Configuration for `Resend` client.
96///
97/// Use [`Config::builder`] to start constructing your custom configuration.
98#[non_exhaustive]
99#[derive(Clone)]
100pub struct Config {
101    pub(crate) user_agent: String,
102    pub(crate) api_key: String,
103    pub(crate) base_url: Url,
104    pub(crate) client: Client,
105    #[cfg(not(feature = "blocking"))]
106    limiter: Arc<
107        RateLimiter<
108            NotKeyed,
109            InMemoryState,
110            MonotonicClock,
111            NoOpMiddleware<<MonotonicClock as governor::clock::Clock>::Instant>,
112        >,
113    >,
114}
115
116impl Config {
117    /// Create new [`ConfigBuilder`] with `api_key` set.
118    ///
119    /// A convenience method, that, internally, will call [`ConfigBuilder::new`].
120    pub fn builder<S>(api_key: S) -> ConfigBuilder
121    where
122        S: Into<String>,
123    {
124        ConfigBuilder::new(api_key.into())
125    }
126
127    /// Creates a new [`Config`].
128    ///
129    /// Note: the `base_url` parameter takes presedence over the `RESEND_BASE_URL` environment
130    /// variable.
131    #[must_use]
132    pub(crate) fn new(api_key: String, client: Client, base_url: Option<Url>) -> Self {
133        let env_base_url = base_url.unwrap_or_else(|| {
134            env::var("RESEND_BASE_URL")
135                .map_or_else(
136                    |_| Url::parse("https://api.resend.com"),
137                    |env_var| Url::parse(env_var.as_str()),
138                )
139                .expect("env variable `RESEND_BASE_URL` should be a valid URL")
140        });
141
142        let env_user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
143
144        // ==== Rate limiting is a non-blocking thing only ====
145        #[cfg(not(feature = "blocking"))]
146        let rate_limit_per_sec = env::var("RESEND_RATE_LIMIT")
147            .unwrap_or_else(|_| "9".to_owned())
148            .parse::<u32>()
149            .expect("env variable `RESEND_RATE_LIMIT` should be a valid u32");
150
151        #[cfg(not(feature = "blocking"))]
152        let quota = Quota::with_period(Duration::from_millis(1100))
153            .expect("Valid quota")
154            .allow_burst(
155                NonZeroU32::new(rate_limit_per_sec).expect("Rate limit is a valid non zero u32"),
156            );
157
158        #[cfg(not(feature = "blocking"))]
159        let limiter = Arc::new(RateLimiter::direct_with_clock(quota, MonotonicClock));
160        // ====================================================
161
162        Self {
163            user_agent: env_user_agent,
164            api_key,
165            base_url: env_base_url,
166            client,
167            #[cfg(not(feature = "blocking"))]
168            limiter,
169        }
170    }
171
172    /// Constructs a new [`RequestBuilder`].
173    pub(crate) fn build(&self, method: Method, path: &str) -> RequestBuilder {
174        let path = self
175            .base_url
176            .join(path)
177            .expect("should be a valid API endpoint");
178
179        self.client
180            .request(method, path)
181            .bearer_auth(self.api_key.as_str())
182            .header(USER_AGENT, self.user_agent.as_str())
183    }
184
185    #[allow(unreachable_pub)]
186    #[maybe_async::maybe_async]
187    pub async fn send(&self, request: RequestBuilder) -> Result<Response> {
188        #[cfg(not(feature = "blocking"))]
189        {
190            let jitter =
191                governor::Jitter::new(Duration::from_millis(10), Duration::from_millis(50));
192            self.limiter.until_ready_with_jitter(jitter).await;
193        }
194
195        let request = request.build()?;
196
197        let response = self.client.execute(request).await?;
198
199        match response.status() {
200            StatusCode::TOO_MANY_REQUESTS => {
201                let headers = response.headers();
202
203                let ratelimit_limit = headers
204                    .get("ratelimit-limit")
205                    .and_then(|v| v.to_str().ok())
206                    .and_then(|v| v.parse::<u64>().ok());
207                let ratelimit_remaining = headers
208                    .get("ratelimit-remaining")
209                    .and_then(|v| v.to_str().ok())
210                    .and_then(|v| v.parse::<u64>().ok());
211                let ratelimit_reset = headers
212                    .get("ratelimit-reset")
213                    .and_then(|v| v.to_str().ok())
214                    .and_then(|v| v.parse::<u64>().ok());
215
216                Err(Error::RateLimit {
217                    ratelimit_limit,
218                    ratelimit_remaining,
219                    ratelimit_reset,
220                })
221            }
222            x if x.is_client_error() || x.is_server_error() => {
223                // TODO: Make this more testable
224                let content_type_is_html = response
225                    .headers()
226                    .get("content-type")
227                    .and_then(|el| el.to_str().ok())
228                    .is_some_and(|content_type| content_type.contains("html"));
229
230                if content_type_is_html {
231                    return Err(Error::Parse(response.text().await?));
232                }
233
234                let error = response.json::<ErrorResponse>().await?;
235                Err(Error::Resend(error))
236            }
237            _ => Ok(response),
238        }
239    }
240}
241
242impl fmt::Debug for Config {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        // Don't output API key.
245        f.debug_struct("Client")
246            .field("api_key", &"re_*********")
247            .field("user_agent", &self.user_agent.as_str())
248            .field("base_url", &self.base_url.as_str())
249            .finish_non_exhaustive()
250    }
251}