Skip to content

Commit e5ed648

Browse files
committed
Auto merge of #4627 - ferrous-systems:pa-log-emails, r=Turbo87
Log sent emails and error out if non-SMTP is used in production This PR does three things: * Refactors custom request metadata to use a thread local instead of a request extension. This was needed to avoid passing `&mut dyn RequestExt` to the email methods (as that's not really clean and caused borrow checker issues). * Logs the email backend, the message ID (if successful) and the error (if failed) in each request that sends emails. * Panics if non-SMTP backends are used in production. This PR is best reviewed commit-by-commit. Fixes #4596 Fixes #4597
2 parents 13e9e6f + b7a75f5 commit e5ed648

File tree

14 files changed

+108
-68
lines changed

14 files changed

+108
-68
lines changed

src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,13 @@ impl App {
164164
read_only_replica_database: replica_database,
165165
github,
166166
github_oauth,
167-
config,
168167
version_id_cacher,
169168
downloads_counter: DownloadsCounter::new(),
170-
emails: Emails::from_environment(),
169+
emails: Emails::from_environment(&config),
171170
service_metrics: ServiceMetrics::new().expect("could not initialize service metrics"),
172171
instance_metrics,
173172
http_client,
173+
config,
174174
}
175175
}
176176

src/config/base.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
use crate::{env, uploaders::Uploader, Env, Replica};
1313

1414
pub struct Base {
15-
pub(super) env: Env,
15+
pub env: Env,
1616
uploader: Uploader,
1717
}
1818

src/controllers.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ mod frontend_prelude {
88
pub use crate::util::errors::{bad_request, server_error};
99
}
1010

