-
Notifications
You must be signed in to change notification settings - Fork 595
/
email.rs
190 lines (160 loc) · 5.95 KB
/
email.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
use crate::config;
use crate::Env;
use lettre::address::Envelope;
use lettre::message::header::ContentType;
use lettre::message::Mailbox;
use lettre::transport::file::FileTransport;
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
use lettre::transport::smtp::SmtpTransport;
use lettre::transport::stub::StubTransport;
use lettre::{Address, Message, Transport};
use rand::distributions::{Alphanumeric, DistString};
pub trait Email {
fn subject(&self) -> String;
fn body(&self) -> String;
}
#[derive(Debug, Clone)]
pub struct Emails {
backend: EmailBackend,
pub domain: String,
from: Address,
}
const DEFAULT_FROM: &str = "noreply@crates.io";
impl Emails {
/// Create a new instance detecting the backend from the environment. This will either connect
/// to a SMTP server or store the emails on the local filesystem.
pub fn from_environment(config: &config::Server) -> Self {
let login = dotenvy::var("MAILGUN_SMTP_LOGIN");
let password = dotenvy::var("MAILGUN_SMTP_PASSWORD");
let server = dotenvy::var("MAILGUN_SMTP_SERVER");
let from = login.as_deref().unwrap_or(DEFAULT_FROM).parse().unwrap();
let backend = match (login, password, server) {
(Ok(login), Ok(password), Ok(server)) => {
let transport = SmtpTransport::relay(&server)
.unwrap()
.credentials(Credentials::new(login, password))
.authentication(vec![Mechanism::Plain])
.build();
EmailBackend::Smtp(Box::new(transport))
}
_ => {
let transport = FileTransport::new("/tmp");
EmailBackend::FileSystem(transport)
}
};
if config.base.env == Env::Production && !matches!(backend, EmailBackend::Smtp { .. }) {
panic!("only the smtp backend is allowed in production");
}
let domain = config.domain_name.clone();
Self {
backend,
domain,
from,
}
}
/// Create a new test backend that stores all the outgoing emails in memory, allowing for tests
/// to later assert the mails were sent.
pub fn new_in_memory() -> Self {
Self {
backend: EmailBackend::Memory(StubTransport::new_ok()),
domain: "crates.io".into(),
from: DEFAULT_FROM.parse().unwrap(),
}
}
/// This is supposed to be used only during tests, to retrieve the messages stored in the
/// "memory" backend. It's not cfg'd away because our integration tests need to access this.
pub fn mails_in_memory(&self) -> Option<Vec<(Envelope, String)>> {
if let EmailBackend::Memory(transport) = &self.backend {
Some(transport.messages())
} else {
None
}
}
pub fn send<E: Email>(&self, recipient: &str, email: E) -> Result<(), EmailError> {
// The message ID is normally generated by the SMTP server, but if we let it generate the
// ID there will be no way for the crates.io application to know the ID of the message it
// just sent, as it's not included in the SMTP response.
//
// Our support staff needs to know the message ID to be able to find misdelivered emails.
// Because of that we're generating a random message ID, hoping the SMTP server doesn't
// replace it when it relays the message.
let message_id = format!(
"<{}@{}>",
Alphanumeric.sample_string(&mut rand::thread_rng(), 32),
self.domain,
);
let from = Mailbox::new(Some(self.domain.clone()), self.from.clone());
let subject = email.subject();
let body = email.body();
let email = Message::builder()
.message_id(Some(message_id.clone()))
.to(recipient.parse()?)
.from(from)
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body)?;
self.backend.send(email).map_err(EmailError::TransportError)
}
}
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error(transparent)]
AddressError(#[from] lettre::address::AddressError),
#[error(transparent)]
MessageBuilderError(#[from] lettre::error::Error),
#[error(transparent)]
TransportError(anyhow::Error),
}
#[derive(Debug, Clone)]
enum EmailBackend {
/// Backend used in production to send mails using SMTP.
///
/// This is using `Box` to avoid a large size difference between variants.
Smtp(Box<SmtpTransport>),
/// Backend used locally during development, will store the emails in the provided directory.
FileSystem(FileTransport),
/// Backend used during tests, will keep messages in memory to allow tests to retrieve them.
Memory(StubTransport),
}
impl EmailBackend {
fn send(&self, message: Message) -> anyhow::Result<()> {
match self {
EmailBackend::Smtp(transport) => transport.send(&message).map(|_| ())?,
EmailBackend::FileSystem(transport) => transport.send(&message).map(|_| ())?,
EmailBackend::Memory(transport) => transport.send(&message).map(|_| ())?,
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct StoredEmail {
pub to: String,
pub subject: String,
pub body: String,
}
#[cfg(test)]
mod tests {
use super::*;
struct TestEmail;
impl Email for TestEmail {
fn subject(&self) -> String {
"test".into()
}
fn body(&self) -> String {
"test".into()
}
}
#[test]
fn sending_to_invalid_email_fails() {
let emails = Emails::new_in_memory();
assert_err!(emails.send(
"String.Format(\"{0}.{1}@live.com\", FirstName, LastName)",
TestEmail
));
}
#[test]
fn sending_to_valid_email_succeeds() {
let emails = Emails::new_in_memory();
assert_ok!(emails.send("someone@example.com", TestEmail));
}
}