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