11-
pub(crate) use prelude::RequestUtils;
12-
1311
mod prelude {
1412
pub use super::helpers::ok_true;
1513
pub use diesel::prelude::*;
@@ -36,8 +34,6 @@ mod prelude {
3634
fn query(&self) -> IndexMap<String, String>;
3735
fn wants_json(&self) -> bool;
3836
fn query_with_params(&self, params: IndexMap<String, String>) -> String;
39-
40-
fn log_metadata<V: std::fmt::Display>(&mut self, key: &'static str, value: V);
4137
}
4238

4339
impl<'a> RequestUtils for dyn RequestExt + 'a {
@@ -74,10 +70,6 @@ mod prelude {
7470
.finish();
7571
format!("?{query_string}")
7672
}
77-
78-
fn log_metadata<V: std::fmt::Display>(&mut self, key: &'static str, value: V) {
79-
crate::middleware::log_request::add_custom_metadata(self, key, value);
80-
}
8173
}
8274
}
8375

src/controllers/helpers/pagination.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ impl PaginationOptionsBuilder {
9292
}
9393

9494
if numeric_page > MAX_PAGE_BEFORE_SUSPECTED_BOT {
95-
req.log_metadata("bot", "suspected");
95+
add_custom_metadata("bot", "suspected");
9696
}
9797

9898
// Block large offsets for known violators of the crawler policy
@@ -105,7 +105,7 @@ impl PaginationOptionsBuilder {
105105
.iter()
106106
.any(|blocked| user_agent.contains(blocked))
107107
{
108-
add_custom_metadata(req, "cause", "large page offset");
108+
add_custom_metadata("cause", "large page offset");
109109
return Err(bad_request("requested page offset is too large"));
110110
}
111111
}

src/controllers/krate/publish.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::models::{
1616
};
1717
use crate::worker;
1818

19+
use crate::middleware::log_request::add_custom_metadata;
1920
use crate::schema::*;
2021
use crate::util::errors::{cargo_err, AppResult};
2122
use crate::util::{read_fill, read_le_u32, CargoVcsInfo, LimitErrorReader, Maximums};
@@ -59,8 +60,8 @@ pub fn publish(req: &mut dyn RequestExt) -> EndpointResult {
5960

6061
let new_crate = parse_new_headers(req)?;
6162

62-
req.log_metadata("crate_name", new_crate.name.to_string());
63-
req.log_metadata("crate_version", new_crate.vers.to_string());
63+
add_custom_metadata("crate_name", new_crate.name.to_string());
64+
add_custom_metadata("crate_version", new_crate.vers.to_string());
6465

6566
let conn = app.primary_database.get()?;
6667
let ids = req.authenticate()?;
@@ -265,7 +266,7 @@ pub fn publish(req: &mut dyn RequestExt) -> EndpointResult {
265266
fn parse_new_headers(req: &mut dyn RequestExt) -> AppResult<EncodableCrateUpload> {
266267
// Read the json upload request
267268
let metadata_length = u64::from(read_le_u32(req.body())?);
268-
req.log_metadata("metadata_length", metadata_length);
269+
add_custom_metadata("metadata_length", metadata_length);
269270

270271
let max = req.app().config.max_upload_size;
271272
if metadata_length > max {

src/controllers/util.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ impl<'a> UserAuthenticationExt for dyn RequestExt + 'a {
129129
}
130130
}
131131

132-
log_request::add_custom_metadata(self, "uid", authenticated_user.user_id());
132+
log_request::add_custom_metadata("uid", authenticated_user.user_id());
133133
if let Some(id) = authenticated_user.api_token_id() {
134-
log_request::add_custom_metadata(self, "tokenid", id);
134+
log_request::add_custom_metadata("tokenid", id);
135135
}
136136

137137
Ok(authenticated_user)

src/controllers/version/downloads.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use super::{extract_crate_name_and_semver, version_and_crate};
66
use crate::controllers::prelude::*;
77
use crate::db::PoolError;
8+
use crate::middleware::log_request::add_custom_metadata;
89
use crate::models::{Crate, VersionDownload};
910
use crate::schema::*;
1011
use crate::views::EncodableVersionDownload;
@@ -108,7 +109,7 @@ pub fn download(req: &mut dyn RequestExt) -> EndpointResult {
108109
.crate_location(&crate_name, &*version);
109110

110111
if let Some((key, value)) = log_metadata {
111-
req.log_metadata(key, value);
112+
add_custom_metadata(key, value);
112113
}
113114

114115
if req.wants_json() {

src/email.rs

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ use std::sync::Mutex;
33

44
use crate::util::errors::{server_error, AppResult};
55

6+
use crate::config;
7+
use crate::middleware::log_request::add_custom_metadata;
8+
use crate::Env;
69
use lettre::transport::file::FileTransport;
710
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
811
use lettre::transport::smtp::SmtpTransport;
912
use lettre::{Message, Transport};
13+
use rand::distributions::{Alphanumeric, DistString};
1014

1115
#[derive(Debug)]
1216
pub struct Emails {
@@ -16,7 +20,7 @@ pub struct Emails {
1620
impl Emails {
1721
/// Create a new instance detecting the backend from the environment. This will either connect
1822
/// to a SMTP server or store the emails on the local filesystem.
19-
pub fn from_environment() -> Self {
23+
pub fn from_environment(config: &config::Server) -> Self {
2024
let backend = match (
2125
dotenv::var("MAILGUN_SMTP_LOGIN"),
2226
dotenv::var("MAILGUN_SMTP_PASSWORD"),
@@ -32,6 +36,10 @@ impl Emails {
3236
},
3337
};
3438

39+
if config.base.env == Env::Production && !matches!(backend, EmailBackend::Smtp { .. }) {
40+
panic!("only the smtp backend is allowed in production");
41+
}
42+
3543
Self { backend }
3644
}
3745

@@ -94,7 +102,21 @@ or go to https://{domain}/me/pending-invites to manage all of your crate ownersh
94102
}
95103

96104
fn send(&self, recipient: &str, subject: &str, body: &str) -> AppResult<()> {
105+
// The message ID is normally generated by the SMTP server, but if we let it generate the
106+
// ID there will be no way for the crates.io application to know the ID of the message it
107+
// just sent, as it's not included in the SMTP response.
108+
//
109+
// Our support staff needs to know the message ID to be able to find misdelivered emails.
110+
// Because of that we're generating a random message ID, hoping the SMTP server doesn't
111+
// replace it when it relays the message.
112+
let message_id = format!(
113+
"<{}@{}>",
114+
Alphanumeric.sample_string(&mut rand::thread_rng(), 32),
115+
crate::config::domain_name(),
116+
);
117+
97118
let email = Message::builder()
119+
.message_id(Some(message_id.clone()))
98120
.to(recipient.parse()?)
99121
.from(self.sender_address().parse()?)
100122
.subject(subject)
@@ -106,23 +128,42 @@ or go to https://{domain}/me/pending-invites to manage all of your crate ownersh
106128
login,
107129
password,
108130
} => {
109-
SmtpTransport::relay(server)?
110-
.credentials(Credentials::new(login.clone(), password.clone()))
111-
.authentication(vec![Mechanism::Plain])
112-
.build()
113-
.send(&email)
114-
.map_err(|_| server_error("Error in sending email"))?;
131+
add_custom_metadata("email_backend", "smtp");
132+
133+
SmtpTransport::relay(server)
134+
.and_then(|transport| {
135+
transport
136+
.credentials(Credentials::new(login.clone(), password.clone()))
137+
.authentication(vec![Mechanism::Plain])
138+
.build()
139+
.send(&email)
140+
})
141+
.map_err(|e| {
142+
add_custom_metadata("email_error", e);
143+
server_error("Failed to send the email")
144+
})?;
145+
146+
add_custom_metadata("email_id", message_id);
115147
}
116148
EmailBackend::FileSystem { path } => {
117-
FileTransport::new(&path)
118-
.send(&email)
119-
.map_err(|_| server_error("Email file could not be generated"))?;
149+
add_custom_metadata("email_backend", "fs");
150+
151+
let id = FileTransport::new(&path).send(&email).map_err(|err| {
152+
add_custom_metadata("email_error", err);
153+
server_error("Email file could not be generated")
154+
})?;
155+
156+
add_custom_metadata("email_path", path.join(format!("{id}.eml")).display());
157+
}
158+
EmailBackend::Memory { mails } => {
159+
add_custom_metadata("email_backend", "memory");
160+
161+
mails.lock().unwrap().push(StoredEmail {
162+
to: recipient.into(),
163+
subject: subject.into(),
164+
body: body.into(),
165+
});
120166
}
121-
EmailBackend::Memory { mails } => mails.lock().unwrap().push(StoredEmail {
122-
to: recipient.into(),
123-
subject: subject.into(),
124-
body: body.into(),
125-
}),
126167
}
127168

128169
Ok(())

src/middleware/balance_capacity.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ impl BalanceCapacity {
5656
fn handle_high_load(&self, request: &mut dyn RequestExt, note: &str) -> AfterResult {
5757
if self.report_only {
5858
// In report-only mode we serve all requests but add log metadata
59-
add_custom_metadata(request, "would_reject", note);
59+
add_custom_metadata("would_reject", note);
6060
self.handle(request)
6161
} else {
6262
// Reject the request
63-
add_custom_metadata(request, "cause", note);
63+
add_custom_metadata("cause", note);
6464
let body = "Service temporarily unavailable";
6565
Response::builder()
6666
.status(StatusCode::SERVICE_UNAVAILABLE)
@@ -84,7 +84,7 @@ impl Handler for BalanceCapacity {
8484

8585
// Begin logging total request count so early stages of load increase can be located
8686
if in_flight_total >= self.log_total_at_count {
87-
add_custom_metadata(request, "in_flight_total", in_flight_total);
87+
add_custom_metadata("in_flight_total", in_flight_total);
8888
}
8989

9090
// Download requests are always accepted and do not affect the capacity tracking
@@ -98,7 +98,7 @@ impl Handler for BalanceCapacity {
9898

9999
// Begin logging non-download request count so early stages of non-download load increase can be located
100100
if load >= self.log_at_percentage {
101-
add_custom_metadata(request, "in_flight_non_dl_requests", count);
101+
add_custom_metadata("in_flight_non_dl_requests", count);
102102
}
103103

104104
// Reject read-only requests as load nears capacity. Bots are likely to send only safe

src/middleware/block_traffic.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ impl Handler for BlockTraffic {
4848
.any(|value| self.blocked_values.iter().any(|v| v == value));
4949
if has_blocked_value {
5050
let cause = format!("blocked due to contents of header {}", self.header_name);
51-
add_custom_metadata(req, "cause", cause);
51+
add_custom_metadata("cause", cause);
5252
let body = format!(
5353
"We are unable to process your request at this time. \
5454
This usually means that you are in violation of our crawler \

0 commit comments

Comments
 (0)