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
let split_params (s : string) : string list =
let parts = ref [] and start = ref 0 and quoted = ref false in
String.iteri
(fun i c ->
if c = '"' then quoted := not !quoted
else if c = ';' && not !quoted then (
parts := String.sub s !start (i - !start) :: !parts;
start := i + 1))
s;
List.rev (String.sub s !start (String.length s - !start) :: !parts)
let parameters (s : string) : string * (string * string) list =
match split_params s with
| [] -> ("", [])
| kind :: params ->
( String.lowercase_ascii (String.trim kind),
List.filter_map
(fun p ->
match String.index_opt p '=' with
| Some i -> Some (String.lowercase_ascii (String.trim (String.sub p 0 i)), Mail.unquote (String.sub p (i + 1) (String.length p - i - 1)))
| None -> None)
params )
let content_type ?(digest = false) (m : Mail.t) : string * (string * string) list =
match Mail.get m "content-type" with Some v -> parameters v | None -> ((if digest then "message/rfc822" else "text/plain"), [])
let starts (prefix : string) (s : string) : bool = String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix
let parts (m : Mail.t) : Mail.t list =
let kind, params = content_type m in
match List.assoc_opt "boundary" params with
| Some b when starts "multipart/" kind ->
let delimiter = "--" ^ b and close = "--" ^ b ^ "--" in
let finish current acc = match current with Some lines -> Mail.parse (String.concat "\n" (List.rev lines)) :: acc | None -> acc in
let rec go lines current acc =
match lines with
| [] -> List.rev (finish current acc)
| l :: rest ->
let l' = String.trim l in
if l' = close then List.rev (finish current acc)
else if l' = delimiter then go rest (Some []) (finish current acc)
else go rest (Option.map (fun ls -> l :: ls) current) acc
in
go (String.split_on_char '\n' m.body) None []
| _ -> []
let hex (c : char) : int option =
match c with '0' .. '9' -> Some (Char.code c - 48) | 'A' .. 'F' -> Some (Char.code c - 55) | 'a' .. 'f' -> Some (Char.code c - 87) | _ -> None
let qp_decode ~(q : bool) (s : string) : string =
let b = Buffer.create (String.length s) and n = String.length s in
let rec go i =
if i < n then
match s.[i] with
| '=' when i + 1 < n && s.[i + 1] = '\n' -> go (i + 2)
| '=' when i + 2 < n && hex s.[i + 1] <> None && hex s.[i + 2] <> None ->
Buffer.add_char b (Char.chr ((Option.get (hex s.[i + 1]) * 16) + Option.get (hex s.[i + 2])));
go (i + 3)
| '_' when q ->
Buffer.add_char b ' ';
go (i + 1)
| c ->
Buffer.add_char b c;
go (i + 1)
in
go 0;
Buffer.contents b
let quoted_printable_decode = qp_decode ~q:false
let quoted_printable_encode (s : string) : string =
let b = Buffer.create (String.length s) and col = ref 0 and n = String.length s in
String.iteri
(fun i c ->
if c = '\n' then (
Buffer.add_char b '\n';
col := 0)
else
let at_end = i + 1 = n || s.[i + 1] = '\n' in
let token =
if (c >= '!' && c <= '~' && c <> '=') || ((c = ' ' || c = '\t') && not at_end) then String.make 1 c else Printf.sprintf "=%02X" (Char.code c)
in
if !col + String.length token > 75 then (
Buffer.add_string b "=\n";
col := 0);
Buffer.add_string b token;
col := !col + String.length token)
s;
Buffer.contents b
let decoded (m : Mail.t) : string =
match Option.map String.lowercase_ascii (Mail.get m "content-transfer-encoding") with
| Some "base64" -> Base64.decode m.body
| Some "quoted-printable" -> quoted_printable_decode m.body
| _ -> m.body
let to_utf8 (charset : string) (s : string) : string =
match String.lowercase_ascii charset with
| "iso-8859-1" | "latin1" | "iso-8859-15" | "windows-1252" ->
let b = Buffer.create (String.length s) in
String.iter
(fun c ->
let k = Char.code c in
if k < 128 then Buffer.add_char b c
else (
Buffer.add_char b (Char.chr (0xC0 lor (k lsr 6)));
Buffer.add_char b (Char.chr (0x80 lor (k land 0x3F)))))
s;
Buffer.contents b
| _ -> s
let word (s : string) : string option =
let n = String.length s in
if n > 6 && starts "=?" s && String.sub s (n - 2) 2 = "?=" then
match String.split_on_char '?' (String.sub s 2 (n - 4)) with
| [ charset; enc; text ] -> (
match String.lowercase_ascii enc with
| "q" -> Some (to_utf8 charset (qp_decode ~q:true text))
| "b" -> Some (to_utf8 charset (Base64.decode text))
| _ -> None)
| _ -> None
else None
let decode_words (s : string) : string =
let words = String.split_on_char ' ' s in
let rec go prev_encoded = function
| [] -> []
| w :: rest -> (
match word w with
| Some d -> (if prev_encoded then d else " " ^ d) :: go true rest
| None -> (" " ^ w) :: go false rest)
in
let s' = String.concat "" (go false words) in
if s' = "" then s' else String.sub s' 1 (String.length s' - 1)
let encode_words (s : string) : string =
if String.for_all (fun c -> Char.code c < 128) s then s
else
let b = Buffer.create (String.length s * 2) in
String.iter
(fun c ->
match c with
| 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '.' | ',' | '-' | '!' -> Buffer.add_char b c
| ' ' -> Buffer.add_char b '_'
| c -> Buffer.add_string b (Printf.sprintf "=%02X" (Char.code c)))
s;
"=?utf-8?Q?" ^ Buffer.contents b ^ "?="
type leaf = { mime : string; filename : string option; data : string; part : Mail.t }
let rec leaves_of ~(digest : bool) (m : Mail.t) : leaf list =
let kind, params = content_type ~digest m in
if starts "multipart/" kind then List.concat_map (leaves_of ~digest:(kind = "multipart/digest")) (parts m)
else
let disposition = Option.map parameters (Mail.get m "content-disposition") in
let filename =
match Option.bind disposition (fun (_, ps) -> List.assoc_opt "filename" ps) with
| Some f -> Some (decode_words f)
| None -> Option.map decode_words (List.assoc_opt "name" params)
in
let data = decoded m in
let data = if starts "text/" kind then to_utf8 (Option.value (List.assoc_opt "charset" params) ~default:"us-ascii") data else data in
[ { mime = kind; filename; data; part = m } ]
let leaves (m : Mail.t) : leaf list = leaves_of ~digest:false m
let is_attachment (l : leaf) : bool =
l.filename <> None
|| (match Mail.get l.part "content-disposition" with Some d -> fst (parameters d) = "attachment" | None -> false)
|| not (starts "text/" l.mime || l.mime = "message/rfc822")
let rec text (m : Mail.t) : string =
let readable = List.filter (fun l -> not (is_attachment l)) (leaves m) in
let plain = List.filter (fun l -> l.mime = "text/plain" || l.mime = "message/rfc822") readable in
let chosen = if plain <> [] then plain else readable in
String.concat "\n"
(List.map
(fun l ->
if l.mime = "message/rfc822" then
let inner = Mail.parse l.data in
let h name = decode_words (Option.value (Mail.get inner name) ~default:"") in
Printf.sprintf "----- From: %s\n----- Subject: %s\n\n%s" (h "from") (h "subject") (text inner)
else l.data)
chosen)
let attachments (m : Mail.t) : leaf list = List.filter is_attachment (leaves m)
let text_part (s : string) : Mail.t =
if String.for_all (fun c -> Char.code c < 128) s then Mail.make [ ("Content-Type", "text/plain; charset=us-ascii") ] s
else Mail.make [ ("Content-Type", "text/plain; charset=utf-8"); ("Content-Transfer-Encoding", "quoted-printable") ] (quoted_printable_encode s)
let type_of_filename (name : string) : string =
let ext = match String.rindex_opt name '.' with Some i -> String.lowercase_ascii (String.sub name (i + 1) (String.length name - i - 1)) | None -> "" in
match ext with
| "png" -> "image/png"
| "gif" -> "image/gif"
| "jpg" | "jpeg" -> "image/jpeg"
| "txt" -> "text/plain"
| "mbox" -> "application/mbox"
| _ -> "application/octet-stream"
let base64_lines (s : string) : string =
let e = Base64.encode s in
let n = String.length e in
String.concat "\n" (List.init ((n + 75) / 76) (fun i -> String.sub e (i * 76) (min 76 (n - (i * 76))))) ^ "\n"
let attachment ~(filename : string) (data : string) : Mail.t =
Mail.make
[
("Content-Type", Printf.sprintf "%s; name=\"%s\"" (type_of_filename filename) filename);
("Content-Transfer-Encoding", "base64");
("Content-Disposition", Printf.sprintf "attachment; filename=\"%s\"" filename);
]
(base64_lines data)
let multipart ~(boundary : string) (parts : Mail.t list) : (string * string) list * string =
let chomp s = if s <> "" && s.[String.length s - 1] = '\n' then String.sub s 0 (String.length s - 1) else s in
( [ ("MIME-Version", "1.0"); ("Content-Type", Printf.sprintf "multipart/mixed; boundary=\"%s\"" boundary) ],
"This is a multi-part message in MIME format.\n"
^ String.concat "" (List.map (fun p -> "--" ^ boundary ^ "\n" ^ chomp (Mail.to_string p) ^ "\n") parts)
^ "--" ^ boundary ^ "--\n" )