Skip to content

Commit fd89112

Browse files
authored
74 fix/fixes (#76)
* feat: add mail processor that gets triggered every 30 secs * feat: add enqueue for the emails sent with smtp server and minor UI fixes on sidebar * add submit retry background process add missing bounces lists in the /contacts/bounces page fix modal close on create/edit action button on Template and Lists page fix the inconsistent state in the Sender and Template selection fields of the Campaign form remove unwanted crates from cargo.toml in order to resolve the dependabot alert and upgrade some of em' to patch versions * fix: change the serverResponse to not send the credentials along and fix the from_sender format for email: * ui: add the loader gif while page loads * fix: resolve the formatter for mjml content in the TemplateModal * fix: resolve the multiple imports of the same structure
1 parent 201c4ee commit fd89112

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2187
-2020
lines changed

backend/Cargo.lock

Lines changed: 164 additions & 605 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/Cargo.toml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ edition = "2021"
55

66
[dependencies]
77
axum = "0.8.1"
8-
tokio = { version = "1.43.0", features = ["full"] }
8+
tokio = { version = "1.43.1", features = ["full"] }
99
dotenv = "0.15.0"
1010
tower-http = { version = "0.6", features = ["cors", "trace"] }
1111
serde = { version = "1.0.217", features = ["derive"] }
@@ -27,7 +27,7 @@ diesel_migrations = "2.2.0"
2727
uuid = { version = "1.12.1", features = ["serde", "v4" ] }
2828

2929
utoipa = { version = "5.3.1", features = ["axum_extras"] }
30-
utoipa-swagger-ui = {version = "9.0.0", features=["axum"] }
30+
utoipa-swagger-ui = {version = "9.0.1", features=["axum"] }
3131
url = "2.5.4"
3232

3333
warp = "0.3.7"
@@ -36,15 +36,14 @@ thiserror = "2"
3636
anyhow = "1.0.95"
3737
mockall = "0.13.1"
3838
async-trait = "0.1.85"
39-
reqwest = { version = "0.12", features = ["json"] }
39+
reqwest = { version = "0.12.15", features = ["json"] }
4040

4141
diesel-derive-enum = {version= "2.1.0", features=["postgres"] }
4242

43-
multipart = "0.18.0"
4443
axum-extra = { version = "0.10.0", features = ["multipart"] }
4544
csv = "1.3.1"
4645

47-
lettre = "0.11.14"
46+
lettre = "0.11.15"
4847
tokenbucket = "0.1.6"
4948
governor = "0.10.0"
5049

backend/src/handlers/contact.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,5 +262,6 @@ pub async fn get_mails_by_contact_id(
262262
scheduled_at: mail.scheduled_at,
263263
attempts: mail.attempts,
264264
last_error: mail.last_error,
265+
from_name: mail.from_name,
265266
}).collect()))
266267
}

backend/src/handlers/mail_handler.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ pub async fn get_all_mails(
8080
scheduled_at: mail.scheduled_at,
8181
attempts: mail.attempts,
8282
last_error: mail.last_error.clone(),
83+
from_name: mail.from_name.clone(),
8384
});
8485
});
8586
Ok(Json(responses))
@@ -119,4 +120,46 @@ pub async fn delete_mail(
119120
let deleted_mail = mail_service.delete_mail(mail_id).await?;
120121

121122
Ok(Json(deleted_mail))
123+
}
124+
125+
126+
#[utoipa::path(
127+
get,
128+
path = "/api/mails/bounce",
129+
responses(
130+
(status = 200, description = "Get all bounced mails", body = Vec<GetMailResponse>),
131+
(status = 404)
132+
)
133+
)]
134+
pub async fn get_bounced_mails(
135+
Extension(mail_service): Extension<Arc<MailService>>,
136+
) -> Result<Json<Vec<GetMailResponse>>, AppError> {
137+
let all_bounced_mails = mail_service.fetch_bounced_mails().await?;
138+
139+
let mut responses = Vec::new();
140+
141+
if all_bounced_mails.is_empty() {
142+
return Ok(Json(vec![]));
143+
}
144+
all_bounced_mails.iter().for_each(|mail| {
145+
responses.push(GetMailResponse {
146+
id: mail.id.clone(),
147+
mail_message: mail.mail_message.clone(),
148+
email: mail.email.clone(),
149+
template_id: mail.template_id.clone(),
150+
campaign_id: mail.campaign_id,
151+
sent_at: mail.sent_at,
152+
open: mail.open,
153+
clicks: mail.clicks,
154+
status: mail.status.clone(),
155+
status_reason: mail.reason.clone(),
156+
server_id: mail.server_id.clone(),
157+
scheduled_at: mail.scheduled_at,
158+
attempts: mail.attempts,
159+
last_error: mail.last_error.clone(),
160+
from_name: mail.from_name.clone(),
161+
});
162+
});
163+
Ok(Json(responses))
164+
122165
}

