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