feat(email): add lib/email crate with SMTP transport and redis pub/sub queue
ZhenYi committed
Added lib/email/src/templates/email_change_confirm.rs lib/email/src/templates/email_change_confirm.rs +62 −0 View lib/email/src/templates/email_change_confirm.rs
@@ -0,0 +1,62 @@
1use maud::html;
2use maud::Markup;
3
4use super::{append_query, mail_shell, url_join};
5
6pub fn mail_email_change_confirm(
7 new_email: &str,
8 ticket_id: &str,
9 code: &str,
10 base_url: &str,
11) -> Markup {
12 let confirm_link = append_query(
13 &url_join(base_url, "/email/change/confirm"),
14 &[("ticket_id", ticket_id), ("code", code)],
15 );
16 let cancel_link = append_query(
17 &url_join(base_url, "/email/change/cancel"),
18 &[("ticket_id", ticket_id)],
19 );
20 let body = html! {
21 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Confirm your new email" }
22 p style="color:#374151;margin:0 0 24px;" {
23 "You requested to change your email to "
24 strong { (new_email) }
25 ". Click the button below to confirm."
26 }
27 table role="presentation" cellpadding="0" cellspacing="0" {
28 tr {
29 td align="center" {
30 a href=(confirm_link) class="btn" { "Confirm email change" }
31 }
32 }
33 }
34 p class="muted" style="margin:16px 0 0;" {
35 "Or enter this code: "
36 strong style="color:#111827;" { (code) }
37 }
38 p class="muted" style="margin:16px 0 0;" {
39 "Didn't request this? "
40 a href=(cancel_link) style="color:#2563eb;" { "Cancel the change" }
41 "."
42 }
43 };
44 mail_shell("Confirm your new email", body)
45}
46
47pub fn render(vars: &serde_json::Value) -> Markup {
48 let new_email = vars
49 .get("new_email")
50 .and_then(|v| v.as_str())
51 .unwrap_or("");
52 let ticket_id = vars
53 .get("ticket_id")
54 .and_then(|v| v.as_str())
55 .unwrap_or("");
56 let code = vars.get("code").and_then(|v| v.as_str()).unwrap_or("");
57 let base_url = vars
58 .get("base_url")
59 .and_then(|v| v.as_str())
60 .unwrap_or("");
61 mail_email_change_confirm(new_email, ticket_id, code, base_url)
62}
No newline at end of file
Added lib/email/src/templates/mod.rs lib/email/src/templates/mod.rs +93 −0 View lib/email/src/templates/mod.rs
@@ -0,0 +1,93 @@
1use maud::{html, Markup, DOCTYPE};
2
3pub mod email_change_confirm;
4pub mod reset_link;
5pub mod verify_code;
6
7pub use email_change_confirm::mail_email_change_confirm;
8pub use reset_link::mail_reset_link;
9pub use verify_code::mail_verify_code;
10
11pub(crate) fn mail_shell(title: &str, body: Markup) -> Markup {
12 html! {
13 (DOCTYPE)
14 html lang="en" xmlns="http://www.w3.org/1999/xhtml" {
15 head {
16 meta charset="utf-8";
17 meta name="viewport" content="width=device-width, initial-scale=1";
18 meta http-equiv="X-UA-Compatible" content="IE=edge";
19 title { (title) }
20 style type="text/css" {
21 "body,table,td,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}"
22 "table,td{mso-table-lspace:0pt;mso-table-rspace:0pt;}"
23 "img{border:0;height:auto;line-height:100%;outline:none;text-decoration:none;}"
24 "table{border-collapse:collapse;}"
25 "body{height:100%;margin:0;padding:0;width:100%;}"
26 "a[x-apple-data-detectors]{color:inherit;text-decoration:none;}"
27 ".btn{display:inline-block;padding:12px 28px;font-size:16px;font-weight:600;"
28 "text-decoration:none;border-radius:8px;color:#ffffff;background-color:#2563eb;"
29 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
30 ".btn:hover{background-color:#1d4ed8;}"
31 ".container{max-width:600px;margin:0 auto;padding:24px;"
32 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
33 ".code{font-size:32px;font-weight:700;letter-spacing:8px;color:#111827;"
34 "padding:16px 24px;background:#f3f4f6;border-radius:8px;text-align:center;margin:16px 0;}"
35 ".muted{color:#6b7280;font-size:14px;}"
36 }
37 }
38 body style="background-color:#f9fafb;margin:0;padding:0;" {
39 table role="presentation" width="100%" cellpadding="0" cellspacing="0" {
40 tr {
41 td align="center" style="padding:24px;" {
42 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);" {
43 tr {
44 td style="padding:32px 40px;" {
45 (body)
46 }
47 }
48 }
49 }
50 }
51 }
52 }
53 }
54 }
55}
56
57pub(crate) fn url_join(base: &str, path: &str) -> String {
58 let base = base.trim_end_matches('/');
59 let path = path.trim_start_matches('/');
60 format!("{base}/{path}")
61}
62
63pub(crate) fn append_query(url: &str, pairs: &[(&str, &str)]) -> String {
64 if pairs.is_empty() {
65 return url.to_string();
66 }
67 let mut s = format!("{url}?");
68 for (i, (k, v)) in pairs.iter().enumerate() {
69 if i > 0 {
70 s.push('&');
71 }
72 s.push_str(&percent_encode(k));
73 s.push('=');
74 s.push_str(&percent_encode(v));
75 }
76 s
77}
78
79fn percent_encode(input: &str) -> String {
80 let mut out = String::with_capacity(input.len());
81 for &b in input.as_bytes() {
82 match b {
83 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
84 out.push(b as char);
85 }
86 _ => {
87 out.push('%');
88 out.push_str(&format!("{b:02X}"));
89 }
90 }
91 }
92 out
93}
No newline at end of file
Added lib/email/src/templates/reset_link.rs lib/email/src/templates/reset_link.rs +40 −0 View lib/email/src/templates/reset_link.rs
@@ -0,0 +1,40 @@
1use maud::html;
2use maud::Markup;
3
4use super::{append_query, mail_shell, url_join};
5
6pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Markup {
7 let link = append_query(
8 &url_join(base_url, "/reset-password"),
9 &[("email", email), ("token", token), ("temp_id", temp_id)],
10 );
11 let body = html! {
12 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Reset your password" }
13 p style="color:#374151;margin:0 0 24px;" {
14 "Click the button below to reset your password. This link expires in 30 minutes."
15 }
16 table role="presentation" cellpadding="0" cellspacing="0" {
17 tr {
18 td align="center" {
19 a href=(link) class="btn" { "Reset password" }
20 }
21 }
22 }
23 p class="muted" style="margin:24px 0 0;" {
24 "If the button doesn't work, copy this link into your browser:"
25 }
26 p style="word-break:break-all;font-size:13px;color:#2563eb;margin:4px 0 0;" { (link) }
27 p class="muted" style="margin:24px 0 0;" {
28 "If you didn't request a password reset, ignore this email."
29 }
30 };
31 mail_shell("Reset your password", body)
32}
33
34pub fn render(vars: &serde_json::Value) -> Markup {
35 let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or("");
36 let token = vars.get("token").and_then(|v| v.as_str()).unwrap_or("");
37 let temp_id = vars.get("temp_id").and_then(|v| v.as_str()).unwrap_or("");
38 let base_url = vars.get("base_url").and_then(|v| v.as_str()).unwrap_or("");
39 mail_reset_link(email, token, temp_id, base_url)
40}
No newline at end of file
Added lib/email/src/templates/verify_code.rs lib/email/src/templates/verify_code.rs +36 −0 View lib/email/src/templates/verify_code.rs
@@ -0,0 +1,36 @@
1use maud::html;
2use maud::Markup;
3
4use super::mail_shell;
5
6pub fn mail_verify_code(code: &str, minutes: u64, purpose: &str) -> Markup {
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 render(vars: &serde_json::Value) -> Markup {
23 let code = vars
24 .get("code")
25 .and_then(|v| v.as_str())
26 .unwrap_or("");
27 let minutes = vars
28 .get("minutes")
29 .and_then(|v| v.as_u64())
30 .unwrap_or(0);
31 let purpose = vars
32 .get("purpose")
33 .and_then(|v| v.as_str())
34 .unwrap_or("Verification");
35 mail_verify_code(code, minutes, purpose)
36}
No newline at end of file
Added lib/email/src/error.rs lib/email/src/error.rs +107 −0 View lib/email/src/error.rs
@@ -0,0 +1,107 @@
1use std::fmt;
2
3use lettre::address::AddressError;
4use lettre::error::Error as LettreError;
5use lettre::transport::smtp::Error as SmtpError;
6
7#[derive(Debug)]
8pub enum EmailError {
9 Config(String),
10 InvalidConfig(String),
11 Disabled,
12 Queue(redis::RedisError),
13 Serde(serde_json::Error),
14 Smtp(SmtpError),
15 Address(AddressError),
16 Lettre(LettreError),
17 TemplateNotFound(String),
18 TemplateRender(String),
19 Base64(base64::DecodeError),
20 AttachmentTooLarge { actual: usize, max: usize },
21 Missing(String),
22 Io(std::io::Error),
23 Cancelled,
24 Other(String),
25}
26
27impl fmt::Display for EmailError {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Self::Config(msg) => write!(f, "email config error: {msg}"),
31 Self::InvalidConfig(msg) => write!(f, "email invalid config: {msg}"),
32 Self::Disabled => write!(f, "email service is disabled"),
33 Self::Queue(err) => write!(f, "email queue error: {err}"),
34 Self::Serde(err) => write!(f, "email serde error: {err}"),
35 Self::Smtp(err) => write!(f, "email smtp error: {err}"),
36 Self::Address(err) => write!(f, "email address error: {err}"),
37 Self::Lettre(err) => write!(f, "email lettre error: {err}"),
38 Self::TemplateNotFound(name) => write!(f, "email template not found: {name}"),
39 Self::TemplateRender(msg) => write!(f, "email template render error: {msg}"),
40 Self::Base64(err) => write!(f, "email base64 decode error: {err}"),
41 Self::AttachmentTooLarge { actual, max } => {
42 write!(f, "attachment too large: {actual} bytes (max {max})")
43 }
44 Self::Missing(msg) => write!(f, "missing: {msg}"),
45 Self::Io(err) => write!(f, "email io error: {err}"),
46 Self::Cancelled => write!(f, "email operation cancelled"),
47 Self::Other(msg) => write!(f, "email error: {msg}"),
48 }
49 }
50}
51
52impl std::error::Error for EmailError {
53 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54 match self {
55 Self::Queue(err) => Some(err),
56 Self::Serde(err) => Some(err),
57 Self::Smtp(err) => Some(err),
58 Self::Address(err) => Some(err),
59 Self::Lettre(err) => Some(err),
60 Self::Base64(err) => Some(err),
61 Self::Io(err) => Some(err),
62 _ => None,
63 }
64 }
65}
66
67impl From<redis::RedisError> for EmailError {
68 fn from(err: redis::RedisError) -> Self {
69 Self::Queue(err)
70 }
71}
72
73impl From<serde_json::Error> for EmailError {
74 fn from(err: serde_json::Error) -> Self {
75 Self::Serde(err)
76 }
77}
78
79impl From<SmtpError> for EmailError {
80 fn from(err: SmtpError) -> Self {
81 Self::Smtp(err)
82 }
83}
84
85impl From<AddressError> for EmailError {
86 fn from(err: AddressError) -> Self {
87 Self::Address(err)
88 }
89}
90
91impl From<LettreError> for EmailError {
92 fn from(err: LettreError) -> Self {
93 Self::Lettre(err)
94 }
95}
96
97impl From<base64::DecodeError> for EmailError {
98 fn from(err: base64::DecodeError) -> Self {
99 Self::Base64(err)
100 }
101}
102
103impl From<std::io::Error> for EmailError {
104 fn from(err: std::io::Error) -> Self {
105 Self::Io(err)
106 }
107}
No newline at end of file
Added lib/email/src/events.rs lib/email/src/events.rs +125 −0 View lib/email/src/events.rs
@@ -0,0 +1,125 @@
1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6pub const CHANNEL_SEND: &str = "send";
7pub const CHANNEL_BATCH: &str = "batch";
8pub const CHANNEL_RENDER: &str = "render";
9pub const CHANNEL_SENT: &str = "sent";
10pub const CHANNEL_FAILED: &str = "failed";
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(default)]
14pub struct EmailAddress {
15 pub address: String,
16 pub name: Option<String>,
17}
18
19impl EmailAddress {
20 pub fn new(address: impl Into<String>) -> Self {
21 Self {
22 address: address.into(),
23 name: None,
24 }
25 }
26
27 pub fn with_name(address: impl Into<String>, name: impl Into<String>) -> Self {
28 Self {
29 address: address.into(),
30 name: Some(name.into()),
31 }
32 }
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(default)]
37pub struct Attachment {
38 pub filename: String,
39 pub content_type: String,
40 pub data_b64: String,
41 pub content_id: Option<String>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(default)]
46pub struct SendEmailRequest {
47 pub request_id: Uuid,
48 pub to: Vec<EmailAddress>,
49 pub cc: Vec<EmailAddress>,
50 pub bcc: Vec<EmailAddress>,
51 pub subject: String,
52 pub text: Option<String>,
53 pub html: Option<String>,
54 pub reply_to: Option<EmailAddress>,
55 pub attachments: Vec<Attachment>,
56 pub metadata: HashMap<String, String>,
57 pub queued_at_ms: i64,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(default)]
62pub struct BatchRecipient {
63 pub request_id: Uuid,
64 pub to: EmailAddress,
65 pub subject: String,
66 pub text: Option<String>,
67 pub html: Option<String>,
68 pub metadata: HashMap<String, String>,
69}
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(default)]
73pub struct SendBatchEmailRequest {
74 pub request_id: Uuid,
75 pub default_subject: String,
76 pub default_text: Option<String>,
77 pub default_html: Option<String>,
78 pub recipients: Vec<BatchRecipient>,
79 pub metadata: HashMap<String, String>,
80 pub queued_at_ms: i64,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(default)]
85pub struct RenderAndSendEmailRequest {
86 pub request_id: Uuid,
87 pub template: String,
88 pub vars: serde_json::Value,
89 pub to: Vec<EmailAddress>,
90 pub cc: Vec<EmailAddress>,
91 pub bcc: Vec<EmailAddress>,
92 pub subject: String,
93 pub text: Option<String>,
94 pub locale: Option<String>,
95 pub metadata: HashMap<String, String>,
96 pub queued_at_ms: i64,
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(default)]
101pub struct EmailSent {
102 pub request_id: Uuid,
103 pub recipient_count: usize,
104 pub smtp_message_id: String,
105 pub sent_at_ms: i64,
106 pub metadata: HashMap<String, String>,
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(default)]
111pub struct EmailFailed {
112 pub request_id: Uuid,
113 pub error: String,
114 pub recipient_count: usize,
115 pub failed_at_ms: i64,
116 pub metadata: HashMap<String, String>,
117}
118
119pub fn now_ms() -> i64 {
120 use std::time::{SystemTime, UNIX_EPOCH};
121 let dur = SystemTime::now()
122 .duration_since(UNIX_EPOCH)
123 .unwrap_or_default();
124 dur.as_millis() as i64
125}
No newline at end of file
Added lib/email/src/lib.rs lib/email/src/lib.rs +22 −0 View lib/email/src/lib.rs
@@ -0,0 +1,22 @@
1pub mod error;
2pub mod events;
3pub mod queue;
4pub mod service;
5pub mod subscriber;
6pub mod template;
7pub mod templates;
8pub mod transport;
9
10pub mod runtime;
11
12pub use error::EmailError;
13pub use events::{
14 Attachment, BatchRecipient, EmailAddress, EmailFailed, EmailSent, RenderAndSendEmailRequest,
15 SendBatchEmailRequest, SendEmailRequest, CHANNEL_BATCH, CHANNEL_FAILED, CHANNEL_RENDER,
16 CHANNEL_SEND, CHANNEL_SENT,
17};
18pub use queue::{EmailQueue, QueueSettings, SubscribeHandle, TypedMessageMeta};
19pub use service::{EmailService, EmailServiceBuilder};
20pub use subscriber::{start_subscribers, EmailSubscribers};
21pub use template::{TemplateFn, TemplateRegistry};
22pub use transport::{build_message, EmailTransport, EmailTransportBuilder};
No newline at end of file
Added lib/email/src/queue.rs lib/email/src/queue.rs +309 −0 View lib/email/src/queue.rs
@@ -0,0 +1,309 @@
1use std::future::Future;
2use std::marker::PhantomData;
3use std::sync::Arc;
4use std::time::Duration;
5
6use redis::aio::ConnectionManager;
7use redis::cluster_async::ClusterConnection;
8use serde::de::DeserializeOwned;
9use serde::Serialize;
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12use tracing::{debug, error, warn};
13
14use crate::error::EmailError;
15
16#[derive(Clone)]
17pub enum QueueConnection {
18 Standalone(ConnectionManager),
19 Cluster(ClusterConnection),
20}
21
22impl QueueConnection {
23 pub fn is_cluster(&self) -> bool {
24 matches!(self, QueueConnection::Cluster(_))
25 }
26}
27
28macro_rules! dispatch_conn {
29 ($conn:expr, |$c:ident| $body:expr) => {
30 match &mut $conn {
31 QueueConnection::Standalone($c) => $body,
32 QueueConnection::Cluster($c) => $body,
33 }
34 };
35}
36
37pub struct EmailQueue {
38 connection: QueueConnection,
39 config: QueueSettings,
40 pubsub_client: redis::Client,
41 #[allow(dead_code)]
42 pubsub_url: String,
43}
44
45#[derive(Clone)]
46pub struct QueueSettings {
47 pub channel_prefix: String,
48 pub max_retries: u32,
49 pub retry_backoff: Duration,
50 pub enabled: bool,
51}
52
53impl EmailQueue {
54 pub async fn connect(url: &str, config: QueueSettings) -> Result<Self, EmailError> {
55 let connection = open_connection(url).await?;
56 let (pubsub_client, pubsub_url) = open_pubsub_client(url)?;
57 Ok(Self {
58 connection,
59 config,
60 pubsub_client,
61 pubsub_url,
62 })
63 }
64
65 pub fn enabled(&self) -> bool {
66 self.config.enabled
67 }
68
69 pub fn channel(&self, name: &str) -> String {
70 format!("{}.{}", self.config.channel_prefix, name)
71 }
72
73 pub fn config(&self) -> &QueueSettings {
74 &self.config
75 }
76
77 pub async fn publish<T: Serialize + ?Sized>(
78 &self,
79 channel: &str,
80 msg: &T,
81 ) -> Result<(), EmailError> {
82 let payload = serde_json::to_vec(msg)?;
83 let mut conn = self.connection.clone();
84 let receiver_count: i64 = dispatch_conn!(conn, |c| {
85 Box::pin(
86 redis::cmd("PUBLISH")
87 .arg(channel)
88 .arg(payload.as_slice())
89 .query_async(c),
90 )
91 .await
92 })?;
93 debug!(
94 channel = %channel,
95 receivers = receiver_count,
96 bytes = payload.len(),
97 "published email message"
98 );
99 Ok(())
100 }
101
102 pub async fn publish_to<T: Serialize + ?Sized>(
103 &self,
104 logical: &str,
105 msg: &T,
106 ) -> Result<(), EmailError> {
107 let channel = self.channel(logical);
108 self.publish(&channel, msg).await
109 }
110
111 pub async fn subscribe<T, F, Fut>(
112 &self,
113 logical: &str,
114 group_id: &str,
115 handler: F,
116 ) -> Result<SubscribeHandle<T>, EmailError>
117 where
118 T: DeserializeOwned + Send + Clone + 'static,
119 F: Fn(T, TypedMessageMeta) -> Fut + Send + Sync + 'static,
120 Fut: Future<Output = Result<(), EmailError>> + Send + 'static,
121 {
122 let _ = group_id;
123 let channel = self.channel(logical);
124 let max_retries = self.config.max_retries;
125 let retry_backoff = self.config.retry_backoff;
126 let cancel = CancellationToken::new();
127 let cancel_child = cancel.clone();
128 let channel_for_log = channel.clone();
129 let mut pubsub = self.pubsub_client.get_async_pubsub().await?;
130 pubsub.subscribe(&channel).await?;
131
132 let join: JoinHandle<Result<(), EmailError>> = tokio::spawn(async move {
133 let mut stream = pubsub.on_message();
134 loop {
135 tokio::select! {
136 _ = cancel_child.cancelled() => {
137 debug!(channel = %channel_for_log, "subscriber cancelled");
138 return Ok(());
139 }
140 msg = futures::StreamExt::next(&mut stream) => {
141 let Some(msg) = msg else {
142 warn!(channel = %channel_for_log, "pubsub stream ended");
143 return Ok(());
144 };
145 let raw: String = match msg.get_payload() {
146 Ok(s) => s,
147 Err(err) => {
148 warn!(channel = %channel_for_log, error = %err, "invalid pubsub payload type");
149 continue;
150 }
151 };
152 let meta = TypedMessageMeta {
153 channel: channel_for_log.clone(),
154 raw: raw.clone(),
155 };
156 let value: T = match serde_json::from_str(&raw) {
157 Ok(v) => v,
158 Err(err) => {
159 warn!(channel = %channel_for_log, error = %err, "message deserialize failed, dropping");
160 continue;
161 }
162 };
163 run_with_retries(
164 &channel_for_log,
165 value,
166 meta,
167 &handler,
168 max_retries,
169 retry_backoff,
170 &cancel_child,
171 )
172 .await;
173 }
174 }
175 }
176 });
177
178 Ok(SubscribeHandle {
179 cancel,
180 join: Some(Arc::new(tokio::sync::Mutex::new(join))),
181 _marker: PhantomData,
182 })
183 }
184}
185
186#[derive(Clone)]
187pub struct TypedMessageMeta {
188 pub channel: String,
189 pub raw: String,
190}
191
192pub struct SubscribeHandle<T> {
193 cancel: CancellationToken,
194 join: Option<Arc<tokio::sync::Mutex<JoinHandle<Result<(), EmailError>>>>>,
195 _marker: PhantomData<T>,
196}
197
198impl<T> SubscribeHandle<T> {
199 pub async fn shutdown(mut self, timeout: Duration) -> Result<(), EmailError> {
200 self.cancel.cancel();
201 if let Some(handle) = self.join.take() {
202 let join = {
203 let mut guard = handle.lock().await;
204 std::mem::replace(
205 &mut *guard,
206 tokio::spawn(async { Ok(()) }),
207 )
208 };
209 match tokio::time::timeout(timeout, join).await {
210 Ok(Ok(res)) => res,
211 Ok(Err(join_err)) => {
212 error!(error = %join_err, "subscriber task join failed");
213 Err(EmailError::Other(format!("subscriber join failed: {join_err}")))
214 }
215 Err(_) => {
216 warn!(timeout_secs = timeout.as_secs(), "subscriber shutdown timed out");
217 Err(EmailError::Cancelled)
218 }
219 }
220 } else {
221 Ok(())
222 }
223 }
224}
225
226impl<T> Drop for SubscribeHandle<T> {
227 fn drop(&mut self) {
228 self.cancel.cancel();
229 }
230}
231
232async fn open_connection(url: &str) -> Result<QueueConnection, EmailError> {
233 if url.split(',').count() > 1 {
234 let nodes: Vec<String> = url
235 .split(',')
236 .map(str::trim)
237 .filter(|s| !s.is_empty())
238 .map(String::from)
239 .collect();
240 let client = redis::cluster::ClusterClient::new(nodes)?;
241 let conn = client.get_async_connection().await?;
242 Ok(QueueConnection::Cluster(conn))
243 } else {
244 let client = redis::Client::open(url)?;
245 let manager = ConnectionManager::new(client).await?;
246 Ok(QueueConnection::Standalone(manager))
247 }
248}
249
250fn open_pubsub_client(url: &str) -> Result<(redis::Client, String), EmailError> {
251 if url.split(',').count() > 1 {
252 let first = url
253 .split(',')
254 .map(str::trim)
255 .find(|s| !s.is_empty())
256 .ok_or_else(|| EmailError::Config("empty redis url".into()))?;
257 let client = redis::Client::open(first)?;
258 Ok((client, first.to_string()))
259 } else {
260 let client = redis::Client::open(url)?;
261 Ok((client, url.to_string()))
262 }
263}
264
265async fn run_with_retries<T, F, Fut>(
266 channel: &str,
267 value: T,
268 meta: TypedMessageMeta,
269 handler: &F,
270 max_retries: u32,
271 retry_backoff: Duration,
272 cancel: &CancellationToken,
273) where
274 T: Send + Clone,
275 F: Fn(T, TypedMessageMeta) -> Fut + Send + Sync,
276 Fut: Future<Output = Result<(), EmailError>> + Send,
277{
278 let mut attempt: u32 = 0;
279 loop {
280 attempt += 1;
281 match handler(value.clone(), meta.clone()).await {
282 Ok(()) => {
283 debug!(channel = %channel, attempt, "message processed");
284 return;
285 }
286 Err(err) if attempt >= max_retries => {
287 error!(
288 channel = %channel,
289 attempt,
290 error = %err,
291 "handler exhausted retries, dropping message"
292 );
293 return;
294 }
295 Err(err) => {
296 warn!(
297 channel = %channel,
298 attempt,
299 error = %err,
300 "handler failed, will retry"
301 );
302 tokio::select! {
303 _ = cancel.cancelled() => return,
304 _ = tokio::time::sleep(retry_backoff) => {}
305 }
306 }
307 }
308 }
309}
No newline at end of file
Added lib/email/src/runtime.rs lib/email/src/runtime.rs +85 −0 View lib/email/src/runtime.rs
@@ -0,0 +1,85 @@
1use std::path::PathBuf;
2use std::time::Duration;
3
4use tracing::{info, warn};
5
6use crate::error::EmailError;
7use crate::service::{EmailService, EmailServiceBuilder};
8use crate::subscriber::EmailSubscribers;
9
10#[derive(Default)]
11pub struct WorkerOptions {
12 pub template_dir: Option<PathBuf>,
13 pub shutdown_timeout: Option<Duration>,
14}
15
16pub struct RunningWorker {
17 pub service: EmailService,
18 pub subscribers: EmailSubscribers,
19}
20
21pub async fn start_worker(
22 email_cfg: config::email::EmailConfig,
23 queue_cfg: config::email_queue::EmailQueueConfig,
24 redis_url: String,
25 opts: WorkerOptions,
26) -> Result<RunningWorker, EmailError> {
27 let _ = opts.template_dir;
28 let builder = EmailServiceBuilder::new(email_cfg)
29 .with_queue(redis_url, queue_cfg)
30 .register_default_templates()
31 .await;
32 let service = builder.build().await?;
33 let subscribers = service.start_subscribers().await?;
34 Ok(RunningWorker {
35 service,
36 subscribers,
37 })
38}
39
40pub async fn run_until_shutdown(
41 email_cfg: config::email::EmailConfig,
42 queue_cfg: config::email_queue::EmailQueueConfig,
43 redis_url: String,
44 opts: WorkerOptions,
45) -> Result<(), EmailError> {
46 let worker = start_worker(email_cfg, queue_cfg, redis_url, opts).await?;
47 if !worker.service.enabled() {
48 warn!("email service is disabled at startup; exiting immediately");
49 return Ok(());
50 }
51 info!("email worker started; waiting for shutdown signal");
52 wait_for_shutdown().await;
53 let timeout = Duration::from_secs(5);
54 worker.subscribers.shutdown(timeout).await?;
55 info!("email worker shut down");
56 Ok(())
57}
58
59pub async fn wait_for_shutdown() {
60 #[cfg(unix)]
61 {
62 use tokio::signal::unix::{signal, SignalKind};
63 let mut sigterm = match signal(SignalKind::terminate()) {
64 Ok(s) => s,
65 Err(err) => {
66 warn!(error = %err, "failed to install SIGTERM handler");
67 let _ = tokio::signal::ctrl_c().await;
68 return;
69 }
70 };
71 tokio::select! {
72 _ = tokio::signal::ctrl_c() => {
73 info!("received SIGINT");
74 }
75 _ = sigterm.recv() => {
76 info!("received SIGTERM");
77 }
78 }
79 }
80 #[cfg(not(unix))]
81 {
82 let _ = tokio::signal::ctrl_c().await;
83 info!("received ctrl-c");
84 }
85}
No newline at end of file
Added lib/email/src/service.rs lib/email/src/service.rs +394 −0 View lib/email/src/service.rs
@@ -0,0 +1,394 @@
1use std::sync::Arc;
2
3use lettre::address::Address;
4use lettre::message::Mailbox;
5use tracing::{debug, info, warn};
6use uuid::Uuid;
7
8use crate::error::EmailError;
9use crate::events::{
10 now_ms, EmailAddress, EmailFailed, EmailSent, RenderAndSendEmailRequest, SendBatchEmailRequest,
11 SendEmailRequest, CHANNEL_BATCH, CHANNEL_FAILED, CHANNEL_RENDER, CHANNEL_SEND, CHANNEL_SENT,
12};
13use crate::queue::EmailQueue;
14use crate::queue::QueueSettings;
15use crate::template::TemplateRegistry;
16use crate::templates;
17use crate::transport::{build_message, EmailTransport, EmailTransportBuilder};
18
19#[derive(Clone)]
20pub struct EmailService {
21 inner: Arc<EmailServiceInner>,
22}
23
24struct EmailServiceInner {
25 config: Arc<config::email::EmailConfig>,
26 transport: Option<EmailTransport>,
27 from: Option<Mailbox>,
28 queue: Option<EmailQueue>,
29 templates: TemplateRegistry,
30 max_attachment_bytes: usize,
31}
32
33impl EmailService {
34 pub fn enabled(&self) -> bool {
35 self.inner.transport.is_some()
36 }
37
38 pub fn config(&self) -> &config::email::EmailConfig {
39 &self.inner.config
40 }
41
42 pub fn templates(&self) -> &TemplateRegistry {
43 &self.inner.templates
44 }
45
46 pub fn queue(&self) -> Option<&EmailQueue> {
47 self.inner.queue.as_ref()
48 }
49
50 pub async fn render(
51 &self,
52 name: &str,
53 vars: &serde_json::Value,
54 ) -> Result<String, EmailError> {
55 self.inner.templates.render(name, vars).await
56 }
57
58 pub async fn send(&self, mut req: SendEmailRequest) -> Result<EmailSent, EmailError> {
59 let transport = self
60 .inner
61 .transport
62 .as_ref()
63 .ok_or(EmailError::Disabled)?;
64 let from = self
65 .inner
66 .from
67 .clone()
68 .ok_or_else(|| EmailError::Config("from address not configured".into()))?;
69
70 if req.request_id.is_nil() {
71 req.request_id = Uuid::now_v7();
72 }
73 let recipient_count = req.to.len() + req.cc.len() + req.bcc.len();
74 if recipient_count == 0 {
75 return Err(EmailError::Missing("email has no recipients".into()));
76 }
77
78 let message = build_message(&req, from, self.inner.max_attachment_bytes)?;
79 let smtp_message_id = transport.send(message).await?;
80 let sent = EmailSent {
81 request_id: req.request_id,
82 recipient_count,
83 smtp_message_id,
84 sent_at_ms: now_ms(),
85 metadata: req.metadata,
86 };
87
88 if let Some(q) = &self.inner.queue {
89 if let Err(err) = q.publish_to(CHANNEL_SENT, &sent).await {
90 warn!(error = %err, "failed to publish EmailSent event");
91 }
92 }
93 info!(
94 request_id = %sent.request_id,
95 recipients = sent.recipient_count,
96 "email sent"
97 );
98 Ok(sent)
99 }
100
101 pub async fn send_mail(
102 &self,
103 to: &str,
104 subject: &str,
105 text: &str,
106 html: &str,
107 ) -> Result<(), EmailError> {
108 let req = SendEmailRequest {
109 request_id: Uuid::now_v7(),
110 to: vec![EmailAddress::new(to)],
111 cc: vec![],
112 bcc: vec![],
113 subject: subject.to_string(),
114 text: if text.is_empty() {
115 None
116 } else {
117 Some(text.to_string())
118 },
119 html: if html.is_empty() {
120 None
121 } else {
122 Some(html.to_string())
123 },
124 reply_to: None,
125 attachments: vec![],
126 metadata: Default::default(),
127 queued_at_ms: now_ms(),
128 };
129 let _ = self.send(req).await?;
130 Ok(())
131 }
132
133 pub async fn send_batch(
134 &self,
135 req: SendBatchEmailRequest,
136 ) -> Result<Vec<EmailSent>, EmailError> {
137 let mut sent_list = Vec::with_capacity(req.recipients.len());
138 let default_meta = req.metadata.clone();
139 let total = req.recipients.len();
140 let batch_id = req.request_id;
141
142 for (idx, recipient) in req.recipients.into_iter().enumerate() {
143 if recipient.to.address.is_empty() {
144 warn!(batch = %batch_id, index = idx, "skipping batch recipient without address");
145 continue;
146 }
147 let mut meta = default_meta.clone();
148 meta.insert("batch_request_id".into(), batch_id.to_string());
149 meta.insert("batch_index".into(), idx.to_string());
150 for (k, v) in &recipient.metadata {
151 meta.insert(k.clone(), v.clone());
152 }
153
154 let send_req = SendEmailRequest {
155 request_id: if recipient.request_id.is_nil() {
156 Uuid::now_v7()
157 } else {
158 recipient.request_id
159 },
160 to: vec![recipient.to.clone()],
161 cc: vec![],
162 bcc: vec![],
163 subject: if recipient.subject.is_empty() {
164 req.default_subject.clone()
165 } else {
166 recipient.subject
167 },
168 text: recipient.text.or_else(|| req.default_text.clone()),
169 html: recipient.html.or_else(|| req.default_html.clone()),
170 reply_to: None,
171 attachments: vec![],
172 metadata: meta,
173 queued_at_ms: now_ms(),
174 };
175 match self.send(send_req).await {
176 Ok(sent) => sent_list.push(sent),
177 Err(err) => {
178 warn!(
179 batch = %batch_id,
180 index = idx,
181 error = %err,
182 "batch recipient failed"
183 );
184 let failed = EmailFailed {
185 request_id: Uuid::nil(),
186 error: err.to_string(),
187 recipient_count: 1,
188 failed_at_ms: now_ms(),
189 metadata: Default::default(),
190 };
191 if let Some(q) = &self.inner.queue {
192 let _ = q.publish_to(CHANNEL_FAILED, &failed).await;
193 }
194 }
195 }
196 }
197 debug!(batch = %batch_id, sent = sent_list.len(), total, "batch processed");
198 Ok(sent_list)
199 }
200
201 pub async fn render_and_send(
202 &self,
203 req: RenderAndSendEmailRequest,
204 ) -> Result<EmailSent, EmailError> {
205 let html = self.inner.templates.render(&req.template, &req.vars).await?;
206 let send_req = SendEmailRequest {
207 request_id: req.request_id,
208 to: req.to,
209 cc: req.cc,
210 bcc: req.bcc,
211 subject: req.subject,
212 text: req.text,
213 html: Some(html),
214 reply_to: None,
215 attachments: vec![],
216 metadata: req.metadata,
217 queued_at_ms: now_ms(),
218 };
219 let _ = req.locale;
220 self.send(send_req).await
221 }
222
223 pub async fn publish_send(&self, req: &SendEmailRequest) -> Result<(), EmailError> {
224 let q = self.inner.queue.as_ref().ok_or(EmailError::Disabled)?;
225 q.publish_to(CHANNEL_SEND, req).await
226 }
227
228 pub async fn publish_batch(&self, req: &SendBatchEmailRequest) -> Result<(), EmailError> {
229 let q = self.inner.queue.as_ref().ok_or(EmailError::Disabled)?;
230 q.publish_to(CHANNEL_BATCH, req).await
231 }
232
233 pub async fn publish_render(
234 &self,
235 req: &RenderAndSendEmailRequest,
236 ) -> Result<(), EmailError> {
237 let q = self.inner.queue.as_ref().ok_or(EmailError::Disabled)?;
238 q.publish_to(CHANNEL_RENDER, req).await
239 }
240
241 pub async fn publish_sent(&self, sent: &EmailSent) -> Result<(), EmailError> {
242 let q = self.inner.queue.as_ref().ok_or(EmailError::Disabled)?;
243 q.publish_to(CHANNEL_SENT, sent).await
244 }
245
246 pub async fn publish_failed(&self, failed: &EmailFailed) -> Result<(), EmailError> {
247 let q = self.inner.queue.as_ref().ok_or(EmailError::Disabled)?;
248 q.publish_to(CHANNEL_FAILED, failed).await
249 }
250
251 pub async fn start_subscribers(
252 &self,
253 ) -> Result<crate::subscriber::EmailSubscribers, EmailError> {
254 crate::subscriber::start_subscribers(self.clone()).await
255 }
256}
257
258pub struct EmailServiceBuilder {
259 email_config: config::email::EmailConfig,
260 queue_config: Option<config::email_queue::EmailQueueConfig>,
261 redis_url: Option<String>,
262 templates: TemplateRegistry,
263}
264
265impl EmailServiceBuilder {
266 pub fn new(email_config: config::email::EmailConfig) -> Self {
267 Self {
268 email_config,
269 queue_config: None,
270 redis_url: None,
271 templates: TemplateRegistry::new(),
272 }
273 }
274
275 pub fn with_queue(
276 mut self,
277 redis_url: impl Into<String>,
278 queue_config: config::email_queue::EmailQueueConfig,
279 ) -> Self {
280 self.redis_url = Some(redis_url.into());
281 self.queue_config = Some(queue_config);
282 self
283 }
284
285 pub fn with_templates(mut self, templates: TemplateRegistry) -> Self {
286 self.templates = templates;
287 self
288 }
289
290 pub async fn register_default_templates(self) -> Self {
291 self.templates
292 .register("verify_code", templates::verify_code::render)
293 .await;
294 self.templates
295 .register("reset_link", templates::reset_link::render)
296 .await;
297 self.templates
298 .register(
299 "email_change_confirm",
300 templates::email_change_confirm::render,
301 )
302 .await;
303 self
304 }
305
306 pub async fn build(self) -> Result<EmailService, EmailError> {
307 if !self.email_config.enabled() {
308 return Ok(EmailService {
309 inner: Arc::new(EmailServiceInner {
310 config: Arc::new(self.email_config),
311 transport: None,
312 from: None,
313 queue: None,
314 templates: self.templates,
315 max_attachment_bytes: 0,
316 }),
317 });
318 }
319
320 self.email_config
321 .is_valid()
322 .map_err(EmailError::InvalidConfig)?;
323
324 let from_addr = self
325 .email_config
326 .from_address()
327 .ok_or_else(|| EmailError::Config("email.from_address missing".into()))?;
328 let parsed: Address = from_addr.parse()?;
329 let from = match self.email_config.from_name() {
330 Some(name) => Mailbox::new(Some(name.to_string()), parsed),
331 None => Mailbox::new(None, parsed),
332 };
333
334 let transport = EmailTransportBuilder::new(
335 self.email_config.smtp_host().to_string(),
336 self.email_config.smtp_port(),
337 self.email_config
338 .smtp_username()
339 .map(|s| s.to_string()),
340 self.email_config
341 .smtp_password()
342 .map(|s| s.to_string()),
343 )
344 .connection_timeout(std::time::Duration::from_millis(
345 self.email_config.connection_timeout_ms(),
346 ))
347 .build()?;
348
349 let queue = match (&self.redis_url, &self.queue_config) {
350 (Some(url), Some(qcfg)) if qcfg.enabled() => {
351 let queue = EmailQueue::connect(
352 url,
353 QueueSettings {
354 channel_prefix: qcfg.channel_prefix().to_string(),
355 max_retries: qcfg.max_retries(),
356 retry_backoff: qcfg.retry_backoff(),
357 enabled: true,
358 },
359 )
360 .await?;
361 Some(queue)
362 }
363 _ => None,
364 };
365
366 let max_attachment_bytes = self.email_config.max_attachment_bytes();
367
368 Ok(EmailService {
369 inner: Arc::new(EmailServiceInner {
370 config: Arc::new(self.email_config),
371 transport: Some(transport),
372 from: Some(from),
373 queue,
374 templates: self.templates,
375 max_attachment_bytes,
376 }),
377 })
378 }
379}
380
381impl Default for EmailService {
382 fn default() -> Self {
383 EmailService {
384 inner: Arc::new(EmailServiceInner {
385 config: Arc::new(config::email::EmailConfig::default()),
386 transport: None,
387 from: None,
388 queue: None,
389 templates: TemplateRegistry::new(),
390 max_attachment_bytes: 0,
391 }),
392 }
393 }
394}
No newline at end of file
Added lib/email/src/subscriber.rs lib/email/src/subscriber.rs +186 −0 View lib/email/src/subscriber.rs
@@ -0,0 +1,186 @@
1use std::time::Duration;
2
3use crate::error::EmailError;
4use crate::events::{
5 now_ms, EmailFailed, RenderAndSendEmailRequest, SendBatchEmailRequest, SendEmailRequest,
6 CHANNEL_BATCH, CHANNEL_RENDER, CHANNEL_SEND,
7};
8use crate::queue::{SubscribeHandle, TypedMessageMeta};
9use crate::service::EmailService;
10
11pub struct EmailSubscribers {
12 single: Option<SubscribeHandle<SendEmailRequest>>,
13 batch: Option<SubscribeHandle<SendBatchEmailRequest>>,
14 render: Option<SubscribeHandle<RenderAndSendEmailRequest>>,
15}
16
17impl EmailSubscribers {
18 pub fn empty() -> Self {
19 Self {
20 single: None,
21 batch: None,
22 render: None,
23 }
24 }
25
26 pub async fn shutdown(self, timeout: Duration) -> Result<(), EmailError> {
27 let s = self.single;
28 let b = self.batch;
29 let r = self.render;
30 let mut last_err: Option<EmailError> = None;
31 if let Some(h) = s {
32 if let Err(e) = h.shutdown(timeout).await {
33 last_err = Some(e);
34 }
35 }
36 if let Some(h) = b {
37 if let Err(e) = h.shutdown(timeout).await {
38 last_err = Some(e);
39 }
40 }
41 if let Some(h) = r {
42 if let Err(e) = h.shutdown(timeout).await {
43 last_err = Some(e);
44 }
45 }
46 match last_err {
47 Some(e) => Err(e),
48 None => Ok(()),
49 }
50 }
51}
52
53pub async fn start_subscribers(
54 service: EmailService,
55) -> Result<EmailSubscribers, EmailError> {
56 let queue = match service.queue() {
57 Some(q) if q.enabled() => q,
58 _ => return Ok(EmailSubscribers::empty()),
59 };
60 let group = service
61 .config()
62 .smtp_host()
63 .to_string();
64
65 let single = queue
66 .subscribe(CHANNEL_SEND, &group, {
67 let svc = service.clone();
68 move |req: SendEmailRequest, _meta: TypedMessageMeta| {
69 let svc = svc.clone();
70 async move {
71 let recipient_count =
72 req.to.len() + req.cc.len() + req.bcc.len();
73 let request_id = req.request_id;
74 let metadata = req.metadata.clone();
75 match svc.send(req).await {
76 Ok(sent) => {
77 if let Err(err) = svc.publish_sent(&sent).await {
78 tracing::warn!(error = %err, "publish EmailSent failed");
79 }
80 }
81 Err(err) => {
82 let failed = EmailFailed {
83 request_id,
84 error: err.to_string(),
85 recipient_count,
86 failed_at_ms: now_ms(),
87 metadata,
88 };
89 tracing::error!(
90 request_id = %request_id,
91 error = %err,
92 "email send failed"
93 );
94 if let Err(pub_err) = svc.publish_failed(&failed).await {
95 tracing::warn!(error = %pub_err, "publish EmailFailed failed");
96 }
97 }
98 }
99 Ok(())
100 }
101 }
102 })
103 .await?;
104
105 let batch = queue
106 .subscribe(CHANNEL_BATCH, &group, {
107 let svc = service.clone();
108 move |req: SendBatchEmailRequest, _meta: TypedMessageMeta| {
109 let svc = svc.clone();
110 async move {
111 let total = req.recipients.len();
112 let request_id = req.request_id;
113 match svc.send_batch(req).await {
114 Ok(_) => {
115 tracing::debug!(request_id = %request_id, total, "batch completed");
116 }
117 Err(err) => {
118 let failed = EmailFailed {
119 request_id,
120 error: err.to_string(),
121 recipient_count: total,
122 failed_at_ms: now_ms(),
123 metadata: Default::default(),
124 };
125 tracing::error!(
126 request_id = %request_id,
127 error = %err,
128 "batch failed"
129 );
130 if let Err(pub_err) = svc.publish_failed(&failed).await {
131 tracing::warn!(error = %pub_err, "publish EmailFailed failed");
132 }
133 }
134 }
135 Ok(())
136 }
137 }
138 })
139 .await?;
140
141 let render = queue
142 .subscribe(CHANNEL_RENDER, &group, {
143 let svc = service.clone();
144 move |req: RenderAndSendEmailRequest, _meta: TypedMessageMeta| {
145 let svc = svc.clone();
146 async move {
147 let request_id = req.request_id;
148 let recipient_count =
149 req.to.len() + req.cc.len() + req.bcc.len();
150 let metadata = req.metadata.clone();
151 match svc.render_and_send(req).await {
152 Ok(sent) => {
153 if let Err(err) = svc.publish_sent(&sent).await {
154 tracing::warn!(error = %err, "publish EmailSent failed");
155 }
156 }
157 Err(err) => {
158 let failed = EmailFailed {
159 request_id,
160 error: err.to_string(),
161 recipient_count,
162 failed_at_ms: now_ms(),
163 metadata,
164 };
165 tracing::error!(
166 request_id = %request_id,
167 error = %err,
168 "render+send failed"
169 );
170 if let Err(pub_err) = svc.publish_failed(&failed).await {
171 tracing::warn!(error = %pub_err, "publish EmailFailed failed");
172 }
173 }
174 }
175 Ok(())
176 }
177 }
178 })
179 .await?;
180
181 Ok(EmailSubscribers {
182 single: Some(single),
183 batch: Some(batch),
184 render: Some(render),
185 })
186}
No newline at end of file
Added lib/email/src/template.rs lib/email/src/template.rs +47 −0 View lib/email/src/template.rs
@@ -0,0 +1,47 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use maud::Markup;
5use tokio::sync::RwLock;
6
7use crate::error::EmailError;
8
9pub type TemplateFn = Arc<dyn Fn(&serde_json::Value) -> Markup + Send + Sync>;
10
11#[derive(Default)]
12pub struct TemplateRegistry {
13 inner: RwLock<HashMap<String, TemplateFn>>,
14}
15
16impl TemplateRegistry {
17 pub fn new() -> Self {
18 Self::default()
19 }
20
21 pub async fn register<F>(&self, name: &str, f: F)
22 where
23 F: Fn(&serde_json::Value) -> Markup + Send + Sync + 'static,
24 {
25 let mut guard = self.inner.write().await;
26 guard.insert(name.to_string(), Arc::new(f));
27 }
28
29 pub async fn render(&self, name: &str, vars: &serde_json::Value) -> Result<String, EmailError> {
30 let guard = self.inner.read().await;
31 let Some(f) = guard.get(name) else {
32 return Err(EmailError::TemplateNotFound(name.to_string()));
33 };
34 let markup = f(vars);
35 Ok(markup.into_string())
36 }
37
38 pub async fn contains(&self, name: &str) -> bool {
39 let guard = self.inner.read().await;
40 guard.contains_key(name)
41 }
42
43 pub async fn names(&self) -> Vec<String> {
44 let guard = self.inner.read().await;
45 guard.keys().cloned().collect()
46 }
47}
No newline at end of file
Added lib/email/src/transport.rs lib/email/src/transport.rs +185 −0 View lib/email/src/transport.rs
@@ -0,0 +1,185 @@
1use std::time::Duration;
2
3use base64::Engine;
4use lettre::address::Address;
5use lettre::message::{
6 Attachment as LettreAttachment, Mailbox, Message, MessageBuilder, MultiPart, SinglePart,
7};
8use lettre::message::header::ContentType;
9use lettre::transport::smtp::authentication::{Credentials, Mechanism};
10use lettre::transport::smtp::AsyncSmtpTransport;
11use lettre::AsyncTransport;
12use lettre::Tokio1Executor;
13
14use crate::error::EmailError;
15use crate::events::{EmailAddress, SendEmailRequest};
16
17pub struct EmailTransport {
18 inner: AsyncSmtpTransport<Tokio1Executor>,
19}
20
21impl EmailTransport {
22 pub async fn send(&self, msg: Message) -> Result<String, EmailError> {
23 let response = self.inner.send(msg).await?;
24 Ok(response.message().next().unwrap_or_default().to_string())
25 }
26}
27
28impl Clone for EmailTransport {
29 fn clone(&self) -> Self {
30 Self {
31 inner: self.inner.clone(),
32 }
33 }
34}
35
36pub struct EmailTransportBuilder {
37 host: String,
38 port: u16,
39 username: Option<String>,
40 password: Option<String>,
41 connection_timeout: Option<Duration>,
42}
43
44impl EmailTransportBuilder {
45 pub fn new(
46 host: impl Into<String>,
47 port: u16,
48 username: Option<String>,
49 password: Option<String>,
50 ) -> Self {
51 Self {
52 host: host.into(),
53 port,
54 username,
55 password,
56 connection_timeout: None,
57 }
58 }
59
60 pub fn connection_timeout(mut self, dur: Duration) -> Self {
61 self.connection_timeout = Some(dur);
62 self
63 }
64
65 pub fn build(self) -> Result<EmailTransport, EmailError> {
66 if self.host.is_empty() {
67 return Err(EmailError::Config("smtp host is empty".into()));
68 }
69 let mut builder = if self.port == 465 {
70 AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)?
71 } else if self.port == 587 {
72 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host)?
73 } else {
74 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(self.host.clone())
75 };
76 builder = builder.port(self.port);
77 if let Some(ct) = self.connection_timeout {
78 builder = builder.timeout(Some(ct));
79 }
80 if let (Some(u), Some(p)) = (&self.username, &self.password) {
81 builder = builder
82 .credentials(Credentials::new(u.clone(), p.clone()))
83 .authentication(vec![Mechanism::Plain]);
84 }
85 Ok(EmailTransport {
86 inner: builder.build(),
87 })
88 }
89}
90
91fn to_mailbox(addr: &EmailAddress) -> Result<Mailbox, EmailError> {
92 let parsed: Address = addr.address.parse()?;
93 let mb = match &addr.name {
94 Some(name) if !name.is_empty() => Mailbox::new(Some(name.clone()), parsed),
95 _ => Mailbox::new(None, parsed),
96 };
97 Ok(mb)
98}
99
100pub fn build_message(
101 req: &SendEmailRequest,
102 from: Mailbox,
103 max_attachment_bytes: usize,
104) -> Result<Message, EmailError> {
105 let has_text = req.text.as_deref().is_some_and(|s| !s.is_empty());
106 let has_html = req.html.as_deref().is_some_and(|s| !s.is_empty());
107 if !has_text && !has_html {
108 return Err(EmailError::Missing(
109 "email body requires text or html".into(),
110 ));
111 }
112
113 let mut builder = MessageBuilder::new().from(from).subject(req.subject.clone());
114
115 for addr in &req.to {
116 let mb = to_mailbox(addr)?;
117 builder = builder.to(mb);
118 }
119 for addr in &req.cc {
120 let mb = to_mailbox(addr)?;
121 builder = builder.cc(mb);
122 }
123 for addr in &req.bcc {
124 let mb = to_mailbox(addr)?;
125 builder = builder.bcc(mb);
126 }
127 if let Some(reply) = &req.reply_to {
128 let mb = to_mailbox(reply)?;
129 builder = builder.reply_to(mb);
130 }
131
132 let body_part: MultiPart = match (has_text, has_html) {
133 (true, true) => {
134 let text = SinglePart::plain(req.text.as_deref().unwrap_or_default().to_string());
135 let html = SinglePart::html(req.html.as_deref().unwrap_or_default().to_string());
136 MultiPart::alternative().singlepart(text).singlepart(html)
137 }
138 (true, false) => MultiPart::alternative().singlepart(SinglePart::plain(
139 req.text.as_deref().unwrap_or_default().to_string(),
140 )),
141 (false, true) => MultiPart::alternative().singlepart(SinglePart::html(
142 req.html.as_deref().unwrap_or_default().to_string(),
143 )),
144 (false, false) => unreachable!(),
145 };
146
147 let message = if req.attachments.is_empty() {
148 builder.multipart(body_part)?
149 } else {
150 let mut mixed = MultiPart::mixed().multipart(body_part);
151 for att in &req.attachments {
152 let bytes = base64::engine::general_purpose::STANDARD
153 .decode(att.data_b64.as_bytes())?;
154 if bytes.len() > max_attachment_bytes {
155 return Err(EmailError::AttachmentTooLarge {
156 actual: bytes.len(),
157 max: max_attachment_bytes,
158 });
159 }
160 let ct_str = if att.content_type.is_empty() {
161 "application/octet-stream".to_string()
162 } else {
163 att.content_type.clone()
164 };
165 let content_type: ContentType = ct_str
166 .parse()
167 .map_err(|e: lettre::message::header::ContentTypeErr| {
168 EmailError::Other(format!("invalid content_type {ct_str:?}: {e}"))
169 })?;
170 if let Some(cid) = &att.content_id {
171 mixed = mixed.singlepart(
172 LettreAttachment::new_inline_with_name(cid.clone(), att.filename.clone())
173 .body(bytes, content_type),
174 );
175 } else {
176 mixed = mixed.singlepart(
177 LettreAttachment::new(att.filename.clone()).body(bytes, content_type),
178 );
179 }
180 }
181 builder.multipart(mixed)?
182 };
183
184 Ok(message)
185}
No newline at end of file
Added lib/email/Cargo.toml lib/email/Cargo.toml +21 −0 View lib/email/Cargo.toml
@@ -0,0 +1,21 @@
1[package]
2name = "email"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8lettre = { workspace = true }
9maud = { workspace = true }
10redis = { workspace = true }
11serde = { workspace = true, features = ["derive"] }
12serde_json = { workspace = true }
13tokio = { workspace = true, features = ["signal", "rt-multi-thread", "macros"] }
14tokio-util = { workspace = true }
15futures = { workspace = true }
16tracing = { workspace = true }
17async-trait = { workspace = true }
18uuid = { workspace = true, features = ["v7", "serde"] }
19base64 = { workspace = true }
20bytes = { workspace = true }
21time = { workspace = true }
No newline at end of file
Modified Cargo.lock Cargo.lock +323 −572 View Cargo.lock
@@ -156,6 +156,19 @@
156156[[package]]
157157name = "apihost"
158158version = "1.0.0"
159dependencies = [
160 "anyhow",
161 "axum",
162 "config",
163 "export",
164 "session",
165 "tokio",
166 "tracing",
167 "tracing-subscriber",
168 "utoipa",
169 "utoipa-swagger-ui",
170 "webapi",
171]
159172
160173[[package]]
161174name = "ar_archive_writer"
@@ -167,6 +180,15 @@
167180]
168181
169182[[package]]
183name = "arbitrary"
184version = "1.4.2"
185source = "registry+https://github.com/rust-lang/crates.io-index"
186checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
187dependencies = [
188 "derive_arbitrary",
189]
190
191[[package]]
170192name = "arc-swap"
171193version = "1.9.2"
172194source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -569,10 +591,10 @@
569591 "axum",
570592 "base64 0.23.1",
571593 "config",
594 "email",
572595 "export",
573596 "hkdf",
574597 "hmac 0.13.0",
575 "mailbaby",
576598 "maud",
577599 "models",
578600 "pow-solver",
@@ -1214,16 +1236,6 @@
12141236 "subtle",
12151237 "time 0.3.55",
12161238 "version_check 0.9.5",
1217]
1218
1219[[package]]
1220name = "core-foundation"
1221version = "0.9.4"
1222source = "registry+https://github.com/rust-lang/crates.io-index"
1223checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
1224dependencies = [
1225 "core-foundation-sys",
1226 "libc",
12271239]
12281240
12291241[[package]]
@@ -1656,6 +1668,17 @@
16561668]
16571669
16581670[[package]]
1671name = "derive_arbitrary"
1672version = "1.4.2"
1673source = "registry+https://github.com/rust-lang/crates.io-index"
1674checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
1675dependencies = [
1676 "proc-macro2",
1677 "quote",
1678 "syn 2.0.119",
1679]
1680
1681[[package]]
16591682name = "derive_builder"
16601683version = "0.20.2"
16611684source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1897,6 +1920,43 @@
18971920]
18981921
18991922[[package]]
1923name = "email"
1924version = "1.0.0"
1925dependencies = [
1926 "async-trait",
1927 "base64 0.23.1",
1928 "bytes",
1929 "config",
1930 "futures",
1931 "lettre",
1932 "maud",
1933 "redis",
1934 "serde",
1935 "serde_json",
1936 "time 0.3.55",
1937 "tokio",
1938 "tokio-util",
1939 "tracing",
1940 "uuid",
1941]
1942
1943[[package]]
1944name = "email-encoding"
1945version = "0.4.2"
1946source = "registry+https://github.com/rust-lang/crates.io-index"
1947checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
1948dependencies = [
1949 "base64 0.23.1",
1950 "memchr",
1951]
1952
1953[[package]]
1954name = "email_address"
1955version = "0.2.9"
1956source = "registry+https://github.com/rust-lang/crates.io-index"
1957checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
1958
1959[[package]]
19001960name = "encoding_rs"
19011961version = "0.8.35"
19021962source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1969,7 +2029,7 @@
19692029dependencies = [
19702030 "axum",
19712031 "config",
1972 "mailbaby",
2032 "email",
19732033 "redis",
19742034 "sea-orm",
19752035 "serde",
@@ -2097,12 +2157,6 @@
20972157 "smallvec",
20982158 "writeable",
20992159]
2100
2101[[package]]
2102name = "fixedbitset"
2103version = "0.5.7"
2104source = "registry+https://github.com/rust-lang/crates.io-index"
2105checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
21062160
21072161[[package]]
21082162name = "flate2"
@@ -2112,6 +2166,7 @@
21122166dependencies = [
21132167 "crc32fast",
21142168 "miniz_oxide",
2169 "zlib-rs",
21152170]
21162171
21172172[[package]]
@@ -2133,12 +2188,6 @@
21332188
21342189[[package]]
21352190name = "foldhash"
2136version = "0.1.5"
2137source = "registry+https://github.com/rust-lang/crates.io-index"
2138checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
2139
2140[[package]]
2141name = "foldhash"
21422191version = "0.2.0"
21432192source = "registry+https://github.com/rust-lang/crates.io-index"
21442193checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
@@ -2459,10 +2508,8 @@
24592508checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
24602509dependencies = [
24612510 "cfg-if",
2462 "js-sys",
24632511 "libc",
24642512 "wasi 0.11.1+wasi-snapshot-preview1",
2465 "wasm-bindgen",
24662513]
24672514
24682515[[package]]
@@ -2564,6 +2611,16 @@
25642611[[package]]
25652612name = "githost"
25662613version = "1.0.0"
2614dependencies = [
2615 "anyhow",
2616 "axum",
2617 "config",
2618 "export",
2619 "git",
2620 "tokio",
2621 "tracing",
2622 "tracing-subscriber",
2623]
25672624
25682625[[package]]
25692626name = "gix"
@@ -3555,25 +3612,6 @@
35553612]
35563613
35573614[[package]]
3558name = "h2"
3559version = "0.4.16"
3560source = "registry+https://github.com/rust-lang/crates.io-index"
3561checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
3562dependencies = [
3563 "atomic-waker",
3564 "bytes",
3565 "fnv",
3566 "futures-core",
3567 "futures-sink",
3568 "http 1.5.0",
3569 "indexmap",
3570 "slab",
3571 "tokio",
3572 "tokio-util",
3573 "tracing",
3574]
3575
3576[[package]]
35773615name = "half"
35783616version = "2.7.1"
35793617source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3627,22 +3665,13 @@
36273665
36283666[[package]]
36293667name = "hashbrown"
3630version = "0.15.5"
3631source = "registry+https://github.com/rust-lang/crates.io-index"
3632checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
3633dependencies = [
3634 "foldhash 0.1.5",
3635]
3636
3637[[package]]
3638name = "hashbrown"
36393668version = "0.16.1"
36403669source = "registry+https://github.com/rust-lang/crates.io-index"
36413670checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
36423671dependencies = [
36433672 "allocator-api2",
36443673 "equivalent",
3645 "foldhash 0.2.0",
3674 "foldhash",
36463675]
36473676
36483677[[package]]
@@ -3653,7 +3682,7 @@
36533682dependencies = [
36543683 "allocator-api2",
36553684 "equivalent",
3656 "foldhash 0.2.0",
3685 "foldhash",
36573686]
36583687
36593688[[package]]
@@ -3846,7 +3875,7 @@
38463875 "futures-channel",
38473876 "futures-core",
38483877 "futures-util",
3849 "h2 0.3.27",
3878 "h2",
38503879 "http 0.2.12",
38513880 "http-body 0.4.6",
38523881 "httparse",
@@ -3870,7 +3899,6 @@
38703899 "bytes",
38713900 "futures-channel",
38723901 "futures-core",
3873 "h2 0.4.16",
38743902 "http 1.5.0",
38753903 "http-body 1.1.0",
38763904 "httparse",
@@ -3879,7 +3907,6 @@
38793907 "pin-project-lite",
38803908 "smallvec",
38813909 "tokio",
3882 "want",
38833910]
38843911
38853912[[package]]
@@ -3894,34 +3921,6 @@
38943921]
38953922
38963923[[package]]
3897name = "hyper-rustls"
3898version = "0.27.9"
3899source = "registry+https://github.com/rust-lang/crates.io-index"
3900checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
3901dependencies = [
3902 "http 1.5.0",
3903 "hyper 1.11.0",
3904 "hyper-util",
3905 "rustls",
3906 "tokio",
3907 "tokio-rustls",
3908 "tower-service",
3909]
3910
3911[[package]]
3912name = "hyper-timeout"
3913version = "0.5.2"
3914source = "registry+https://github.com/rust-lang/crates.io-index"
3915checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
3916dependencies = [
3917 "hyper 1.11.0",
3918 "hyper-util",
3919 "pin-project-lite",
3920 "tokio",
3921 "tower-service",
3922]
3923
3924[[package]]
39253924name = "hyper-tls"
39263925version = "0.5.0"
39273926source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3940,23 +3939,13 @@
39403939source = "registry+https://github.com/rust-lang/crates.io-index"
39413940checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
39423941dependencies = [
3943 "base64 0.22.1",
39443942 "bytes",
3945 "futures-channel",
3946 "futures-util",
39473943 "http 1.5.0",
39483944 "http-body 1.1.0",
39493945 "hyper 1.11.0",
3950 "ipnet",
3951 "libc",
3952 "percent-encoding 2.3.2",
39533946 "pin-project-lite",
3954 "socket2 0.6.5",
3955 "system-configuration",
39563947 "tokio",
39573948 "tower-service",
3958 "tracing",
3959 "windows-registry",
39603949]
39613950
39623951[[package]]
@@ -4512,12 +4501,6 @@
45124501 "cfg-if",
45134502 "libc",
45144503]
4515
4516[[package]]
4517name = "ipnet"
4518version = "2.12.1"
4519source = "registry+https://github.com/rust-lang/crates.io-index"
4520checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
45214504
45224505[[package]]
45234506name = "issues"
@@ -4525,8 +4508,8 @@
45254508dependencies = [
45264509 "axum",
45274510 "config",
4511 "email",
45284512 "export",
4529 "mailbaby",
45304513 "models",
45314514 "sea-orm",
45324515 "serde",
@@ -4620,55 +4603,6 @@
46204603checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
46214604dependencies = [
46224605 "jiff-tzdb",
4623]
4624
4625[[package]]
4626name = "jni"
4627version = "0.22.4"
4628source = "registry+https://github.com/rust-lang/crates.io-index"
4629checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
4630dependencies = [
4631 "cfg-if",
4632 "combine",
4633 "jni-macros",
4634 "jni-sys",
4635 "log 0.4.33",
4636 "simd_cesu8",
4637 "thiserror 2.0.20",
4638 "walkdir",
4639 "windows-link",
4640]
4641
4642[[package]]
4643name = "jni-macros"
4644version = "0.22.4"
4645source = "registry+https://github.com/rust-lang/crates.io-index"
4646checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
4647dependencies = [
4648 "proc-macro2",
4649 "quote",
4650 "rustc_version",
4651 "simd_cesu8",
4652 "syn 2.0.119",
4653]
4654
4655[[package]]
4656name = "jni-sys"
4657version = "0.4.1"
4658source = "registry+https://github.com/rust-lang/crates.io-index"
4659checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
4660dependencies = [
4661 "jni-sys-macros",
4662]
4663
4664[[package]]
4665name = "jni-sys-macros"
4666version = "0.4.1"
4667source = "registry+https://github.com/rust-lang/crates.io-index"
4668checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
4669dependencies = [
4670 "quote",
4671 "syn 2.0.119",
46724606]
46734607
46744608[[package]]
@@ -4728,22 +4662,49 @@
47284662]
47294663
47304664[[package]]
4731name = "lexical-core"
4732version = "1.0.6"
4665name = "lettre"
4666version = "0.11.23"
47334667source = "registry+https://github.com/rust-lang/crates.io-index"
4734checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594"
4668checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
47354669dependencies = [
4736 "lexical-parse-float",
4737 "lexical-parse-integer",
4738 "lexical-util",
4739 "lexical-write-float",
4740 "lexical-write-integer",
4741]
4742
4743[[package]]
4744name = "lexical-parse-float"
4745version = "1.0.6"
4670 "async-trait",
4671 "base64 0.23.1",
4672 "email-encoding",
4673 "email_address",
4674 "fastrand",
4675 "futures-io",
4676 "futures-util",
4677 "httpdate",
4678 "idna 1.1.0",
4679 "mime 0.3.17",
4680 "nom",
4681 "percent-encoding 2.3.2",
4682 "quoted_printable",
4683 "rustls",
4684 "rustls-native-certs",
4685 "socket2 0.6.5",
4686 "tokio",
4687 "tokio-rustls",
4688 "url 2.5.8",
4689]
4690
4691[[package]]
4692name = "lexical-core"
4693version = "1.0.6"
47464694source = "registry+https://github.com/rust-lang/crates.io-index"
4695checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594"
4696dependencies = [
4697 "lexical-parse-float",
4698 "lexical-parse-integer",
4699 "lexical-util",
4700 "lexical-write-float",
4701 "lexical-write-integer",
4702]
4703
4704[[package]]
4705name = "lexical-parse-float"
4706version = "1.0.6"
4707source = "registry+https://github.com/rust-lang/crates.io-index"
47474708checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56"
47484709dependencies = [
47494710 "lexical-parse-integer",
@@ -4850,12 +4811,6 @@
48504811version = "0.4.33"
48514812source = "registry+https://github.com/rust-lang/crates.io-index"
48524813checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
4853
4854[[package]]
4855name = "lru-slab"
4856version = "0.1.2"
4857source = "registry+https://github.com/rust-lang/crates.io-index"
4858checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
48594814
48604815[[package]]
48614816name = "lz4"
@@ -4888,20 +4843,24 @@
48884843]
48894844
48904845[[package]]
4891name = "mailbaby"
4892version = "0.3.0"
4893source = "git+https://github.com/mailbabys/mailbaby-rs?rev=e408acf48a19e4fd554b020a865d144706f7c2de#e408acf48a19e4fd554b020a865d144706f7c2de"
4846name = "mails"
4847version = "1.0.0"
48944848dependencies = [
4895 "base64 0.23.1",
4896 "prost",
4897 "reqwest",
4898 "serde",
4899 "serde_json",
4900 "thiserror 2.0.20",
4849 "anyhow",
4850 "config",
4851 "email",
49014852 "tokio",
4902 "tonic",
4903 "tonic-prost",
4904 "tonic-prost-build",
4853 "tracing",
4854 "tracing-subscriber",
4855]
4856
4857[[package]]
4858name = "matchers"
4859version = "0.2.0"
4860source = "registry+https://github.com/rust-lang/crates.io-index"
4861checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
4862dependencies = [
4863 "regex-automata",
49054864]
49064865
49074866[[package]]
@@ -5012,6 +4971,16 @@
50124971checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
50134972
50144973[[package]]
4974name = "mime_guess"
4975version = "2.0.5"
4976source = "registry+https://github.com/rust-lang/crates.io-index"
4977checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
4978dependencies = [
4979 "mime 0.3.17",
4980 "unicase 2.9.0",
4981]
4982
4983[[package]]
50154984name = "minidom"
50164985version = "0.15.2"
50174986source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5125,12 +5094,6 @@
51255094 "spin",
51265095 "version_check 0.9.5",
51275096]
5128
5129[[package]]
5130name = "multimap"
5131version = "0.10.1"
5132source = "registry+https://github.com/rust-lang/crates.io-index"
5133checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
51345097
51355098[[package]]
51365099name = "native-tls"
@@ -5175,6 +5138,15 @@
51755138]
51765139
51775140[[package]]
5141name = "nom"
5142version = "8.0.0"
5143source = "registry+https://github.com/rust-lang/crates.io-index"
5144checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
5145dependencies = [
5146 "memchr",
5147]
5148
5149[[package]]
51785150name = "nonempty"
51795151version = "0.7.0"
51805152source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5190,6 +5162,15 @@
51905162]
51915163
51925164[[package]]
5165name = "nu-ansi-term"
5166version = "0.50.3"
5167source = "registry+https://github.com/rust-lang/crates.io-index"
5168checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5169dependencies = [
5170 "windows-sys 0.61.2",
5171]
5172
5173[[package]]
51935174name = "num-bigint"
51945175version = "0.4.8"
51955176source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5700,17 +5681,6 @@
57005681]
57015682
57025683[[package]]
5703name = "petgraph"
5704version = "0.8.3"
5705source = "registry+https://github.com/rust-lang/crates.io-index"
5706checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
5707dependencies = [
5708 "fixedbitset",
5709 "hashbrown 0.15.5",
5710 "indexmap",
5711]
5712
5713[[package]]
57145684name = "pgvector"
57155685version = "0.4.2"
57165686source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5929,16 +5899,6 @@
59295899]
59305900
59315901[[package]]
5932name = "prettyplease"
5933version = "0.2.37"
5934source = "registry+https://github.com/rust-lang/crates.io-index"
5935checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
5936dependencies = [
5937 "proc-macro2",
5938 "syn 2.0.119",
5939]
5940
5941[[package]]
59425902name = "primefield"
59435903version = "0.14.0"
59445904source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6017,59 +5977,6 @@
60175977]
60185978
60195979[[package]]
6020name = "prost"
6021version = "0.14.4"
6022source = "registry+https://github.com/rust-lang/crates.io-index"
6023checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
6024dependencies = [
6025 "bytes",
6026 "prost-derive",
6027]
6028
6029[[package]]
6030name = "prost-build"
6031version = "0.14.4"
6032source = "registry+https://github.com/rust-lang/crates.io-index"
6033checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
6034dependencies = [
6035 "heck 0.5.0",
6036 "itertools 0.14.0",
6037 "log 0.4.33",
6038 "multimap",
6039 "petgraph",
6040 "prettyplease",
6041 "prost",
6042 "prost-types",
6043 "pulldown-cmark",
6044 "pulldown-cmark-to-cmark",
6045 "regex",
6046 "syn 2.0.119",
6047 "tempfile",
6048]
6049
6050[[package]]
6051name = "prost-derive"
6052version = "0.14.4"
6053source = "registry+https://github.com/rust-lang/crates.io-index"
6054checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
6055dependencies = [
6056 "anyhow",
6057 "itertools 0.14.0",
6058 "proc-macro2",
6059 "quote",
6060 "syn 2.0.119",
6061]
6062
6063[[package]]
6064name = "prost-types"
6065version = "0.14.4"
6066source = "registry+https://github.com/rust-lang/crates.io-index"
6067checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
6068dependencies = [
6069 "prost",
6070]
6071
6072[[package]]
60735980name = "psm"
60745981version = "0.1.32"
60755982source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6118,15 +6025,6 @@
61186025checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
61196026
61206027[[package]]
6121name = "pulldown-cmark-to-cmark"
6122version = "22.0.1"
6123source = "registry+https://github.com/rust-lang/crates.io-index"
6124checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
6125dependencies = [
6126 "pulldown-cmark",
6127]
6128
6129[[package]]
61306028name = "quick-xml"
61316029version = "0.28.2"
61326030source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6137,63 +6035,6 @@
61376035]
61386036
61396037[[package]]
6140name = "quinn"
6141version = "0.11.11"
6142source = "registry+https://github.com/rust-lang/crates.io-index"
6143checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
6144dependencies = [
6145 "bytes",
6146 "cfg_aliases",
6147 "pin-project-lite",
6148 "quinn-proto",
6149 "quinn-udp",
6150 "rustc-hash",
6151 "rustls",
6152 "socket2 0.6.5",
6153 "thiserror 2.0.20",
6154 "tokio",
6155 "tracing",
6156 "web-time",
6157]
6158
6159[[package]]
6160name = "quinn-proto"
6161version = "0.11.16"
6162source = "registry+https://github.com/rust-lang/crates.io-index"
6163checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
6164dependencies = [
6165 "aws-lc-rs",
6166 "bytes",
6167 "getrandom 0.4.3",
6168 "lru-slab",
6169 "rand 0.10.2",
6170 "rand_pcg",
6171 "ring",
6172 "rustc-hash",
6173 "rustls",
6174 "rustls-pki-types",
6175 "slab",
6176 "thiserror 2.0.20",
6177 "tinyvec",
6178 "tracing",
6179 "web-time",
6180]
6181
6182[[package]]
6183name = "quinn-udp"
6184version = "0.5.15"
6185source = "registry+https://github.com/rust-lang/crates.io-index"
6186checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
6187dependencies = [
6188 "cfg_aliases",
6189 "libc",
6190 "once_cell",
6191 "socket2 0.6.5",
6192 "tracing",
6193 "windows-sys 0.61.2",
6194]
6195
6196[[package]]
61976038name = "quote"
61986039version = "1.0.47"
61996040source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6203,6 +6044,12 @@
62036044]
62046045
62056046[[package]]
6047name = "quoted_printable"
6048version = "0.5.2"
6049source = "registry+https://github.com/rust-lang/crates.io-index"
6050checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
6051
6052[[package]]
62066053name = "r-efi"
62076054version = "5.3.0"
62086055source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6338,15 +6185,6 @@
63386185]
63396186
63406187[[package]]
6341name = "rand_pcg"
6342version = "0.10.2"
6343source = "registry+https://github.com/rust-lang/crates.io-index"
6344checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
6345dependencies = [
6346 "rand_core 0.10.1",
6347]
6348
6349[[package]]
63506188name = "redis"
63516189version = "1.6.0"
63526190source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6436,47 +6274,6 @@
64366274]
64376275
64386276[[package]]
6439name = "reqwest"
6440version = "0.13.4"
6441source = "registry+https://github.com/rust-lang/crates.io-index"
6442checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
6443dependencies = [
6444 "base64 0.22.1",
6445 "bytes",
6446 "encoding_rs",
6447 "futures-core",
6448 "h2 0.4.16",
6449 "http 1.5.0",
6450 "http-body 1.1.0",
6451 "http-body-util",
6452 "hyper 1.11.0",
6453 "hyper-rustls",
6454 "hyper-util",
6455 "js-sys",
6456 "log 0.4.33",
6457 "mime 0.3.17",
6458 "percent-encoding 2.3.2",
6459 "pin-project-lite",
6460 "quinn",
6461 "rustls",
6462 "rustls-pki-types",
6463 "rustls-platform-verifier",
6464 "serde",
6465 "serde_json",
6466 "serde_urlencoded",
6467 "sync_wrapper",
6468 "tokio",
6469 "tokio-rustls",
6470 "tower",
6471 "tower-http",
6472 "tower-service",
6473 "url 2.5.8",
6474 "wasm-bindgen",
6475 "wasm-bindgen-futures",
6476 "web-sys",
6477]
6478
6479[[package]]
64806277name = "rfc6979"
64816278version = "0.4.0"
64826279source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6676,6 +6473,41 @@
66766473]
66776474
66786475[[package]]
6476name = "rust-embed"
6477version = "8.12.0"
6478source = "registry+https://github.com/rust-lang/crates.io-index"
6479checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
6480dependencies = [
6481 "rust-embed-impl",
6482 "rust-embed-utils",
6483 "walkdir",
6484]
6485
6486[[package]]
6487name = "rust-embed-impl"
6488version = "8.12.0"
6489source = "registry+https://github.com/rust-lang/crates.io-index"
6490checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
6491dependencies = [
6492 "mime_guess",
6493 "proc-macro2",
6494 "quote",
6495 "rust-embed-utils",
6496 "syn 2.0.119",
6497 "walkdir",
6498]
6499
6500[[package]]
6501name = "rust-embed-utils"
6502version = "8.12.0"
6503source = "registry+https://github.com/rust-lang/crates.io-index"
6504checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
6505dependencies = [
6506 "sha2 0.11.0",
6507 "walkdir",
6508]
6509
6510[[package]]
66796511name = "rust-ini"
66806512version = "0.18.0"
66816513source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6739,12 +6571,6 @@
67396571]
67406572
67416573[[package]]
6742name = "rustc-hash"
6743version = "2.1.3"
6744source = "registry+https://github.com/rust-lang/crates.io-index"
6745checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
6746
6747[[package]]
67486574name = "rustc_version"
67496575version = "0.4.1"
67506576source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6772,7 +6598,6 @@
67726598source = "registry+https://github.com/rust-lang/crates.io-index"
67736599checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
67746600dependencies = [
6775 "aws-lc-rs",
67766601 "log 0.4.33",
67776602 "once_cell",
67786603 "ring",
@@ -6800,44 +6625,15 @@
68006625source = "registry+https://github.com/rust-lang/crates.io-index"
68016626checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
68026627dependencies = [
6803 "web-time",
68046628 "zeroize",
68056629]
68066630
68076631[[package]]
6808name = "rustls-platform-verifier"
6809version = "0.7.0"
6810source = "registry+https://github.com/rust-lang/crates.io-index"
6811checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
6812dependencies = [
6813 "core-foundation 0.10.1",
6814 "core-foundation-sys",
6815 "jni",
6816 "log 0.4.33",
6817 "once_cell",
6818 "rustls",
6819 "rustls-native-certs",
6820 "rustls-platform-verifier-android",
6821 "rustls-webpki",
6822 "security-framework",
6823 "security-framework-sys",
6824 "webpki-root-certs",
6825 "windows-sys 0.61.2",
6826]
6827
6828[[package]]
6829name = "rustls-platform-verifier-android"
6830version = "0.1.1"
6831source = "registry+https://github.com/rust-lang/crates.io-index"
6832checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
6833
6834[[package]]
68356632name = "rustls-webpki"
68366633version = "0.103.14"
68376634source = "registry+https://github.com/rust-lang/crates.io-index"
68386635checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
68396636dependencies = [
6840 "aws-lc-rs",
68416637 "ring",
68426638 "rustls-pki-types",
68436639 "untrusted 0.9.0",
@@ -7105,7 +6901,7 @@
71056901checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
71066902dependencies = [
71076903 "bitflags 2.13.1",
7108 "core-foundation 0.10.1",
6904 "core-foundation",
71096905 "core-foundation-sys",
71106906 "libc",
71116907 "security-framework-sys",
@@ -7321,6 +7117,15 @@
73217117]
73227118
73237119[[package]]
7120name = "sharded-slab"
7121version = "0.1.7"
7122source = "registry+https://github.com/rust-lang/crates.io-index"
7123checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
7124dependencies = [
7125 "lazy_static",
7126]
7127
7128[[package]]
73247129name = "shell-words"
73257130version = "1.1.1"
73267131source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7379,16 +7184,6 @@
73797184checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
73807185
73817186[[package]]
7382name = "simd_cesu8"
7383version = "1.2.0"
7384source = "registry+https://github.com/rust-lang/crates.io-index"
7385checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
7386dependencies = [
7387 "rustc_version",
7388 "simdutf8",
7389]
7390
7391[[package]]
73927187name = "simdutf8"
73937188version = "0.1.5"
73947189source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7453,8 +7248,8 @@
74537248 "axum",
74547249 "base64 0.23.1",
74557250 "config",
7251 "email",
74567252 "export",
7457 "mailbaby",
74587253 "models",
74597254 "sea-orm",
74607255 "serde",
@@ -7933,13 +7728,21 @@
79337728version = "1.0.2"
79347729source = "registry+https://github.com/rust-lang/crates.io-index"
79357730checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
7936dependencies = [
7937 "futures-core",
7938]
79397731
79407732[[package]]
79417733name = "synchost"
79427734version = "1.0.0"
7735dependencies = [
7736 "anyhow",
7737 "cache",
7738 "config",
7739 "export",
7740 "git",
7741 "tokio",
7742 "tokio-util",
7743 "tracing",
7744 "tracing-subscriber",
7745]
79437746
79447747[[package]]
79457748name = "synstructure"
@@ -7950,27 +7753,6 @@
79507753 "proc-macro2",
79517754 "quote",
79527755 "syn 2.0.119",
7953]
7954
7955[[package]]
7956name = "system-configuration"
7957version = "0.7.0"
7958source = "registry+https://github.com/rust-lang/crates.io-index"
7959checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
7960dependencies = [
7961 "bitflags 2.13.1",
7962 "core-foundation 0.9.4",
7963 "system-configuration-sys",
7964]
7965
7966[[package]]
7967name = "system-configuration-sys"
7968version = "0.6.0"
7969source = "registry+https://github.com/rust-lang/crates.io-index"
7970checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
7971dependencies = [
7972 "core-foundation-sys",
7973 "libc",
79747756]
79757757
79767758[[package]]
@@ -8039,6 +7821,15 @@
80397821]
80407822
80417823[[package]]
7824name = "thread_local"
7825version = "1.1.10"
7826source = "registry+https://github.com/rust-lang/crates.io-index"
7827checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
7828dependencies = [
7829 "cfg-if",
7830]
7831
7832[[package]]
80427833name = "time"
80437834version = "0.1.45"
80447835source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8259,75 +8050,6 @@
82598050checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
82608051
82618052[[package]]
8262name = "tonic"
8263version = "0.14.6"
8264source = "registry+https://github.com/rust-lang/crates.io-index"
8265checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
8266dependencies = [
8267 "async-trait",
8268 "axum",
8269 "base64 0.22.1",
8270 "bytes",
8271 "h2 0.4.16",
8272 "http 1.5.0",
8273 "http-body 1.1.0",
8274 "http-body-util",
8275 "hyper 1.11.0",
8276 "hyper-timeout",
8277 "hyper-util",
8278 "percent-encoding 2.3.2",
8279 "pin-project",
8280 "socket2 0.6.5",
8281 "sync_wrapper",
8282 "tokio",
8283 "tokio-rustls",
8284 "tokio-stream",
8285 "tower",
8286 "tower-layer",
8287 "tower-service",
8288 "tracing",
8289]
8290
8291[[package]]
8292name = "tonic-build"
8293version = "0.14.6"
8294source = "registry+https://github.com/rust-lang/crates.io-index"
8295checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
8296dependencies = [
8297 "prettyplease",
8298 "proc-macro2",
8299 "quote",
8300 "syn 2.0.119",
8301]
8302
8303[[package]]
8304name = "tonic-prost"
8305version = "0.14.6"
8306source = "registry+https://github.com/rust-lang/crates.io-index"
8307checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
8308dependencies = [
8309 "bytes",
8310 "prost",
8311 "tonic",
8312]
8313
8314[[package]]
8315name = "tonic-prost-build"
8316version = "0.14.6"
8317source = "registry+https://github.com/rust-lang/crates.io-index"
8318checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
8319dependencies = [
8320 "prettyplease",
8321 "proc-macro2",
8322 "prost-build",
8323 "prost-types",
8324 "quote",
8325 "syn 2.0.119",
8326 "tempfile",
8327 "tonic-build",
8328]
8329
8330[[package]]
83318053name = "tower"
83328054version = "0.5.3"
83338055source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8335,36 +8057,15 @@
83358057dependencies = [
83368058 "futures-core",
83378059 "futures-util",
8338 "indexmap",
83398060 "pin-project-lite",
8340 "slab",
83418061 "sync_wrapper",
83428062 "tokio",
8343 "tokio-util",
83448063 "tower-layer",
83458064 "tower-service",
83468065 "tracing",
83478066]
83488067
83498068[[package]]
8350name = "tower-http"
8351version = "0.6.11"
8352source = "registry+https://github.com/rust-lang/crates.io-index"
8353checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
8354dependencies = [
8355 "bitflags 2.13.1",
8356 "bytes",
8357 "futures-util",
8358 "http 1.5.0",
8359 "http-body 1.1.0",
8360 "pin-project-lite",
8361 "tower",
8362 "tower-layer",
8363 "tower-service",
8364 "url 2.5.8",
8365]
8366
8367[[package]]
83688069name = "tower-layer"
83698070version = "0.3.3"
83708071source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8406,6 +8107,36 @@
84068107checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
84078108dependencies = [
84088109 "once_cell",
8110 "valuable",
8111]
8112
8113[[package]]
8114name = "tracing-log"
8115version = "0.2.0"
8116source = "registry+https://github.com/rust-lang/crates.io-index"
8117checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
8118dependencies = [
8119 "log 0.4.33",
8120 "once_cell",
8121 "tracing-core",
8122]
8123
8124[[package]]
8125name = "tracing-subscriber"
8126version = "0.3.23"
8127source = "registry+https://github.com/rust-lang/crates.io-index"
8128checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
8129dependencies = [
8130 "matchers",
8131 "nu-ansi-term",
8132 "once_cell",
8133 "regex-automata",
8134 "sharded-slab",
8135 "smallvec",
8136 "thread_local",
8137 "tracing",
8138 "tracing-core",
8139 "tracing-log",
84098140]
84108141
84118142[[package]]
@@ -8635,6 +8366,24 @@
86358366]
86368367
86378368[[package]]
8369name = "utoipa-swagger-ui"
8370version = "9.0.2"
8371source = "registry+https://github.com/rust-lang/crates.io-index"
8372checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
8373dependencies = [
8374 "axum",
8375 "base64 0.22.1",
8376 "mime_guess",
8377 "regex",
8378 "rust-embed",
8379 "serde",
8380 "serde_json",
8381 "url 2.5.8",
8382 "utoipa",
8383 "zip",
8384]
8385
8386[[package]]
86388387name = "uuid"
86398388version = "1.24.1"
86408389source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8647,6 +8396,12 @@
86478396]
86488397
86498398[[package]]
8399name = "valuable"
8400version = "0.1.1"
8401source = "registry+https://github.com/rust-lang/crates.io-index"
8402checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
8403
8404[[package]]
86508405name = "vcpkg"
86518406version = "0.2.15"
86528407source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8767,16 +8522,6 @@
87678522]
87688523
87698524[[package]]
8770name = "web-sys"
8771version = "0.3.104"
8772source = "registry+https://github.com/rust-lang/crates.io-index"
8773checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
8774dependencies = [
8775 "js-sys",
8776 "wasm-bindgen",
8777]
8778
8779[[package]]
87808525name = "web-time"
87818526version = "1.1.0"
87828527source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8803,15 +8548,6 @@
88038548]
88048549
88058550[[package]]
8806name = "webpki-root-certs"
8807version = "1.0.9"
8808source = "registry+https://github.com/rust-lang/crates.io-index"
8809checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
8810dependencies = [
8811 "rustls-pki-types",
8812]
8813
8814[[package]]
88158551name = "webpki-roots"
88168552version = "1.0.9"
88178553source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8938,17 +8674,6 @@
89388674dependencies = [
89398675 "windows-core",
89408676 "windows-link",
8941]
8942
8943[[package]]
8944name = "windows-registry"
8945version = "0.6.1"
8946source = "registry+https://github.com/rust-lang/crates.io-index"
8947checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
8948dependencies = [
8949 "windows-link",
8950 "windows-result",
8951 "windows-strings",
89528677]
89538678
89548679[[package]]
@@ -9318,6 +9043,20 @@
93189043]
93199044
93209045[[package]]
9046name = "zip"
9047version = "3.0.0"
9048source = "registry+https://github.com/rust-lang/crates.io-index"
9049checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
9050dependencies = [
9051 "arbitrary",
9052 "crc32fast",
9053 "flate2",
9054 "indexmap",
9055 "memchr",
9056 "zopfli",
9057]
9058
9059[[package]]
93219060name = "zlib-rs"
93229061version = "0.6.7"
93239062source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9330,6 +9069,18 @@
93309069checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
93319070
93329071[[package]]
9072name = "zopfli"
9073version = "0.8.3"
9074source = "registry+https://github.com/rust-lang/crates.io-index"
9075checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
9076dependencies = [
9077 "bumpalo",
9078 "crc32fast",
9079 "log 0.4.33",
9080 "simd-adler32",
9081]
9082
9083[[package]]
93339084name = "zstd"
93349085version = "0.13.3"
93359086source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml Cargo.toml +9 −0 View Cargo.toml
@@ -3,6 +3,7 @@
33 "lib/auth",
44 "lib/cache",
55 "lib/config",
6 "lib/email",
67 "lib/export",
78 "lib/models",
89 "lib/pow-solver",
@@ -15,6 +16,7 @@
1516 "lib/webapi",
1617 "app/apihost",
1718 "app/githost",
19 "app/mails",
1820 "app/synchost"
1921]
2022
@@ -29,6 +31,7 @@
2931auth = { path = "lib/auth" }
3032cache = { path = "lib/cache" }
3133config = { path = "lib/config" }
34email = { path = "lib/email" }
3235export = { path = "lib/export" }
3336models = { path = "lib/models" }
3437pow-solver = { path = "lib/pow-solver" }
@@ -38,6 +41,7 @@
3841space = { path = "lib/space" }
3942issues = { path = "lib/issues" }
4043git = { path = "lib/git" }
44webapi = { path = "lib/webapi" }
4145
4246
4347
@@ -75,6 +79,7 @@
7579globset = "0.4"
7680russh = "0.62"
7781utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
82utoipa-swagger-ui = { version = "9", features = ["axum"] }
7883thiserror = "2.0.20"
7984gix = "0.86.0"
8085gitbaby = { git="https://gitea.gitdata.me/GitDataAi/GitBaby.git", rev = "41154f520b" }
@@ -85,3 +90,7 @@
8590maud = { version = "0.27", features = ["axum"] }
8691pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
8792
93lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls", "ring", "rustls-native-certs", "builder"] }
94anyhow = "1"
95tracing-subscriber = { version = "0.3", features = ["env-filter"] }
96