backend/src/main.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,32 @@ async fn main() {
5757

5858
// Instantiate the server service and repository one time, and inject it to the process_mails background process...
5959
let server_repo = Arc::new(servers_repo::ServerRepoImpl);
60-
let server_service = servers_services::ServerService::new(server_repo);
60+
let server_service = Arc::new(servers_services::ServerService::new(server_repo));
6161

6262
let mail_repo = Arc::new(mail_repository::MailRepositoryImpl);
63-
let mail_service = mail_service::MailService::new(mail_repo);
63+
let mail_service = Arc::new(mail_service::MailService::new(mail_repo));
6464

6565
// Worker for processing mails...
66-
tokio::spawn(async move {
67-
if let Err(err) = mail_service.process_mails(server_service.into()).await.map_err(|err| AppError::InternalServerError(Some(format!("Mail worker error: {:?}", err.to_string())))) {
68-
eprintln!("Error occurred in mail worker: {:?}", err);
69-
}
70-
});
66+
{
67+
let mail_service = Arc::clone(&mail_service);
68+
let server_service = Arc::clone(&server_service);
69+
tokio::spawn(async move {
70+
if let Err(err) = mail_service.process_mails(server_service.into()).await.map_err(|err| AppError::InternalServerError(Some(format!("Mail worker error: {:?}", err.to_string())))) {
71+
eprintln!("Error occurred in mail worker: {:?}", err);
72+
}
73+
});
74+
}
75+
76+
// Worker for retrying submitted but not delivered mails...
77+
{
78+
let mail_service = Arc::clone(&mail_service);
79+
let server_service = Arc::clone(&server_service);
80+
tokio::spawn(async move {
81+
if let Err(err) = mail_service.process_submitted_mails(server_service.into()).await.map_err(|err| AppError::InternalServerError(Some(format!("Mail retry worker error: {:?}", err.to_string())))) {
82+
eprintln!("Error occurred in mail retry worker: {:?}", err);
83+
}
84+
});
85+
}
7186

7287
// Address configuration
7388
let addr = env::var("SERVER_ADDRESS").unwrap_or_else(|_| "0.0.0.0:8000".to_string());

backend/src/models/mail.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ pub struct MailWithDetails {
8080

8181
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
8282
pub reason: Option<String>, // This comes from bounce_logs.reason
83+
84+
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
85+
pub from_name: Option<String>,
8386
}
8487

8588

@@ -108,6 +111,7 @@ pub struct GetMailResponse {
108111
pub clicks: i32,
109112
pub status: String,
110113
pub status_reason: Option<String>,
114+
pub from_name: Option<String>,
111115

112116
#[schema(value_type = String, example = "2023-01-01T00:00:00Z")]
113117
pub scheduled_at: DateTime<Utc>,

backend/src/repositories/campaign.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ impl CampaignRepository for CampaginRepositoryImpl {
5252
created_at,
5353
updated_at
5454
))
55+
.order(updated_at.desc())
5556
.load::<Campaign>(&mut conn)
5657
}
5758
async fn update_campaign(&self, campaign_id: Uuid, payload: UpdateCampaignRequest)->Result<Campaign, diesel::result::Error> {
@@ -63,7 +64,8 @@ impl CampaignRepository for CampaginRepositoryImpl {
6364
template_id.eq(&payload.template_id),
6465
status.eq(&payload.status),
6566
campaign_senders.eq(&payload.campaign_senders),
66-
scheduled_at.eq(&payload.scheduled_at)
67+
scheduled_at.eq(&payload.scheduled_at),
68+
updated_at.eq(diesel::dsl::now),
6769
))
6870
.get_result(&mut conn)
6971
}

