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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
module Config = struct
type ca_certs =
| Ca_cert of string
| Ca_path of string
| Detect
type t =
{ username : string option
; password : string option
; hostname : string
; port : int option
; with_starttls : bool
; ca_certs : ca_certs
; mechanism : Sendmail.mechanism
}
let create ?(mechanism = Sendmail.PLAIN) ~username ~password ~hostname ~with_starttls ()
=
let username =
if String.equal username "" then Option.none else Option.some username
in
let password =
if String.equal password "" then Option.none else Option.some password
in
{ username
; password
; hostname
; with_starttls
; port = None
; ca_certs = Detect
; mechanism
}
;;
let make ~username ~password ~hostname ~with_starttls =
create ~username ~password ~hostname ~with_starttls ()
;;
let set_port port config = { config with port }
let set_ca_cert path config = { config with ca_certs = Ca_cert path }
let set_ca_path path config = { config with ca_certs = Ca_path path }
end
type body =
| Plain of string
| Html of string
| Mixed of string * string * string option
type mt_body =
| MtSimple of Mrmime.Mt.part
| MtMultipart of Mrmime.Mt.multipart
type recipient =
| To of string
| Cc of string
| Bcc of string
exception Invalid_email_address of string
let stream_of_string s =
let once = ref false in
fun () ->
if !once
then None
else (
once := true;
Some (s, 0, String.length s))
;;
let str_to_colombe_address str_address =
match Emile.of_string str_address with
| Ok mailbox ->
(match Colombe_emile.to_forward_path mailbox with
| Ok address -> address
| Error _ -> raise (Invalid_email_address str_address))
| Error _ -> raise (Invalid_email_address str_address)
;;
let domain_of_reverse_path = function
| None -> Rresult.R.error_msgf "reverse-path is empty"
| Some { Colombe.Path.domain; _ } -> Ok domain
;;
let to_recipient_to_address : recipient -> Mrmime.Address.t option =
fun recipient ->
match recipient with
| To address ->
(match Mrmime.Mailbox.of_string address with
| Ok mailbox -> Some (Mrmime.Address.mailbox mailbox)
| Error _ -> raise (Invalid_email_address address))
| Cc _ -> None
| Bcc _ -> None
;;
let cc_recipient_to_address : recipient -> Mrmime.Address.t option =
fun recipient ->
match recipient with
| To _ -> None
| Cc address ->
(match Mrmime.Mailbox.of_string address with
| Ok mailbox -> Some (Mrmime.Address.mailbox mailbox)
| Error _ -> raise (Invalid_email_address address))
| Bcc _ -> None
;;
let now () = Some (Ptime_clock.now ())
let create_email ?reply_to ~from ~recipients ~subject ~body () =
try
let open Mrmime in
let to_mailbox s =
match Mailbox.of_string s with
| Ok v -> v
| Error _ -> raise (Invalid_email_address from)
in
let subject = Unstructured.Craft.v subject in
let date = Date.of_ptime ~zone:Date.Zone.GMT (Ptime_clock.now ()) in
let from_addr = from |> to_mailbox in
let to_addresses = List.filter_map to_recipient_to_address recipients in
let cc_addresses = List.filter_map cc_recipient_to_address recipients in
let =
[ Field.(Field (Field_name.subject, Unstructured, subject))
; Field.(Field (Field_name.date, Date, date))
; Field.(Field (Field_name.from, Mailbox, from_addr))
; Field.(Field (Field_name.v "To", Addresses, to_addresses))
; Field.(Field (Field_name.cc, Addresses, cc_addresses))
]
@ (reply_to
|> Option.map (fun a ->
Field.(
Field
(Field_name.reply_to, Addresses, [ a |> to_mailbox |> Address.mailbox ])))
|> Option.to_list)
in
let =
let content1 =
let open Content_type in
make
`Text
(Subtype.v `Text "plain")
Parameters.(of_list [ k "charset", v "utf-8" ])
in
Header.of_list
Field.
[ Field (Field_name.content_type, Content, content1)
; Field (Field_name.content_encoding, Encoding, `Quoted_printable)
]
in
let =
let content1 =
let open Content_type in
make
`Text
(Subtype.v `Text "html")
Parameters.(of_list [ k "charset", v "utf-8" ])
in
Header.of_list
Field.
[ Field (Field_name.content_type, Content, content1)
; Field (Field_name.content_encoding, Encoding, `Quoted_printable)
]
in
let body =
let multipart_content_alternative =
let open Content_type in
make `Multipart (Subtype.v `Multipart "alternative") Parameters.empty
in
match body with
| Plain text ->
MtSimple (Mt.part ~header:plain_text_headers (stream_of_string text))
| Html html -> MtSimple (Mt.part ~header:html_headers (stream_of_string html))
| Mixed (text, html, boundary) ->
let plain = Mt.part ~header:plain_text_headers (stream_of_string text) in
let html = Mt.part ~header:html_headers (stream_of_string html) in
let =
Header.of_list
Field.
[ Field (Field_name.content_type, Content, multipart_content_alternative) ]
in
(match boundary with
| None -> MtMultipart (Mt.multipart ~rng:Mt.rng ~header [ plain; html ])
| Some boundary ->
MtMultipart (Mt.multipart ~rng:Mt.rng ~header ~boundary [ plain; html ]))
in
match body with
| MtSimple part -> Ok (Mt.make (Mrmime.Header.of_list headers) Mt.simple part)
| MtMultipart multi -> Ok (Mt.make (Mrmime.Header.of_list headers) Mt.multi multi)
with
| Invalid_email_address address ->
Error (Printf.sprintf "Invalid email address: %s" address)
| ex -> Error (Printexc.to_string ex)
;;
let build_email ~from ~recipients ~subject ~body =
create_email ~from ~recipients ~subject ~body ()
;;
let ca_cert_peer_verifier path =
let ( let* ) = Lwt.bind in
let* certs = X509_lwt.certs_of_pem path in
Lwt.return (X509.Authenticator.chain_of_trust ~time:now certs)
;;
let ca_path_peer_verifier path =
let ( let* ) = Lwt.bind in
let* certs = X509_lwt.certs_of_pem_dir path in
Lwt.return (X509.Authenticator.chain_of_trust ~time:now certs)
;;
let send =
let detected_auth = ref None in
fun ~config:c ~sender ~recipients ~message ->
let open Config in
let ( let* ) = Lwt.bind in
let authentication : Sendmail.authentication option =
match c.username, c.password with
| Some username, Some password ->
Some { Sendmail.username; password; mechanism = c.mechanism }
| _ -> None
in
let port =
match c.port, c.with_starttls with
| None, true -> 587
| None, false -> 465
| Some v, _ -> v
in
let mail = Mrmime.Mt.to_stream message in
let from_mailbox =
match Emile.of_string sender with
| Ok v -> v
| Error (`Invalid (_, _)) -> failwith "Invalid sender address"
in
let from_addr =
match Colombe_emile.to_reverse_path from_mailbox with
| Ok v -> v
| Error (`Msg msg) -> failwith msg
in
let recipients =
List.map
(fun recipient ->
(match recipient with
| To a -> a
| Cc a -> a
| Bcc a -> a)
|> str_to_colombe_address)
recipients
in
let domain =
match domain_of_reverse_path from_addr with
| Ok v -> v
| Error _ -> failwith "Failed to extract domain of sender address"
in
let hostname =
match Domain_name.of_string c.hostname with
| Error _ -> failwith "Config hostname is not valid hostname"
| Ok hostname ->
(match Domain_name.host hostname with
| Error _ -> failwith "Config hostname is not valid hostname"
| Ok hostname -> hostname)
in
let* tls_peer_verifier =
match c.ca_certs with
| Ca_path path -> ca_path_peer_verifier path
| Ca_cert path -> ca_cert_peer_verifier path
| Detect ->
let auth =
match !detected_auth with
| None ->
let auth = Ca_certs.authenticator () in
detected_auth := Some auth;
auth
| Some auth -> auth
in
(match auth with
| Ok auth -> Lwt.return auth
| Error (`Msg msg) ->
Logs.err (fun m -> m "%s" msg);
failwith "Could not create authenticator")
in
if c.with_starttls
then
let* res =
Sendmail_handler.run_with_starttls
~hostname
~port
~domain
?authentication
~tls_authenticator:tls_peer_verifier
~from:from_addr
~recipients
~mail
in
match res with
| Ok () -> Lwt.return ()
| Error err ->
Lwt.fail_with
(Fmt.str "Sending email failed, %a" Sendmail_with_starttls.pp_error err)
else
let* res =
Sendmail_handler.run
~hostname
~port
~domain
?authentication
~tls_authenticator:tls_peer_verifier
~from:from_addr
~recipients
~mail
in
match res with
| Ok () -> Lwt.return ()
| Error err ->
Lwt.fail_with (Fmt.str "Sending email failed, %a" Sendmail.pp_error err)
;;