Source file letters.ml

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 headers =
      [ 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 plain_text_headers =
      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 html_headers =
      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 =
          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 =
  (* Store the auto-detected CA cert authenticator so it doesn't have to be detected every
     time a mail is sent *)
  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)
;;