refactor(mail): drop mailbaby, switch auth/space/issues to EmailService
ZhenYi committed
Modified lib/auth/src/services/mod.rs lib/auth/src/services/mod.rs +2 −13 View lib/auth/src/services/mod.rs
@@ -2,7 +2,6 @@
22
33use config::AppConfig;
44use export::{AppDatabase, AppRedis, AppState};
5use mailbaby::Email;
65
76use crate::errors::AuthError;
87
@@ -55,20 +54,10 @@
5554 text: &str,
5655 html: &str,
5756 ) -> Result<(), AuthError> {
58 let builder = Email::builder(subject).to([to]).text_body(text);
59 let builder = if html.is_empty() {
60 builder
61 } else {
62 builder.html_body(html)
63 };
64 let email = builder
65 .build()
66 .map_err(|error| AuthError::Internal(format!("mail build failed: {error}")))?;
6757 self.state
6858 .email
69 .send(&email)
59 .send_mail(to, subject, text, html)
7060 .await
71 .map(|_| ())
72 .map_err(AuthError::from)
61 .map_err(|error| AuthError::Internal(format!("email send failed: {error}")))
7362 }
7463}
No newline at end of file
Modified lib/auth/src/templates/common.rs lib/auth/src/templates/common.rs +0 −85 View lib/auth/src/templates/common.rs
@@ -17,89 +17,4 @@
1717 }
1818 };
1919 Html(v.into_string())
20}
21
22pub(crate) fn mail_shell(title: &str, body: Markup) -> Html<String> {
23 let v = html! {
24 (DOCTYPE)
25 html lang="en" xmlns="http://www.w3.org/1999/xhtml" {
26 head {
27 meta charset="utf-8";
28 meta name="viewport" content="width=device-width, initial-scale=1";
29 meta http-equiv="X-UA-Compatible" content="IE=edge";
30 title { (title) }
31 style type="text/css" {
32 "body,table,td,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}"
33 "table,td{mso-table-lspace:0pt;mso-table-rspace:0pt;}"
34 "img{border:0;height:auto;line-height:100%;outline:none;text-decoration:none;}"
35 "table{border-collapse:collapse!important;}"
36 "body{height:100%!important;margin:0!important;padding:0!important;width:100%!important;}"
37 "a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}"
38 ".btn{display:inline-block;padding:12px 28px;font-size:16px;font-weight:600;"
39 "text-decoration:none;border-radius:8px;color:#ffffff;background-color:#2563eb;"
40 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
41 ".btn:hover{background-color:#1d4ed8;}"
42 ".container{max-width:600px;margin:0 auto;padding:24px;"
43 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
44 ".code{font-size:32px;font-weight:700;letter-spacing:8px;color:#111827;"
45 "padding:16px 24px;background:#f3f4f6;border-radius:8px;text-align:center;margin:16px 0;}"
46 ".muted{color:#6b7280;font-size:14px;}"
47 }
48 }
49 body style="background-color:#f9fafb;margin:0;padding:0;" {
50 table role="presentation" width="100%" cellpadding="0" cellspacing="0" {
51 tr {
52 td align="center" style="padding:24px;" {
53 table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.1);" {
54 tr {
55 td style="padding:32px 40px;" {
56 (body)
57 }
58 }
59 }
60 }
61 }
62 }
63 }
64 }
65 };
66 Html(v.into_string())
67}
68
69pub(super) fn url_join(base: &str, path: &str) -> String {
70 let base = base.trim_end_matches('/');
71 let path = path.trim_start_matches('/');
72 format!("{base}/{path}")
73}
74
75pub(super) fn append_query(url: &str, pairs: &[(&str, &str)]) -> String {
76 if pairs.is_empty() {
77 return url.to_string();
78 }
79 let mut s = format!("{url}?");
80 for (i, (k, v)) in pairs.iter().enumerate() {
81 if i > 0 {
82 s.push('&');
83 }
84 s.push_str(&percent_encode(k));
85 s.push('=');
86 s.push_str(&percent_encode(v));
87 }
88 s
89}
90
91fn percent_encode(input: &str) -> String {
92 let mut out = String::with_capacity(input.len());
93 for &b in input.as_bytes() {
94 match b {
95 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
96 out.push(b as char);
97 }
98 _ => {
99 out.push('%');
100 out.push_str(&format!("{b:02X}"));
101 }
102 }
103 }
104 out
10520}
No newline at end of file
Deleted lib/auth/src/templates/mail.rs lib/auth/src/templates/mail.rs +0 −93
@@ -1,93 +0,0 @@
1use axum::response::Html;
2use maud::html;
3
4use super::common::{append_query, mail_shell, url_join};
5
6pub fn mail_verify_code(code: &str, minutes: u64, purpose: &str) -> Html<String> {
7 let body = html! {
8 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { (purpose) }
9 p style="color:#374151;margin:0 0 8px;" {
10 "Enter the following code to continue. It expires in "
11 (minutes)
12 " minutes."
13 }
14 div class="code" { (code) }
15 p class="muted" style="margin:16px 0 0;" {
16 "If you didn't request this, you can safely ignore this email."
17 }
18 };
19 mail_shell(purpose, body)
20}
21
22pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Html<String> {
23 let link = append_query(
24 &url_join(base_url, "/reset-password"),
25 &[
26 ("email", email),
27 ("token", token),
28 ("temp_id", temp_id),
29 ],
30 );
31 let body = html! {
32 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Reset your password" }
33 p style="color:#374151;margin:0 0 24px;" {
34 "Click the button below to reset your password. This link expires in 30 minutes."
35 }
36 table role="presentation" cellpadding="0" cellspacing="0" {
37 tr {
38 td align="center" {
39 a href=(link) class="btn" { "Reset password" }
40 }
41 }
42 }
43 p class="muted" style="margin:24px 0 0;" {
44 "If the button doesn't work, copy this link into your browser:"
45 }
46 p style="word-break:break-all;font-size:13px;color:#2563eb;margin:4px 0 0;" { (link) }
47 p class="muted" style="margin:24px 0 0;" {
48 "If you didn't request a password reset, ignore this email."
49 }
50 };
51 mail_shell("Reset your password", body)
52}
53
54pub fn mail_email_change_confirm(
55 new_email: &str,
56 ticket_id: &str,
57 code: &str,
58 base_url: &str,
59) -> Html<String> {
60 let confirm_link = append_query(
61 &url_join(base_url, "/email/change/confirm"),
62 &[("ticket_id", ticket_id), ("code", code)],
63 );
64 let cancel_link = append_query(
65 &url_join(base_url, "/email/change/cancel"),
66 &[("ticket_id", ticket_id)],
67 );
68 let body = html! {
69 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Confirm your new email" }
70 p style="color:#374151;margin:0 0 24px;" {
71 "You requested to change your email to "
72 strong { (new_email) }
73 ". Click the button below to confirm."
74 }
75 table role="presentation" cellpadding="0" cellspacing="0" {
76 tr {
77 td align="center" {
78 a href=(confirm_link) class="btn" { "Confirm email change" }
79 }
80 }
81 }
82 p class="muted" style="margin:16px 0 0;" {
83 "Or enter this code: "
84 strong style="color:#111827;" { (code) }
85 }
86 p class="muted" style="margin:16px 0 0;" {
87 "Didn't request this? "
88 a href=(cancel_link) style="color:#2563eb;" { "Cancel the change" }
89 "."
90 }
91 };
92 mail_shell("Confirm your new email", body)
93}
No newline at end of file
Modified lib/auth/src/templates/mod.rs lib/auth/src/templates/mod.rs +22 −2 View lib/auth/src/templates/mod.rs
@@ -1,7 +1,27 @@
11mod common;
2mod mail;
32mod page;
43
54pub(crate) use common::page_shell;
6pub use mail::{mail_email_change_confirm, mail_reset_link, mail_verify_code};
5use axum::response::Html;
6
7pub fn mail_verify_code(code: &str, minutes: u64, purpose: &str) -> Html<String> {
8 Html(email::templates::mail_verify_code(code, minutes, purpose).into_string())
9}
10
11pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Html<String> {
12 Html(email::templates::mail_reset_link(email, token, temp_id, base_url).into_string())
13}
14
15pub fn mail_email_change_confirm(
16 new_email: &str,
17 ticket_id: &str,
18 code: &str,
19 base_url: &str,
20) -> Html<String> {
21 Html(
22 email::templates::mail_email_change_confirm(new_email, ticket_id, code, base_url)
23 .into_string(),
24 )
25}
26
727pub use page::{page_agreement, page_message, page_verify_result, render_markdown};
No newline at end of file
Modified lib/auth/Cargo.toml lib/auth/Cargo.toml +1 −1 View lib/auth/Cargo.toml
@@ -27,6 +27,6 @@
2727rand = { workspace = true }
2828ssh-key = { workspace = true }
2929tracing = { workspace = true }
30mailbaby = { workspace = true }
30email = { workspace = true }
3131maud = { workspace = true }
3232pulldown-cmark = { workspace = true }
No newline at end of file
Deleted lib/export/src/email.rs lib/export/src/email.rs +0 −223
@@ -1,223 +0,0 @@
1use config::mailbaby::{MailbabyAuthScheme, MailbabyChannel, MailbabyConfig};
2use mailbaby::grpc;
3use mailbaby::rest;
4use mailbaby::{Auth, BatchResponse, Email, SendResponse};
5
6use crate::error::ExportError;
7
8#[derive(Clone)]
9pub enum EmailClient {
10 Rest(rest::MailBabyClient),
11 Grpc(grpc::GrpcClient),
12}
13
14#[derive(Clone)]
15pub struct AppEmail {
16 config: MailbabyConfig,
17 client: Option<EmailClient>,
18}
19
20impl AppEmail {
21 pub fn new(config: MailbabyConfig) -> Result<Self, ExportError> {
22 config.validate().map_err(ExportError::InvalidConfig)?;
23 if !config.enabled() {
24 return Ok(Self {
25 config,
26 client: None,
27 });
28 }
29 match config.channel() {
30 MailbabyChannel::Rest => {
31 let client = build_rest(&config)?;
32 Ok(Self {
33 config,
34 client: Some(EmailClient::Rest(client)),
35 })
36 }
37 MailbabyChannel::Grpc => Err(ExportError::InvalidConfig(
38 "grpc channel requires AppEmail::connect".to_string(),
39 )),
40 }
41 }
42
43 pub async fn connect(config: MailbabyConfig) -> Result<Self, ExportError> {
44 config.validate().map_err(ExportError::InvalidConfig)?;
45 if !config.enabled() {
46 return Ok(Self {
47 config,
48 client: None,
49 });
50 }
51 let client = match config.channel() {
52 MailbabyChannel::Rest => EmailClient::Rest(build_rest(&config)?),
53 MailbabyChannel::Grpc => EmailClient::Grpc(build_grpc(&config).await?),
54 };
55 Ok(Self {
56 config,
57 client: Some(client),
58 })
59 }
60
61 pub fn config(&self) -> &MailbabyConfig {
62 &self.config
63 }
64
65 pub fn enabled(&self) -> bool {
66 self.client.is_some()
67 }
68
69 pub fn channel(&self) -> MailbabyChannel {
70 self.config.channel()
71 }
72
73 pub fn client(&self) -> Result<&EmailClient, ExportError> {
74 self.client.as_ref().ok_or(ExportError::Disabled)
75 }
76
77 pub fn apply_defaults(&self, mut email: Email) -> Email {
78 if email.account.is_empty() {
79 email.account = self.config.account().unwrap_or_default().to_string();
80 }
81 if email.from.is_empty() {
82 email.from = self.config.from_address().unwrap_or_default().to_string();
83 }
84 if email.from_name.is_empty() {
85 email.from_name = self.config.from_name().unwrap_or_default().to_string();
86 }
87 if email.reply_to.is_empty() {
88 email.reply_to = self.config.reply_to().unwrap_or_default().to_string();
89 }
90 email
91 }
92
93 pub async fn send(&self, email: &Email) -> Result<SendResponse, ExportError> {
94 self.client()?.send(email).await
95 }
96
97 pub async fn send_mail(
98 &self,
99 to: &str,
100 subject: &str,
101 text: &str,
102 html: &str,
103 ) -> Result<(), ExportError> {
104 let email = Email::builder(subject)
105 .to([to])
106 .text_body(text)
107 .html_body(html)
108 .build()?;
109 self.send(&email).await.map(|_| ())
110 }
111
112 pub async fn send_queued(&self, email: &Email) -> Result<SendResponse, ExportError> {
113 self.client()?.send_queued(email).await
114 }
115
116 pub async fn send_batch(
117 &self,
118 emails: &[Email],
119 queue: bool,
120 ) -> Result<BatchResponse, ExportError> {
121 self.client()?.send_batch(emails, queue).await
122 }
123
124 pub async fn ping(&self) -> Result<String, ExportError> {
125 self.client()?.ping().await
126 }
127
128 pub async fn live(&self) -> Result<(), ExportError> {
129 self.client()?.live().await
130 }
131
132 pub async fn ready(&self) -> Result<(), ExportError> {
133 self.client()?.ready().await
134 }
135}
136
137impl EmailClient {
138 pub async fn send(&self, email: &Email) -> Result<SendResponse, ExportError> {
139 match self {
140 EmailClient::Rest(client) => Ok(client.send(email).await?),
141 EmailClient::Grpc(client) => Ok(client.send(email).await?),
142 }
143 }
144
145 pub async fn send_queued(&self, email: &Email) -> Result<SendResponse, ExportError> {
146 match self {
147 EmailClient::Rest(client) => Ok(client.send_async(email).await?),
148 EmailClient::Grpc(client) => Ok(client.send_async(email).await?),
149 }
150 }
151
152 pub async fn send_batch(
153 &self,
154 emails: &[Email],
155 queue: bool,
156 ) -> Result<BatchResponse, ExportError> {
157 match self {
158 EmailClient::Rest(client) => Ok(client.send_batch(emails, queue).await?),
159 EmailClient::Grpc(client) => Ok(client.send_batch(emails, queue).await?),
160 }
161 }
162
163 pub async fn ping(&self) -> Result<String, ExportError> {
164 match self {
165 EmailClient::Grpc(client) => Ok(client.ping().await?),
166 EmailClient::Rest(_) => Err(ExportError::Unsupported(
167 "ping is only available over the grpc channel".to_string(),
168 )),
169 }
170 }
171
172 pub async fn live(&self) -> Result<(), ExportError> {
173 match self {
174 EmailClient::Rest(client) => Ok(client.live().await?),
175 EmailClient::Grpc(_) => Err(ExportError::Unsupported(
176 "the live probe is only available over the rest channel".to_string(),
177 )),
178 }
179 }
180
181 pub async fn ready(&self) -> Result<(), ExportError> {
182 match self {
183 EmailClient::Rest(client) => Ok(client.ready().await?),
184 EmailClient::Grpc(_) => Err(ExportError::Unsupported(
185 "the ready probe is only available over the rest channel".to_string(),
186 )),
187 }
188 }
189}
190
191fn build_rest(config: &MailbabyConfig) -> Result<rest::MailBabyClient, ExportError> {
192 let mut builder = rest::MailBabyClient::builder(config.url()).timeout(config.request_timeout());
193 if let Some(auth) = build_auth(config)? {
194 builder = builder.auth(auth);
195 }
196 Ok(builder.build()?)
197}
198
199async fn build_grpc(config: &MailbabyConfig) -> Result<grpc::GrpcClient, ExportError> {
200 let mut builder = grpc::GrpcClient::builder(config.url()).timeout(config.request_timeout());
201 if let Some(auth) = build_auth(config)? {
202 builder = builder.auth(auth);
203 }
204 Ok(builder.build().await?)
205}
206
207fn build_auth(config: &MailbabyConfig) -> Result<Option<Auth>, ExportError> {
208 let Some(key) = config.api_key() else {
209 return Ok(None);
210 };
211 let auth = match config.auth_scheme() {
212 MailbabyAuthScheme::Header => {
213 let auth = Auth::api_key(key);
214 match config.api_key_header() {
215 Some(header) => auth.with_header(header),
216 None => auth,
217 }
218 }
219 MailbabyAuthScheme::Bearer => Auth::api_key(key).bearer(),
220 MailbabyAuthScheme::Query => Auth::api_key(key).query(),
221 };
222 Ok(Some(auth))
223}
No newline at end of file
Modified lib/export/src/error.rs lib/export/src/error.rs +6 −6 View lib/export/src/error.rs
@@ -7,7 +7,7 @@
77 Database(DbErr),
88 Redis(redis::RedisError),
99 Storage(storage::StorageError),
10 MailBaby(mailbaby::Error),
10 Email(email::EmailError),
1111 InvalidConfig(String),
1212 Connection(String),
1313 Disabled,
@@ -20,7 +20,7 @@
2020 ExportError::Database(err) => write!(f, "database error: {err}"),
2121 ExportError::Redis(err) => write!(f, "redis error: {err}"),
2222 ExportError::Storage(err) => write!(f, "storage error: {err}"),
23 ExportError::MailBaby(err) => write!(f, "mailbaby error: {err}"),
23 ExportError::Email(err) => write!(f, "email error: {err}"),
2424 ExportError::InvalidConfig(msg) => write!(f, "invalid config: {msg}"),
2525 ExportError::Connection(msg) => write!(f, "connection error: {msg}"),
2626 ExportError::Disabled => write!(f, "email service is disabled"),
@@ -35,7 +35,7 @@
3535 ExportError::Database(err) => Some(err),
3636 ExportError::Redis(err) => Some(err),
3737 ExportError::Storage(err) => Some(err),
38 ExportError::MailBaby(err) => Some(err),
38 ExportError::Email(err) => Some(err),
3939 _ => None,
4040 }
4141 }
@@ -59,8 +59,8 @@
5959 }
6060}
6161
62impl From<mailbaby::Error> for ExportError {
63 fn from(err: mailbaby::Error) -> Self {
64 ExportError::MailBaby(err)
62impl From<email::EmailError> for ExportError {
63 fn from(err: email::EmailError) -> Self {
64 ExportError::Email(err)
6565 }
6666}
No newline at end of file
Modified lib/export/src/lib.rs lib/export/src/lib.rs +9 −4 View lib/export/src/lib.rs
@@ -2,23 +2,23 @@
22pub mod error;
33pub mod redis;
44pub mod storage;
5pub mod email;
65pub mod rest;
76
87use std::sync::Arc;
98use config::AppConfig;
109pub use database::{AppDatabase, DatabaseRole};
11pub use email::{AppEmail, EmailClient};
1210pub use error::ExportError;
1311pub use redis::{AppRedis, RedisConnection};
1412pub use storage::AppStorage;
1513
14pub use email::EmailService;
15
1616#[derive(Clone)]
1717pub struct AppState {
1818 pub database: Arc<AppDatabase>,
1919 pub redis: AppRedis,
2020 pub storage: Arc<AppStorage>,
21 pub email: AppEmail,
21 pub email: EmailService,
2222 pub config: AppConfig,
2323}
2424
@@ -36,7 +36,12 @@
3636 let database = Arc::new(AppDatabase::connect(config.database.clone()).await?);
3737 let redis = AppRedis::connect(config.redis.clone()).await?;
3838 let storage = Arc::new(AppStorage::connect(config.s3.clone())?);
39 let email = AppEmail::connect(config.mailbaby.clone()).await?;
39 let redis_url = redis.config().url().to_string();
40 let builder = email::EmailServiceBuilder::new(config.email.clone())
41 .with_queue(redis_url, config.email_queue.clone())
42 .register_default_templates()
43 .await;
44 let email = builder.build().await?;
4045 Ok(Self {
4146 database,
4247 redis,
Modified lib/export/Cargo.toml lib/export/Cargo.toml +1 −1 View lib/export/Cargo.toml
@@ -9,7 +9,7 @@
99sea-orm = { workspace = true }
1010redis = { workspace = true }
1111tokio = { workspace = true }
12mailbaby = { workspace = true }
12email = { workspace = true }
1313axum = { workspace = true }
1414serde = { workspace = true, features = ["derive"] }
1515utoipa = { workspace = true }
No newline at end of file
Modified lib/issues/src/services/service.rs lib/issues/src/services/service.rs +4 −15 View lib/issues/src/services/service.rs
@@ -3,9 +3,8 @@
33
44use config::AppConfig;
55use export::database::AppDatabase;
6use export::email::AppEmail;
6use email::EmailService;
77use export::AppState;
8use mailbaby::Email;
98use models::issue::issue_event_line;
109use models::space::space_members::{self, MemberStatus};
1110use models::space::spaces;
@@ -39,7 +38,7 @@
3938 &self.state.database
4039 }
4140
42 pub fn email(&self) -> &AppEmail {
41 pub fn email(&self) -> &EmailService {
4342 &self.state.email
4443 }
4544
@@ -212,20 +211,10 @@
212211 text: &str,
213212 html: &str,
214213 ) -> Result<(), IssueError> {
215 let builder = Email::builder(subject).to([to]).text_body(text);
216 let builder = if html.is_empty() {
217 builder
218 } else {
219 builder.html_body(html)
220 };
221 let email = builder
222 .build()
223 .map_err(|error| IssueError::Internal(format!("mail build failed: {error}")))?;
224214 self.state
225215 .email
226 .send(&email)
216 .send_mail(to, subject, text, html)
227217 .await
228 .map(|_| ())
229 .map_err(IssueError::from)
218 .map_err(|error| IssueError::Internal(format!("email send failed: {error}")))
230219 }
231220}
Modified lib/issues/Cargo.toml lib/issues/Cargo.toml +1 −1 View lib/issues/Cargo.toml
@@ -6,6 +6,7 @@
66[dependencies]
77config = { workspace = true }
88export = { workspace = true }
9email = { workspace = true }
910models = { workspace = true }
1011session = { workspace = true }
1112space = { workspace = true }
@@ -16,5 +17,4 @@
1617utoipa = { workspace = true }
1718uuid = { workspace = true }
1819time = { workspace = true }
19mailbaby = { workspace = true }
2020tracing = { workspace = true }
No newline at end of file
Modified lib/space/src/services/service.rs lib/space/src/services/service.rs +5 −15 View lib/space/src/services/service.rs
@@ -1,8 +1,8 @@
11use std::sync::Arc;
22
33use config::AppConfig;
4use export::{AppDatabase, AppEmail, AppState, AppStorage};
5use mailbaby::Email;
4use export::{AppDatabase, AppState, AppStorage};
5use email::EmailService;
66
77use crate::errors::SpaceError;
88
@@ -28,7 +28,7 @@
2828 &self.state.storage
2929 }
3030
31 pub fn email(&self) -> &AppEmail {
31 pub fn email(&self) -> &EmailService {
3232 &self.state.email
3333 }
3434
@@ -39,20 +39,10 @@
3939 text: &str,
4040 html: &str,
4141 ) -> Result<(), SpaceError> {
42 let builder = Email::builder(subject).to([to]).text_body(text);
43 let builder = if html.is_empty() {
44 builder
45 } else {
46 builder.html_body(html)
47 };
48 let email = builder
49 .build()
50 .map_err(|error| SpaceError::Internal(format!("mail build failed: {error}")))?;
5142 self.state
5243 .email
53 .send(&email)
44 .send_mail(to, subject, text, html)
5445 .await
55 .map(|_| ())
56 .map_err(SpaceError::from)
46 .map_err(|error| SpaceError::Internal(format!("email send failed: {error}")))
5747 }
5848}
Modified lib/space/Cargo.toml lib/space/Cargo.toml +1 −1 View lib/space/Cargo.toml
@@ -6,6 +6,7 @@
66[dependencies]
77config = { workspace = true }
88export = { workspace = true }
9email = { workspace = true }
910models = { workspace = true }
1011session = { workspace = true }
1112storage = { workspace = true }
@@ -17,5 +18,4 @@
1718uuid = { workspace = true }
1819time = { workspace = true }
1920base64 = { workspace = true }
20mailbaby = { workspace = true }
2121tracing = { workspace = true }
No newline at end of file