backend/src/repositories/list_repo.rs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ pub async fn get_connection_pool() -> DbPooledConnection {
1919
#[async_trait]
2020
pub trait ListRepository {
2121
async fn create_list(&self, payload: CreateListRequest) -> Result<List, diesel::result::Error>;
22-
async fn get_all_lists(&self, namespaceId: Uuid) -> Result<Vec<List>, diesel::result::Error>;
23-
async fn get_list_by_id(&self, namespaceId: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error>;
24-
async fn update_list(&self, namespaceId: Uuid, list_id: Uuid, payload: UpdateListRequest) -> Result<List, diesel::result::Error>;
25-
async fn delete_list(&self, namespaceId: Uuid, list_id: Uuid)->Result<List, diesel::result::Error>;
22+
async fn get_all_lists(&self, namespace_uuid: Uuid) -> Result<Vec<List>, diesel::result::Error>;
23+
async fn get_list_by_id(&self, namespace_uuid: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error>;
24+
async fn update_list(&self, namespace_uuid: Uuid, list_id: Uuid, payload: UpdateListRequest) -> Result<List, diesel::result::Error>;
25+
async fn delete_list(&self, namespace_uuid: Uuid, list_id: Uuid)->Result<List, diesel::result::Error>;
2626

2727
}
2828

@@ -46,7 +46,7 @@ async fn create_list (
4646
.get_result::<List>(&mut conn)
4747
}
4848

49-
async fn get_all_lists(&self, namespaceId: Uuid) -> Result<Vec<List>, diesel::result::Error> {
49+
async fn get_all_lists(&self, namespace_uuid: Uuid) -> Result<Vec<List>, diesel::result::Error> {
5050
let mut conn = get_connection_pool().await;
5151

5252
lists
@@ -58,23 +58,24 @@ async fn get_all_lists(&self, namespaceId: Uuid) -> Result<Vec<List>, diesel::re
5858
created_at,
5959
updated_at
6060
))
61-
.filter(namespace_id.eq(namespaceId))
61+
.filter(namespace_id.eq(namespace_uuid))
62+
.order(updated_at.desc())
6263
.load::<List>(&mut conn)
6364
}
6465

65-
async fn get_list_by_id(&self, namespaceId: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error> {
66+
async fn get_list_by_id(&self, namespace_uuid: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error> {
6667
let mut conn = get_connection_pool().await;
6768

6869
// Ensure you're querying with both Uuids
6970
lists
70-
.filter(namespace_id.eq(namespaceId)) // Make sure this is referencing the correct variable
71+
.filter(namespace_id.eq(namespace_uuid)) // Make sure this is referencing the correct variable
7172
.filter(id.eq(list_id)) // Filter by list_id as well
7273
.first(&mut conn)
7374
}
7475

7576
async fn update_list (
7677
&self,
77-
namespaceId: Uuid,
78+
namespace_uuid: Uuid,
7879
list_id: Uuid,
7980
payload: UpdateListRequest
8081
) -> Result<List, diesel::result::Error> {
@@ -83,18 +84,19 @@ async fn update_list (
8384

8485
diesel::update(lists)
8586
.filter(id.eq(list_id))
86-
.filter(namespace_id.eq(namespaceId))
87+
.filter(namespace_id.eq(namespace_uuid))
8788
.set((
8889
name.eq(payload.name),
89-
description.eq(payload.description)
90+
description.eq(payload.description),
91+
updated_at.eq(diesel::dsl::now),
9092
))
9193
.get_result(&mut conn)
9294
}
9395

94-
async fn delete_list(&self, namespaceId: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error> {
96+
async fn delete_list(&self, namespace_uuid: Uuid, list_id: Uuid) -> Result<List, diesel::result::Error> {
9597
let mut conn = get_connection_pool().await;
9698

97-
diesel::delete(lists.filter(namespace_id.eq(namespaceId)))
99+
diesel::delete(lists.filter(namespace_id.eq(namespace_uuid)))
98100
.filter(id.eq(list_id))
99101
.get_result(&mut conn)
100102
}

backend/src/repositories/mail_repository.rs

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
use crate::servers::servers_model::ServerTypeEnum;
12
use crate::{ appState::DbPooledConnection, GLOBAL_APP_STATE };
23
use crate::schema::mails::dsl::*;
34
use crate::schema::contacts::dsl as contacts_dsl;
45
use crate::schema::bounce_logs::dsl as bounce_logs_dsl;
6+
use crate::schema::campaign_senders::dsl as campaign_senders_dsl;
7+
use crate::schema::campaigns::dsl as campaigns_dsl;
8+
use crate::schema::servers::{dsl as servers_dsl, server_type};
59
use diesel::prelude::*;
6-
use chrono::{ Utc, DateTime };
10+
use chrono::{ Utc, DateTime, Duration };
711
use crate::models::mail::{
812
Mail, MailWithDetails, NewMail, UpdateMailRequest
913
};
1014
use uuid::Uuid;
15+
use diesel::dsl::sql;
16+
use diesel::sql_types::{ Nullable, Text };
1117
use mockall::{ automock, predicate::* };
1218
use async_trait::async_trait;
1319

@@ -28,7 +34,10 @@ pub trait MailRepository {
2834
async fn delete_mail(&self, mail_id: String) -> Result<Mail, diesel::result::Error>;
2935
async fn increment_mail_clicks(&self, mail_id: String) -> Result<Mail, diesel::result::Error>;
3036
async fn get_mails_by_contact(&self, c_id: Uuid) -> Result<Vec<MailWithDetails>, diesel::result::Error>;
31-
async fn get_queued_mails(&self) -> Result<Vec<MailWithDetails>, diesel::result::Error>;
37+
async fn get_mails_by_status(&self, mail_status: &str, is_ascending: bool) -> Result<Vec<MailWithDetails>, diesel::result::Error>;
38+
async fn get_stale_submitted_mails(&self) -> Result<Vec<MailWithDetails>, diesel::result::Error>;
39+
async fn update_mail_attempts(&self, mail_id: String) -> Result<Mail, diesel::result::Error>;
40+
async fn udpate_mail_last_try_error(&self, mail_id: String, error: &str) -> Result<Mail, diesel::result::Error>;
3241
}
3342

3443
pub struct MailRepositoryImpl;
@@ -65,6 +74,7 @@ impl MailRepository for MailRepositoryImpl {
6574
last_error,
6675
contacts_dsl::email,
6776
bounce_logs_dsl::reason.nullable(),
77+
sql::<Nullable<Text>>("NULL")
6878
))
6979
.into_boxed();
7080

@@ -144,19 +154,58 @@ impl MailRepository for MailRepositoryImpl {
144154
attempts,
145155
last_error,
146156
contacts_dsl::email,
147-
bounce_logs_dsl::reason.nullable()
157+
bounce_logs_dsl::reason.nullable(),
158+
sql::<Nullable<Text>>("NULL")
148159
))
149160
.filter(contact_id.eq(c_id))
150161
.order(sent_at.desc())
151162
.load::<MailWithDetails>(&mut conn)
152163
}
153164

154-
async fn get_queued_mails(&self) -> Result<Vec<MailWithDetails>, diesel::result::Error> {
165+
async fn get_mails_by_status(&self, mail_status: &str, is_ascending: bool) -> Result<Vec<MailWithDetails>, diesel::result::Error> {
155166
let mut conn = get_connection_pool().await;
167+
168+
let mut query_results = mails
169+
.inner_join(contacts_dsl::contacts.on(contact_id.eq(contacts_dsl::id)))
170+
.inner_join(campaigns_dsl::campaigns.on(campaign_id.eq(campaigns_dsl::id.nullable())))
171+
.inner_join(campaign_senders_dsl::campaign_senders.on(campaigns_dsl::campaign_senders.eq(campaign_senders_dsl::id.nullable())))
172+
.left_outer_join(bounce_logs_dsl::bounce_logs.on(id.eq(bounce_logs_dsl::mail_id)))
173+
.select((
174+
id,
175+
mail_message,
176+
template_id,
177+
campaign_id,
178+
server_id,
179+
sent_at,
180+
status,
181+
open,
182+
clicks,
183+
scheduled_at,
184+
attempts,
185+
last_error,
186+
contacts_dsl::email,
187+
bounce_logs_dsl::reason.nullable(),
188+
campaign_senders_dsl::from_name.nullable(),
189+
))
190+
.filter(status.eq(mail_status))
191+
.into_boxed();
192+
193+
if is_ascending {
194+
query_results = query_results.order(sent_at.asc());
195+
} else {
196+
query_results = query_results.order(sent_at.desc());
197+
}
198+
query_results.load::<MailWithDetails>(&mut conn)
199+
}
200+
201+
async fn get_stale_submitted_mails(&self) -> Result<Vec<MailWithDetails>, diesel::result::Error> {
202+
let mut conn = get_connection_pool().await;
203+
let stale_duration_minutes = 30; // only send retry those mails that are older than 30 minutes...
156204

157205
mails
158206
.inner_join(contacts_dsl::contacts.on(contact_id.eq(contacts_dsl::id)))
159207
.left_outer_join(bounce_logs_dsl::bounce_logs.on(id.eq(bounce_logs_dsl::mail_id)))
208+
.left_outer_join(servers_dsl::servers.on(server_id.eq(servers_dsl::id.nullable())))
160209
.select((
161210
id,
162211
mail_message,
@@ -172,9 +221,29 @@ impl MailRepository for MailRepositoryImpl {
172221
last_error,
173222
contacts_dsl::email,
174223
bounce_logs_dsl::reason.nullable(),
224+
sql::<Nullable<Text>>("NULL")
175225
))
176-
.filter(status.eq("queued"))
177-
.order(sent_at.asc()) // Order by sent_at ascending here...
226+
.filter(status.eq("submitted"))
227+
.filter(server_type.eq(ServerTypeEnum::AWS))
228+
.filter(sent_at.lt(Utc::now().naive_utc() - Duration::minutes(stale_duration_minutes)))
229+
.filter(attempts.le(3))
230+
.order(sent_at.asc())
178231
.load::<MailWithDetails>(&mut conn)
179232
}
233+
234+
async fn update_mail_attempts(&self, mail_id: String) -> Result<Mail, diesel::result::Error> {
235+
let mut conn = get_connection_pool().await;
236+
237+
diesel::update(mails.find(mail_id))
238+
.set(attempts.eq(attempts + 1))
239+
.get_result(&mut conn)
240+
}
241+
242+
async fn udpate_mail_last_try_error(&self, mail_id: String, error: &str) -> Result<Mail, diesel::result::Error> {
243+
let mut conn = get_connection_pool().await;
244+
245+
diesel::update(mails.find(mail_id))
246+
.set(last_error.eq(error))
247+
.get_result(&mut conn)
248+
}
180249
}

backend/src/repositories/template_repo.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ impl TemplateRepository for TemplateRespositoryImpl {
5858
created_at,
5959
updated_at,
6060
)) // Select columns explicitly
61+
.order(updated_at.desc())
6162
.load::<Template>(&mut conn)
6263
}
6364

0 commit comments

Comments
 (0)