34beb1a7 Andy McCall 1 month ago ↶
1
2
3
4
3556d08e Andy McCall 1 month ago ↶
5
use crate :: config:: Email;
34beb1a7 Andy McCall 1 month ago ↶
6
use crate :: Shared;
7
3556d08e Andy McCall 1 month ago ↶
8
pub const SETTING_SMTP : & str = " mail.smtp" ;
9
pub const SETTING_FROM : & str = " mail.from" ;
10
11
12
13
pub async fn load ( db : & crate:: db:: Pool, from_config : & Email) -> Email {
14
let mut mail = from_config.clone ( ) ;
15
if let Ok ( Some ( s) ) = crate :: db:: accounts:: setting( db, SETTING_SMTP ) .await {
16
mail.smtp = s;
17
mail.from = crate :: db:: accounts:: setting( db, SETTING_FROM ) .await.ok ( ) .flatten ( ) .unwrap_or_default ( ) ;
18
}
19
mail
20
}
21
22
pub fn settings ( app : & Shared) -> Email {
23
app.mail.read ( ) .unwrap_or_else ( | p | p.into_inner ( ) ) .clone ( )
24
}
25
26
27
28
pub async fn save ( app : & Shared, smtp : & str , from : & str ) -> sqlx:: Result< ( ) > {
29
crate :: db:: accounts:: set_setting( & app.db, SETTING_SMTP , smtp) .await? ;
30
crate :: db:: accounts:: set_setting( & app.db, SETTING_FROM , from) .await? ;
31
* app.mail.write ( ) .unwrap_or_else ( | p | p.into_inner ( ) ) = Email { smtp: smtp.to_string ( ) , from: from.to_string ( ) } ;
32
Ok ( ( ) )
33
}
34
35
36
pub fn masked ( smtp : & str ) -> String {
37
let Some ( ( scheme, rest) ) = smtp.split_once ( " ://" ) else { return smtp.to_string ( ) } ;
38
let Some ( ( userinfo, host) ) = rest.rsplit_once ( ' @' ) else { return smtp.to_string ( ) } ;
39
match userinfo.split_once ( ' :' ) {
40
Some ( ( user, _ ) ) => format! ( " {scheme} ://{user} :***@{host} " ) ,
41
None => smtp.to_string ( ) ,
42
}
43
}
44
34beb1a7 Andy McCall 1 month ago ↶
45
46
pub fn enabled ( app : & Shared) -> bool {
3556d08e Andy McCall 1 month ago ↶
47
let cfg = settings ( app) ;
34beb1a7 Andy McCall 1 month ago ↶
48
! cfg.smtp.is_empty ( ) & & ! cfg.from.is_empty ( )
49
}
50
cd46e56b Andy McCall 21 days ago ↶
51
52
# [ derive ( Clone, Copy, Debug ) ]
53
pub struct Action < 'a > {
54
pub label : & 'a str ,
55
pub url : & 'a str ,
56
}
57
58
59
34beb1a7 Andy McCall 1 month ago ↶
60
pub async fn send ( app : & Shared, to_name : & str , to_addr : & str , subject : & str , body : & str ) -> Result< ( ) , String> {
cd46e56b Andy McCall 21 days ago ↶
61
send_letter ( app, to_name, to_addr, subject, body, None , None ) .await
62
}
63
64
65
66
67
68
pub async fn send_letter (
69
app : & Shared,
70
to_name : & str ,
71
to_addr : & str ,
72
subject : & str ,
73
body : & str ,
74
action : Option< Action< '_ > > ,
75
unsubscribe : Option< & str > ,
76
) -> Result< ( ) , String> {
34beb1a7 Andy McCall 1 month ago ↶
77
if ! enabled ( app) {
78
return Err ( " this hub does not send mail" .into ( ) ) ;
79
}
2c47eecf Andy McCall 18 days ago ↶
80
let sent = deliver ( app, to_name, to_addr, subject, body, action, unsubscribe) .await;
81
let counter = if sent.is_ok ( ) { & crate :: metrics:: MAIL_SENT } else { & crate :: metrics:: MAIL_FAILED } ;
82
counter.fetch_add ( 1 , std:: sync:: atomic:: Ordering:: Relaxed) ;
83
sent
84
}
85
86
async fn deliver (
87
app : & Shared,
88
to_name : & str ,
89
to_addr : & str ,
90
subject : & str ,
91
body : & str ,
92
action : Option< Action< '_ > > ,
93
unsubscribe : Option< & str > ,
94
) -> Result< ( ) , String> {
3556d08e Andy McCall 1 month ago ↶
95
let cfg = settings ( app) ;
cd46e56b Andy McCall 21 days ago ↶
96
let site = app.config.site.name.clone ( ) ;
97
let html = html ( & site, app.config.base_url ( ) , subject, body, action, unsubscribe) ;
98
let subject = format! ( " [{site} ] {subject} " ) ;
34beb1a7 Andy McCall 1 month ago ↶
99
use lettre:: message:: header:: ContentType;
cd46e56b Andy McCall 21 days ago ↶
100
use lettre:: message:: { Attachment, MultiPart, SinglePart} ;
34beb1a7 Andy McCall 1 month ago ↶
101
use lettre:: { AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor} ;
102
let from = cfg.from.parse ( ) .map_err ( | e | format! ( " [email] from: {e} " ) ) ? ;
103
let to = format! ( " {to_name} <{to_addr} >" ) .parse ( ) .or_else ( | _| to_addr.parse ( ) ) .map_err ( | e | format! ( " address {to_addr} : {e} " ) ) ? ;
cd46e56b Andy McCall 21 days ago ↶
104
let png = ContentType:: parse( " image/png" ) .map_err ( | e | e.to_string ( ) ) ? ;
34beb1a7 Andy McCall 1 month ago ↶
105
let message = Message:: builder( )
106
.from ( from)
107
.to ( to)
108
.subject ( subject)
cd46e56b Andy McCall 21 days ago ↶
109
.multipart (
110
MultiPart:: alternative( ) .singlepart ( SinglePart:: plain( body.to_string ( ) ) ) .multipart (
111
MultiPart:: related( )
112
.singlepart ( SinglePart:: html( html) )
113
.singlepart ( Attachment:: new_inline( " logo" .to_string ( ) ) .body ( LOGO .to_vec ( ) , png) ) ,
114
) ,
115
)
34beb1a7 Andy McCall 1 month ago ↶
116
.map_err ( | e | e.to_string ( ) ) ? ;
cd46e56b Andy McCall 21 days ago ↶
117
if let Some ( dir) = cfg.smtp.strip_prefix ( " file://" ) {
118
let name = format! ( " {} -{} .eml" , crate :: db:: now_ms( ) , crate :: auth:: random_hex( 4 ) ) ;
119
return tokio:: fs:: write( std:: path:: Path:: new( dir) .join ( name) , message.formatted ( ) )
120
.await
121
.map_err ( | e | format! ( " write mail to {dir} : {e} " ) ) ;
122
}
123
let transport = AsyncSmtpTransport:: < Tokio1Executor> :: from_url( & secured ( & cfg.smtp) ) .map_err ( | e | format! ( " [email] smtp: {e} " ) ) ? .build ( ) ;
34beb1a7 Andy McCall 1 month ago ↶
124
transport.send ( message) .await.map ( | _| ( ) ) .map_err ( | e | format! ( " sending to {to_addr} : {e} " ) )
cd46e56b Andy McCall 21 days ago ↶
125
}
126
127
128
129
const LOGO : & [ u8 ] = include_bytes! ( " ../../../assets/icons/oxygenhub-mail.png" ) ;
130
131
fn esc ( s : & str ) -> String {
132
s.replace ( ' &' , " &" ) .replace ( ' <' , " <" ) .replace ( ' >' , " >" ) .replace ( ' "' , " "" )
133
}
134
135
136
fn linked ( text : & str ) -> String {
137
let re = regex:: Regex:: new( r "https?://[^\s<>]+[^\s<>.,;:!?)]" ) .expect ( " a valid pattern" ) ;
138
let mut out = String :: new( ) ;
139
let mut last = 0 ;
140
for m in re.find_iter ( text) {
141
out.push_str ( & esc ( & text[ last.. m.start ( ) ] ) ) ;
142
let u = esc ( m.as_str ( ) ) ;
143
out.push_str ( & format! ( r # "<a href="{u} " style="color:#0969da;text-decoration:underline;word-break:break-all;">{u} </a>"# ) ) ;
144
last = m.end ( ) ;
145
}
146
out.push_str ( & esc ( & text[ last.. ] ) ) ;
147
out.replace ( ' \n ' , " <br>" )
148
}
149
150
151
152
153
154
155
156
pub fn html ( site : & str , base : & str , subject : & str , body : & str , action : Option< Action< '_ > > , unsubscribe : Option< & str > ) -> String {
157
let base = base.trim_end_matches ( ' /' ) ;
158
let host = base.split ( " ://" ) .nth ( 1 ) .unwrap_or ( base) ;
159
let button = action
160
.map ( | a | {
161
let ( label, url) = ( esc ( a.label) , esc ( a.url) ) ;
162
format! (
163
r # "<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 12px;"><tr><td style="border-radius:8px;background-color:#0072FF;background-image:linear-gradient(90deg,#00C8F0,#0072FF);">
164
<a href="{url} " style="display:inline-block;padding:13px 26px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{label} </a></td></tr></table>
165
<p style="margin:0 0 20px;font-size:12px;line-height:1.5;color:#6e7781;" class="ox-mute">Or open this link: <a href="{url} " style="color:#0969da;word-break:break-all;">{url} </a></p>"#
166
)
167
} )
168
.unwrap_or_default ( ) ;
169
170
171
let mut placed = false ;
172
let mut paras = String :: new( ) ;
173
for p in body.split ( " \n \n " ) .map ( str :: trim) .filter ( | p | ! p.is_empty ( ) ) {
174
if action.is_some_and ( | a | p = = a.url ) {
175
if ! placed {
176
paras.push_str ( & button) ;
177
placed = true ;
178
}
179
continue ;
180
}
181
if unsubscribe.is_some_and ( | u | p.contains ( u) ) {
182
continue ;
183
}
184
paras.push_str ( & format! (
185
r # "<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#1f2328;" class="ox-text">{} </p>"# ,
186
linked ( p)
187
) ) ;
188
}
189
let button = if placed { String :: new( ) } else { button } ;
190
let stop =
191
unsubscribe.map ( | u | format! ( r # " · <a href="{} " style="color:#6e7781;">Stop these emails</a>"# , esc ( u) ) ) .unwrap_or_default ( ) ;
192
let ( site_e, subject_e, host_e) = ( esc ( site) , esc ( subject) , esc ( host) ) ;
193
format! (
194
r # "<!doctype html>
195
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
196
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
197
<title>{subject_e} </title>
198
<style>
199
@media (prefers-color-scheme: dark) {{
200
.ox-ground {{ background-color:#0b1020 !important; }}
201
.ox-card {{ background-color:#111a2e !important; border-color:#22304d !important; }}
202
.ox-text, .ox-head {{ color:#e6edf3 !important; }}
203
.ox-mute {{ color:#8b98ad !important; }}
204
}}
205
@media (max-width:600px) {{ .ox-pad {{ padding-left:20px !important; padding-right:20px !important; }} }}
206
</style></head>
207
<body style="margin:0;padding:0;background-color:#eef2f8;" class="ox-ground">
208
<div style="display:none;max-height:0;overflow:hidden;">{subject_e} </div>
209
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eef2f8;" class="ox-ground"><tr><td align="center" style="padding:32px 12px;">
210
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background-color:#ffffff;border:1px solid #dfe5ee;border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;" class="ox-card">
211
<tr><td style="background-color:#070c18;padding:20px 32px;" class="ox-pad">
212
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
213
<td style="vertical-align:middle;"><a href="{base} "><img src="cid:logo" width="40" height="40" alt="" style="display:block;border:0;border-radius:9px;"></a></td>
214
<td style="vertical-align:middle;padding-left:12px;font-size:20px;letter-spacing:0.2px;"><a href="{base} " style="text-decoration:none;color:#ffffff;">Oxygen<span style="color:#00F0FF;font-weight:700;">Hub</span></a></td>
215
</tr></table></td></tr>
216
<tr><td style="height:4px;line-height:4px;font-size:0;background-color:#0072FF;background-image:linear-gradient(90deg,#00F0FF,#0072FF);"> </td></tr>
217
<tr><td style="padding:32px 32px 12px;" class="ox-pad">
218
<h1 style="margin:0 0 18px;font-size:21px;line-height:1.3;font-weight:650;color:#0b1220;" class="ox-head">{subject_e} </h1>
219
{paras} {button}
220
</td></tr>
221
<tr><td style="padding:18px 32px 26px;border-top:1px solid #e6ebf2;" class="ox-pad">
222
<p style="margin:0 0 6px;font-size:12px;line-height:1.6;color:#6e7781;" class="ox-mute"><a href="{base} " style="color:#6e7781;">{site_e} </a> · <a href="{base} /explore" style="color:#6e7781;">Explore</a> · <a href="{base} /help" style="color:#6e7781;">Help</a> · <a href="{base} /settings/notifications" style="color:#6e7781;">Notification settings</a>{stop} </p>
223
<p style="margin:0;font-size:12px;line-height:1.6;color:#8b949e;" class="ox-mute">Sent by {site_e} at {host_e} , where your work is signed and yours.</p>
224
</td></tr>
225
</table></td></tr></table>
226
</body></html>
227
"#
228
)
229
}
230
231
# [ cfg ( test ) ]
232
mod html_tests {
233
use super :: * ;
234
235
# [ test ]
236
fn a_message_is_a_card_with_its_button_and_the_site_s_links ( ) {
237
let body =
238
" Confirm that [email protected] is yours:\n \n https://hub.example/verify/abc\n \n The link works for 48 hours. <b>Not</b> HTML." ;
239
let h = html (
240
" OxygenHub" ,
241
" https://hub.example/" ,
242
" Confirm your email address" ,
243
body,
244
Some ( Action { label: " Confirm my email address" , url: " https://hub.example/verify/abc" } ) ,
245
Some ( " https://hub.example/settings/notifications?unsubscribe=k" ) ,
246
) ;
247
assert! ( h.contains ( r # "<img src="cid:logo""# ) , " the mark, inline" ) ;
248
assert! ( h.contains ( " >Confirm my email address</a>" ) & & h.contains ( r # "href="https://hub.example/verify/abc""# ) ) ;
249
assert_eq! (
250
h.matches ( " https://hub.example/verify/abc</a>" ) .count ( ) ,
251
1 ,
252
" the address once, under the button, not as a paragraph too"
253
) ;
254
assert! ( h.contains ( " <b>Not</b> HTML." ) , " the body is escaped" ) ;
255
assert! ( h.contains ( r # "href="https://hub.example/explore""# ) & & h.contains ( " Stop these emails" ) ) ;
256
assert! ( h.contains ( " at hub.example," ) ) ;
257
}
258
}
259
260
261
262
263
264
pub fn secured ( url : & str ) -> String {
265
let Some ( rest) = url.strip_prefix ( " smtp://" ) else { return url.to_string ( ) } ;
266
if url.contains ( " tls=" ) {
267
return url.to_string ( ) ;
268
}
269
let host = rest.rsplit ( ' @' ) .next ( ) .unwrap_or ( rest) ;
270
let host = host.split ( [ ' /' , ' ?' ] ) .next ( ) .unwrap_or ( host) ;
271
let host = host.rsplit_once ( ' :' ) .map_or ( host, | ( h , _) | h ) .trim_matches ( [ ' [' , ' ]' ] ) ;
272
if matches! ( host, " localhost" | " 127.0.0.1" | " ::1" ) {
273
return url.to_string ( ) ;
274
}
275
format! ( " {url} {} tls=required" , if url.contains ( ' ?' ) { " &" } else { " ?" } )
276
}
277
278
# [ cfg ( test ) ]
279
mod secured_tests {
280
use super :: secured;
281
282
# [ test ]
283
fn smtp_urls_require_starttls_unless_they_say_otherwise ( ) {
284
assert_eq! ( secured ( " smtp://resend:[email protected] :587" ) , " smtp://resend:[email protected] :587?tls=required" ) ;
285
assert_eq! ( secured ( " smtps://resend:[email protected] :465" ) , " smtps://resend:[email protected] :465" ) ;
286
assert_eq! ( secured ( " smtp://relay.example:25?tls=opportunistic" ) , " smtp://relay.example:25?tls=opportunistic" ) ;
287
assert_eq! ( secured ( " smtp://localhost:25" ) , " smtp://localhost:25" , " a relay on this machine" ) ;
288
assert_eq! ( secured ( " smtp://u:[email protected] :1025" ) , " smtp://u:[email protected] :1025" ) ;
289
assert_eq! ( secured ( " file:///tmp/mail" ) , " file:///tmp/mail" ) ;
290
}
291
}
292
293
294
295
296
pub const SERVICES : & [ ( & str , & str ) ] = & [
297
( " off" , " Off" ) ,
298
( " resend" , " Resend" ) ,
299
( " ses" , " Amazon SES" ) ,
300
( " mailgun" , " Mailgun" ) ,
301
( " smtp" , " Another SMTP server" ) ,
302
( " url" , " An SMTP URL (advanced)" ) ,
303
] ;
304
305
306
307
# [ derive ( Clone, Debug, Default, PartialEq, Eq ) ]
308
pub struct Choice {
309
pub service : String,
310
311
pub region : String,
312
pub user : String,
313
pub host : String,
314
pub port : String,
315
316
pub security : String,
317
318
pub has_secret : bool ,
319
}
320
321
322
323
fn enc ( s : & str ) -> String {
324
s.bytes ( )
325
.map ( | b | match b {
326
b ' A' .. = b ' Z' | b ' a' .. = b ' z' | b ' 0' .. = b ' 9' | b ' -' | b ' _' | b ' .' | b ' ~' => ( b as char ) .to_string ( ) ,
327
_ => format! ( " %{b:02X} " ) ,
328
} )
329
.collect ( )
330
}
331
332
fn dec ( s : & str ) -> String {
333
let b = s.as_bytes ( ) ;
334
let mut out = Vec :: new( ) ;
335
let mut i = 0 ;
336
while i < b.len ( ) {
337
if b[ i] = = b ' %' & & i + 2 < b.len ( ) {
338
if let Ok ( v) = u8 :: from_str_radix( & s[ i + 1 .. i + 3 ] , 16 ) {
339
out.push ( v) ;
340
i + = 3 ;
341
continue ;
342
}
343
}
344
out.push ( b[ i] ) ;
345
i + = 1 ;
346
}
347
String :: from_utf8_lossy( & out) .into_owned ( )
348
}
349
350
351
352
fn parts ( url : & str ) -> Option< ( & str , & str , & str , & str , & str , & str ) > {
353
let ( scheme, rest) = url.split_once ( " ://" ) ? ;
354
let ( rest, query) = rest.split_once ( ' ?' ) .unwrap_or ( ( rest, " " ) ) ;
355
let ( userinfo, hostport) = rest.rsplit_once ( ' @' ) .unwrap_or ( ( " " , rest) ) ;
356
let ( user, pass) = userinfo.split_once ( ' :' ) .unwrap_or ( ( userinfo, " " ) ) ;
357
let hostport = hostport.trim_end_matches ( ' /' ) ;
358
let ( host, port) = hostport.rsplit_once ( ' :' ) .unwrap_or ( ( hostport, " " ) ) ;
359
Some ( ( scheme, user, pass, host, port, query) )
360
}
361
362
363
pub fn describe ( url : & str ) -> Choice {
364
if url.is_empty ( ) {
365
return Choice { service: " off" .into ( ) , .. Default :: default( ) } ;
366
}
367
let Some ( ( scheme, user, pass, host, port, query) ) = parts ( url) .filter ( | p | p.0 = = " smtp" | | p.0 = = " smtps" ) else {
368
return Choice { service: " url" .into ( ) , .. Default :: default( ) } ;
369
} ;
370
let mut c = Choice {
371
user: dec ( user) ,
372
host: host.to_string ( ) ,
373
port: port.to_string ( ) ,
374
has_secret: ! pass.is_empty ( ) ,
375
security: if scheme = = " smtps" {
376
" tls" .into ( )
377
} else if query.contains ( " tls=none" ) {
378
" none" .into ( )
379
} else {
380
" starttls" .into ( )
381
} ,
382
.. Default :: default( )
383
} ;
384
c.service = if host = = " smtp.resend.com" {
385
" resend" .into ( )
386
} else if let Some ( region) = host.strip_prefix ( " email-smtp." ) .and_then ( | h | h.strip_suffix ( " .amazonaws.com" ) ) {
387
c.region = region.to_string ( ) ;
388
" ses" .into ( )
389
} else if host = = " smtp.mailgun.org" | | host = = " smtp.eu.mailgun.org" {
390
c.region = if host.contains ( " .eu." ) { " eu" .into ( ) } else { " us" .into ( ) } ;
391
" mailgun" .into ( )
392
} else if query.contains ( " tls=opportunistic" ) {
393
" url" .into ( )
394
} else {
395
" smtp" .into ( )
396
} ;
397
c
398
}
399
400
401
# [ derive ( Clone, Debug, Default ) ]
402
pub struct Asked {
403
pub service : String,
404
pub region : String,
405
pub user : String,
406
407
408
pub secret : String,
409
pub host : String,
410
pub port : String,
411
pub security : String,
412
}
413
414
415
416
pub fn compose ( a : & Asked, current : & str ) -> Result< String, String> {
417
let region = a.region.trim ( ) ;
418
let ( scheme, user, host, port, query) : ( & str , String , String , String , & str ) = match a.service.as_str ( ) {
419
" resend" => ( " smtps" , " resend" .into ( ) , " smtp.resend.com" .into ( ) , " 465" .into ( ) , " " ) ,
420
" ses" => {
421
if region.is_empty ( ) | | ! region.chars ( ) .all ( | c | c.is_ascii_lowercase ( ) | | c.is_ascii_digit ( ) | | c = = ' -' ) {
422
return Err ( " Give the SES region, such as eu-west-2." .into ( ) ) ;
423
}
424
if a.user.trim ( ) .is_empty ( ) {
425
return Err ( " Give the SES SMTP user name (from SES › SMTP settings; not your AWS access key)." .into ( ) ) ;
426
}
427
( " smtps" , a.user.trim ( ) .to_string ( ) , format! ( " email-smtp.{region} .amazonaws.com" ) , " 465" .into ( ) , " " )
428
}
429
" mailgun" => {
430
let host = match region {
431
" eu" => " smtp.eu.mailgun.org" ,
432
" us" | " " => " smtp.mailgun.org" ,
433
_ => return Err ( " Mailgun's region is us or eu." .into ( ) ) ,
434
} ;
435
if a.user.trim ( ) .is_empty ( ) {
436
return Err ( " Give the Mailgun SMTP login, such as [email protected] ." .into ( ) ) ;
437
}
438
( " smtps" , a.user.trim ( ) .to_string ( ) , host.into ( ) , " 465" .into ( ) , " " )
439
}
440
" smtp" => {
441
let host = a.host.trim ( ) ;
442
if host.is_empty ( ) | | host.contains ( [ ' /' , ' @' , ' ' ] ) {
443
return Err ( " Give the SMTP server's host name." .into ( ) ) ;
444
}
445
let port = a.port.trim ( ) ;
446
if ! port.is_empty ( ) & & port.parse:: < u16 > ( ) .is_err ( ) {
447
return Err ( " The port is a number, such as 465 or 587." .into ( ) ) ;
448
}
449
match a.security.as_str ( ) {
450
" tls" => ( " smtps" , a.user.trim ( ) .to_string ( ) , host.into ( ) , if port.is_empty ( ) { " 465" .into ( ) } else { port.into ( ) } , " " ) ,
451
" none" => {
452
( " smtp" , a.user.trim ( ) .to_string ( ) , host.into ( ) , if port.is_empty ( ) { " 25" .into ( ) } else { port.into ( ) } , " tls=none" )
453
}
454
_ => (
455
" smtp" ,
456
a.user.trim ( ) .to_string ( ) ,
457
host.into ( ) ,
458
if port.is_empty ( ) { " 587" .into ( ) } else { port.into ( ) } ,
459
" tls=required" ,
460
) ,
461
}
462
}
463
other => return Err ( format! ( " {other:?} is not a mail service this panel knows" ) ) ,
464
} ;
465
let secret = if ! a.secret.is_empty ( ) {
466
enc ( & a.secret)
467
} else {
468
match parts ( current) {
469
Some ( ( _ , u, p, h, _ , _ ) ) if ! p.is_empty ( ) & & h = = host & & dec ( u) = = user => p.to_string ( ) ,
470
_ if a.service = = " smtp" & & user.is_empty ( ) => String :: new( ) ,
471
_ => {
472
return Err ( match a.service.as_str ( ) {
473
" resend" => " Give the Resend API key (re_…)." .into ( ) ,
474
_ => " Give the password." .into ( ) ,
475
} )
476
}
477
}
478
} ;
479
let login = match ( user.is_empty ( ) , secret.is_empty ( ) ) {
480
( true , _ ) => String :: new( ) ,
481
( false , true ) => format! ( " {} @" , enc ( & user) ) ,
482
( false , false ) => format! ( " {} :{secret} @" , enc ( & user) ) ,
483
} ;
484
let query = if query.is_empty ( ) { String :: new( ) } else { format! ( " ?{query} " ) } ;
485
Ok ( format! ( " {scheme} ://{login} {host} :{port} {query} " ) )
486
}
487
488
# [ cfg ( test ) ]
489
mod panel_tests {
490
use super :: * ;
491
492
fn asked ( service : & str ) -> Asked {
493
Asked { service: service.into ( ) , .. Default :: default( ) }
494
}
495
496
# [ test ]
497
fn services_compose_their_smtp_urls_and_read_back ( ) {
498
let resend = compose ( & Asked { secret: " re_test_123" .into ( ) , .. asked ( " resend" ) } , " " ) .unwrap ( ) ;
499
assert_eq! ( resend, " smtps://resend:[email protected] :465" ) ;
500
let c = describe ( & resend) ;
501
assert_eq! ( ( c.service.as_str ( ) , c.has_secret) , ( " resend" , true ) ) ;
502
503
assert_eq! ( compose ( & asked ( " resend" ) , & resend) .unwrap ( ) , resend) ;
504
assert! ( compose ( & asked ( " resend" ) , " " ) .unwrap_err ( ) .contains ( " API key" ) ) ;
505
let ses =
506
compose ( & Asked { region: " eu-west-2" .into ( ) , user: " AKIAEXAMPLE" .into ( ) , secret: " p@ss/word" .into ( ) , .. asked ( " ses" ) } , " " )
507
.unwrap ( ) ;
508
assert_eq! ( ses, " smtps://AKIAEXAMPLE:p%40ss%[email protected] :465" ) ;
509
assert_eq! ( ( describe ( & ses) .service.as_str ( ) , describe ( & ses) .region.as_str ( ) ) , ( " ses" , " eu-west-2" ) ) ;
510
511
assert! ( compose ( & Asked { region: " eu-west-2" .into ( ) , user: " AKIAEXAMPLE" .into ( ) , .. asked ( " ses" ) } , & resend) .is_err ( ) ) ;
512
let mg =
513
compose ( & Asked { region: " eu" .into ( ) , user: " [email protected] " .into ( ) , secret: " s" .into ( ) , .. asked ( " mailgun" ) } , " " )
514
.unwrap ( ) ;
515
assert_eq! ( mg, " smtps://postmaster%40mg.example.org:[email protected] :465" ) ;
516
assert_eq! ( describe ( & mg) .user, " [email protected] " ) ;
517
let plain =
518
compose ( & Asked { host: " mail.example.org" .into ( ) , user: " hub" .into ( ) , secret: " x" .into ( ) , .. asked ( " smtp" ) } , " " ) .unwrap ( ) ;
519
assert_eq! ( plain, " smtp://hub:[email protected] :587?tls=required" ) ;
520
assert_eq! ( describe ( & plain) .security, " starttls" ) ;
521
let relay = compose ( & Asked { host: " localhost" .into ( ) , port: " 25" .into ( ) , security: " none" .into ( ) , .. asked ( " smtp" ) } , " " ) .unwrap ( ) ;
522
assert_eq! ( relay, " smtp://localhost:25?tls=none" ) ;
523
assert_eq! ( describe ( " file:///tmp/mail" ) .service, " url" ) ;
524
assert_eq! ( describe ( " " ) .service, " off" ) ;
525
}
34beb1a7 Andy McCall 1 month ago ↶
526
}