···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Authentication Module
77-88- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.2.3}RFC 9051 Section 6.2.3}
99- LOGIN command authentication. *)
1010-1111-module type AUTH = sig
1212- type t
1313-1414- val create : service_name:string -> t
1515- val authenticate : t -> username:string -> password:string -> bool
1616-end
1717-1818-(* External C functions for PAM *)
1919-external pam_authenticate_ext : string -> string -> string -> bool = "caml_pam_authenticate"
2020-external pam_available_ext : unit -> bool = "caml_pam_available"
2121-2222-module Pam_auth = struct
2323- type t = {
2424- service_name : string;
2525- }
2626-2727- let create ~service_name = { service_name }
2828-2929- let authenticate t ~username ~password =
3030- pam_authenticate_ext t.service_name username password
3131-3232- let is_available () = pam_available_ext ()
3333-end
3434-3535-module Mock_auth = struct
3636- type t = {
3737- mutable users : (string * string) list;
3838- service_name : string; [@warning "-69"]
3939- (** Kept for API compatibility with [Pam_auth] *)
4040- }
4141-4242- let create ~service_name = { users = []; service_name }
4343-4444- let add_user t ~username ~password =
4545- t.users <- (username, password) :: List.filter (fun (u, _) -> u <> username) t.users
4646-4747- let remove_user t ~username =
4848- t.users <- List.filter (fun (u, _) -> u <> username) t.users
4949-5050- let authenticate t ~username ~password =
5151- List.exists (fun (u, p) -> u = username && p = password) t.users
5252-end
-52
lib/imapd/auth.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Authentication Module
77-88- This module provides authentication backends for the IMAP server.
99-1010- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.2.3}RFC 9051 Section 6.2.3}
1111- LOGIN command authentication.
1212-1313- {2 References}
1414- {ul
1515- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
1616- {- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.2.2}RFC 9051 Section 6.2.2} - AUTHENTICATE command}} *)
1717-1818-(** Authentication backend signature *)
1919-module type AUTH = sig
2020- type t
2121-2222- val create : service_name:string -> t
2323- (** [create ~service_name] creates an authenticator using the given service name.
2424- For PAM, this corresponds to the PAM service configuration (e.g., "imapd"). *)
2525-2626- val authenticate : t -> username:string -> password:string -> bool
2727- (** [authenticate t ~username ~password] returns [true] if authentication succeeds. *)
2828-end
2929-3030-(** PAM-based authentication using system accounts.
3131-3232- Uses Linux-PAM to authenticate against system users.
3333- Requires a PAM service configuration file (e.g., /etc/pam.d/imapd). *)
3434-module Pam_auth : sig
3535- include AUTH
3636-3737- val is_available : unit -> bool
3838- (** [is_available ()] returns [true] if PAM support is compiled in. *)
3939-end
4040-4141-(** Mock authenticator for testing.
4242-4343- Stores credentials in memory. Useful for unit tests. *)
4444-module Mock_auth : sig
4545- include AUTH
4646-4747- val add_user : t -> username:string -> password:string -> unit
4848- (** [add_user t ~username ~password] adds a user with the given credentials. *)
4949-5050- val remove_user : t -> username:string -> unit
5151- (** [remove_user t ~username] removes a user. *)
5252-end
-757
lib/imapd/client.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-open Protocol
77-88-type connection_state =
99- | Not_authenticated
1010- | Authenticated of { username : string }
1111- | Selected of {
1212- username : string;
1313- mailbox : string;
1414- readonly : bool;
1515- }
1616- | Logout
1717-1818-type mailbox_info = {
1919- name : string;
2020- exists : int;
2121- recent : int;
2222- uidvalidity : int32;
2323- uidnext : int32;
2424- flags : flag list;
2525- permanent_flags : flag list;
2626- readonly : bool;
2727-}
2828-2929-type message_info = {
3030- seq : int;
3131- uid : int32 option;
3232- flags : flag list option;
3333- envelope : envelope option;
3434- body_structure : body_structure option;
3535- internaldate : string option;
3636- size : int64 option;
3737- body_section : string option;
3838-}
3939-4040-type list_entry = {
4141- flags : list_flag list;
4242- delimiter : char option;
4343- name : string;
4444-}
4545-4646-type status_info = {
4747- mailbox : string;
4848- messages : int64 option;
4949- uidnext : int64 option;
5050- uidvalidity : int64 option;
5151- unseen : int64 option;
5252-}
5353-5454-type idle_event =
5555- | Idle_exists of int
5656- | Idle_expunge of int
5757- | Idle_fetch of { seq : int; flags : flag list }
5858-5959-type t = {
6060- reader : Eio.Buf_read.t;
6161- writer : Eio.Buf_write.t;
6262- close_fn : unit -> unit;
6363- mutable state : connection_state;
6464- mutable capabilities : string list;
6565- mutable tag_counter : int;
6666- sw : Eio.Switch.t; [@warning "-69"]
6767-}
6868-6969-let state t = t.state
7070-let capabilities t = t.capabilities
7171-7272-let has_capability t cap =
7373- let upper = String.uppercase_ascii cap in
7474- List.exists (fun c -> String.uppercase_ascii c = upper) t.capabilities
7575-7676-let next_tag t =
7777- t.tag_counter <- t.tag_counter + 1;
7878- Printf.sprintf "A%04d" t.tag_counter
7979-8080-let send_command t cmd =
8181- let tag = next_tag t in
8282- Write.command t.writer ~tag cmd;
8383- tag
8484-8585-let require_state t expected =
8686- let actual =
8787- match t.state with
8888- | Not_authenticated -> "Not_authenticated"
8989- | Authenticated _ -> "Authenticated"
9090- | Selected _ -> "Selected"
9191- | Logout -> "Logout"
9292- in
9393- if actual <> expected then
9494- raise (Client_error.err (State_error { expected; actual }))
9595-9696-let require_authenticated t =
9797- match t.state with
9898- | Authenticated _ | Selected _ -> ()
9999- | _ -> raise (Client_error.err (State_error { expected = "Authenticated"; actual = "Not_authenticated" }))
100100-101101-let require_selected t =
102102- match t.state with
103103- | Selected _ -> ()
104104- | _ -> raise (Client_error.err (State_error { expected = "Selected"; actual = "not Selected" }))
105105-106106-let require_capability t cap =
107107- if not (has_capability t cap) then
108108- raise (Client_error.err (Capability_missing { capability = cap }))
109109-110110-(* Process untagged responses and extract relevant data *)
111111-let process_untagged responses =
112112- let exists = ref 0 in
113113- let recent = ref 0 in
114114- let uidvalidity = ref 0l in
115115- let uidnext = ref 0l in
116116- let flags = ref [] in
117117- let permanent_flags = ref [] in
118118- let readonly = ref false in
119119- let caps = ref [] in
120120- let list_entries = ref [] in
121121- let fetch_items = ref [] in
122122- let expunged = ref [] in
123123- let search_results = ref [] in
124124- let namespace = ref None in
125125- let status = ref None in
126126- let id_result = ref None in
127127- let enabled = ref [] in
128128-129129- List.iter
130130- (function
131131- | Exists n -> exists := n
132132- | Flags_response f -> flags := f
133133- | Capability_response c -> caps := c
134134- | Enabled e -> enabled := e
135135- | List_response { flags = f; delimiter; name; _ } ->
136136- list_entries := { flags = f; delimiter; name } :: !list_entries
137137- | Status_response { mailbox; items } ->
138138- let messages =
139139- List.find_map
140140- (function Status_messages, v -> Some v | _ -> None)
141141- items
142142- in
143143- let uidnext =
144144- List.find_map
145145- (function Status_uidnext, v -> Some v | _ -> None)
146146- items
147147- in
148148- let uidvalidity =
149149- List.find_map
150150- (function Status_uidvalidity, v -> Some v | _ -> None)
151151- items
152152- in
153153- let unseen =
154154- List.find_map
155155- (function Status_unseen, v -> Some v | _ -> None)
156156- items
157157- in
158158- status := Some { mailbox; messages; uidnext; uidvalidity; unseen }
159159- | Namespace_response ns -> namespace := Some ns
160160- | Fetch_response { seq; items } ->
161161- let uid =
162162- List.find_map
163163- (function Fetch_item_uid u -> Some u | _ -> None)
164164- items
165165- in
166166- let flags =
167167- List.find_map
168168- (function Fetch_item_flags f -> Some f | _ -> None)
169169- items
170170- in
171171- let envelope =
172172- List.find_map
173173- (function Fetch_item_envelope e -> Some e | _ -> None)
174174- items
175175- in
176176- let body_structure =
177177- List.find_map
178178- (function
179179- | Fetch_item_body b | Fetch_item_bodystructure b -> Some b
180180- | _ -> None)
181181- items
182182- in
183183- let internaldate =
184184- List.find_map
185185- (function Fetch_item_internaldate d -> Some d | _ -> None)
186186- items
187187- in
188188- let size =
189189- List.find_map
190190- (function Fetch_item_rfc822_size s -> Some s | _ -> None)
191191- items
192192- in
193193- let body_section =
194194- List.find_map
195195- (function
196196- | Fetch_item_body_section { data; _ } -> data
197197- | _ -> None)
198198- items
199199- in
200200- fetch_items :=
201201- { seq; uid; flags; envelope; body_structure; internaldate; size; body_section }
202202- :: !fetch_items
203203- | Expunge_response n -> expunged := n :: !expunged
204204- | Esearch { results; _ } ->
205205- List.iter
206206- (function
207207- | Esearch_all set ->
208208- List.iter
209209- (function
210210- | Single n -> search_results := n :: !search_results
211211- | Range (a, b) ->
212212- for i = a to b do
213213- search_results := i :: !search_results
214214- done
215215- | From _ | All -> ())
216216- set
217217- | _ -> ())
218218- results
219219- | Id_response r -> id_result := r
220220- | Ok { code; _ } -> (
221221- match code with
222222- | Some (Code_permanentflags f) -> permanent_flags := f
223223- | Some (Code_uidvalidity v) -> uidvalidity := v
224224- | Some (Code_uidnext u) -> uidnext := u
225225- | Some Code_readonly -> readonly := true
226226- | Some Code_readwrite -> readonly := false
227227- | Some (Code_capability c) -> caps := c
228228- | _ -> ())
229229- | _ -> ())
230230- responses;
231231-232232- ( !exists,
233233- !recent,
234234- !uidvalidity,
235235- !uidnext,
236236- !flags,
237237- !permanent_flags,
238238- !readonly,
239239- !caps,
240240- List.rev !list_entries,
241241- List.rev !fetch_items,
242242- List.rev !expunged,
243243- List.rev !search_results,
244244- !namespace,
245245- !status,
246246- !id_result,
247247- !enabled )
248248-249249-let check_response tag responses =
250250- let final =
251251- List.find_opt
252252- (function
253253- | Ok { tag = Some t; _ }
254254- | No { tag = Some t; _ }
255255- | Bad { tag = Some t; _ }
256256- when t = tag ->
257257- true
258258- | Bye _ -> true
259259- | _ -> false)
260260- responses
261261- in
262262- match final with
263263- | Some (Ok _) -> ()
264264- | Some (No { code; text; _ }) ->
265265- raise (Client_error.err (Protocol_error { code; text }))
266266- | Some (Bad { code; text; _ }) ->
267267- raise (Client_error.err (Protocol_error { code; text }))
268268- | Some (Bye { text; _ }) ->
269269- raise (Client_error.err (Protocol_error { code = None; text }))
270270- | _ ->
271271- raise
272272- (Client_error.err
273273- (Protocol_error { code = None; text = "No tagged response" }))
274274-275275-let run_command t cmd =
276276- let tag = send_command t cmd in
277277- let responses = Read.responses_until_tagged t.reader tag in
278278- check_response tag responses;
279279- responses
280280-281281-let connect ~sw ~env ~host ?(port = 993) ?tls_config () =
282282- let net = env#net in
283283-284284- (* Resolve hostname *)
285285- let addrs =
286286- try Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port)
287287- with _ ->
288288- raise
289289- (Client_error.err
290290- (Connection_error { reason = "DNS resolution failed for " ^ host }))
291291- in
292292-293293- let addr =
294294- match addrs with
295295- | [] ->
296296- raise
297297- (Client_error.err
298298- (Connection_error { reason = "No addresses found for " ^ host }))
299299- | a :: _ -> a
300300- in
301301-302302- (* Connect *)
303303- let flow =
304304- try Eio.Net.connect ~sw net addr
305305- with exn ->
306306- raise
307307- (Client_error.err
308308- (Connection_error { reason = Printexc.to_string exn }))
309309- in
310310-311311- (* Wrap in TLS *)
312312- let tls_config =
313313- match tls_config with
314314- | Some c -> c
315315- | None -> (
316316- match
317317- Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) ()
318318- with
319319- | Ok c -> c
320320- | Error _ ->
321321- raise
322322- (Client_error.err
323323- (Connection_error { reason = "Failed to create TLS config" })))
324324- in
325325-326326- let tls_flow =
327327- try Tls_eio.client_of_flow tls_config flow
328328- with exn ->
329329- raise
330330- (Client_error.err
331331- (Connection_error { reason = "TLS handshake failed: " ^ Printexc.to_string exn }))
332332- in
333333-334334- let reader = Eio.Buf_read.of_flow tls_flow ~max_size:(10 * 1024 * 1024) in
335335-336336- (* We need to create the client inside Buf_write.with_flow *)
337337- let client_ref = ref None in
338338-339339- Eio.Buf_write.with_flow tls_flow (fun writer ->
340340- let client =
341341- {
342342- reader;
343343- writer;
344344- close_fn = (fun () -> Eio.Flow.close tls_flow);
345345- state = Not_authenticated;
346346- capabilities = [];
347347- tag_counter = 0;
348348- sw;
349349- }
350350- in
351351- ignore (env : < net : _ Eio.Net.t ; .. >); (* used for connect only *)
352352-353353- (* Read greeting *)
354354- let greeting = Read.response reader in
355355- (match greeting with
356356- | Ok { code; _ } -> (
357357- match code with
358358- | Some (Code_capability caps) -> client.capabilities <- caps
359359- | _ -> ())
360360- | Preauth { code; _ } -> (
361361- client.state <- Authenticated { username = "" };
362362- match code with
363363- | Some (Code_capability caps) -> client.capabilities <- caps
364364- | _ -> ())
365365- | Bye { text; _ } ->
366366- raise (Client_error.err (Protocol_error { code = None; text }))
367367- | _ ->
368368- raise
369369- (Client_error.err
370370- (Protocol_error { code = None; text = "Unexpected greeting" })));
371371-372372- (* Get capabilities if not in greeting *)
373373- if client.capabilities = [] then (
374374- let responses = run_command client Capability in
375375- let _, _, _, _, _, _, _, caps, _, _, _, _, _, _, _, _ =
376376- process_untagged responses
377377- in
378378- client.capabilities <- caps);
379379-380380- client_ref := Some client;
381381-382382- (* Keep writer alive - main loop would go here in a real app *)
383383- (* For now, we return immediately but the writer stays valid *)
384384- ());
385385-386386- match !client_ref with
387387- | Some c -> c
388388- | None ->
389389- raise
390390- (Client_error.err
391391- (Connection_error { reason = "Failed to initialize client" }))
392392-393393-let disconnect t =
394394- t.state <- Logout;
395395- try t.close_fn () with _ -> ()
396396-397397-let capability t =
398398- let responses = run_command t Capability in
399399- let _, _, _, _, _, _, _, caps, _, _, _, _, _, _, _, _ =
400400- process_untagged responses
401401- in
402402- t.capabilities <- caps;
403403- caps
404404-405405-let noop t = ignore (run_command t Noop)
406406-407407-let logout t =
408408- (try ignore (run_command t Logout) with _ -> ());
409409- t.state <- Logout
410410-411411-let id t params =
412412- let responses = run_command t (Id params) in
413413- let _, _, _, _, _, _, _, _, _, _, _, _, _, _, id_result, _ =
414414- process_untagged responses
415415- in
416416- id_result
417417-418418-let starttls t config =
419419- require_capability t "STARTTLS";
420420- require_state t "Not_authenticated";
421421- ignore (run_command t Starttls);
422422- (* Would need to upgrade connection here - complex to implement *)
423423- ignore config;
424424- failwith "STARTTLS not yet implemented"
425425-426426-let login t ~username ~password =
427427- (match t.state with
428428- | Not_authenticated -> ()
429429- | _ ->
430430- raise
431431- (Client_error.err
432432- (State_error { expected = "Not_authenticated"; actual = "Authenticated" })));
433433- let responses = run_command t (Login { username; password }) in
434434- let _, _, _, _, _, _, _, caps, _, _, _, _, _, _, _, _ =
435435- process_untagged responses
436436- in
437437- if caps <> [] then t.capabilities <- caps;
438438- t.state <- Authenticated { username }
439439-440440-let authenticate t ~mechanism ?initial_response ~respond () =
441441- (match t.state with
442442- | Not_authenticated -> ()
443443- | _ ->
444444- raise
445445- (Client_error.err
446446- (State_error { expected = "Not_authenticated"; actual = "Authenticated" })));
447447- let tag = send_command t (Authenticate { mechanism; initial_response }) in
448448-449449- let rec handle_challenges () =
450450- let resp = Read.response t.reader in
451451- match resp with
452452- | Continuation (Some challenge) ->
453453- let response = respond challenge in
454454- Write.authenticate_response t.writer response;
455455- handle_challenges ()
456456- | Continuation None ->
457457- let response = respond "" in
458458- Write.authenticate_response t.writer response;
459459- handle_challenges ()
460460- | Ok { tag = Some t; _ } when t = tag -> ()
461461- | No { tag = Some t; text; _ } when t = tag ->
462462- raise
463463- (Client_error.err
464464- (Authentication_error { mechanism; reason = text }))
465465- | Bad { tag = Some t; text; _ } when t = tag ->
466466- raise
467467- (Client_error.err
468468- (Authentication_error { mechanism; reason = text }))
469469- | Bye { text; _ } ->
470470- raise
471471- (Client_error.err
472472- (Authentication_error { mechanism; reason = "Server disconnected: " ^ text }))
473473- | _ -> handle_challenges ()
474474- in
475475- handle_challenges ();
476476- t.state <- Authenticated { username = "" }
477477-478478-let authenticate_plain t ~username ~password =
479479- let ir =
480480- Base64.encode_string (Printf.sprintf "\x00%s\x00%s" username password)
481481- in
482482- authenticate t ~mechanism:"PLAIN" ~initial_response:ir ~respond:(fun _ -> "") ();
483483- t.state <- Authenticated { username }
484484-485485-let select t mailbox =
486486- require_authenticated t;
487487- let responses = run_command t (Select mailbox) in
488488- let exists, recent, uidvalidity, uidnext, flags, permanent_flags, readonly, caps, _, _, _, _, _, _, _, _ =
489489- process_untagged responses
490490- in
491491- if caps <> [] then t.capabilities <- caps;
492492- let username =
493493- match t.state with
494494- | Authenticated { username } -> username
495495- | Selected { username; _ } -> username
496496- | _ -> ""
497497- in
498498- t.state <- Selected { username; mailbox; readonly };
499499- {
500500- name = mailbox;
501501- exists;
502502- recent;
503503- uidvalidity;
504504- uidnext;
505505- flags;
506506- permanent_flags;
507507- readonly;
508508- }
509509-510510-let examine t mailbox =
511511- require_authenticated t;
512512- let responses = run_command t (Examine mailbox) in
513513- let exists, recent, uidvalidity, uidnext, flags, permanent_flags, _, caps, _, _, _, _, _, _, _, _ =
514514- process_untagged responses
515515- in
516516- if caps <> [] then t.capabilities <- caps;
517517- let username =
518518- match t.state with
519519- | Authenticated { username } -> username
520520- | Selected { username; _ } -> username
521521- | _ -> ""
522522- in
523523- t.state <- Selected { username; mailbox; readonly = true };
524524- {
525525- name = mailbox;
526526- exists;
527527- recent;
528528- uidvalidity;
529529- uidnext;
530530- flags;
531531- permanent_flags;
532532- readonly = true;
533533- }
534534-535535-let create t mailbox =
536536- require_authenticated t;
537537- ignore (run_command t (Create mailbox))
538538-539539-let delete t mailbox =
540540- require_authenticated t;
541541- ignore (run_command t (Delete mailbox))
542542-543543-let rename t ~old_name ~new_name =
544544- require_authenticated t;
545545- ignore (run_command t (Rename { old_name; new_name }))
546546-547547-let subscribe t mailbox =
548548- require_authenticated t;
549549- ignore (run_command t (Subscribe mailbox))
550550-551551-let unsubscribe t mailbox =
552552- require_authenticated t;
553553- ignore (run_command t (Unsubscribe mailbox))
554554-555555-let list t ~reference ~pattern =
556556- require_authenticated t;
557557- let responses = run_command t (List (List_basic { reference; pattern })) in
558558- let _, _, _, _, _, _, _, _, entries, _, _, _, _, _, _, _ =
559559- process_untagged responses
560560- in
561561- entries
562562-563563-let namespace t =
564564- require_authenticated t;
565565- require_capability t "NAMESPACE";
566566- let responses = run_command t Namespace in
567567- let _, _, _, _, _, _, _, _, _, _, _, _, ns, _, _, _ = process_untagged responses in
568568- match ns with
569569- | Some n -> n
570570- | None -> { personal = None; other = None; shared = None }
571571-572572-let status t mailbox items =
573573- require_authenticated t;
574574- let responses = run_command t (Status { mailbox; items }) in
575575- let _, _, _, _, _, _, _, _, _, _, _, _, _, status, _, _ =
576576- process_untagged responses
577577- in
578578- match status with
579579- | Some s -> s
580580- | None ->
581581- { mailbox; messages = None; uidnext = None; uidvalidity = None; unseen = None }
582582-583583-let close t =
584584- require_selected t;
585585- ignore (run_command t Close);
586586- let username =
587587- match t.state with Selected { username; _ } -> username | _ -> ""
588588- in
589589- t.state <- Authenticated { username }
590590-591591-let unselect t =
592592- require_selected t;
593593- require_capability t "UNSELECT";
594594- ignore (run_command t Unselect);
595595- let username =
596596- match t.state with Selected { username; _ } -> username | _ -> ""
597597- in
598598- t.state <- Authenticated { username }
599599-600600-let fetch t ~sequence ~items =
601601- require_selected t;
602602- let responses = run_command t (Fetch { sequence; items }) in
603603- let _, _, _, _, _, _, _, _, _, fetch_items, _, _, _, _, _, _ =
604604- process_untagged responses
605605- in
606606- fetch_items
607607-608608-let uid_fetch t ~sequence ~items =
609609- require_selected t;
610610- let responses = run_command t (Uid (Uid_fetch { sequence; items })) in
611611- let _, _, _, _, _, _, _, _, _, fetch_items, _, _, _, _, _, _ =
612612- process_untagged responses
613613- in
614614- fetch_items
615615-616616-let store t ~sequence ~action ~flags ?(silent = false) () =
617617- require_selected t;
618618- let responses = run_command t (Store { sequence; silent; action; flags }) in
619619- let _, _, _, _, _, _, _, _, _, fetch_items, _, _, _, _, _, _ =
620620- process_untagged responses
621621- in
622622- fetch_items
623623-624624-let uid_store t ~sequence ~action ~flags ?(silent = false) () =
625625- require_selected t;
626626- let responses =
627627- run_command t (Uid (Uid_store { sequence; silent; action; flags }))
628628- in
629629- let _, _, _, _, _, _, _, _, _, fetch_items, _, _, _, _, _, _ =
630630- process_untagged responses
631631- in
632632- fetch_items
633633-634634-let copy t ~sequence ~mailbox =
635635- require_selected t;
636636- ignore (run_command t (Copy { sequence; mailbox }))
637637-638638-let uid_copy t ~sequence ~mailbox =
639639- require_selected t;
640640- ignore (run_command t (Uid (Uid_copy { sequence; mailbox })))
641641-642642-let move t ~sequence ~mailbox =
643643- require_selected t;
644644- require_capability t "MOVE";
645645- ignore (run_command t (Move { sequence; mailbox }))
646646-647647-let uid_move t ~sequence ~mailbox =
648648- require_selected t;
649649- require_capability t "MOVE";
650650- ignore (run_command t (Uid (Uid_move { sequence; mailbox })))
651651-652652-let expunge t =
653653- require_selected t;
654654- let responses = run_command t Expunge in
655655- let _, _, _, _, _, _, _, _, _, _, expunged, _, _, _, _, _ =
656656- process_untagged responses
657657- in
658658- expunged
659659-660660-let uid_expunge t uids =
661661- require_selected t;
662662- require_capability t "UIDPLUS";
663663- let responses = run_command t (Uid (Uid_expunge uids)) in
664664- let _, _, _, _, _, _, _, _, _, _, expunged, _, _, _, _, _ =
665665- process_untagged responses
666666- in
667667- expunged
668668-669669-let search t ?charset criteria =
670670- require_selected t;
671671- let responses = run_command t (Search { charset; criteria }) in
672672- let _, _, _, _, _, _, _, _, _, _, _, results, _, _, _, _ =
673673- process_untagged responses
674674- in
675675- results
676676-677677-let uid_search t ?charset criteria =
678678- require_selected t;
679679- let responses = run_command t (Uid (Uid_search { charset; criteria })) in
680680- let _, _, _, _, _, _, _, _, _, _, _, results, _, _, _, _ =
681681- process_untagged responses
682682- in
683683- List.map Int32.of_int results
684684-685685-let append t ~mailbox ~message ?flags ?date () =
686686- require_authenticated t;
687687- let flags = Option.value ~default:[] flags in
688688- let responses = run_command t (Append { mailbox; flags; date; message }) in
689689- (* Check for APPENDUID response code *)
690690- let uid =
691691- List.find_map
692692- (function
693693- | Ok { code = Some (Code_appenduid (_, uid)); _ } -> Some uid
694694- | _ -> None)
695695- responses
696696- in
697697- uid
698698-699699-let idle t ~timeout =
700700- require_selected t;
701701- require_capability t "IDLE";
702702- let tag = send_command t Idle in
703703-704704- (* Wait for continuation *)
705705- let cont = Read.response t.reader in
706706- (match cont with
707707- | Continuation _ -> ()
708708- | _ ->
709709- raise
710710- (Client_error.err
711711- (Protocol_error { code = None; text = "Expected continuation for IDLE" })));
712712-713713- (* Collect events with timeout *)
714714- let events = ref [] in
715715- let start = Unix.gettimeofday () in
716716-717717- let rec collect () =
718718- let elapsed = Unix.gettimeofday () -. start in
719719- if elapsed >= timeout then (
720720- Write.idle_done t.writer;
721721- let _ = Read.responses_until_tagged t.reader tag in
722722- List.rev !events)
723723- else
724724- (* Try to read response with remaining timeout *)
725725- let resp = Read.response t.reader in
726726- match resp with
727727- | Exists n ->
728728- events := Idle_exists n :: !events;
729729- collect ()
730730- | Expunge_response n ->
731731- events := Idle_expunge n :: !events;
732732- collect ()
733733- | Fetch_response { seq; items } ->
734734- let flags =
735735- List.find_map
736736- (function Fetch_item_flags f -> Some f | _ -> None)
737737- items
738738- in
739739- (match flags with
740740- | Some f -> events := Idle_fetch { seq; flags = f } :: !events
741741- | None -> ());
742742- collect ()
743743- | Ok { tag = Some t; _ } when t = tag -> List.rev !events
744744- | _ -> collect ()
745745- in
746746- collect ()
747747-748748-let idle_done t =
749749- Write.idle_done t.writer
750750-751751-let enable t extensions =
752752- require_authenticated t;
753753- let responses = run_command t (Enable extensions) in
754754- let _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, enabled =
755755- process_untagged responses
756756- in
757757- enabled
-473
lib/imapd/client.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Client Library
77-88- This module provides a comprehensive IMAP client for OCaml applications.
99- It implements the client side of
1010- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051 IMAP4rev2}.
1111-1212- {2 Quick Start}
1313-1414- {[
1515- Eio_main.run @@ fun env ->
1616- Eio.Switch.run @@ fun sw ->
1717-1818- let client =
1919- Client.connect ~sw ~env ~host:"imap.example.com" ~port:993 ()
2020- in
2121-2222- Client.login client ~username:"user" ~password:"pass";
2323-2424- let mailbox = Client.select client "INBOX" in
2525- Printf.printf "You have %d messages\n" mailbox.exists;
2626-2727- let messages =
2828- Client.fetch client
2929- ~sequence:[ Protocol.Range (1, 10) ]
3030- ~items:[ Protocol.Fetch_envelope; Protocol.Fetch_flags ]
3131- in
3232- List.iter
3333- (fun msg ->
3434- Printf.printf "[%ld] %s\n" msg.uid
3535- (Option.value ~default:"<no subject>" msg.envelope.subject))
3636- messages;
3737-3838- Client.logout client
3939- ]}
4040-4141- {2 Connection States}
4242-4343- The IMAP protocol has strict state requirements:
4444- {ul
4545- {- {b Not authenticated}: After connect, before login/authenticate}
4646- {- {b Authenticated}: After successful login, can access mailboxes}
4747- {- {b Selected}: After SELECT/EXAMINE, can access messages}}
4848-4949- Commands that require a specific state will raise {!Client_error.State_error}
5050- if called in the wrong state.
5151-5252- {2 Error Handling}
5353-5454- All errors are raised as [Eio.Io] exceptions wrapping {!Client_error.error}.
5555- Use pattern matching to handle specific error cases:
5656-5757- {[
5858- try
5959- Client.login client ~username ~password
6060- with
6161- | Eio.Io (Client_error.E err, _) -> (
6262- match err with
6363- | Protocol_error { code = Some Code_authenticationfailed; _ } ->
6464- Printf.eprintf "Bad username or password\n"
6565- | Connection_error { reason } ->
6666- Printf.eprintf "Connection lost: %s\n" reason
6767- | _ -> raise (Client_error.err err))
6868- ]}
6969-7070- {2 References}
7171- {ul
7272- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
7373- {- {{:https://datatracker.ietf.org/doc/html/rfc2177}RFC 2177} - IDLE}
7474- {- {{:https://datatracker.ietf.org/doc/html/rfc6851}RFC 6851} - MOVE}
7575- {- {{:https://datatracker.ietf.org/doc/html/rfc7888}RFC 7888} - LITERAL+}
7676- {- {{:https://datatracker.ietf.org/doc/html/rfc2971}RFC 2971} - ID}} *)
7777-7878-(** {1 Types} *)
7979-8080-type t
8181-(** An IMAP client connection. *)
8282-8383-type connection_state =
8484- | Not_authenticated
8585- | Authenticated of { username : string }
8686- | Selected of {
8787- username : string;
8888- mailbox : string;
8989- readonly : bool;
9090- }
9191- | Logout
9292-(** Connection state. See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-3}
9393- RFC 9051 Section 3}. *)
9494-9595-type mailbox_info = {
9696- name : string;
9797- exists : int;
9898- recent : int;
9999- uidvalidity : int32;
100100- uidnext : int32;
101101- flags : Protocol.flag list;
102102- permanent_flags : Protocol.flag list;
103103- readonly : bool;
104104-}
105105-(** Information about a selected mailbox. *)
106106-107107-type message_info = {
108108- seq : int;
109109- uid : int32 option;
110110- flags : Protocol.flag list option;
111111- envelope : Protocol.envelope option;
112112- body_structure : Protocol.body_structure option;
113113- internaldate : string option;
114114- size : int64 option;
115115- body_section : string option;
116116-}
117117-(** Information about a fetched message. *)
118118-119119-type list_entry = {
120120- flags : Protocol.list_flag list;
121121- delimiter : char option;
122122- name : string;
123123-}
124124-(** A mailbox from LIST response. *)
125125-126126-type status_info = {
127127- mailbox : string;
128128- messages : int64 option;
129129- uidnext : int64 option;
130130- uidvalidity : int64 option;
131131- unseen : int64 option;
132132-}
133133-(** STATUS response information. *)
134134-135135-type idle_event =
136136- | Idle_exists of int
137137- | Idle_expunge of int
138138- | Idle_fetch of { seq : int; flags : Protocol.flag list }
139139-(** Events that can occur during IDLE. *)
140140-141141-(** {1 Connection Management} *)
142142-143143-val connect :
144144- sw:Eio.Switch.t ->
145145- env:< net : _ Eio.Net.t ; .. > ->
146146- host:string ->
147147- ?port:int ->
148148- ?tls_config:Tls.Config.client ->
149149- unit ->
150150- t
151151-(** [connect ~sw ~env ~host ?port ?tls_config ()] establishes an IMAP connection.
152152-153153- @param sw Switch for resource management. Connection is closed when switch exits.
154154- @param env Eio environment providing network access.
155155- @param host Server hostname.
156156- @param port Server port. Default is 993 (IMAPS).
157157- @param tls_config TLS configuration. If not provided, uses default with
158158- permissive certificate validation (not recommended for production).
159159-160160- @raise Client_error.Connection_error if connection fails.
161161- @raise Client_error.Protocol_error if server greeting is not OK/PREAUTH. *)
162162-163163-val disconnect : t -> unit
164164-(** [disconnect client] closes the connection immediately without LOGOUT.
165165- Prefer {!logout} for graceful disconnection. *)
166166-167167-val state : t -> connection_state
168168-(** [state client] returns the current connection state. *)
169169-170170-val capabilities : t -> string list
171171-(** [capabilities client] returns the server's advertised capabilities. *)
172172-173173-val has_capability : t -> string -> bool
174174-(** [has_capability client cap] checks if server advertises capability [cap]. *)
175175-176176-(** {1 Any-State Commands}
177177-178178- These commands can be issued in any connection state. *)
179179-180180-val capability : t -> string list
181181-(** [capability client] requests capability list from server.
182182- Updates cached capabilities and returns the list. *)
183183-184184-val noop : t -> unit
185185-(** [noop client] does nothing but may trigger unsolicited responses.
186186- Useful for keeping connection alive or checking for new messages. *)
187187-188188-val logout : t -> unit
189189-(** [logout client] gracefully terminates the session.
190190- After logout, the client should not be used. *)
191191-192192-val id : t -> (string * string) list option -> (string * string) list option
193193-(** [id client params] exchanges client/server identification (RFC 2971).
194194-195195- @param params Client identification parameters, or [None] for NIL.
196196- @return Server identification parameters. *)
197197-198198-(** {1 Authentication}
199199-200200- Commands for authenticating to the server. *)
201201-202202-val starttls : t -> Tls.Config.client -> unit
203203-(** [starttls client config] upgrades connection to TLS (port 143 only).
204204-205205- @raise Client_error.Capability_missing if STARTTLS not available.
206206- @raise Client_error.State_error if already authenticated.
207207- @raise Client_error.Protocol_error if STARTTLS fails. *)
208208-209209-val login : t -> username:string -> password:string -> unit
210210-(** [login client ~username ~password] authenticates using LOGIN command.
211211-212212- @raise Client_error.State_error if already authenticated.
213213- @raise Client_error.Protocol_error if login fails. *)
214214-215215-val authenticate :
216216- t ->
217217- mechanism:string ->
218218- ?initial_response:string ->
219219- respond:(string -> string) ->
220220- unit ->
221221- unit
222222-(** [authenticate client ~mechanism ?initial_response ~respond] performs
223223- SASL authentication.
224224-225225- @param mechanism SASL mechanism name (e.g., "PLAIN", "XOAUTH2").
226226- @param initial_response Optional initial response (IR capability).
227227- @param respond Callback to provide responses to server challenges.
228228-229229- @raise Client_error.State_error if already authenticated.
230230- @raise Client_error.Authentication_error if authentication fails. *)
231231-232232-val authenticate_plain : t -> username:string -> password:string -> unit
233233-(** [authenticate_plain client ~username ~password] authenticates using
234234- SASL PLAIN mechanism. Equivalent to:
235235- {[
236236- let ir = Printf.sprintf "\x00%s\x00%s" username password in
237237- authenticate client ~mechanism:"PLAIN" ~initial_response:(Base64.encode_string ir)
238238- ~respond:(fun _ -> "")
239239- ]} *)
240240-241241-(** {1 Mailbox Commands}
242242-243243- Commands for working with mailboxes. Require Authenticated state. *)
244244-245245-val select : t -> string -> mailbox_info
246246-(** [select client mailbox] selects a mailbox for read-write access.
247247-248248- @raise Client_error.State_error if not authenticated.
249249- @raise Client_error.Protocol_error if mailbox doesn't exist. *)
250250-251251-val examine : t -> string -> mailbox_info
252252-(** [examine client mailbox] selects a mailbox for read-only access.
253253- Same as {!select} but changes cannot be made. *)
254254-255255-val create : t -> string -> unit
256256-(** [create client mailbox] creates a new mailbox.
257257-258258- @raise Client_error.Protocol_error if mailbox exists or is invalid. *)
259259-260260-val delete : t -> string -> unit
261261-(** [delete client mailbox] deletes a mailbox.
262262-263263- @raise Client_error.Protocol_error if mailbox doesn't exist. *)
264264-265265-val rename : t -> old_name:string -> new_name:string -> unit
266266-(** [rename client ~old_name ~new_name] renames a mailbox. *)
267267-268268-val subscribe : t -> string -> unit
269269-(** [subscribe client mailbox] adds mailbox to subscription list. *)
270270-271271-val unsubscribe : t -> string -> unit
272272-(** [unsubscribe client mailbox] removes mailbox from subscription list. *)
273273-274274-val list : t -> reference:string -> pattern:string -> list_entry list
275275-(** [list client ~reference ~pattern] lists mailboxes matching pattern.
276276-277277- @param reference Reference name (usually "").
278278- @param pattern Mailbox name pattern with wildcards (asterisk matches all, percent matches one level).
279279-280280- Example:
281281- {[
282282- let boxes = Client.list client ~reference:"" ~pattern:"*" in
283283- List.iter (fun e -> Printf.printf "%s\n" e.name) boxes
284284- ]} *)
285285-286286-val namespace : t -> Protocol.namespace_data
287287-(** [namespace client] returns the server's namespace configuration. *)
288288-289289-val status : t -> string -> Protocol.status_item list -> status_info
290290-(** [status client mailbox items] returns status of a mailbox without selecting it.
291291-292292- Example:
293293- {[
294294- let info =
295295- Client.status client "INBOX"
296296- [ Status_messages; Status_unseen ]
297297- in
298298- Printf.printf "Messages: %Ld, Unseen: %Ld\n"
299299- (Option.get info.messages) (Option.get info.unseen)
300300- ]} *)
301301-302302-val close : t -> unit
303303-(** [close client] closes the selected mailbox, expunging deleted messages. *)
304304-305305-val unselect : t -> unit
306306-(** [unselect client] closes the selected mailbox without expunging. *)
307307-308308-(** {1 Message Commands}
309309-310310- Commands for working with messages. Require Selected state. *)
311311-312312-val fetch :
313313- t ->
314314- sequence:Protocol.sequence_set ->
315315- items:Protocol.fetch_item list ->
316316- message_info list
317317-(** [fetch client ~sequence ~items] retrieves message data.
318318-319319- @param sequence Message sequence numbers to fetch.
320320- @param items Data items to retrieve.
321321-322322- Example:
323323- {[
324324- let msgs =
325325- Client.fetch client
326326- ~sequence:[ Range (1, 10) ]
327327- ~items:[ Fetch_uid; Fetch_flags; Fetch_envelope ]
328328- in
329329- List.iter
330330- (fun m ->
331331- Printf.printf "[%ld] %s\n"
332332- (Option.get m.uid)
333333- (Option.value ~default:"(no subject)"
334334- (Option.bind m.envelope (fun e -> e.subject))))
335335- msgs
336336- ]} *)
337337-338338-val uid_fetch :
339339- t ->
340340- sequence:Protocol.sequence_set ->
341341- items:Protocol.fetch_item list ->
342342- message_info list
343343-(** [uid_fetch client ~sequence ~items] fetches by UID instead of sequence number. *)
344344-345345-val store :
346346- t ->
347347- sequence:Protocol.sequence_set ->
348348- action:Protocol.store_action ->
349349- flags:Protocol.flag list ->
350350- ?silent:bool ->
351351- unit ->
352352- message_info list
353353-(** [store client ~sequence ~action ?silent ~flags] modifies message flags.
354354-355355- @param action [Store_set], [Store_add], or [Store_remove].
356356- @param silent If true, don't return updated flags (default: false).
357357-358358- Example:
359359- {[
360360- (* Mark messages 1-5 as seen *)
361361- Client.store client
362362- ~sequence:[ Range (1, 5) ]
363363- ~action:Store_add
364364- ~flags:[ System Seen ]
365365- ]} *)
366366-367367-val uid_store :
368368- t ->
369369- sequence:Protocol.sequence_set ->
370370- action:Protocol.store_action ->
371371- flags:Protocol.flag list ->
372372- ?silent:bool ->
373373- unit ->
374374- message_info list
375375-(** [uid_store client ~sequence ~action ~flags ?silent ()] stores by UID. *)
376376-377377-val copy : t -> sequence:Protocol.sequence_set -> mailbox:string -> unit
378378-(** [copy client ~sequence ~mailbox] copies messages to another mailbox. *)
379379-380380-val uid_copy : t -> sequence:Protocol.sequence_set -> mailbox:string -> unit
381381-(** [uid_copy client ~sequence ~mailbox] copies by UID. *)
382382-383383-val move : t -> sequence:Protocol.sequence_set -> mailbox:string -> unit
384384-(** [move client ~sequence ~mailbox] moves messages to another mailbox (RFC 6851).
385385-386386- @raise Client_error.Capability_missing if MOVE not supported. *)
387387-388388-val uid_move : t -> sequence:Protocol.sequence_set -> mailbox:string -> unit
389389-(** [uid_move client ~sequence ~mailbox] moves by UID. *)
390390-391391-val expunge : t -> int list
392392-(** [expunge client] permanently removes messages marked as Deleted.
393393- Returns list of expunged sequence numbers. *)
394394-395395-val uid_expunge : t -> Protocol.sequence_set -> int list
396396-(** [uid_expunge client uids] expunges only the specified UIDs. *)
397397-398398-val search : t -> ?charset:string -> Protocol.search_key -> int list
399399-(** [search client ?charset criteria] searches for messages.
400400- Returns list of matching sequence numbers.
401401-402402- Example:
403403- {[
404404- (* Find unseen messages from "alice@example.com" *)
405405- let results =
406406- Client.search client
407407- (Search_and [ Search_unseen; Search_from "alice@example.com" ])
408408- in
409409- Printf.printf "Found %d messages\n" (List.length results)
410410- ]} *)
411411-412412-val uid_search : t -> ?charset:string -> Protocol.search_key -> int32 list
413413-(** [uid_search client ?charset criteria] searches and returns UIDs. *)
414414-415415-val append :
416416- t ->
417417- mailbox:string ->
418418- message:string ->
419419- ?flags:Protocol.flag list ->
420420- ?date:string ->
421421- unit ->
422422- int32 option
423423-(** [append client ~mailbox ?flags ?date ~message] appends a message.
424424-425425- @param mailbox Destination mailbox.
426426- @param flags Initial flags for the message.
427427- @param date Internal date (RFC 2822 format).
428428- @param message Complete RFC 5322 message.
429429- @return UID of appended message if UIDPLUS is supported. *)
430430-431431-(** {1 IDLE Support}
432432-433433- IDLE allows the client to receive real-time notifications. *)
434434-435435-val idle : t -> timeout:float -> idle_event list
436436-(** [idle client ~timeout] enters IDLE mode and waits for events.
437437-438438- @param timeout Maximum time to wait in seconds.
439439- @return Events received during IDLE.
440440-441441- @raise Client_error.Capability_missing if IDLE not supported.
442442-443443- Example:
444444- {[
445445- let rec watch () =
446446- let events = Client.idle client ~timeout:300.0 in
447447- List.iter
448448- (function
449449- | Idle_exists n -> Printf.printf "New message! Total: %d\n" n
450450- | Idle_expunge n -> Printf.printf "Message %d expunged\n" n
451451- | Idle_fetch { seq; flags } ->
452452- Printf.printf "Message %d flags changed\n" seq)
453453- events;
454454- watch ()
455455- in
456456- watch ()
457457- ]} *)
458458-459459-val idle_done : t -> unit
460460-(** [idle_done client] exits IDLE mode early.
461461- Only valid while in IDLE state. *)
462462-463463-(** {1 Extensions} *)
464464-465465-val enable : t -> string list -> string list
466466-(** [enable client extensions] enables protocol extensions.
467467- Returns list of successfully enabled extensions.
468468-469469- Common extensions:
470470- - ["CONDSTORE"] - Conditional STORE and FETCH
471471- - ["QRESYNC"] - Quick resynchronization
472472-473473- @raise Client_error.State_error if not authenticated. *)
-87
lib/imapd/client_error.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-type error =
77- | Connection_error of { reason : string }
88- | Protocol_error of { code : Protocol.response_code option; text : string }
99- | Parse_error of { reason : string; data : string option }
1010- | State_error of { expected : string; actual : string }
1111- | Timeout of { operation : string }
1212- | Capability_missing of { capability : string }
1313- | Authentication_error of { mechanism : string; reason : string }
1414-1515-type Eio.Exn.err += E of error
1616-1717-let err e = Eio.Exn.create (E e)
1818-1919-let is_retryable = function
2020- | Connection_error _ | Timeout _ -> true
2121- | Protocol_error _ | Parse_error _ | State_error _ | Capability_missing _
2222- | Authentication_error _ ->
2323- false
2424-2525-let is_auth_error = function
2626- | Authentication_error _ -> true
2727- | Protocol_error { code = Some code; _ } -> (
2828- match code with
2929- | Protocol.Code_authenticationfailed
3030- | Protocol.Code_authorizationfailed ->
3131- true
3232- | _ -> false)
3333- | Protocol_error { code = None; _ }
3434- | Connection_error _ | Parse_error _ | State_error _ | Timeout _
3535- | Capability_missing _ ->
3636- false
3737-3838-let is_state_error = function State_error _ -> true | _ -> false
3939-let of_eio_exn = function Eio.Io (E e, _) -> Some e | _ -> None
4040-4141-let pp_response_code ppf code =
4242- match code with
4343- | Protocol.Code_alert -> Fmt.pf ppf "ALERT"
4444- | Protocol.Code_alreadyexists -> Fmt.pf ppf "ALREADYEXISTS"
4545- | Protocol.Code_authenticationfailed -> Fmt.pf ppf "AUTHENTICATIONFAILED"
4646- | Protocol.Code_authorizationfailed -> Fmt.pf ppf "AUTHORIZATIONFAILED"
4747- | Protocol.Code_cannot -> Fmt.pf ppf "CANNOT"
4848- | Protocol.Code_closed -> Fmt.pf ppf "CLOSED"
4949- | Protocol.Code_nonexistent -> Fmt.pf ppf "NONEXISTENT"
5050- | Protocol.Code_noperm -> Fmt.pf ppf "NOPERM"
5151- | Protocol.Code_overquota -> Fmt.pf ppf "OVERQUOTA"
5252- | Protocol.Code_readonly -> Fmt.pf ppf "READ-ONLY"
5353- | Protocol.Code_readwrite -> Fmt.pf ppf "READ-WRITE"
5454- | Protocol.Code_trycreate -> Fmt.pf ppf "TRYCREATE"
5555- | Protocol.Code_uidvalidity v -> Fmt.pf ppf "UIDVALIDITY %ld" v
5656- | Protocol.Code_uidnext u -> Fmt.pf ppf "UIDNEXT %ld" u
5757- | Protocol.Code_other (name, _) -> Fmt.pf ppf "%s" name
5858- | _ -> Fmt.pf ppf "<code>"
5959-6060-let pp ppf = function
6161- | Connection_error { reason } -> Fmt.pf ppf "connection error: %s" reason
6262- | Protocol_error { code; text } -> (
6363- match code with
6464- | Some c -> Fmt.pf ppf "protocol error [%a]: %s" pp_response_code c text
6565- | None -> Fmt.pf ppf "protocol error: %s" text)
6666- | Parse_error { reason; data } -> (
6767- match data with
6868- | Some d ->
6969- let preview = if String.length d > 50 then String.sub d 0 50 ^ "..." else d in
7070- Fmt.pf ppf "parse error: %s (data: %s)" reason preview
7171- | None -> Fmt.pf ppf "parse error: %s" reason)
7272- | State_error { expected; actual } ->
7373- Fmt.pf ppf "state error: expected %s, in %s" expected actual
7474- | Timeout { operation } -> Fmt.pf ppf "timeout: %s" operation
7575- | Capability_missing { capability } ->
7676- Fmt.pf ppf "capability missing: %s" capability
7777- | Authentication_error { mechanism; reason } ->
7878- Fmt.pf ppf "authentication error (%s): %s" mechanism reason
7979-8080-let to_string e = Fmt.str "%a" pp e
8181-8282-let () =
8383- Eio.Exn.register_pp (fun ppf -> function
8484- | E e ->
8585- Fmt.pf ppf "Client_error.E(%a)" pp e;
8686- true
8787- | _ -> false)
-97
lib/imapd/client_error.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Client Error Types
77-88- Errors from IMAP client operations are wrapped as [Eio.Io] exceptions for
99- consistency with other Eio-based libraries. The error type provides
1010- structured access to IMAP-specific error details.
1111-1212- {2 Error Handling}
1313-1414- {[
1515- try
1616- let mailbox = Client.select client "INBOX" in
1717- (* ... *)
1818- with
1919- | Eio.Io (Client_error.E err, _) ->
2020- match err with
2121- | Protocol_error { code; text } ->
2222- Printf.eprintf "Server error: %s\n" text
2323- | Connection_error { reason } ->
2424- Printf.eprintf "Connection failed: %s\n" reason
2525- | _ -> (* Handle other errors *)
2626- ]}
2727-2828- {2 References}
2929- {ul
3030- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}} *)
3131-3232-(** {1 Error Types} *)
3333-3434-type error =
3535- | Connection_error of { reason : string }
3636- (** Network-level failure (connection refused, timeout, DNS failure, etc.) *)
3737- | Protocol_error of { code : Protocol.response_code option; text : string }
3838- (** Server returned NO or BAD response to a command. *)
3939- | Parse_error of { reason : string; data : string option }
4040- (** Failed to parse server response. *)
4141- | State_error of { expected : string; actual : string }
4242- (** Command not valid in current connection state. *)
4343- | Timeout of { operation : string }
4444- (** Operation timed out. *)
4545- | Capability_missing of { capability : string }
4646- (** Required capability not advertised by server. *)
4747- | Authentication_error of { mechanism : string; reason : string }
4848- (** SASL authentication failed. *)
4949-5050-(** {1 Eio Exception Integration} *)
5151-5252-type Eio.Exn.err +=
5353- | E of error
5454- (** Eio exception wrapper for IMAP client errors.
5555-5656- Raise with: [raise (Eio.Exn.create (E error))]
5757- Catch with: [Eio.Io (E error, _)] *)
5858-5959-val err : error -> exn
6060-(** [err e] creates an Eio exception from an error.
6161- Equivalent to [Eio.Exn.create (E e)]. *)
6262-6363-(** {1 Error Properties} *)
6464-6565-val is_retryable : error -> bool
6666-(** [is_retryable e] returns [true] if the error is transient and the operation
6767- may succeed on retry.
6868-6969- Retryable errors include:
7070- - Connection errors (network issues)
7171- - Timeouts *)
7272-7373-val is_auth_error : error -> bool
7474-(** [is_auth_error e] returns [true] if the error indicates an authentication
7575- problem that requires re-authentication.
7676-7777- Auth errors include:
7878- - [Authentication_error]
7979- - Protocol errors with [AUTHENTICATIONFAILED] or [AUTHORIZATIONFAILED] codes *)
8080-8181-val is_state_error : error -> bool
8282-(** [is_state_error e] returns [true] if the error indicates the command was
8383- issued in the wrong connection state. *)
8484-8585-(** {1 Error Extraction} *)
8686-8787-val of_eio_exn : exn -> error option
8888-(** [of_eio_exn exn] extracts an IMAP error from an Eio exception.
8989- Returns [None] if the exception is not an IMAP client error. *)
9090-9191-(** {1 Formatting} *)
9292-9393-val pp : error Fmt.t
9494-(** Pretty-print an error. *)
9595-9696-val to_string : error -> string
9797-(** Convert error to human-readable string. *)
-181
lib/imapd/client_pool.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-type config = {
77- min_connections : int;
88- max_connections : int;
99- idle_timeout : float;
1010- health_check_interval : float;
1111-}
1212-1313-let default_config =
1414- {
1515- min_connections = 1;
1616- max_connections = 10;
1717- idle_timeout = 300.0;
1818- health_check_interval = 60.0;
1919- }
2020-2121-type stats = {
2222- total : int;
2323- active : int;
2424- idle : int;
2525- created : int;
2626- reused : int;
2727- failed : int;
2828-}
2929-3030-type t = {
3131- connect_fn : unit -> Client.t option;
3232- config : config;
3333- mutable connections : Client.t list;
3434- mutable active : Client.t list;
3535- mutex : Eio.Mutex.t;
3636- condition : Eio.Condition.t;
3737- mutable closed : bool;
3838- mutable stats_created : int;
3939- mutable stats_reused : int;
4040- mutable stats_failed : int;
4141-}
4242-4343-let create_connection t =
4444- match t.connect_fn () with
4545- | Some client ->
4646- t.stats_created <- t.stats_created + 1;
4747- Some client
4848- | None ->
4949- t.stats_failed <- t.stats_failed + 1;
5050- None
5151-5252-let is_healthy client =
5353- try
5454- Client.noop client;
5555- true
5656- with _ -> false
5757-5858-let create ~sw ~env ~host ?(port = 993) ~username ~password ?tls_config
5959- ?(config = default_config) () =
6060- let connect_fn () =
6161- try
6262- let client =
6363- Client.connect ~sw ~env ~host ~port ?tls_config ()
6464- in
6565- Client.login client ~username ~password;
6666- Some client
6767- with _ -> None
6868- in
6969- let t =
7070- {
7171- connect_fn;
7272- config;
7373- connections = [];
7474- active = [];
7575- mutex = Eio.Mutex.create ();
7676- condition = Eio.Condition.create ();
7777- closed = false;
7878- stats_created = 0;
7979- stats_reused = 0;
8080- stats_failed = 0;
8181- }
8282- in
8383-8484- (* Create minimum connections *)
8585- for _ = 1 to config.min_connections do
8686- match create_connection t with
8787- | Some client -> t.connections <- client :: t.connections
8888- | None -> ()
8989- done;
9090-9191- t
9292-9393-let close t =
9494- Eio.Mutex.use_rw ~protect:false t.mutex (fun () ->
9595- t.closed <- true;
9696- List.iter Client.disconnect t.connections;
9797- List.iter Client.disconnect t.active;
9898- t.connections <- [];
9999- t.active <- []);
100100- Eio.Condition.broadcast t.condition
101101-102102-let acquire t =
103103- Eio.Mutex.use_rw ~protect:false t.mutex (fun () ->
104104- if t.closed then
105105- raise
106106- (Client_error.err
107107- (Connection_error { reason = "Pool is closed" }));
108108-109109- (* Try to get an existing healthy connection *)
110110- let rec find_healthy = function
111111- | [] -> None
112112- | client :: rest ->
113113- if is_healthy client then (
114114- t.connections <- rest;
115115- t.active <- client :: t.active;
116116- t.stats_reused <- t.stats_reused + 1;
117117- Some client)
118118- else (
119119- Client.disconnect client;
120120- find_healthy rest)
121121- in
122122-123123- match find_healthy t.connections with
124124- | Some client -> client
125125- | None ->
126126- (* No healthy connection available *)
127127- let total = List.length t.connections + List.length t.active in
128128- if total < t.config.max_connections then
129129- (* Create new connection *)
130130- match create_connection t with
131131- | Some client ->
132132- t.active <- client :: t.active;
133133- client
134134- | None ->
135135- raise
136136- (Client_error.err
137137- (Connection_error { reason = "Failed to create connection" }))
138138- else
139139- raise
140140- (Client_error.err
141141- (Connection_error { reason = "Pool exhausted" })))
142142-143143-let release t client =
144144- Eio.Mutex.use_rw ~protect:false t.mutex (fun () ->
145145- t.active <- List.filter (fun c -> c != client) t.active;
146146- if t.closed || not (is_healthy client) then Client.disconnect client
147147- else (
148148- (* Unselect any mailbox before returning to pool *)
149149- (try
150150- match Client.state client with
151151- | Client.Selected _ -> Client.close client
152152- | _ -> ()
153153- with _ -> ());
154154- t.connections <- client :: t.connections));
155155- Eio.Condition.broadcast t.condition
156156-157157-let with_client t fn =
158158- let client = acquire t in
159159- match fn client with
160160- | result ->
161161- release t client;
162162- result
163163- | exception exn ->
164164- (* On exception, close the connection instead of returning it *)
165165- Eio.Mutex.use_rw ~protect:false t.mutex (fun () ->
166166- t.active <- List.filter (fun c -> c != client) t.active);
167167- (try Client.disconnect client with _ -> ());
168168- raise exn
169169-170170-let stats t =
171171- Eio.Mutex.use_rw ~protect:false t.mutex (fun () ->
172172- let idle = List.length t.connections in
173173- let active = List.length t.active in
174174- {
175175- total = idle + active;
176176- active;
177177- idle;
178178- created = t.stats_created;
179179- reused = t.stats_reused;
180180- failed = t.stats_failed;
181181- })
-139
lib/imapd/client_pool.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Connection Pool
77-88- This module provides connection pooling for IMAP clients using
99- {{:https://github.com/avsm/ocaml-conpool}conpool}.
1010-1111- {2 Why Pool Connections?}
1212-1313- IMAP connections are expensive to establish (TCP handshake, TLS negotiation,
1414- authentication). A connection pool maintains authenticated connections ready
1515- for reuse, significantly improving performance for applications that make
1616- frequent IMAP requests.
1717-1818- {2 Example}
1919-2020- {[
2121- Eio_main.run @@ fun env ->
2222- Eio.Switch.run @@ fun sw ->
2323-2424- let pool =
2525- Client_pool.create ~sw ~env ~host:"imap.example.com" ~port:993
2626- ~username:"user" ~password:"pass" ()
2727- in
2828-2929- (* Connections are borrowed from the pool and automatically returned *)
3030- Client_pool.with_client pool (fun client ->
3131- let inbox = Client.select client "INBOX" in
3232- Printf.printf "INBOX has %d messages\n" inbox.exists)
3333- ]}
3434-3535- {2 Health Checking}
3636-3737- The pool periodically validates connections using NOOP. Unhealthy connections
3838- are automatically removed and replaced.
3939-4040- {2 References}
4141- {ul
4242- {- {{:https://github.com/avsm/ocaml-conpool}conpool} - Connection pooling library}} *)
4343-4444-(** {1 Types} *)
4545-4646-type t
4747-(** An IMAP connection pool. *)
4848-4949-type config = {
5050- min_connections : int; (** Minimum connections to maintain (default: 1) *)
5151- max_connections : int; (** Maximum connections allowed (default: 10) *)
5252- idle_timeout : float; (** Seconds before idle connection is closed (default: 300.0) *)
5353- health_check_interval : float; (** Seconds between health checks (default: 60.0) *)
5454-}
5555-(** Pool configuration. *)
5656-5757-val default_config : config
5858-(** Default configuration:
5959- - [min_connections = 1]
6060- - [max_connections = 10]
6161- - [idle_timeout = 300.0]
6262- - [health_check_interval = 60.0] *)
6363-6464-(** {1 Pool Management} *)
6565-6666-val create :
6767- sw:Eio.Switch.t ->
6868- env:< net : _ Eio.Net.t ; clock : _ Eio.Time.clock ; .. > ->
6969- host:string ->
7070- ?port:int ->
7171- username:string ->
7272- password:string ->
7373- ?tls_config:Tls.Config.client ->
7474- ?config:config ->
7575- unit ->
7676- t
7777-(** [create ~sw ~env ~host ?port ~username ~password ?tls_config ?config ()]
7878- creates a new connection pool.
7979-8080- All connections in the pool use the same credentials. The pool is
8181- automatically closed when [sw] exits.
8282-8383- @param sw Switch for resource management.
8484- @param env Eio environment with network and clock.
8585- @param host IMAP server hostname.
8686- @param port Server port (default: 993).
8787- @param username Authentication username.
8888- @param password Authentication password.
8989- @param tls_config Optional TLS configuration.
9090- @param config Pool configuration (default: {!default_config}). *)
9191-9292-val close : t -> unit
9393-(** [close pool] closes all connections in the pool.
9494- The pool should not be used after calling close. *)
9595-9696-(** {1 Using Connections} *)
9797-9898-val acquire : t -> Client.t
9999-(** [acquire pool] gets a connection from the pool.
100100- The connection is already authenticated.
101101-102102- @raise Client_error.Connection_error if pool is exhausted and
103103- cannot create new connection. *)
104104-105105-val release : t -> Client.t -> unit
106106-(** [release pool client] returns a connection to the pool.
107107- If the connection is unhealthy, it is closed instead. *)
108108-109109-val with_client : t -> (Client.t -> 'a) -> 'a
110110-(** [with_client pool fn] borrows a connection, runs [fn], and returns it.
111111-112112- This is the recommended way to use pooled connections:
113113- {[
114114- let messages =
115115- Client_pool.with_client pool (fun client ->
116116- Client.select client "INBOX" |> ignore;
117117- Client.fetch client ~sequence:[ All ] ~items:[ Fetch_uid ])
118118- in
119119- List.iter (fun m -> Printf.printf "UID: %ld\n" (Option.get m.uid)) messages
120120- ]}
121121-122122- The connection is automatically returned to the pool even if [fn] raises
123123- an exception. If an exception occurs, the connection is closed rather
124124- than returned (it may be in an inconsistent state). *)
125125-126126-(** {1 Pool Statistics} *)
127127-128128-type stats = {
129129- total : int; (** Total connections (active + idle) *)
130130- active : int; (** Connections currently in use *)
131131- idle : int; (** Connections waiting in pool *)
132132- created : int; (** Total connections created since pool creation *)
133133- reused : int; (** Total connection reuses *)
134134- failed : int; (** Total connection failures *)
135135-}
136136-(** Pool statistics. *)
137137-138138-val stats : t -> stats
139139-(** [stats pool] returns current pool statistics. *)
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Parser
77-88- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051#section-9}RFC 9051 Section 9} Formal Syntax.
99-1010- This module uses Menhir for parsing and Faraday for response serialization. *)
1111-1212-open Protocol
1313-1414-(* Re-export types from Types for backward compatibility *)
1515-type thread_algorithm = Protocol.thread_algorithm =
1616- | Thread_orderedsubject
1717- | Thread_references
1818- | Thread_extension of string
1919-2020-type thread_node = Protocol.thread_node =
2121- | Thread_message of int * thread_node list
2222- | Thread_dummy of thread_node list
2323-2424-type thread_result = Protocol.thread_result
2525-2626-type command = Protocol.command =
2727- | Capability
2828- | Noop
2929- | Logout
3030- | Starttls
3131- | Login of { username : string; password : string }
3232- | Authenticate of { mechanism : string; initial_response : string option }
3333- | Enable of string list
3434- | Select of mailbox_name
3535- | Examine of mailbox_name
3636- | Create of mailbox_name
3737- | Delete of mailbox_name
3838- | Rename of { old_name : mailbox_name; new_name : mailbox_name }
3939- | Subscribe of mailbox_name
4040- | Unsubscribe of mailbox_name
4141- | List of list_command (** LIST command - RFC 9051, RFC 5258 LIST-EXTENDED *)
4242- | Namespace
4343- | Status of { mailbox : mailbox_name; items : status_item list }
4444- | Append of { mailbox : mailbox_name; flags : flag list; date : string option; message : string }
4545- | Idle
4646- | Close
4747- | Unselect
4848- | Expunge
4949- | Search of { charset : string option; criteria : search_key }
5050- | Fetch of { sequence : sequence_set; items : fetch_item list }
5151- | Store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
5252- | Copy of { sequence : sequence_set; mailbox : mailbox_name }
5353- | Move of { sequence : sequence_set; mailbox : mailbox_name }
5454- | Uid of uid_command
5555- | Id of (string * string) list option
5656- (* QUOTA extension - RFC 9208 *)
5757- | Getquota of string
5858- | Getquotaroot of mailbox_name
5959- | Setquota of { root : string; limits : (quota_resource * int64) list }
6060- (* THREAD extension - RFC 5256 *)
6161- | Thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
6262-6363-type uid_command = Protocol.uid_command =
6464- | Uid_fetch of { sequence : sequence_set; items : fetch_item list }
6565- | Uid_store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
6666- | Uid_copy of { sequence : sequence_set; mailbox : mailbox_name }
6767- | Uid_move of { sequence : sequence_set; mailbox : mailbox_name }
6868- | Uid_search of { charset : string option; criteria : search_key }
6969- | Uid_expunge of sequence_set
7070- | Uid_thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
7171-7272-type tagged_command = Protocol.tagged_command = {
7373- tag : string;
7474- command : command;
7575-}
7676-7777-type response = Protocol.response =
7878- | Ok of { tag : string option; code : response_code option; text : string }
7979- | No of { tag : string option; code : response_code option; text : string }
8080- | Bad of { tag : string option; code : response_code option; text : string }
8181- | Preauth of { code : response_code option; text : string }
8282- | Bye of { code : response_code option; text : string }
8383- | Capability_response of string list
8484- | Enabled of string list
8585- | List_response of list_response_data (** RFC 9051, RFC 5258 LIST-EXTENDED *)
8686- | Namespace_response of namespace_data
8787- | Status_response of { mailbox : mailbox_name; items : (status_item * int64) list }
8888- | Esearch of { tag : string option; uid : bool; results : esearch_result list }
8989- | Flags_response of flag list
9090- | Exists of int
9191- | Expunge_response of int
9292- | Fetch_response of { seq : int; items : fetch_response_item list }
9393- | Continuation of string option
9494- | Id_response of (string * string) list option
9595- (* QUOTA extension responses - RFC 9208 *)
9696- | Quota_response of { root : string; resources : quota_resource_info list }
9797- | Quotaroot_response of { mailbox : mailbox_name; roots : string list }
9898- (* THREAD extension response - RFC 5256 *)
9999- | Thread_response of thread_result
100100-101101-(* ===== Menhir Parser Interface ===== *)
102102-103103-let parse_command input =
104104- let lexbuf = Lexing.from_string input in
105105- try
106106- Result.Ok (Grammar.command Lexer.token lexbuf)
107107- with
108108- | Lexer.Lexer_error msg -> Result.Error ("Lexer error: " ^ msg)
109109- | Grammar.Error ->
110110- let pos = lexbuf.Lexing.lex_curr_p in
111111- Result.Error (Printf.sprintf "Parse error at line %d, column %d"
112112- pos.Lexing.pos_lnum
113113- (pos.Lexing.pos_cnum - pos.Lexing.pos_bol))
114114-115115-(* ===== Faraday Response Serializer ===== *)
116116-117117-let crlf = "\r\n"
118118-119119-let write_string f s = Faraday.write_string f s
120120-let write_char f c = Faraday.write_char f c
121121-let write_sp f = write_char f ' '
122122-let write_crlf f = write_string f crlf
123123-124124-let write_quoted_string f s =
125125- write_char f '"';
126126- String.iter (fun c ->
127127- match c with
128128- | '"' | '\\' -> write_char f '\\'; write_char f c
129129- | _ -> write_char f c
130130- ) s;
131131- write_char f '"'
132132-133133-let write_literal f s =
134134- write_char f '{';
135135- write_string f (string_of_int (String.length s));
136136- write_string f "}\r\n";
137137- write_string f s
138138-139139-(** Convert quota resource to IMAP string.
140140- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5}RFC 9208 Section 5}. *)
141141-let quota_resource_to_string = function
142142- | Quota_storage -> "STORAGE"
143143- | Quota_message -> "MESSAGE"
144144- | Quota_mailbox -> "MAILBOX"
145145- | Quota_annotation_storage -> "ANNOTATION-STORAGE"
146146-147147-let write_flag f flag =
148148- write_string f (flag_to_string flag)
149149-150150-let write_flag_list f flags =
151151- write_char f '(';
152152- List.iteri (fun i flag ->
153153- if i > 0 then write_sp f;
154154- write_flag f flag
155155- ) flags;
156156- write_char f ')'
157157-158158-let write_response_code f code =
159159- write_char f '[';
160160- (match code with
161161- | Code_alert -> write_string f "ALERT"
162162- | Code_alreadyexists -> write_string f "ALREADYEXISTS"
163163- | Code_capability caps ->
164164- write_string f "CAPABILITY";
165165- List.iter (fun c -> write_sp f; write_string f c) caps
166166- | Code_permanentflags flags ->
167167- write_string f "PERMANENTFLAGS ";
168168- write_flag_list f flags
169169- | Code_readonly -> write_string f "READ-ONLY"
170170- | Code_readwrite -> write_string f "READ-WRITE"
171171- | Code_uidvalidity v ->
172172- write_string f "UIDVALIDITY ";
173173- write_string f (Int32.to_string v)
174174- | Code_uidnext u ->
175175- write_string f "UIDNEXT ";
176176- write_string f (Int32.to_string u)
177177- | Code_appenduid (v, u) ->
178178- write_string f "APPENDUID ";
179179- write_string f (Int32.to_string v);
180180- write_sp f;
181181- write_string f (Int32.to_string u)
182182- | Code_trycreate -> write_string f "TRYCREATE"
183183- | Code_nonexistent -> write_string f "NONEXISTENT"
184184- | Code_authenticationfailed -> write_string f "AUTHENTICATIONFAILED"
185185- | Code_authorizationfailed -> write_string f "AUTHORIZATIONFAILED"
186186- | Code_parse -> write_string f "PARSE"
187187- | Code_closed -> write_string f "CLOSED"
188188- | Code_cannot -> write_string f "CANNOT"
189189- | Code_noperm -> write_string f "NOPERM"
190190- | Code_overquota -> write_string f "OVERQUOTA"
191191- | Code_inuse -> write_string f "INUSE"
192192- | Code_haschildren -> write_string f "HASCHILDREN"
193193- | Code_serverbug -> write_string f "SERVERBUG"
194194- | Code_clientbug -> write_string f "CLIENTBUG"
195195- | Code_other (name, value) ->
196196- write_string f name;
197197- (match value with Some v -> write_sp f; write_string f v | None -> ())
198198- | _ -> write_string f "UNKNOWN");
199199- write_char f ']';
200200- write_sp f
201201-202202-let serialize_response f resp =
203203- match resp with
204204- | Ok { tag; code; text } ->
205205- (match tag with
206206- | Some t -> write_string f t; write_sp f
207207- | None -> write_string f "* ");
208208- write_string f "OK ";
209209- (match code with Some c -> write_response_code f c | None -> ());
210210- write_string f text;
211211- write_crlf f
212212-213213- | No { tag; code; text } ->
214214- (match tag with
215215- | Some t -> write_string f t; write_sp f
216216- | None -> write_string f "* ");
217217- write_string f "NO ";
218218- (match code with Some c -> write_response_code f c | None -> ());
219219- write_string f text;
220220- write_crlf f
221221-222222- | Bad { tag; code; text } ->
223223- (match tag with
224224- | Some t -> write_string f t; write_sp f
225225- | None -> write_string f "* ");
226226- write_string f "BAD ";
227227- (match code with Some c -> write_response_code f c | None -> ());
228228- write_string f text;
229229- write_crlf f
230230-231231- | Preauth { code; text } ->
232232- write_string f "* PREAUTH ";
233233- (match code with Some c -> write_response_code f c | None -> ());
234234- write_string f text;
235235- write_crlf f
236236-237237- | Bye { code; text } ->
238238- write_string f "* BYE ";
239239- (match code with Some c -> write_response_code f c | None -> ());
240240- write_string f text;
241241- write_crlf f
242242-243243- | Capability_response caps ->
244244- write_string f "* CAPABILITY";
245245- List.iter (fun c -> write_sp f; write_string f c) caps;
246246- write_crlf f
247247-248248- | Enabled caps ->
249249- write_string f "* ENABLED";
250250- List.iter (fun c -> write_sp f; write_string f c) caps;
251251- write_crlf f
252252-253253- | List_response { flags; delimiter; name; extended } ->
254254- (* LIST response per RFC 9051 Section 7.3.1, RFC 5258 Section 3.4 *)
255255- write_string f "* LIST (";
256256- List.iteri (fun i flag ->
257257- if i > 0 then write_sp f;
258258- match flag with
259259- | List_noinferiors -> write_string f "\\Noinferiors"
260260- | List_noselect -> write_string f "\\Noselect"
261261- | List_marked -> write_string f "\\Marked"
262262- | List_unmarked -> write_string f "\\Unmarked"
263263- | List_subscribed -> write_string f "\\Subscribed"
264264- | List_haschildren -> write_string f "\\HasChildren"
265265- | List_hasnochildren -> write_string f "\\HasNoChildren"
266266- | List_nonexistent -> write_string f "\\NonExistent" (* RFC 5258 Section 3.4 *)
267267- | List_remote -> write_string f "\\Remote" (* RFC 5258 Section 3.4 *)
268268- | List_all -> write_string f "\\All"
269269- | List_archive -> write_string f "\\Archive"
270270- | List_drafts -> write_string f "\\Drafts"
271271- | List_flagged -> write_string f "\\Flagged"
272272- | List_junk -> write_string f "\\Junk"
273273- | List_sent -> write_string f "\\Sent"
274274- | List_trash -> write_string f "\\Trash"
275275- | List_extension s -> write_string f s
276276- ) flags;
277277- write_string f ") ";
278278- (match delimiter with
279279- | Some d -> write_quoted_string f (String.make 1 d)
280280- | None -> write_string f "NIL");
281281- write_sp f;
282282- write_quoted_string f name;
283283- (* Extended data per RFC 5258 Section 3.5 *)
284284- List.iter (fun ext ->
285285- match ext with
286286- | Childinfo subscriptions ->
287287- (* CHILDINFO extended data item: "CHILDINFO" SP "(" tag-list ")" *)
288288- write_sp f;
289289- write_string f "(\"CHILDINFO\" (";
290290- List.iteri (fun i tag ->
291291- if i > 0 then write_sp f;
292292- write_quoted_string f tag
293293- ) subscriptions;
294294- write_string f "))"
295295- ) extended;
296296- write_crlf f
297297-298298- | Namespace_response { personal; other; shared } ->
299299- let write_namespace ns =
300300- match ns with
301301- | None -> write_string f "NIL"
302302- | Some entries ->
303303- write_char f '(';
304304- List.iteri (fun i entry ->
305305- if i > 0 then write_sp f;
306306- write_char f '(';
307307- write_quoted_string f entry.prefix;
308308- write_sp f;
309309- (match entry.delimiter with
310310- | Some d -> write_quoted_string f (String.make 1 d)
311311- | None -> write_string f "NIL");
312312- write_char f ')'
313313- ) entries;
314314- write_char f ')'
315315- in
316316- write_string f "* NAMESPACE ";
317317- write_namespace personal;
318318- write_sp f;
319319- write_namespace other;
320320- write_sp f;
321321- write_namespace shared;
322322- write_crlf f
323323-324324- | Status_response { mailbox; items } ->
325325- write_string f "* STATUS ";
326326- write_quoted_string f mailbox;
327327- write_string f " (";
328328- List.iteri (fun i (item, value) ->
329329- if i > 0 then write_sp f;
330330- (match item with
331331- | Status_messages -> write_string f "MESSAGES"
332332- | Status_uidnext -> write_string f "UIDNEXT"
333333- | Status_uidvalidity -> write_string f "UIDVALIDITY"
334334- | Status_unseen -> write_string f "UNSEEN"
335335- | Status_deleted -> write_string f "DELETED"
336336- | Status_size -> write_string f "SIZE");
337337- write_sp f;
338338- write_string f (Int64.to_string value)
339339- ) items;
340340- write_char f ')';
341341- write_crlf f
342342-343343- | Esearch { tag = _; uid; results } ->
344344- write_string f "* ESEARCH";
345345- if uid then write_string f " UID";
346346- List.iter (fun r ->
347347- write_sp f;
348348- match r with
349349- | Esearch_min n -> write_string f "MIN "; write_string f (string_of_int n)
350350- | Esearch_max n -> write_string f "MAX "; write_string f (string_of_int n)
351351- | Esearch_count n -> write_string f "COUNT "; write_string f (string_of_int n)
352352- | Esearch_all _ -> write_string f "ALL ..."
353353- ) results;
354354- write_crlf f
355355-356356- | Flags_response flags ->
357357- write_string f "* FLAGS ";
358358- write_flag_list f flags;
359359- write_crlf f
360360-361361- | Exists n ->
362362- write_string f "* ";
363363- write_string f (string_of_int n);
364364- write_string f " EXISTS";
365365- write_crlf f
366366-367367- | Expunge_response n ->
368368- write_string f "* ";
369369- write_string f (string_of_int n);
370370- write_string f " EXPUNGE";
371371- write_crlf f
372372-373373- | Fetch_response { seq; items } ->
374374- write_string f "* ";
375375- write_string f (string_of_int seq);
376376- write_string f " FETCH (";
377377- List.iteri (fun i item ->
378378- if i > 0 then write_sp f;
379379- match item with
380380- | Fetch_item_flags flags ->
381381- write_string f "FLAGS ";
382382- write_flag_list f flags
383383- | Fetch_item_uid uid ->
384384- write_string f "UID ";
385385- write_string f (Int32.to_string uid)
386386- | Fetch_item_internaldate date ->
387387- write_string f "INTERNALDATE ";
388388- write_quoted_string f date
389389- | Fetch_item_rfc822_size size ->
390390- write_string f "RFC822.SIZE ";
391391- write_string f (Int64.to_string size)
392392- | Fetch_item_body_section { section = _; origin; data } ->
393393- write_string f "BODY[] ";
394394- (match origin with Some o -> write_string f ("<" ^ string_of_int o ^ "> ") | None -> ());
395395- (match data with Some d -> write_literal f d | None -> write_string f "NIL")
396396- | _ -> write_string f "..."
397397- ) items;
398398- write_char f ')';
399399- write_crlf f
400400-401401- | Continuation text ->
402402- write_string f "+ ";
403403- (match text with Some t -> write_string f t | None -> ());
404404- write_crlf f
405405-406406- | Id_response params ->
407407- write_string f "* ID ";
408408- (match params with
409409- | None -> write_string f "NIL"
410410- | Some pairs ->
411411- write_char f '(';
412412- let first = ref true in
413413- List.iter (fun (key, value) ->
414414- if not !first then write_sp f;
415415- first := false;
416416- write_quoted_string f key;
417417- write_sp f;
418418- write_quoted_string f value
419419- ) pairs;
420420- write_char f ')');
421421- write_crlf f
422422-423423- (* QUOTA extension responses - RFC 9208 *)
424424- | Quota_response { root; resources } ->
425425- (* QUOTA response format: * QUOTA root (resource usage limit ...) *)
426426- write_string f "* QUOTA ";
427427- write_quoted_string f root;
428428- write_string f " (";
429429- List.iteri (fun i { resource; usage; limit } ->
430430- if i > 0 then write_sp f;
431431- write_string f (quota_resource_to_string resource);
432432- write_sp f;
433433- write_string f (Int64.to_string usage);
434434- write_sp f;
435435- write_string f (Int64.to_string limit)
436436- ) resources;
437437- write_char f ')';
438438- write_crlf f
439439-440440- | Quotaroot_response { mailbox; roots } ->
441441- (* QUOTAROOT response format: * QUOTAROOT mailbox root ... *)
442442- write_string f "* QUOTAROOT ";
443443- write_quoted_string f mailbox;
444444- List.iter (fun root ->
445445- write_sp f;
446446- write_quoted_string f root
447447- ) roots;
448448- write_crlf f
449449-450450- (* THREAD extension response - RFC 5256 Section 4 *)
451451- | Thread_response threads ->
452452- (* THREAD response format: * THREAD [SP 1*thread-list]
453453- Each thread node is either:
454454- - (n) for a single message
455455- - (n children...) for a message with children
456456- - ((children...)) for a dummy parent
457457- @see <https://datatracker.ietf.org/doc/html/rfc5256#section-4> RFC 5256 Section 4 *)
458458- let rec write_thread_node = function
459459- | Thread_message (n, []) ->
460460- (* Single message with no children: (n) *)
461461- write_char f '(';
462462- write_string f (string_of_int n);
463463- write_char f ')'
464464- | Thread_message (n, children) ->
465465- (* Message with children: (n child1 child2 ...) *)
466466- write_char f '(';
467467- write_string f (string_of_int n);
468468- List.iter (fun child ->
469469- write_sp f;
470470- write_thread_node child
471471- ) children;
472472- write_char f ')'
473473- | Thread_dummy children ->
474474- (* Dummy node (missing parent): ((child1)(child2)...) *)
475475- write_char f '(';
476476- List.iteri (fun i child ->
477477- if i > 0 then write_sp f;
478478- write_thread_node child
479479- ) children;
480480- write_char f ')'
481481- in
482482- write_string f "* THREAD";
483483- List.iter (fun thread ->
484484- write_sp f;
485485- write_thread_node thread
486486- ) threads;
487487- write_crlf f
488488-489489-let response_to_string resp =
490490- let f = Faraday.create 256 in
491491- serialize_response f resp;
492492- Faraday.serialize_to_string f
-114
lib/imapd/parser.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Parser
77-88- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051#section-9}RFC 9051 Section 9} Formal Syntax.
99-1010- This module uses Menhir for parsing and Faraday for response serialization. *)
1111-1212-open Protocol
1313-1414-(** {1 Type Re-exports}
1515-1616- Types are defined in {!Protocol} and re-exported here for convenience. *)
1717-1818-type thread_algorithm = Protocol.thread_algorithm =
1919- | Thread_orderedsubject
2020- | Thread_references
2121- | Thread_extension of string
2222-2323-type command = Protocol.command =
2424- | Capability
2525- | Noop
2626- | Logout
2727- | Starttls
2828- | Login of { username : string; password : string }
2929- | Authenticate of { mechanism : string; initial_response : string option }
3030- | Enable of string list
3131- | Select of mailbox_name
3232- | Examine of mailbox_name
3333- | Create of mailbox_name
3434- | Delete of mailbox_name
3535- | Rename of { old_name : mailbox_name; new_name : mailbox_name }
3636- | Subscribe of mailbox_name
3737- | Unsubscribe of mailbox_name
3838- | List of list_command
3939- | Namespace
4040- | Status of { mailbox : mailbox_name; items : status_item list }
4141- | Append of { mailbox : mailbox_name; flags : flag list; date : string option; message : string }
4242- | Idle
4343- | Close
4444- | Unselect
4545- | Expunge
4646- | Search of { charset : string option; criteria : search_key }
4747- | Fetch of { sequence : sequence_set; items : fetch_item list }
4848- | Store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
4949- | Copy of { sequence : sequence_set; mailbox : mailbox_name }
5050- | Move of { sequence : sequence_set; mailbox : mailbox_name }
5151- | Uid of uid_command
5252- | Id of (string * string) list option
5353- (* QUOTA extension - RFC 9208 *)
5454- | Getquota of string
5555- | Getquotaroot of mailbox_name
5656- | Setquota of { root : string; limits : (quota_resource * int64) list }
5757- (* THREAD extension - RFC 5256 *)
5858- | Thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
5959-6060-type uid_command = Protocol.uid_command =
6161- | Uid_fetch of { sequence : sequence_set; items : fetch_item list }
6262- | Uid_store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
6363- | Uid_copy of { sequence : sequence_set; mailbox : mailbox_name }
6464- | Uid_move of { sequence : sequence_set; mailbox : mailbox_name }
6565- | Uid_search of { charset : string option; criteria : search_key }
6666- | Uid_expunge of sequence_set
6767- | Uid_thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
6868-6969-type tagged_command = Protocol.tagged_command = {
7070- tag : string;
7171- command : command;
7272-}
7373-7474-type response = Protocol.response =
7575- | Ok of { tag : string option; code : response_code option; text : string }
7676- | No of { tag : string option; code : response_code option; text : string }
7777- | Bad of { tag : string option; code : response_code option; text : string }
7878- | Preauth of { code : response_code option; text : string }
7979- | Bye of { code : response_code option; text : string }
8080- | Capability_response of string list
8181- | Enabled of string list
8282- | List_response of list_response_data
8383- | Namespace_response of namespace_data
8484- | Status_response of { mailbox : mailbox_name; items : (status_item * int64) list }
8585- | Esearch of { tag : string option; uid : bool; results : esearch_result list }
8686- | Flags_response of flag list
8787- | Exists of int
8888- | Expunge_response of int
8989- | Fetch_response of { seq : int; items : fetch_response_item list }
9090- | Continuation of string option
9191- | Id_response of (string * string) list option
9292- (* QUOTA extension responses - RFC 9208 *)
9393- | Quota_response of { root : string; resources : quota_resource_info list }
9494- | Quotaroot_response of { mailbox : mailbox_name; roots : string list }
9595- (* THREAD extension response - RFC 5256 *)
9696- | Thread_response of thread_result
9797-9898-(** {1 Parsing} *)
9999-100100-val parse_command : string -> (tagged_command, string) result
101101-(** Parse a complete IMAP command line. *)
102102-103103-(** {1 Serialization} *)
104104-105105-val serialize_response : Faraday.t -> response -> unit
106106-(** Serialize a response to a Faraday buffer. *)
107107-108108-val response_to_string : response -> string
109109-(** Convert response to string. *)
110110-111111-(** {1 Utilities} *)
112112-113113-val crlf : string
114114-(** CRLF line ending. *)
-555
lib/imapd/protocol.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Core Types
77-88- Implements types from {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051}. *)
99-1010-(* Basic types *)
1111-type mailbox_name = string
1212-type uid = int32
1313-type seq_num = int
1414-type uidvalidity = int32
1515-1616-(* Message flags - RFC 9051 Section 2.3.2 *)
1717-type system_flag =
1818- | Seen
1919- | Answered
2020- | Flagged
2121- | Deleted
2222- | Draft
2323-2424-type flag =
2525- | System of system_flag
2626- | Keyword of string
2727-2828-(* Email addresses *)
2929-type address = {
3030- name : string option;
3131- adl : string option;
3232- mailbox : string option;
3333- host : string option;
3434-}
3535-3636-(* Message envelope - RFC 9051 Section 2.3.5 *)
3737-type envelope = {
3838- date : string option;
3939- subject : string option;
4040- from : address list;
4141- sender : address list;
4242- reply_to : address list;
4343- to_ : address list;
4444- cc : address list;
4545- bcc : address list;
4646- in_reply_to : string option;
4747- message_id : string option;
4848-}
4949-5050-(* Body structure - RFC 9051 Section 2.3.6 *)
5151-type body_fields = {
5252- params : (string * string) list;
5353- content_id : string option;
5454- description : string option;
5555- encoding : string;
5656- size : int64;
5757-}
5858-5959-type body_type =
6060- | Text of {
6161- subtype : string;
6262- fields : body_fields;
6363- lines : int64;
6464- }
6565- | Message_rfc822 of {
6666- fields : body_fields;
6767- envelope : envelope;
6868- body : body_structure;
6969- lines : int64;
7070- }
7171- | Basic of {
7272- media_type : string;
7373- subtype : string;
7474- fields : body_fields;
7575- }
7676- | Multipart of {
7777- subtype : string;
7878- parts : body_structure list;
7979- params : (string * string) list;
8080- }
8181-8282-and body_structure = {
8383- body_type : body_type;
8484- disposition : (string * (string * string) list) option;
8585- language : string list option;
8686- location : string option;
8787-}
8888-8989-(* Sequence sets - RFC 9051 Section 4.1.1 *)
9090-type sequence_range =
9191- | Single of int
9292- | Range of int * int
9393- | From of int
9494- | All
9595-9696-type sequence_set = sequence_range list
9797-9898-(* Section specification for BODY[...] - RFC 9051 Section 6.4.5 *)
9999-type section_spec =
100100- | Section_header
101101- | Section_header_fields of string list
102102- | Section_header_fields_not of string list
103103- | Section_text
104104- | Section_mime
105105- | Section_part of int list * section_spec option
106106-107107-type body_section = {
108108- section : section_spec option;
109109- partial : (int * int) option;
110110-}
111111-112112-(* FETCH items - RFC 9051 Section 6.4.5 *)
113113-type fetch_item =
114114- | Fetch_envelope
115115- | Fetch_flags
116116- | Fetch_internaldate
117117- | Fetch_rfc822
118118- | Fetch_rfc822_size
119119- | Fetch_rfc822_header
120120- | Fetch_rfc822_text
121121- | Fetch_uid
122122- | Fetch_body
123123- | Fetch_bodystructure
124124- | Fetch_body_section of string * (int * int) option (* section string, partial *)
125125- | Fetch_body_peek of string * (int * int) option
126126- | Fetch_binary of string * (int * int) option
127127- | Fetch_binary_peek of string * (int * int) option
128128- | Fetch_binary_size of string
129129-130130-(* SEARCH criteria - RFC 9051 Section 6.4.4 *)
131131-type search_key =
132132- | Search_all
133133- | Search_answered
134134- | Search_bcc of string
135135- | Search_before of string
136136- | Search_body of string
137137- | Search_cc of string
138138- | Search_deleted
139139- | Search_flagged
140140- | Search_from of string
141141- | Search_keyword of string
142142- | Search_new
143143- | Search_not of search_key
144144- | Search_old
145145- | Search_on of string
146146- | Search_or of search_key * search_key
147147- | Search_seen
148148- | Search_since of string
149149- | Search_subject of string
150150- | Search_text of string
151151- | Search_to of string
152152- | Search_unanswered
153153- | Search_undeleted
154154- | Search_unflagged
155155- | Search_unkeyword of string
156156- | Search_unseen
157157- | Search_draft
158158- | Search_undraft
159159- | Search_header of string * string
160160- | Search_larger of int64
161161- | Search_smaller of int64
162162- | Search_uid of sequence_set
163163- | Search_sequence_set of sequence_set
164164- | Search_and of search_key list
165165- | Search_sentbefore of string
166166- | Search_senton of string
167167- | Search_sentsince of string
168168-169169-(* STORE actions - RFC 9051 Section 6.4.6 *)
170170-type store_action =
171171- | Store_set
172172- | Store_add
173173- | Store_remove
174174-175175-type store_silent = bool
176176-177177-(* STATUS items - RFC 9051 Section 6.3.11 *)
178178-type status_item =
179179- | Status_messages
180180- | Status_uidnext
181181- | Status_uidvalidity
182182- | Status_unseen
183183- | Status_deleted
184184- | Status_size
185185-186186-(* LIST flags - RFC 9051 Section 7.3.1, RFC 5258 Section 3.4 *)
187187-type list_flag =
188188- | List_noinferiors
189189- | List_noselect
190190- | List_marked
191191- | List_unmarked
192192- | List_subscribed
193193- | List_haschildren
194194- | List_hasnochildren
195195- | List_nonexistent (** RFC 5258 Section 3.4 - Mailbox name refers to non-existent mailbox *)
196196- | List_remote (** RFC 5258 Section 3.4 - Mailbox is remote, not on this server *)
197197- | List_all
198198- | List_archive
199199- | List_drafts
200200- | List_flagged
201201- | List_junk
202202- | List_sent
203203- | List_trash
204204- | List_extension of string
205205-206206-(** LIST selection options per RFC 5258 Section 3.1
207207-208208- Selection options control which mailboxes are returned by LIST:
209209- - SUBSCRIBED: Return subscribed mailboxes (like LSUB)
210210- - REMOTE: Include remote mailboxes (not on this server)
211211- - RECURSIVEMATCH: Include ancestors of matched mailboxes
212212- - SPECIAL-USE: Return only special-use mailboxes (RFC 6154) *)
213213-type list_select_option =
214214- | List_select_subscribed (** RFC 5258 Section 3.1.1 *)
215215- | List_select_remote (** RFC 5258 Section 3.1.2 *)
216216- | List_select_recursivematch (** RFC 5258 Section 3.1.3 *)
217217- | List_select_special_use (** RFC 6154 Section 3 *)
218218-219219-(** LIST return options per RFC 5258 Section 3.2
220220-221221- Return options control what additional data is returned:
222222- - SUBSCRIBED: Include \Subscribed flag
223223- - CHILDREN: Include \HasChildren/\HasNoChildren flags
224224- - SPECIAL-USE: Include special-use flags (RFC 6154) *)
225225-type list_return_option =
226226- | List_return_subscribed (** RFC 5258 Section 3.2.1 *)
227227- | List_return_children (** RFC 5258 Section 3.2.2 *)
228228- | List_return_special_use (** RFC 6154 Section 3 *)
229229-230230-(** Extended data items in LIST response per RFC 5258 Section 3.5 *)
231231-type list_extended_item =
232232- | Childinfo of string list (** RFC 5258 Section 3.5 - CHILDINFO extended data *)
233233-234234-(** LIST command variants per RFC 5258 *)
235235-type list_command =
236236- | List_basic of {
237237- reference : string; (** Reference name (context for pattern) *)
238238- pattern : string; (** Mailbox pattern with wildcards *)
239239- }
240240- | List_extended of {
241241- selection : list_select_option list; (** RFC 5258 Section 3.1 *)
242242- reference : string;
243243- patterns : string list; (** Multiple patterns allowed *)
244244- return_opts : list_return_option list; (** RFC 5258 Section 3.2 *)
245245- }
246246-247247-(** Extended LIST response per RFC 5258 Section 3.4 *)
248248-type list_response_data = {
249249- flags : list_flag list;
250250- delimiter : char option;
251251- name : mailbox_name;
252252- extended : list_extended_item list; (** RFC 5258 Section 3.5 *)
253253-}
254254-255255-(* Connection state - RFC 9051 Section 3 *)
256256-type connection_state =
257257- | Not_authenticated
258258- | Authenticated of { username : string }
259259- | Selected of { username : string; mailbox : mailbox_name; readonly : bool }
260260- | Logout
261261-262262-(* Mailbox state *)
263263-type mailbox_state = {
264264- name : mailbox_name;
265265- exists : int;
266266- uidvalidity : uidvalidity;
267267- uidnext : uid;
268268- flags : flag list;
269269- permanent_flags : flag list;
270270- readonly : bool;
271271-}
272272-273273-(* Message representation *)
274274-type message = {
275275- uid : uid;
276276- seq : seq_num;
277277- flags : flag list;
278278- internal_date : string;
279279- size : int64;
280280- envelope : envelope option;
281281- body_structure : body_structure option;
282282- raw_headers : string option;
283283- raw_body : string option;
284284-}
285285-286286-(* Response codes - RFC 9051 Section 7.1 *)
287287-type response_code =
288288- | Code_alert
289289- | Code_alreadyexists
290290- | Code_appenduid of uidvalidity * uid
291291- | Code_authenticationfailed
292292- | Code_authorizationfailed
293293- | Code_badcharset of string list
294294- | Code_cannot
295295- | Code_capability of string list
296296- | Code_clientbug
297297- | Code_closed
298298- | Code_contactadmin
299299- | Code_copyuid of uidvalidity * sequence_set * sequence_set
300300- | Code_corruption
301301- | Code_expired
302302- | Code_expungeissued
303303- | Code_haschildren
304304- | Code_inuse
305305- | Code_limit
306306- | Code_nonexistent
307307- | Code_noperm
308308- | Code_overquota
309309- | Code_parse
310310- | Code_permanentflags of flag list
311311- | Code_privacyrequired
312312- | Code_readonly
313313- | Code_readwrite
314314- | Code_serverbug
315315- | Code_trycreate
316316- | Code_uidnotsticky
317317- | Code_uidvalidity of uidvalidity
318318- | Code_uidnext of uid
319319- | Code_unavailable
320320- | Code_unknown_cte
321321- | Code_other of string * string option
322322-323323-(* Utility functions *)
324324-325325-(** Validate a username for path safety.
326326- Prevents path traversal attacks by rejecting dangerous characters. *)
327327-let is_safe_username username =
328328- let len = String.length username in
329329- if len = 0 || len > 256 then false
330330- else
331331- (* Reject null bytes, path separators, and path traversal *)
332332- not (String.contains username '\x00') &&
333333- not (String.contains username '/') &&
334334- not (String.contains username '\\') &&
335335- username <> "." &&
336336- username <> ".." &&
337337- (* Reject leading/trailing dots and spaces *)
338338- username.[0] <> '.' &&
339339- username.[len - 1] <> '.' &&
340340- username.[0] <> ' ' &&
341341- username.[len - 1] <> ' '
342342-343343-(** Validate a mailbox name for path safety.
344344- Prevents path traversal attacks. Allows '/' as hierarchy delimiter. *)
345345-let is_safe_mailbox_name name =
346346- let len = String.length name in
347347- if len = 0 || len > 1024 then false
348348- else
349349- (* Reject null bytes and backslashes *)
350350- not (String.contains name '\x00') &&
351351- not (String.contains name '\\') &&
352352- (* Reject path components that are . or .. *)
353353- let parts = String.split_on_char '/' name in
354354- not (List.exists (fun p -> p = "." || p = "..") parts)
355355-356356-let normalize_mailbox_name name =
357357- if String.uppercase_ascii name = "INBOX" then "INBOX"
358358- else name
359359-360360-let is_inbox name =
361361- String.uppercase_ascii name = "INBOX"
362362-363363-let system_flag_to_string = function
364364- | Seen -> "\\Seen"
365365- | Answered -> "\\Answered"
366366- | Flagged -> "\\Flagged"
367367- | Deleted -> "\\Deleted"
368368- | Draft -> "\\Draft"
369369-370370-let flag_to_string = function
371371- | System sf -> system_flag_to_string sf
372372- | Keyword kw -> kw
373373-374374-let string_to_flag s =
375375- let s_upper = String.uppercase_ascii s in
376376- match s_upper with
377377- | "\\SEEN" -> Some (System Seen)
378378- | "\\ANSWERED" -> Some (System Answered)
379379- | "\\FLAGGED" -> Some (System Flagged)
380380- | "\\DELETED" -> Some (System Deleted)
381381- | "\\DRAFT" -> Some (System Draft)
382382- | _ ->
383383- if String.length s > 0 && s.[0] <> '\\' then
384384- Some (Keyword s)
385385- else
386386- None
387387-388388-(* === THREAD Types - RFC 5256 === *)
389389-390390-(** Threading algorithm for the THREAD command.
391391- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-3}RFC 5256 Section 3}. *)
392392-type thread_algorithm =
393393- | Thread_orderedsubject
394394- (** ORDEREDSUBJECT algorithm (RFC 5256 Section 3.1).
395395- Groups messages by base subject, then sorts by sent date. *)
396396- | Thread_references
397397- (** REFERENCES algorithm (RFC 5256 Section 3.2).
398398- Implements the JWZ threading algorithm using Message-ID,
399399- In-Reply-To, and References headers. *)
400400- | Thread_extension of string
401401- (** Future algorithm extensions. *)
402402-403403-(** A thread node in the THREAD response.
404404- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-4}RFC 5256 Section 4}. *)
405405-type thread_node =
406406- | Thread_message of int * thread_node list
407407- (** A message with its sequence number/UID and child threads. *)
408408- | Thread_dummy of thread_node list
409409- (** A placeholder for a missing parent message. *)
410410-411411-(** Thread result: a list of root-level thread trees.
412412- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-4}RFC 5256 Section 4}. *)
413413-type thread_result = thread_node list
414414-415415-(* === Quota Types - RFC 9208 === *)
416416-417417-(** Quota resource types.
418418- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5}RFC 9208 Section 5}. *)
419419-type quota_resource =
420420- | Quota_storage (** STORAGE - physical space in KB.
421421- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.1}RFC 9208 Section 5.1}. *)
422422- | Quota_message (** MESSAGE - number of messages.
423423- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.2}RFC 9208 Section 5.2}. *)
424424- | Quota_mailbox (** MAILBOX - number of mailboxes.
425425- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.3}RFC 9208 Section 5.3}. *)
426426- | Quota_annotation_storage (** ANNOTATION-STORAGE - annotation size in KB.
427427- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.4}RFC 9208 Section 5.4}. *)
428428-429429-(** A single quota resource with usage and limit.
430430- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.2.2}RFC 9208 Section 4.2.2}. *)
431431-type quota_resource_info = {
432432- resource : quota_resource;
433433- usage : int64; (** Current usage *)
434434- limit : int64; (** Maximum allowed *)
435435-}
436436-437437-(* === Commands - RFC 9051 Section 6 === *)
438438-439439-type command =
440440- | Capability
441441- | Noop
442442- | Logout
443443- | Starttls
444444- | Login of { username : string; password : string }
445445- | Authenticate of { mechanism : string; initial_response : string option }
446446- | Enable of string list
447447- | Select of mailbox_name
448448- | Examine of mailbox_name
449449- | Create of mailbox_name
450450- | Delete of mailbox_name
451451- | Rename of { old_name : mailbox_name; new_name : mailbox_name }
452452- | Subscribe of mailbox_name
453453- | Unsubscribe of mailbox_name
454454- | List of list_command (** LIST command - RFC 9051, RFC 5258 LIST-EXTENDED *)
455455- | Namespace
456456- | Status of { mailbox : mailbox_name; items : status_item list }
457457- | Append of { mailbox : mailbox_name; flags : flag list; date : string option; message : string }
458458- | Idle
459459- | Close
460460- | Unselect
461461- | Expunge
462462- | Search of { charset : string option; criteria : search_key }
463463- | Fetch of { sequence : sequence_set; items : fetch_item list }
464464- | Store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
465465- | Copy of { sequence : sequence_set; mailbox : mailbox_name }
466466- | Move of { sequence : sequence_set; mailbox : mailbox_name }
467467- | Uid of uid_command
468468- | Id of (string * string) list option (** RFC 2971 - NIL or list of field/value pairs *)
469469- (* QUOTA extension - RFC 9208 *)
470470- | Getquota of string (** GETQUOTA quota-root.
471471- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.2}RFC 9208 Section 4.2}. *)
472472- | Getquotaroot of mailbox_name (** GETQUOTAROOT mailbox.
473473- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.3}RFC 9208 Section 4.3}. *)
474474- | Setquota of { root : string; limits : (quota_resource * int64) list } (** SETQUOTA.
475475- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.1}RFC 9208 Section 4.1}. *)
476476- (* THREAD extension - RFC 5256 *)
477477- | Thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
478478- (** THREAD command.
479479- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-3}RFC 5256 Section 3}. *)
480480-481481-and uid_command =
482482- | Uid_fetch of { sequence : sequence_set; items : fetch_item list }
483483- | Uid_store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
484484- | Uid_copy of { sequence : sequence_set; mailbox : mailbox_name }
485485- | Uid_move of { sequence : sequence_set; mailbox : mailbox_name }
486486- | Uid_search of { charset : string option; criteria : search_key }
487487- | Uid_expunge of sequence_set
488488- | Uid_thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
489489- (** UID THREAD command - RFC 5256. Returns UIDs instead of sequence numbers. *)
490490-491491-type tagged_command = {
492492- tag : string;
493493- command : command;
494494-}
495495-496496-(* === Responses - RFC 9051 Section 7 === *)
497497-498498-type namespace_entry = {
499499- prefix : string;
500500- delimiter : char option;
501501-}
502502-503503-type namespace_data = {
504504- personal : namespace_entry list option;
505505- other : namespace_entry list option;
506506- shared : namespace_entry list option;
507507-}
508508-509509-type esearch_result =
510510- | Esearch_min of int
511511- | Esearch_max of int
512512- | Esearch_count of int
513513- | Esearch_all of sequence_set
514514-515515-type fetch_response_item =
516516- | Fetch_item_envelope of envelope
517517- | Fetch_item_flags of flag list
518518- | Fetch_item_internaldate of string
519519- | Fetch_item_rfc822_size of int64
520520- | Fetch_item_uid of uid
521521- | Fetch_item_body of body_structure
522522- | Fetch_item_bodystructure of body_structure
523523- | Fetch_item_body_section of { section : section_spec option; origin : int option; data : string option }
524524- | Fetch_item_binary of { section : int list; data : string option }
525525- | Fetch_item_binary_size of { section : int list; size : int64 }
526526-527527-type response =
528528- | Ok of { tag : string option; code : response_code option; text : string }
529529- | No of { tag : string option; code : response_code option; text : string }
530530- | Bad of { tag : string option; code : response_code option; text : string }
531531- | Preauth of { code : response_code option; text : string }
532532- | Bye of { code : response_code option; text : string }
533533- | Capability_response of string list
534534- | Enabled of string list
535535- | List_response of list_response_data (** RFC 9051, RFC 5258 LIST-EXTENDED *)
536536- | Namespace_response of namespace_data
537537- | Status_response of { mailbox : mailbox_name; items : (status_item * int64) list }
538538- | Esearch of { tag : string option; uid : bool; results : esearch_result list }
539539- | Flags_response of flag list
540540- | Exists of int
541541- | Expunge_response of int
542542- | Fetch_response of { seq : int; items : fetch_response_item list }
543543- | Continuation of string option
544544- | Id_response of (string * string) list option (** RFC 2971 - NIL or list of field/value pairs *)
545545- (* QUOTA extension responses - RFC 9208 *)
546546- | Quota_response of { root : string; resources : quota_resource_info list }
547547- (** QUOTA response.
548548- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.1}RFC 9208 Section 5.1}. *)
549549- | Quotaroot_response of { mailbox : mailbox_name; roots : string list }
550550- (** QUOTAROOT response.
551551- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.2}RFC 9208 Section 5.2}. *)
552552- (* THREAD extension response - RFC 5256 *)
553553- | Thread_response of thread_result
554554- (** THREAD response - a list of thread trees.
555555- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-4}RFC 5256 Section 4}. *)
-577
lib/imapd/protocol.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Core Types
77-88- This module defines the core types for the IMAP4rev2 protocol as specified in
99- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051}.
1010-1111- {2 References}
1212- {ul
1313- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
1414- {- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3}RFC 9051 Section 2.3} - Message Attributes}
1515- {- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-3}RFC 9051 Section 3} - State and Flow Diagram}} *)
1616-1717-(** {1 Basic Types} *)
1818-1919-type mailbox_name = string
2020-(** Mailbox name. INBOX is case-insensitive per {{:https://datatracker.ietf.org/doc/html/rfc9051#section-5.1}RFC 9051 Section 5.1}. *)
2121-2222-type uid = int32
2323-(** Unique identifier for a message.
2424- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.1.1}RFC 9051 Section 2.3.1.1}. *)
2525-2626-type seq_num = int
2727-(** Message sequence number (1-based).
2828- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.1.2}RFC 9051 Section 2.3.1.2}. *)
2929-3030-type uidvalidity = int32
3131-(** UIDVALIDITY value for a mailbox.
3232- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.1.1}RFC 9051 Section 2.3.1.1}. *)
3333-3434-(** {1 Message Flags}
3535-3636- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.2}RFC 9051 Section 2.3.2}. *)
3737-3838-type system_flag =
3939- | Seen (** Message has been read *)
4040- | Answered (** Message has been answered *)
4141- | Flagged (** Message is flagged for urgent/special attention *)
4242- | Deleted (** Message is marked for deletion *)
4343- | Draft (** Message has not completed composition *)
4444-4545-type flag =
4646- | System of system_flag
4747- | Keyword of string (** User-defined keyword (e.g., "$Forwarded", "$Junk") *)
4848-4949-(** {1 Email Addresses}
5050-5151- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-7.5.2}RFC 9051 Section 7.5.2} ENVELOPE structure. *)
5252-5353-type address = {
5454- name : string option; (** Display name *)
5555- adl : string option; (** Source route (obsolete, usually NIL) *)
5656- mailbox : string option; (** Local part of email address *)
5757- host : string option; (** Domain part of email address *)
5858-}
5959-6060-(** {1 Message Envelope}
6161-6262- Parsed representation of RFC 5322 header.
6363- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.5}RFC 9051 Section 2.3.5}. *)
6464-6565-type envelope = {
6666- date : string option;
6767- subject : string option;
6868- from : address list;
6969- sender : address list;
7070- reply_to : address list;
7171- to_ : address list;
7272- cc : address list;
7373- bcc : address list;
7474- in_reply_to : string option;
7575- message_id : string option;
7676-}
7777-7878-(** {1 Body Structure}
7979-8080- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3.6}RFC 9051 Section 2.3.6}. *)
8181-8282-type body_fields = {
8383- params : (string * string) list; (** Content-Type parameters *)
8484- content_id : string option;
8585- description : string option;
8686- encoding : string; (** Content-Transfer-Encoding *)
8787- size : int64; (** Size in octets *)
8888-}
8989-9090-type body_type =
9191- | Text of {
9292- subtype : string;
9393- fields : body_fields;
9494- lines : int64;
9595- }
9696- | Message_rfc822 of {
9797- fields : body_fields;
9898- envelope : envelope;
9999- body : body_structure;
100100- lines : int64;
101101- }
102102- | Basic of {
103103- media_type : string;
104104- subtype : string;
105105- fields : body_fields;
106106- }
107107- | Multipart of {
108108- subtype : string;
109109- parts : body_structure list;
110110- params : (string * string) list;
111111- }
112112-113113-and body_structure = {
114114- body_type : body_type;
115115- disposition : (string * (string * string) list) option;
116116- language : string list option;
117117- location : string option;
118118-}
119119-120120-(** {1 Sequence Sets}
121121-122122- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-4.1.1}RFC 9051 Section 4.1.1}. *)
123123-124124-type sequence_range =
125125- | Single of int (** Single message number *)
126126- | Range of int * int (** Range n:m *)
127127- | From of int (** n:* (from n to end) *)
128128- | All (** * (all messages) *)
129129-130130-type sequence_set = sequence_range list
131131-132132-(** {1 Section Specification}
133133-134134- For BODY[...] fetch items.
135135- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5}RFC 9051 Section 6.4.5}. *)
136136-137137-type section_spec =
138138- | Section_header
139139- | Section_header_fields of string list
140140- | Section_header_fields_not of string list
141141- | Section_text
142142- | Section_mime
143143- | Section_part of int list * section_spec option
144144-145145-type body_section = {
146146- section : section_spec option;
147147- partial : (int * int) option; (** <offset.length> *)
148148-}
149149-150150-(** {1 FETCH Items}
151151-152152- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5}RFC 9051 Section 6.4.5}. *)
153153-154154-type fetch_item =
155155- | Fetch_envelope
156156- | Fetch_flags
157157- | Fetch_internaldate
158158- | Fetch_rfc822
159159- | Fetch_rfc822_size
160160- | Fetch_rfc822_header
161161- | Fetch_rfc822_text
162162- | Fetch_uid
163163- | Fetch_body
164164- | Fetch_bodystructure
165165- | Fetch_body_section of string * (int * int) option (** section string, partial *)
166166- | Fetch_body_peek of string * (int * int) option
167167- | Fetch_binary of string * (int * int) option
168168- | Fetch_binary_peek of string * (int * int) option
169169- | Fetch_binary_size of string
170170-171171-(** {1 SEARCH Criteria}
172172-173173- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.4}RFC 9051 Section 6.4.4}. *)
174174-175175-type search_key =
176176- | Search_all
177177- | Search_answered
178178- | Search_bcc of string
179179- | Search_before of string (** date *)
180180- | Search_body of string
181181- | Search_cc of string
182182- | Search_deleted
183183- | Search_flagged
184184- | Search_from of string
185185- | Search_keyword of string
186186- | Search_new
187187- | Search_not of search_key
188188- | Search_old
189189- | Search_on of string (** date *)
190190- | Search_or of search_key * search_key
191191- | Search_seen
192192- | Search_since of string (** date *)
193193- | Search_subject of string
194194- | Search_text of string
195195- | Search_to of string
196196- | Search_unanswered
197197- | Search_undeleted
198198- | Search_unflagged
199199- | Search_unkeyword of string
200200- | Search_unseen
201201- | Search_draft
202202- | Search_undraft
203203- | Search_header of string * string
204204- | Search_larger of int64
205205- | Search_smaller of int64
206206- | Search_uid of sequence_set
207207- | Search_sequence_set of sequence_set
208208- | Search_and of search_key list
209209- | Search_sentbefore of string
210210- | Search_senton of string
211211- | Search_sentsince of string
212212-213213-(** {1 STORE Actions}
214214-215215- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.6}RFC 9051 Section 6.4.6}. *)
216216-217217-type store_action =
218218- | Store_set (** FLAGS - replace flags *)
219219- | Store_add (** +FLAGS - add flags *)
220220- | Store_remove (** -FLAGS - remove flags *)
221221-222222-type store_silent = bool (** .SILENT modifier *)
223223-224224-(** {1 STATUS Items}
225225-226226- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.11}RFC 9051 Section 6.3.11}. *)
227227-228228-type status_item =
229229- | Status_messages
230230- | Status_uidnext
231231- | Status_uidvalidity
232232- | Status_unseen
233233- | Status_deleted
234234- | Status_size
235235-236236-(** {1 LIST Flags}
237237-238238- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-7.3.1}RFC 9051 Section 7.3.1}. *)
239239-240240-type list_flag =
241241- | List_noinferiors (** \Noinferiors *)
242242- | List_noselect (** \Noselect *)
243243- | List_marked (** \Marked *)
244244- | List_unmarked (** \Unmarked *)
245245- | List_subscribed (** \Subscribed *)
246246- | List_haschildren (** \HasChildren *)
247247- | List_hasnochildren (** \HasNoChildren *)
248248- | List_nonexistent (** \NonExistent - RFC 5258 Section 3.4 *)
249249- | List_remote (** \Remote - RFC 5258 Section 3.4 *)
250250- | List_all (** \All - special-use *)
251251- | List_archive (** \Archive *)
252252- | List_drafts (** \Drafts *)
253253- | List_flagged (** \Flagged *)
254254- | List_junk (** \Junk *)
255255- | List_sent (** \Sent *)
256256- | List_trash (** \Trash *)
257257- | List_extension of string (** Other flags *)
258258-259259-(** LIST selection options per RFC 5258 Section 3.1 *)
260260-type list_select_option =
261261- | List_select_subscribed (** RFC 5258 Section 3.1.1 *)
262262- | List_select_remote (** RFC 5258 Section 3.1.2 *)
263263- | List_select_recursivematch (** RFC 5258 Section 3.1.3 *)
264264- | List_select_special_use (** RFC 6154 Section 3 *)
265265-266266-(** LIST return options per RFC 5258 Section 3.2 *)
267267-type list_return_option =
268268- | List_return_subscribed (** RFC 5258 Section 3.2.1 *)
269269- | List_return_children (** RFC 5258 Section 3.2.2 *)
270270- | List_return_special_use (** RFC 6154 Section 3 *)
271271-272272-(** Extended data items in LIST response per RFC 5258 Section 3.5 *)
273273-type list_extended_item =
274274- | Childinfo of string list (** RFC 5258 Section 3.5 - CHILDINFO extended data *)
275275-276276-(** LIST command variants per RFC 5258 *)
277277-type list_command =
278278- | List_basic of {
279279- reference : string; (** Reference name *)
280280- pattern : string; (** Mailbox pattern *)
281281- }
282282- | List_extended of {
283283- selection : list_select_option list;
284284- reference : string;
285285- patterns : string list;
286286- return_opts : list_return_option list;
287287- }
288288-289289-(** Extended LIST response per RFC 5258 Section 3.4 *)
290290-type list_response_data = {
291291- flags : list_flag list;
292292- delimiter : char option;
293293- name : mailbox_name;
294294- extended : list_extended_item list;
295295-}
296296-297297-(** {1 Connection State}
298298-299299- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-3}RFC 9051 Section 3}. *)
300300-301301-type connection_state =
302302- | Not_authenticated
303303- | Authenticated of { username : string }
304304- | Selected of { username : string; mailbox : mailbox_name; readonly : bool }
305305- | Logout
306306-307307-(** {1 Mailbox State}
308308-309309- Information about a selected mailbox. *)
310310-311311-type mailbox_state = {
312312- name : mailbox_name;
313313- exists : int; (** Number of messages *)
314314- uidvalidity : uidvalidity;
315315- uidnext : uid;
316316- flags : flag list; (** Available flags *)
317317- permanent_flags : flag list; (** Flags that can be changed permanently *)
318318- readonly : bool;
319319-}
320320-321321-(** {1 Message Representation} *)
322322-323323-type message = {
324324- uid : uid;
325325- seq : seq_num;
326326- flags : flag list;
327327- internal_date : string;
328328- size : int64;
329329- envelope : envelope option;
330330- body_structure : body_structure option;
331331- raw_headers : string option;
332332- raw_body : string option;
333333-}
334334-335335-(** {1 Response Codes}
336336-337337- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-7.1}RFC 9051 Section 7.1}. *)
338338-339339-type response_code =
340340- | Code_alert
341341- | Code_alreadyexists
342342- | Code_appenduid of uidvalidity * uid
343343- | Code_authenticationfailed
344344- | Code_authorizationfailed
345345- | Code_badcharset of string list
346346- | Code_cannot
347347- | Code_capability of string list
348348- | Code_clientbug
349349- | Code_closed
350350- | Code_contactadmin
351351- | Code_copyuid of uidvalidity * sequence_set * sequence_set
352352- | Code_corruption
353353- | Code_expired
354354- | Code_expungeissued
355355- | Code_haschildren
356356- | Code_inuse
357357- | Code_limit
358358- | Code_nonexistent
359359- | Code_noperm
360360- | Code_overquota
361361- | Code_parse
362362- | Code_permanentflags of flag list
363363- | Code_privacyrequired
364364- | Code_readonly
365365- | Code_readwrite
366366- | Code_serverbug
367367- | Code_trycreate
368368- | Code_uidnotsticky
369369- | Code_uidvalidity of uidvalidity
370370- | Code_uidnext of uid
371371- | Code_unavailable
372372- | Code_unknown_cte
373373- | Code_other of string * string option
374374-375375-(** {1 Quota Types}
376376-377377- See {{:https://datatracker.ietf.org/doc/html/rfc9208}RFC 9208 - IMAP QUOTA Extension}. *)
378378-379379-(** Quota resource types.
380380- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5}RFC 9208 Section 5}. *)
381381-type quota_resource =
382382- | Quota_storage (** STORAGE - physical space in KB.
383383- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.1}RFC 9208 Section 5.1}. *)
384384- | Quota_message (** MESSAGE - number of messages.
385385- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.2}RFC 9208 Section 5.2}. *)
386386- | Quota_mailbox (** MAILBOX - number of mailboxes.
387387- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.3}RFC 9208 Section 5.3}. *)
388388- | Quota_annotation_storage (** ANNOTATION-STORAGE - annotation size in KB.
389389- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.4}RFC 9208 Section 5.4}. *)
390390-391391-(** A single quota resource with usage and limit.
392392- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.2.2}RFC 9208 Section 4.2.2}. *)
393393-type quota_resource_info = {
394394- resource : quota_resource;
395395- usage : int64; (** Current usage *)
396396- limit : int64; (** Maximum allowed *)
397397-}
398398-399399-(** {1 Thread Types}
400400-401401- See {{:https://datatracker.ietf.org/doc/html/rfc5256}RFC 5256 - IMAP SORT and THREAD Extensions}. *)
402402-403403-(** Threading algorithm for the THREAD command.
404404- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-3}RFC 5256 Section 3}. *)
405405-type thread_algorithm =
406406- | Thread_orderedsubject
407407- (** ORDEREDSUBJECT algorithm (RFC 5256 Section 3.1). *)
408408- | Thread_references
409409- (** REFERENCES algorithm (RFC 5256 Section 3.2). *)
410410- | Thread_extension of string
411411- (** Future algorithm extensions. *)
412412-413413-(** A thread node in the THREAD response.
414414- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-4}RFC 5256 Section 4}. *)
415415-type thread_node =
416416- | Thread_message of int * thread_node list
417417- (** A message with its sequence number/UID and child threads. *)
418418- | Thread_dummy of thread_node list
419419- (** A placeholder for a missing parent message. *)
420420-421421-(** Thread result: a list of root-level thread trees. *)
422422-type thread_result = thread_node list
423423-424424-(** {1 Commands}
425425-426426- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6}RFC 9051 Section 6}. *)
427427-428428-type command =
429429- | Capability
430430- | Noop
431431- | Logout
432432- | Starttls
433433- | Login of { username : string; password : string }
434434- | Authenticate of { mechanism : string; initial_response : string option }
435435- | Enable of string list
436436- | Select of mailbox_name
437437- | Examine of mailbox_name
438438- | Create of mailbox_name
439439- | Delete of mailbox_name
440440- | Rename of { old_name : mailbox_name; new_name : mailbox_name }
441441- | Subscribe of mailbox_name
442442- | Unsubscribe of mailbox_name
443443- | List of list_command (** LIST command - RFC 9051, RFC 5258 LIST-EXTENDED *)
444444- | Namespace
445445- | Status of { mailbox : mailbox_name; items : status_item list }
446446- | Append of { mailbox : mailbox_name; flags : flag list; date : string option; message : string }
447447- | Idle
448448- | Close
449449- | Unselect
450450- | Expunge
451451- | Search of { charset : string option; criteria : search_key }
452452- | Fetch of { sequence : sequence_set; items : fetch_item list }
453453- | Store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
454454- | Copy of { sequence : sequence_set; mailbox : mailbox_name }
455455- | Move of { sequence : sequence_set; mailbox : mailbox_name }
456456- | Uid of uid_command
457457- | Id of (string * string) list option (** RFC 2971 *)
458458- (* QUOTA extension - RFC 9208 *)
459459- | Getquota of string (** GETQUOTA quota-root.
460460- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.2}RFC 9208 Section 4.2}. *)
461461- | Getquotaroot of mailbox_name (** GETQUOTAROOT mailbox.
462462- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.3}RFC 9208 Section 4.3}. *)
463463- | Setquota of { root : string; limits : (quota_resource * int64) list } (** SETQUOTA.
464464- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-4.1}RFC 9208 Section 4.1}. *)
465465- (* THREAD extension - RFC 5256 *)
466466- | Thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
467467- (** THREAD command.
468468- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-3}RFC 5256 Section 3}. *)
469469-470470-and uid_command =
471471- | Uid_fetch of { sequence : sequence_set; items : fetch_item list }
472472- | Uid_store of { sequence : sequence_set; silent : bool; action : store_action; flags : flag list }
473473- | Uid_copy of { sequence : sequence_set; mailbox : mailbox_name }
474474- | Uid_move of { sequence : sequence_set; mailbox : mailbox_name }
475475- | Uid_search of { charset : string option; criteria : search_key }
476476- | Uid_expunge of sequence_set
477477- | Uid_thread of { algorithm : thread_algorithm; charset : string; criteria : search_key }
478478- (** UID THREAD command - RFC 5256. Returns UIDs instead of sequence numbers. *)
479479-480480-type tagged_command = {
481481- tag : string;
482482- command : command;
483483-}
484484-485485-(** {1 Responses}
486486-487487- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-7}RFC 9051 Section 7}. *)
488488-489489-type namespace_entry = {
490490- prefix : string;
491491- delimiter : char option;
492492-}
493493-494494-type namespace_data = {
495495- personal : namespace_entry list option;
496496- other : namespace_entry list option;
497497- shared : namespace_entry list option;
498498-}
499499-500500-type esearch_result =
501501- | Esearch_min of int
502502- | Esearch_max of int
503503- | Esearch_count of int
504504- | Esearch_all of sequence_set
505505-506506-type fetch_response_item =
507507- | Fetch_item_envelope of envelope
508508- | Fetch_item_flags of flag list
509509- | Fetch_item_internaldate of string
510510- | Fetch_item_rfc822_size of int64
511511- | Fetch_item_uid of uid
512512- | Fetch_item_body of body_structure
513513- | Fetch_item_bodystructure of body_structure
514514- | Fetch_item_body_section of { section : section_spec option; origin : int option; data : string option }
515515- | Fetch_item_binary of { section : int list; data : string option }
516516- | Fetch_item_binary_size of { section : int list; size : int64 }
517517-518518-type response =
519519- | Ok of { tag : string option; code : response_code option; text : string }
520520- | No of { tag : string option; code : response_code option; text : string }
521521- | Bad of { tag : string option; code : response_code option; text : string }
522522- | Preauth of { code : response_code option; text : string }
523523- | Bye of { code : response_code option; text : string }
524524- | Capability_response of string list
525525- | Enabled of string list
526526- | List_response of list_response_data (** RFC 9051, RFC 5258 LIST-EXTENDED *)
527527- | Namespace_response of namespace_data
528528- | Status_response of { mailbox : mailbox_name; items : (status_item * int64) list }
529529- | Esearch of { tag : string option; uid : bool; results : esearch_result list }
530530- | Flags_response of flag list
531531- | Exists of int
532532- | Expunge_response of int
533533- | Fetch_response of { seq : int; items : fetch_response_item list }
534534- | Continuation of string option
535535- | Id_response of (string * string) list option (** RFC 2971 *)
536536- (* QUOTA extension responses - RFC 9208 *)
537537- | Quota_response of { root : string; resources : quota_resource_info list }
538538- (** QUOTA response.
539539- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.1}RFC 9208 Section 5.1}. *)
540540- | Quotaroot_response of { mailbox : mailbox_name; roots : string list }
541541- (** QUOTAROOT response.
542542- See {{:https://datatracker.ietf.org/doc/html/rfc9208#section-5.2}RFC 9208 Section 5.2}. *)
543543- (* THREAD extension response - RFC 5256 *)
544544- | Thread_response of thread_result
545545- (** THREAD response containing thread tree.
546546- See {{:https://datatracker.ietf.org/doc/html/rfc5256#section-4}RFC 5256 Section 4}. *)
547547-548548-(** {1 Utility Functions} *)
549549-550550-(** {2 Security Validation} *)
551551-552552-val is_safe_username : string -> bool
553553-(** Validate username for path safety. Prevents path traversal attacks.
554554- Returns false for usernames containing null bytes, path separators,
555555- or traversal patterns like [..]. *)
556556-557557-val is_safe_mailbox_name : mailbox_name -> bool
558558-(** Validate mailbox name for path safety. Prevents path traversal attacks.
559559- Returns false for names containing null bytes, backslashes, or
560560- path components that are [.] or [..]. Allows [/] as hierarchy delimiter. *)
561561-562562-(** {2 Mailbox Utilities} *)
563563-564564-val normalize_mailbox_name : mailbox_name -> mailbox_name
565565-(** Normalize mailbox name. INBOX is case-insensitive. *)
566566-567567-val is_inbox : mailbox_name -> bool
568568-(** Check if mailbox name is INBOX (case-insensitive). *)
569569-570570-val flag_to_string : flag -> string
571571-(** Convert flag to IMAP string representation. *)
572572-573573-val string_to_flag : string -> flag option
574574-(** Parse IMAP flag string. *)
575575-576576-val system_flag_to_string : system_flag -> string
577577-(** Convert system flag to string. *)
-979
lib/imapd/read.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-open Protocol
77-module R = Eio.Buf_read
88-99-let is_atom_char = function
1010- | '(' | ')' | '{' | ' ' | '\x00' .. '\x1f' | '\x7f' | '%' | '*' | '"' | '\\'
1111- | '[' | ']' ->
1212- false
1313- | _ -> true
1414-1515-let is_digit c = c >= '0' && c <= '9'
1616-let[@warning "-32"] is_space c = c = ' '
1717-1818-let sp r =
1919- let c = R.any_char r in
2020- if c <> ' ' then failwith (Printf.sprintf "expected SP, got %C" c)
2121-2222-let crlf r =
2323- let c1 = R.any_char r in
2424- let c2 = R.any_char r in
2525- if c1 <> '\r' || c2 <> '\n' then
2626- failwith (Printf.sprintf "expected CRLF, got %C%C" c1 c2)
2727-2828-let peek_char r = R.peek_char r
2929-3030-let[@warning "-32"] skip_while p r =
3131- while
3232- match peek_char r with Some c when p c -> true | _ -> false
3333- do
3434- ignore (R.any_char r)
3535- done
3636-3737-let take_while p r =
3838- let buf = Buffer.create 32 in
3939- while
4040- match peek_char r with
4141- | Some c when p c ->
4242- Buffer.add_char buf c;
4343- ignore (R.any_char r);
4444- true
4545- | _ -> false
4646- do
4747- ()
4848- done;
4949- Buffer.contents buf
5050-5151-let atom r =
5252- let s = take_while is_atom_char r in
5353- if String.length s = 0 then failwith "expected atom";
5454- s
5555-5656-let number r =
5757- let s = take_while is_digit r in
5858- if String.length s = 0 then failwith "expected number";
5959- int_of_string s
6060-6161-let number32 r = Int32.of_string (take_while is_digit r)
6262-let number64 r = Int64.of_string (take_while is_digit r)
6363-6464-let quoted_string r =
6565- let c = R.any_char r in
6666- if c <> '"' then failwith (Printf.sprintf "expected '\"', got %C" c);
6767- let buf = Buffer.create 64 in
6868- let rec loop () =
6969- match R.any_char r with
7070- | '"' -> Buffer.contents buf
7171- | '\\' ->
7272- let c = R.any_char r in
7373- Buffer.add_char buf c;
7474- loop ()
7575- | c ->
7676- Buffer.add_char buf c;
7777- loop ()
7878- in
7979- loop ()
8080-8181-let literal r =
8282- let c = R.any_char r in
8383- if c <> '{' then failwith (Printf.sprintf "expected '{', got %C" c);
8484- let len = number r in
8585- (* Handle optional '+' for LITERAL+ *)
8686- (match peek_char r with Some '+' -> ignore (R.any_char r) | _ -> ());
8787- let c = R.any_char r in
8888- if c <> '}' then failwith (Printf.sprintf "expected '}', got %C" c);
8989- crlf r;
9090- R.take len r
9191-9292-let is_nil r =
9393- (* Check if the next 3 characters spell "NIL" (case-insensitive) *)
9494- R.ensure r 3;
9595- let buf = R.peek r in
9696- if Cstruct.length buf >= 3 then
9797- let c1 = Cstruct.get_char buf 0 in
9898- let c2 = Cstruct.get_char buf 1 in
9999- let c3 = Cstruct.get_char buf 2 in
100100- (c1 = 'N' || c1 = 'n') && (c2 = 'I' || c2 = 'i') && (c3 = 'L' || c3 = 'l')
101101- else false
102102-103103-let nil r =
104104- ignore (R.take 3 r);
105105- (* Consume "NIL" *)
106106- ()
107107-108108-let astring r =
109109- match peek_char r with
110110- | Some '"' -> quoted_string r
111111- | Some '{' -> literal r
112112- | _ -> atom r
113113-114114-let nstring r =
115115- if is_nil r then (
116116- nil r;
117117- None)
118118- else Some (astring r)
119119-120120-(* Parse a flag *)
121121-let flag r =
122122- match peek_char r with
123123- | Some '\\' ->
124124- ignore (R.any_char r);
125125- let name = atom r in
126126- let upper = String.uppercase_ascii name in
127127- let sf =
128128- match upper with
129129- | "SEEN" -> Some Seen
130130- | "ANSWERED" -> Some Answered
131131- | "FLAGGED" -> Some Flagged
132132- | "DELETED" -> Some Deleted
133133- | "DRAFT" -> Some Draft
134134- | _ -> None
135135- in
136136- (match sf with Some f -> System f | None -> Keyword name)
137137- | Some '$' ->
138138- ignore (R.any_char r);
139139- let name = atom r in
140140- Keyword name
141141- | _ ->
142142- let name = atom r in
143143- Keyword name
144144-145145-let flag_list r =
146146- let c = R.any_char r in
147147- if c <> '(' then failwith (Printf.sprintf "expected '(', got %C" c);
148148- let rec loop acc =
149149- match peek_char r with
150150- | Some ')' ->
151151- ignore (R.any_char r);
152152- List.rev acc
153153- | Some ' ' ->
154154- ignore (R.any_char r);
155155- loop acc
156156- | Some _ ->
157157- let f = flag r in
158158- loop (f :: acc)
159159- | None -> failwith "unexpected EOF in flag list"
160160- in
161161- loop []
162162-163163-(* Parse address: (name adl mailbox host) *)
164164-let address r =
165165- let c = R.any_char r in
166166- if c <> '(' then failwith (Printf.sprintf "expected '(' for address, got %C" c);
167167- let name = nstring r in
168168- sp r;
169169- let adl = nstring r in
170170- sp r;
171171- let mailbox = nstring r in
172172- sp r;
173173- let host = nstring r in
174174- let c = R.any_char r in
175175- if c <> ')' then failwith (Printf.sprintf "expected ')' for address, got %C" c);
176176- { name; adl; mailbox; host }
177177-178178-let address_list r =
179179- if is_nil r then (
180180- nil r;
181181- [])
182182- else
183183- let c = R.any_char r in
184184- if c <> '(' then
185185- failwith (Printf.sprintf "expected '(' for address list, got %C" c);
186186- let rec loop acc =
187187- match peek_char r with
188188- | Some ')' ->
189189- ignore (R.any_char r);
190190- List.rev acc
191191- | Some ' ' ->
192192- ignore (R.any_char r);
193193- loop acc
194194- | Some '(' ->
195195- let addr = address r in
196196- loop (addr :: acc)
197197- | Some c -> failwith (Printf.sprintf "unexpected %C in address list" c)
198198- | None -> failwith "unexpected EOF in address list"
199199- in
200200- loop []
201201-202202-let envelope r =
203203- let c = R.any_char r in
204204- if c <> '(' then failwith (Printf.sprintf "expected '(' for envelope, got %C" c);
205205- let date = nstring r in
206206- sp r;
207207- let subject = nstring r in
208208- sp r;
209209- let from = address_list r in
210210- sp r;
211211- let sender = address_list r in
212212- sp r;
213213- let reply_to = address_list r in
214214- sp r;
215215- let to_ = address_list r in
216216- sp r;
217217- let cc = address_list r in
218218- sp r;
219219- let bcc = address_list r in
220220- sp r;
221221- let in_reply_to = nstring r in
222222- sp r;
223223- let message_id = nstring r in
224224- let c = R.any_char r in
225225- if c <> ')' then failwith (Printf.sprintf "expected ')' for envelope, got %C" c);
226226- { date; subject; from; sender; reply_to; to_; cc; bcc; in_reply_to; message_id }
227227-228228-(* Parse body extension data - skip over complex nested structures *)
229229-let rec skip_body_ext r =
230230- match peek_char r with
231231- | Some '(' ->
232232- ignore (R.any_char r);
233233- let rec loop () =
234234- match peek_char r with
235235- | Some ')' ->
236236- ignore (R.any_char r);
237237- ()
238238- | Some ' ' ->
239239- ignore (R.any_char r);
240240- loop ()
241241- | _ ->
242242- skip_body_ext r;
243243- loop ()
244244- in
245245- loop ()
246246- | Some '"' ->
247247- ignore (quoted_string r);
248248- ()
249249- | Some '{' ->
250250- ignore (literal r);
251251- ()
252252- | Some c when is_digit c ->
253253- ignore (take_while is_digit r);
254254- ()
255255- | Some 'N' | Some 'n' when is_nil r ->
256256- nil r;
257257- ()
258258- | _ ->
259259- ignore (take_while is_atom_char r);
260260- ()
261261-262262-(* Parse body parameters: NIL or ((key value) ...) *)
263263-let body_params r =
264264- if is_nil r then (
265265- nil r;
266266- [])
267267- else
268268- let c = R.any_char r in
269269- if c <> '(' then
270270- failwith (Printf.sprintf "expected '(' for params, got %C" c);
271271- let rec loop acc =
272272- match peek_char r with
273273- | Some ')' ->
274274- ignore (R.any_char r);
275275- List.rev acc
276276- | Some ' ' ->
277277- ignore (R.any_char r);
278278- loop acc
279279- | Some '(' ->
280280- ignore (R.any_char r);
281281- let key = astring r in
282282- sp r;
283283- let value = astring r in
284284- let c = R.any_char r in
285285- if c <> ')' then
286286- failwith (Printf.sprintf "expected ')' for param pair, got %C" c);
287287- loop ((key, value) :: acc)
288288- | Some c -> failwith (Printf.sprintf "unexpected %C in params" c)
289289- | None -> failwith "unexpected EOF in params"
290290- in
291291- loop []
292292-293293-let body_fields r =
294294- let params = body_params r in
295295- sp r;
296296- let content_id = nstring r in
297297- sp r;
298298- let description = nstring r in
299299- sp r;
300300- let encoding = astring r in
301301- sp r;
302302- let size = number64 r in
303303- { params; content_id; description; encoding; size }
304304-305305-(* Forward declaration for recursive parsing *)
306306-let rec body_structure r =
307307- let c = R.any_char r in
308308- if c <> '(' then
309309- failwith (Printf.sprintf "expected '(' for body structure, got %C" c);
310310- match peek_char r with
311311- | Some '(' ->
312312- (* Multipart *)
313313- let rec read_parts acc =
314314- match peek_char r with
315315- | Some '(' ->
316316- let part = body_structure r in
317317- read_parts (part :: acc)
318318- | _ -> List.rev acc
319319- in
320320- let parts = read_parts [] in
321321- sp r;
322322- let subtype = astring r in
323323- (* Optional extension data *)
324324- let params =
325325- match peek_char r with
326326- | Some ' ' ->
327327- sp r;
328328- body_params r
329329- | _ -> []
330330- in
331331- (* Skip remaining extension data *)
332332- while
333333- match peek_char r with
334334- | Some ' ' ->
335335- sp r;
336336- skip_body_ext r;
337337- true
338338- | _ -> false
339339- do
340340- ()
341341- done;
342342- let c = R.any_char r in
343343- if c <> ')' then
344344- failwith (Printf.sprintf "expected ')' for multipart, got %C" c);
345345- {
346346- body_type = Multipart { subtype; parts; params };
347347- disposition = None;
348348- language = None;
349349- location = None;
350350- }
351351- | _ ->
352352- (* Single part *)
353353- let media_type = astring r in
354354- sp r;
355355- let subtype = astring r in
356356- sp r;
357357- let fields = body_fields r in
358358- let body_type, extra_fields =
359359- let upper = String.uppercase_ascii media_type in
360360- if upper = "TEXT" then (
361361- sp r;
362362- let lines = number64 r in
363363- (Text { subtype; fields; lines }, 0))
364364- else if upper = "MESSAGE" && String.uppercase_ascii subtype = "RFC822"
365365- then (
366366- sp r;
367367- let env = envelope r in
368368- sp r;
369369- let body = body_structure r in
370370- sp r;
371371- let lines = number64 r in
372372- (Message_rfc822 { fields; envelope = env; body; lines }, 0))
373373- else (Basic { media_type; subtype; fields }, 0)
374374- in
375375- ignore extra_fields;
376376- (* Skip optional extension data *)
377377- while
378378- match peek_char r with
379379- | Some ' ' ->
380380- sp r;
381381- skip_body_ext r;
382382- true
383383- | _ -> false
384384- do
385385- ()
386386- done;
387387- let c = R.any_char r in
388388- if c <> ')' then
389389- failwith (Printf.sprintf "expected ')' for body part, got %C" c);
390390- { body_type; disposition = None; language = None; location = None }
391391-392392-(* Parse sequence set *)
393393-let sequence_range r =
394394- let n = number r in
395395- match peek_char r with
396396- | Some ':' ->
397397- ignore (R.any_char r);
398398- (match peek_char r with
399399- | Some '*' ->
400400- ignore (R.any_char r);
401401- From n
402402- | _ ->
403403- let m = number r in
404404- Range (n, m))
405405- | _ -> Single n
406406-407407-let sequence_set r =
408408- let rec loop acc =
409409- let range = sequence_range r in
410410- match peek_char r with
411411- | Some ',' ->
412412- ignore (R.any_char r);
413413- loop (range :: acc)
414414- | _ -> List.rev (range :: acc)
415415- in
416416- loop []
417417-418418-(* Parse response code *)
419419-let response_code r =
420420- let c = R.any_char r in
421421- if c <> '[' then failwith (Printf.sprintf "expected '[', got %C" c);
422422- let name = atom r in
423423- let upper = String.uppercase_ascii name in
424424- let code =
425425- match upper with
426426- | "ALERT" -> Code_alert
427427- | "ALREADYEXISTS" -> Code_alreadyexists
428428- | "AUTHENTICATIONFAILED" -> Code_authenticationfailed
429429- | "AUTHORIZATIONFAILED" -> Code_authorizationfailed
430430- | "CANNOT" -> Code_cannot
431431- | "CLIENTBUG" -> Code_clientbug
432432- | "CLOSED" -> Code_closed
433433- | "CONTACTADMIN" -> Code_contactadmin
434434- | "CORRUPTION" -> Code_corruption
435435- | "EXPIRED" -> Code_expired
436436- | "EXPUNGEISSUED" -> Code_expungeissued
437437- | "HASCHILDREN" -> Code_haschildren
438438- | "INUSE" -> Code_inuse
439439- | "LIMIT" -> Code_limit
440440- | "NONEXISTENT" -> Code_nonexistent
441441- | "NOPERM" -> Code_noperm
442442- | "OVERQUOTA" -> Code_overquota
443443- | "PARSE" -> Code_parse
444444- | "PRIVACYREQUIRED" -> Code_privacyrequired
445445- | "READ-ONLY" -> Code_readonly
446446- | "READ-WRITE" -> Code_readwrite
447447- | "SERVERBUG" -> Code_serverbug
448448- | "TRYCREATE" -> Code_trycreate
449449- | "UIDNOTSTICKY" -> Code_uidnotsticky
450450- | "UNAVAILABLE" -> Code_unavailable
451451- | "UNKNOWN-CTE" -> Code_unknown_cte
452452- | "UIDVALIDITY" ->
453453- sp r;
454454- Code_uidvalidity (number32 r)
455455- | "UIDNEXT" ->
456456- sp r;
457457- Code_uidnext (number32 r)
458458- | "APPENDUID" ->
459459- sp r;
460460- let v = number32 r in
461461- sp r;
462462- let u = number32 r in
463463- Code_appenduid (v, u)
464464- | "COPYUID" ->
465465- sp r;
466466- let v = number32 r in
467467- sp r;
468468- let src = sequence_set r in
469469- sp r;
470470- let dst = sequence_set r in
471471- Code_copyuid (v, src, dst)
472472- | "CAPABILITY" ->
473473- let rec loop acc =
474474- match peek_char r with
475475- | Some ' ' ->
476476- sp r;
477477- let cap = atom r in
478478- loop (cap :: acc)
479479- | _ -> List.rev acc
480480- in
481481- Code_capability (loop [])
482482- | "PERMANENTFLAGS" ->
483483- sp r;
484484- Code_permanentflags (flag_list r)
485485- | "BADCHARSET" ->
486486- let charsets =
487487- match peek_char r with
488488- | Some ' ' ->
489489- sp r;
490490- let c = R.any_char r in
491491- if c <> '(' then [] (* Malformed, return empty *)
492492- else
493493- let rec loop acc =
494494- match peek_char r with
495495- | Some ')' ->
496496- ignore (R.any_char r);
497497- List.rev acc
498498- | Some ' ' ->
499499- sp r;
500500- loop acc
501501- | _ ->
502502- let cs = astring r in
503503- loop (cs :: acc)
504504- in
505505- loop []
506506- | _ -> []
507507- in
508508- Code_badcharset charsets
509509- | _ ->
510510- (* Unknown code, possibly with a value *)
511511- let value =
512512- match peek_char r with
513513- | Some ' ' ->
514514- sp r;
515515- Some (take_while (fun c -> c <> ']') r)
516516- | _ -> None
517517- in
518518- Code_other (name, value)
519519- in
520520- let c = R.any_char r in
521521- if c <> ']' then failwith (Printf.sprintf "expected ']', got %C" c);
522522- code
523523-524524-(* Parse a list flag *)
525525-let list_flag r =
526526- match peek_char r with
527527- | Some '\\' ->
528528- ignore (R.any_char r);
529529- let name = atom r in
530530- let upper = String.uppercase_ascii name in
531531- (match upper with
532532- | "NOINFERIORS" -> List_noinferiors
533533- | "NOSELECT" -> List_noselect
534534- | "MARKED" -> List_marked
535535- | "UNMARKED" -> List_unmarked
536536- | "SUBSCRIBED" -> List_subscribed
537537- | "HASCHILDREN" -> List_haschildren
538538- | "HASNOCHILDREN" -> List_hasnochildren
539539- | "ALL" -> List_all
540540- | "ARCHIVE" -> List_archive
541541- | "DRAFTS" -> List_drafts
542542- | "FLAGGED" -> List_flagged
543543- | "JUNK" -> List_junk
544544- | "SENT" -> List_sent
545545- | "TRASH" -> List_trash
546546- | _ -> List_extension ("\\" ^ name))
547547- | _ ->
548548- let name = atom r in
549549- List_extension name
550550-551551-let list_flag_list r =
552552- let c = R.any_char r in
553553- if c <> '(' then failwith (Printf.sprintf "expected '(', got %C" c);
554554- let rec loop acc =
555555- match peek_char r with
556556- | Some ')' ->
557557- ignore (R.any_char r);
558558- List.rev acc
559559- | Some ' ' ->
560560- ignore (R.any_char r);
561561- loop acc
562562- | _ ->
563563- let f = list_flag r in
564564- loop (f :: acc)
565565- in
566566- loop []
567567-568568-(* Parse fetch response items *)
569569-let fetch_item r =
570570- let name = atom r in
571571- let upper = String.uppercase_ascii name in
572572- match upper with
573573- | "FLAGS" ->
574574- sp r;
575575- Fetch_item_flags (flag_list r)
576576- | "UID" ->
577577- sp r;
578578- Fetch_item_uid (number32 r)
579579- | "INTERNALDATE" ->
580580- sp r;
581581- Fetch_item_internaldate (quoted_string r)
582582- | "RFC822.SIZE" ->
583583- sp r;
584584- Fetch_item_rfc822_size (number64 r)
585585- | "ENVELOPE" ->
586586- sp r;
587587- Fetch_item_envelope (envelope r)
588588- | "BODY" -> (
589589- match peek_char r with
590590- | Some '[' ->
591591- ignore (R.any_char r);
592592- let _section = take_while (fun c -> c <> ']') r in
593593- ignore (R.any_char r);
594594- (* ] *)
595595- let origin =
596596- match peek_char r with
597597- | Some '<' ->
598598- ignore (R.any_char r);
599599- let o = number r in
600600- ignore (R.any_char r);
601601- (* > *)
602602- Some o
603603- | _ -> None
604604- in
605605- sp r;
606606- let data = nstring r in
607607- Fetch_item_body_section { section = None; origin; data }
608608- (* Simplified: we don't parse section spec *)
609609- | Some ' ' ->
610610- sp r;
611611- Fetch_item_body (body_structure r)
612612- | _ -> Fetch_item_body (body_structure r))
613613- | "BODYSTRUCTURE" ->
614614- sp r;
615615- Fetch_item_bodystructure (body_structure r)
616616- | _ -> failwith (Printf.sprintf "unknown fetch item: %s" name)
617617-618618-let fetch_items r =
619619- let c = R.any_char r in
620620- if c <> '(' then failwith (Printf.sprintf "expected '(' for fetch, got %C" c);
621621- let rec loop acc =
622622- match peek_char r with
623623- | Some ')' ->
624624- ignore (R.any_char r);
625625- List.rev acc
626626- | Some ' ' ->
627627- sp r;
628628- loop acc
629629- | _ ->
630630- let item = fetch_item r in
631631- loop (item :: acc)
632632- in
633633- loop []
634634-635635-(* Parse status items *)
636636-let status_items r =
637637- let c = R.any_char r in
638638- if c <> '(' then failwith (Printf.sprintf "expected '(' for status, got %C" c);
639639- let rec loop acc =
640640- match peek_char r with
641641- | Some ')' ->
642642- ignore (R.any_char r);
643643- List.rev acc
644644- | Some ' ' ->
645645- sp r;
646646- loop acc
647647- | _ ->
648648- let name = atom r in
649649- sp r;
650650- let value = number64 r in
651651- let item =
652652- match String.uppercase_ascii name with
653653- | "MESSAGES" -> Status_messages
654654- | "UIDNEXT" -> Status_uidnext
655655- | "UIDVALIDITY" -> Status_uidvalidity
656656- | "UNSEEN" -> Status_unseen
657657- | "DELETED" -> Status_deleted
658658- | "SIZE" -> Status_size
659659- | _ -> Status_messages (* Unknown, default *)
660660- in
661661- loop ((item, value) :: acc)
662662- in
663663- loop []
664664-665665-(* Parse namespace entry *)
666666-let namespace_entry r =
667667- let c = R.any_char r in
668668- if c <> '(' then
669669- failwith (Printf.sprintf "expected '(' for namespace entry, got %C" c);
670670- let prefix = quoted_string r in
671671- sp r;
672672- let delimiter =
673673- if is_nil r then (
674674- nil r;
675675- None)
676676- else
677677- let s = quoted_string r in
678678- if String.length s > 0 then Some s.[0] else None
679679- in
680680- (* Skip any extension data *)
681681- while
682682- match peek_char r with
683683- | Some ' ' ->
684684- sp r;
685685- skip_body_ext r;
686686- true
687687- | _ -> false
688688- do
689689- ()
690690- done;
691691- let c = R.any_char r in
692692- if c <> ')' then
693693- failwith (Printf.sprintf "expected ')' for namespace entry, got %C" c);
694694- { prefix; delimiter }
695695-696696-let namespace_list r =
697697- if is_nil r then (
698698- nil r;
699699- None)
700700- else
701701- let c = R.any_char r in
702702- if c <> '(' then
703703- failwith (Printf.sprintf "expected '(' for namespace list, got %C" c);
704704- let rec loop acc =
705705- match peek_char r with
706706- | Some ')' ->
707707- ignore (R.any_char r);
708708- Some (List.rev acc)
709709- | Some ' ' ->
710710- sp r;
711711- loop acc
712712- | Some '(' ->
713713- let entry = namespace_entry r in
714714- loop (entry :: acc)
715715- | Some c -> failwith (Printf.sprintf "unexpected %C in namespace" c)
716716- | None -> failwith "unexpected EOF in namespace"
717717- in
718718- loop []
719719-720720-(* Read until CRLF *)
721721-let read_text r =
722722- let buf = Buffer.create 64 in
723723- let rec loop () =
724724- match peek_char r with
725725- | Some '\r' -> Buffer.contents buf
726726- | Some c ->
727727- Buffer.add_char buf c;
728728- ignore (R.any_char r);
729729- loop ()
730730- | None -> Buffer.contents buf
731731- in
732732- loop ()
733733-734734-(* Parse response *)
735735-let response r =
736736- match peek_char r with
737737- | Some '+' ->
738738- (* Continuation *)
739739- ignore (R.any_char r);
740740- (match peek_char r with
741741- | Some ' ' ->
742742- sp r;
743743- let text = read_text r in
744744- crlf r;
745745- Continuation (if String.length text > 0 then Some text else None)
746746- | Some '\r' ->
747747- crlf r;
748748- Continuation None
749749- | _ ->
750750- let text = read_text r in
751751- crlf r;
752752- Continuation (Some text))
753753- | Some '*' ->
754754- (* Untagged response *)
755755- ignore (R.any_char r);
756756- sp r;
757757- (* Check if it's a number (EXISTS, EXPUNGE, FETCH) *)
758758- (match peek_char r with
759759- | Some c when is_digit c ->
760760- let n = number r in
761761- sp r;
762762- let kind = atom r in
763763- let upper = String.uppercase_ascii kind in
764764- (match upper with
765765- | "EXISTS" ->
766766- crlf r;
767767- Exists n
768768- | "EXPUNGE" ->
769769- crlf r;
770770- Expunge_response n
771771- | "FETCH" ->
772772- sp r;
773773- let items = fetch_items r in
774774- crlf r;
775775- Fetch_response { seq = n; items }
776776- | _ ->
777777- (* Unknown numbered response, skip to end of line *)
778778- ignore (read_text r);
779779- crlf r;
780780- Ok { tag = None; code = None; text = "" })
781781- | _ ->
782782- let keyword = atom r in
783783- let upper = String.uppercase_ascii keyword in
784784- (match upper with
785785- | "OK" ->
786786- sp r;
787787- let code =
788788- match peek_char r with
789789- | Some '[' -> Some (response_code r)
790790- | _ -> None
791791- in
792792- (match code with Some _ -> sp r | None -> ());
793793- let text = read_text r in
794794- crlf r;
795795- Ok { tag = None; code; text }
796796- | "NO" ->
797797- sp r;
798798- let code =
799799- match peek_char r with
800800- | Some '[' -> Some (response_code r)
801801- | _ -> None
802802- in
803803- (match code with Some _ -> sp r | None -> ());
804804- let text = read_text r in
805805- crlf r;
806806- No { tag = None; code; text }
807807- | "BAD" ->
808808- sp r;
809809- let code =
810810- match peek_char r with
811811- | Some '[' -> Some (response_code r)
812812- | _ -> None
813813- in
814814- (match code with Some _ -> sp r | None -> ());
815815- let text = read_text r in
816816- crlf r;
817817- Bad { tag = None; code; text }
818818- | "PREAUTH" ->
819819- sp r;
820820- let code =
821821- match peek_char r with
822822- | Some '[' -> Some (response_code r)
823823- | _ -> None
824824- in
825825- (match code with Some _ -> sp r | None -> ());
826826- let text = read_text r in
827827- crlf r;
828828- Preauth { code; text }
829829- | "BYE" ->
830830- sp r;
831831- let code =
832832- match peek_char r with
833833- | Some '[' -> Some (response_code r)
834834- | _ -> None
835835- in
836836- (match code with Some _ -> sp r | None -> ());
837837- let text = read_text r in
838838- crlf r;
839839- Bye { code; text }
840840- | "CAPABILITY" ->
841841- let rec loop acc =
842842- match peek_char r with
843843- | Some ' ' ->
844844- sp r;
845845- let cap = atom r in
846846- loop (cap :: acc)
847847- | Some '\r' -> List.rev acc
848848- | _ -> List.rev acc
849849- in
850850- let caps = loop [] in
851851- crlf r;
852852- Capability_response caps
853853- | "FLAGS" ->
854854- sp r;
855855- let flags = flag_list r in
856856- crlf r;
857857- Flags_response flags
858858- | "LIST" ->
859859- sp r;
860860- let flags = list_flag_list r in
861861- sp r;
862862- let delimiter =
863863- if is_nil r then (
864864- nil r;
865865- None)
866866- else
867867- let s = quoted_string r in
868868- if String.length s > 0 then Some s.[0] else None
869869- in
870870- sp r;
871871- let name = astring r in
872872- crlf r;
873873- List_response { flags; delimiter; name; extended = [] }
874874- | "STATUS" ->
875875- sp r;
876876- let mailbox = astring r in
877877- sp r;
878878- let items = status_items r in
879879- crlf r;
880880- Status_response { mailbox; items }
881881- | "NAMESPACE" ->
882882- sp r;
883883- let personal = namespace_list r in
884884- sp r;
885885- let other = namespace_list r in
886886- sp r;
887887- let shared = namespace_list r in
888888- crlf r;
889889- Namespace_response { personal; other; shared }
890890- | "ENABLED" ->
891891- let rec loop acc =
892892- match peek_char r with
893893- | Some ' ' ->
894894- sp r;
895895- let cap = atom r in
896896- loop (cap :: acc)
897897- | Some '\r' -> List.rev acc
898898- | _ -> List.rev acc
899899- in
900900- let caps = loop [] in
901901- crlf r;
902902- Enabled caps
903903- | "ID" ->
904904- sp r;
905905- let params =
906906- if is_nil r then (
907907- nil r;
908908- None)
909909- else
910910- let c = R.any_char r in
911911- if c <> '(' then None
912912- else
913913- let rec loop acc =
914914- match peek_char r with
915915- | Some ')' ->
916916- ignore (R.any_char r);
917917- Some (List.rev acc)
918918- | Some ' ' ->
919919- sp r;
920920- loop acc
921921- | Some '"' ->
922922- let key = quoted_string r in
923923- sp r;
924924- let value =
925925- if is_nil r then (
926926- nil r;
927927- "")
928928- else quoted_string r
929929- in
930930- loop ((key, value) :: acc)
931931- | _ -> Some (List.rev acc)
932932- in
933933- loop []
934934- in
935935- crlf r;
936936- Id_response params
937937- | "ESEARCH" ->
938938- (* Simplified ESEARCH parsing *)
939939- ignore (read_text r);
940940- crlf r;
941941- Esearch { tag = None; uid = false; results = [] }
942942- | _ ->
943943- (* Unknown untagged response, skip to end of line *)
944944- ignore (read_text r);
945945- crlf r;
946946- Ok { tag = None; code = None; text = "" }))
947947- | _ ->
948948- (* Tagged response *)
949949- let tag = atom r in
950950- sp r;
951951- let status = atom r in
952952- let upper = String.uppercase_ascii status in
953953- sp r;
954954- let code =
955955- match peek_char r with Some '[' -> Some (response_code r) | _ -> None
956956- in
957957- (match code with Some _ -> sp r | None -> ());
958958- let text = read_text r in
959959- crlf r;
960960- (match upper with
961961- | "OK" -> Ok { tag = Some tag; code; text }
962962- | "NO" -> No { tag = Some tag; code; text }
963963- | "BAD" -> Bad { tag = Some tag; code; text }
964964- | _ -> Bad { tag = Some tag; code = None; text = "Unknown status" })
965965-966966-let responses_until_tagged r expected_tag =
967967- let rec loop acc =
968968- let resp = response r in
969969- let acc = resp :: acc in
970970- match resp with
971971- | Ok { tag = Some t; _ } | No { tag = Some t; _ } | Bad { tag = Some t; _ }
972972- when t = expected_tag ->
973973- List.rev acc
974974- | Bye _ ->
975975- (* Server disconnecting, return what we have *)
976976- List.rev acc
977977- | _ -> loop acc
978978- in
979979- loop []
-118
lib/imapd/read.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Response Parsing
77-88- This module parses IMAP server responses for client-side use.
99- Uses [Eio.Buf_read] for efficient buffered input.
1010-1111- {2 Wire Format}
1212-1313- IMAP responses are parsed according to
1414- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-9}RFC 9051 Section 9}.
1515- Each response line is terminated with CRLF.
1616-1717- {2 Example}
1818-1919- {[
2020- let reader = Eio.Buf_read.of_flow flow ~max_size:1_000_000 in
2121- let greeting = Read.response reader in
2222- match greeting with
2323- | Ok { tag = None; code; text } ->
2424- Printf.printf "Server greeting: %s\n" text
2525- | Bye { text; _ } ->
2626- Printf.eprintf "Server disconnecting: %s\n" text
2727- | _ -> failwith "Unexpected greeting"
2828- ]}
2929-3030- {2 References}
3131- {ul
3232- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}} *)
3333-3434-(** {1 Primitive Parsers}
3535-3636- Low-level parsers for IMAP data types. *)
3737-3838-val atom : Eio.Buf_read.t -> string
3939-(** [atom r] reads an atom (unquoted token). *)
4040-4141-val quoted_string : Eio.Buf_read.t -> string
4242-(** [quoted_string r] reads a quoted string with escape handling. *)
4343-4444-val literal : Eio.Buf_read.t -> string
4545-(** [literal r] reads a literal [{n}CRLF...] and returns the data. *)
4646-4747-val astring : Eio.Buf_read.t -> string
4848-(** [astring r] reads an atom or string. *)
4949-5050-val nstring : Eio.Buf_read.t -> string option
5151-(** [nstring r] reads NIL or a string. *)
5252-5353-val number : Eio.Buf_read.t -> int
5454-(** [number r] reads a decimal number. *)
5555-5656-val number32 : Eio.Buf_read.t -> int32
5757-(** [number32 r] reads a 32-bit number (for UIDs). *)
5858-5959-val number64 : Eio.Buf_read.t -> int64
6060-(** [number64 r] reads a 64-bit number. *)
6161-6262-val sp : Eio.Buf_read.t -> unit
6363-(** [sp r] reads and discards a single space. *)
6464-6565-val crlf : Eio.Buf_read.t -> unit
6666-(** [crlf r] reads and discards CRLF line terminator. *)
6767-6868-(** {1 Structured Parsers}
6969-7070- Parsers for IMAP structured data types. *)
7171-7272-val flag : Eio.Buf_read.t -> Protocol.flag
7373-(** [flag r] reads a message flag. *)
7474-7575-val flag_list : Eio.Buf_read.t -> Protocol.flag list
7676-(** [flag_list r] reads a parenthesized flag list. *)
7777-7878-val address : Eio.Buf_read.t -> Protocol.address
7979-(** [address r] reads an envelope address. *)
8080-8181-val envelope : Eio.Buf_read.t -> Protocol.envelope
8282-(** [envelope r] reads a message envelope. *)
8383-8484-val body_structure : Eio.Buf_read.t -> Protocol.body_structure
8585-(** [body_structure r] reads a BODYSTRUCTURE response. *)
8686-8787-val response_code : Eio.Buf_read.t -> Protocol.response_code
8888-(** [response_code r] reads a bracketed response code. *)
8989-9090-val sequence_set : Eio.Buf_read.t -> Protocol.sequence_set
9191-(** [sequence_set r] reads a sequence set like [1,3:5,10:*]. *)
9292-9393-(** {1 Response Parsers}
9494-9595- High-level response parsing. *)
9696-9797-val response : Eio.Buf_read.t -> Protocol.response
9898-(** [response r] reads a complete IMAP response (one or more lines).
9999-100100- This handles:
101101- - Tagged responses (OK/NO/BAD)
102102- - Untagged responses (untagged, including PREAUTH, BYE, capabilities, etc.)
103103- - Continuation requests
104104-105105- Example:
106106- {[
107107- let resp = response reader in
108108- match resp with
109109- | Ok { tag = Some t; text; _ } -> Printf.printf "%s OK: %s\n" t text
110110- | Exists n -> Printf.printf "Mailbox has %d messages\n" n
111111- | Continuation _ -> Printf.printf "Server ready for more data\n"
112112- | _ -> ()
113113- ]} *)
114114-115115-val responses_until_tagged : Eio.Buf_read.t -> string -> Protocol.response list
116116-(** [responses_until_tagged r tag] reads responses until a tagged response
117117- matching [tag] is received. Returns all responses including the final
118118- tagged response. *)
-1215
lib/imapd/server.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Server
77-88- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} state machine. *)
99-1010-open Protocol
1111-open Parser
1212-1313-(* Module alias to access Storage types without conflicting with functor parameter *)
1414-module Storage_types = Storage
1515-1616-(** Base capabilities per RFC 9051.
1717- @see <https://datatracker.ietf.org/doc/html/rfc9051> RFC 9051: IMAP4rev2
1818- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3: UTF8=ACCEPT *)
1919-let base_capabilities_pre_tls = [
2020- "IMAP4rev2";
2121- "IMAP4rev1"; (* For compatibility *)
2222- "AUTH=PLAIN";
2323- "STARTTLS";
2424- "IDLE";
2525- "NAMESPACE";
2626- "UIDPLUS";
2727- "MOVE";
2828- "ENABLE";
2929- "LITERAL+";
3030- "ID";
3131- "UNSELECT"; (* RFC 3691 *)
3232- "SPECIAL-USE"; (* RFC 6154 *)
3333- "LIST-EXTENDED"; (* RFC 5258 *)
3434- "CONDSTORE"; (* RFC 7162 - modification sequences for flags *)
3535- (* QUOTA extension - RFC 9208 *)
3636- "QUOTA";
3737- "QUOTA=RES-STORAGE"; (* RFC 9208 Section 5.1 *)
3838- "QUOTA=RES-MESSAGE"; (* RFC 9208 Section 5.2 *)
3939- (* UTF-8 support - RFC 6855 *)
4040- "UTF8=ACCEPT"; (* RFC 6855 Section 3 *)
4141- (* THREAD extension - RFC 5256 *)
4242- "THREAD=ORDEREDSUBJECT"; (* RFC 5256 Section 3.1 *)
4343- "THREAD=REFERENCES"; (* RFC 5256 Section 3.2 *)
4444-]
4545-4646-let base_capabilities_post_tls = [
4747- "IMAP4rev2";
4848- "IMAP4rev1";
4949- "AUTH=PLAIN";
5050- "IDLE";
5151- "NAMESPACE";
5252- "UIDPLUS";
5353- "MOVE";
5454- "ENABLE";
5555- "LITERAL+";
5656- "ID";
5757- "UNSELECT"; (* RFC 3691 *)
5858- "SPECIAL-USE"; (* RFC 6154 *)
5959- "LIST-EXTENDED"; (* RFC 5258 *)
6060- "CONDSTORE"; (* RFC 7162 - modification sequences for flags *)
6161- (* QUOTA extension - RFC 9208 *)
6262- "QUOTA";
6363- "QUOTA=RES-STORAGE"; (* RFC 9208 Section 5.1 *)
6464- "QUOTA=RES-MESSAGE"; (* RFC 9208 Section 5.2 *)
6565- (* UTF-8 support - RFC 6855 *)
6666- "UTF8=ACCEPT"; (* RFC 6855 Section 3 *)
6767- (* THREAD extension - RFC 5256 *)
6868- "THREAD=ORDEREDSUBJECT"; (* RFC 5256 Section 3.1 *)
6969- "THREAD=REFERENCES"; (* RFC 5256 Section 3.2 *)
7070-]
7171-7272-(* Server configuration *)
7373-type config = {
7474- hostname : string;
7575- capabilities : string list;
7676- greeting : string option;
7777- autologout_timeout : float;
7878- tls_config : Tls.Config.server option;
7979-}
8080-8181-let default_config = {
8282- hostname = "localhost";
8383- capabilities = [];
8484- greeting = None;
8585- autologout_timeout = 1800.0; (* 30 minutes per RFC 9051 Section 5.4 *)
8686- tls_config = None;
8787-}
8888-8989-module Make
9090- (Storage : Storage.STORAGE)
9191- (Auth : Auth.AUTH) = struct
9292-9393- (** Connection state with UTF-8 mode tracking.
9494- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3 *)
9595- type connection_state =
9696- | Not_authenticated
9797- | Authenticated of { username : string; utf8_enabled : bool }
9898- | Selected of { username : string; mailbox : string; readonly : bool; utf8_enabled : bool }
9999- | Logout
100100-101101- (* Action returned by command handlers *)
102102- type command_action =
103103- | Continue
104104- | Upgrade_tls of string (* tag for response *)
105105-106106- type t = {
107107- config : config;
108108- storage : Storage.t;
109109- auth : Auth.t;
110110- }
111111-112112- let create ~config ~storage ~auth = { config; storage; auth }
113113-114114- let all_capabilities t ~tls_active =
115115- let base = if tls_active then base_capabilities_post_tls else base_capabilities_pre_tls in
116116- base @ t.config.capabilities
117117-118118- (* Send a response to the client *)
119119- let send_response flow response =
120120- let data = response_to_string response in
121121- Eio.Flow.copy_string data flow
122122-123123- (* Send greeting *)
124124- let send_greeting t flow ~tls_active =
125125- let caps = all_capabilities t ~tls_active in
126126- let greeting = match t.config.greeting with
127127- | Some g -> g
128128- | None -> "IMAP4rev2 Service Ready"
129129- in
130130- let response = Ok {
131131- tag = None;
132132- code = Some (Code_capability caps);
133133- text = greeting;
134134- } in
135135- send_response flow response
136136-137137- (* Process CAPABILITY command - valid in any state *)
138138- let handle_capability t flow tag ~tls_active =
139139- let caps = all_capabilities t ~tls_active in
140140- send_response flow (Capability_response caps);
141141- send_response flow (Ok { tag = Some tag; code = None; text = "CAPABILITY completed" })
142142-143143- (* Process NOOP command - valid in any state *)
144144- let handle_noop flow tag =
145145- send_response flow (Ok { tag = Some tag; code = None; text = "NOOP completed" })
146146-147147- (* Process ID command - RFC 2971 - valid in any state *)
148148- let handle_id flow tag _client_params =
149149- (* Return server identification *)
150150- let server_id = Some [
151151- ("name", "imapd");
152152- ("vendor", "OCaml IMAP");
153153- ("version", "0.1.0");
154154- ("support-url", "https://github.com/mtelvers/imapd");
155155- ] in
156156- send_response flow (Id_response server_id);
157157- send_response flow (Ok { tag = Some tag; code = None; text = "ID completed" })
158158-159159- (* Process LOGOUT command - valid in any state *)
160160- let handle_logout flow tag =
161161- send_response flow (Bye { code = None; text = "IMAP4rev2 Server logging out" });
162162- send_response flow (Ok { tag = Some tag; code = None; text = "LOGOUT completed" });
163163- Logout
164164-165165- (* Process LOGIN command - only valid in Not_authenticated state *)
166166- let handle_login t flow tag ~username ~password ~tls_active state =
167167- match state with
168168- | Not_authenticated ->
169169- (* Security: Validate username before authentication *)
170170- if not (Protocol.is_safe_username username) then begin
171171- send_response flow (No {
172172- tag = Some tag;
173173- code = Some Code_authenticationfailed;
174174- text = "LOGIN failed"
175175- });
176176- state
177177- end else if Auth.authenticate t.auth ~username ~password then begin
178178- let caps = all_capabilities t ~tls_active in
179179- send_response flow (Ok {
180180- tag = Some tag;
181181- code = Some (Code_capability caps);
182182- text = "LOGIN completed"
183183- });
184184- Authenticated { username; utf8_enabled = false }
185185- end else begin
186186- send_response flow (No {
187187- tag = Some tag;
188188- code = Some Code_authenticationfailed;
189189- text = "LOGIN failed"
190190- });
191191- state
192192- end
193193- | _ ->
194194- send_response flow (Bad {
195195- tag = Some tag;
196196- code = None;
197197- text = "Command not valid in this state"
198198- });
199199- state
200200-201201- (* Process SELECT/EXAMINE command - only valid in Authenticated/Selected state *)
202202- let handle_select t flow tag mailbox ~readonly state =
203203- let username, utf8_enabled = match state with
204204- | Authenticated { username; utf8_enabled } -> Some username, utf8_enabled
205205- | Selected { username; utf8_enabled; _ } -> Some username, utf8_enabled
206206- | _ -> None, false
207207- in
208208- match username with
209209- | None ->
210210- send_response flow (Bad {
211211- tag = Some tag;
212212- code = None;
213213- text = "Command not valid in this state"
214214- });
215215- state
216216- | Some username ->
217217- (* Security: Validate mailbox name *)
218218- if not (Protocol.is_safe_mailbox_name mailbox) then begin
219219- send_response flow (No {
220220- tag = Some tag;
221221- code = None;
222222- text = "Invalid mailbox name"
223223- });
224224- Authenticated { username; utf8_enabled }
225225- end else
226226- match Storage.select_mailbox t.storage ~username mailbox ~readonly with
227227- | Error _ ->
228228- send_response flow (No {
229229- tag = Some tag;
230230- code = Some Code_nonexistent;
231231- text = "Mailbox does not exist"
232232- });
233233- Authenticated { username; utf8_enabled }
234234- | Ok mb_state ->
235235- (* Send untagged responses *)
236236- send_response flow (Flags_response mb_state.flags);
237237- send_response flow (Exists mb_state.exists);
238238- send_response flow (Ok {
239239- tag = None;
240240- code = Some (Code_permanentflags mb_state.permanent_flags);
241241- text = "Flags permitted"
242242- });
243243- send_response flow (Ok {
244244- tag = None;
245245- code = Some (Code_uidvalidity mb_state.uidvalidity);
246246- text = "UIDs valid"
247247- });
248248- send_response flow (Ok {
249249- tag = None;
250250- code = Some (Code_uidnext mb_state.uidnext);
251251- text = "Predicted next UID"
252252- });
253253- (* Send tagged OK *)
254254- let code = if readonly then Some Code_readonly else Some Code_readwrite in
255255- send_response flow (Ok {
256256- tag = Some tag;
257257- code;
258258- text = if readonly then "EXAMINE completed" else "SELECT completed"
259259- });
260260- Selected { username; mailbox; readonly; utf8_enabled }
261261-262262- (* Process LIST command - RFC 9051, RFC 5258 LIST-EXTENDED *)
263263- let handle_list t flow tag list_cmd state =
264264- let username = match state with
265265- | Authenticated { username; _ } -> Some username
266266- | Selected { username; _ } -> Some username
267267- | _ -> None
268268- in
269269- match username with
270270- | None ->
271271- send_response flow (Bad {
272272- tag = Some tag;
273273- code = None;
274274- text = "Command not valid in this state"
275275- });
276276- state
277277- | Some username ->
278278- (* Extract reference and patterns from list command *)
279279- let reference, patterns = match list_cmd with
280280- | List_basic { reference; pattern } -> (reference, [pattern])
281281- | List_extended { reference; patterns; _ } -> (reference, patterns)
282282- in
283283- (* Process each pattern and collect mailboxes *)
284284- let all_mailboxes = List.concat_map (fun pattern ->
285285- Storage.list_mailboxes t.storage ~username ~reference ~pattern
286286- ) patterns in
287287- (* Send LIST responses with extended data if needed *)
288288- List.iter (fun (mb : Storage_types.mailbox_info) ->
289289- send_response flow (List_response {
290290- flags = mb.flags;
291291- delimiter = mb.delimiter;
292292- name = mb.name;
293293- extended = []; (* Extended data can be populated based on return options *)
294294- })
295295- ) all_mailboxes;
296296- send_response flow (Ok { tag = Some tag; code = None; text = "LIST completed" });
297297- state
298298-299299- (* Process STATUS command *)
300300- let handle_status t flow tag mailbox ~items state =
301301- (* Security: Validate mailbox name *)
302302- if not (Protocol.is_safe_mailbox_name mailbox) then begin
303303- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
304304- state
305305- end else
306306- let username = match state with
307307- | Authenticated { username; _ } -> Some username
308308- | Selected { username; _ } -> Some username
309309- | _ -> None
310310- in
311311- match username with
312312- | None ->
313313- send_response flow (Bad {
314314- tag = Some tag;
315315- code = None;
316316- text = "Command not valid in this state"
317317- });
318318- state
319319- | Some username ->
320320- match Storage.status_mailbox t.storage ~username mailbox ~items with
321321- | Error _ ->
322322- send_response flow (No {
323323- tag = Some tag;
324324- code = Some Code_nonexistent;
325325- text = "Mailbox does not exist"
326326- });
327327- state
328328- | Ok results ->
329329- send_response flow (Status_response { mailbox; items = results });
330330- send_response flow (Ok { tag = Some tag; code = None; text = "STATUS completed" });
331331- state
332332-333333- (* Process FETCH command *)
334334- let handle_fetch t flow tag ~sequence ~items state =
335335- match state with
336336- | Selected { username; mailbox; _ } ->
337337- (match Storage.fetch_messages t.storage ~username ~mailbox ~sequence ~items with
338338- | Error _ ->
339339- send_response flow (No { tag = Some tag; code = None; text = "FETCH failed" })
340340- | Ok messages ->
341341- List.iter (fun (msg : message) ->
342342- let fetch_items = [
343343- Fetch_item_uid msg.uid;
344344- Fetch_item_flags msg.flags;
345345- Fetch_item_rfc822_size msg.size;
346346- Fetch_item_internaldate msg.internal_date;
347347- ] in
348348- send_response flow (Fetch_response { seq = msg.seq; items = fetch_items })
349349- ) messages;
350350- send_response flow (Ok { tag = Some tag; code = None; text = "FETCH completed" }));
351351- state
352352- | _ ->
353353- send_response flow (Bad {
354354- tag = Some tag;
355355- code = None;
356356- text = "Command not valid in this state"
357357- });
358358- state
359359-360360- (* Process STORE command *)
361361- let handle_store t flow tag ~sequence ~silent ~action ~flags state =
362362- match state with
363363- | Selected { username; mailbox; readonly; _ } ->
364364- if readonly then begin
365365- send_response flow (No { tag = Some tag; code = None; text = "Mailbox is read-only" });
366366- state
367367- end else begin
368368- match Storage.store_flags t.storage ~username ~mailbox ~sequence ~action ~flags with
369369- | Error _ ->
370370- send_response flow (No { tag = Some tag; code = None; text = "STORE failed" });
371371- state
372372- | Ok messages ->
373373- if not silent then
374374- List.iter (fun (msg : message) ->
375375- send_response flow (Fetch_response {
376376- seq = msg.seq;
377377- items = [Fetch_item_flags msg.flags]
378378- })
379379- ) messages;
380380- send_response flow (Ok { tag = Some tag; code = None; text = "STORE completed" });
381381- state
382382- end
383383- | _ ->
384384- send_response flow (Bad {
385385- tag = Some tag;
386386- code = None;
387387- text = "Command not valid in this state"
388388- });
389389- state
390390-391391- (* Process EXPUNGE command *)
392392- let handle_expunge t flow tag state =
393393- match state with
394394- | Selected { username; mailbox; readonly; _ } ->
395395- if readonly then begin
396396- send_response flow (No { tag = Some tag; code = None; text = "Mailbox is read-only" });
397397- state
398398- end else begin
399399- match Storage.expunge t.storage ~username ~mailbox with
400400- | Error _ ->
401401- send_response flow (No { tag = Some tag; code = None; text = "EXPUNGE failed" });
402402- state
403403- | Ok _uids ->
404404- (* Send EXPUNGE responses for each removed message *)
405405- send_response flow (Ok { tag = Some tag; code = None; text = "EXPUNGE completed" });
406406- state
407407- end
408408- | _ ->
409409- send_response flow (Bad {
410410- tag = Some tag;
411411- code = None;
412412- text = "Command not valid in this state"
413413- });
414414- state
415415-416416- (* Process CLOSE command *)
417417- let handle_close t flow tag state =
418418- match state with
419419- | Selected { username; mailbox; readonly; utf8_enabled } ->
420420- (* Silently expunge if not readonly *)
421421- if not readonly then
422422- ignore (Storage.expunge t.storage ~username ~mailbox);
423423- send_response flow (Ok { tag = Some tag; code = None; text = "CLOSE completed" });
424424- Authenticated { username; utf8_enabled }
425425- | _ ->
426426- send_response flow (Bad {
427427- tag = Some tag;
428428- code = None;
429429- text = "Command not valid in this state"
430430- });
431431- state
432432-433433- (* Process UNSELECT command *)
434434- let handle_unselect flow tag state =
435435- match state with
436436- | Selected { username; utf8_enabled; _ } ->
437437- send_response flow (Ok { tag = Some tag; code = None; text = "UNSELECT completed" });
438438- Authenticated { username; utf8_enabled }
439439- | _ ->
440440- send_response flow (Bad {
441441- tag = Some tag;
442442- code = None;
443443- text = "Command not valid in this state"
444444- });
445445- state
446446-447447- (* Process CREATE command *)
448448- let handle_create t flow tag mailbox state =
449449- (* Security: Validate mailbox name *)
450450- if not (Protocol.is_safe_mailbox_name mailbox) then begin
451451- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
452452- state
453453- end else
454454- let username = match state with
455455- | Authenticated { username; _ } -> Some username
456456- | Selected { username; _ } -> Some username
457457- | _ -> None
458458- in
459459- match username with
460460- | None ->
461461- send_response flow (Bad {
462462- tag = Some tag;
463463- code = None;
464464- text = "Command not valid in this state"
465465- });
466466- state
467467- | Some username ->
468468- match Storage.create_mailbox t.storage ~username mailbox with
469469- | Ok () ->
470470- send_response flow (Ok { tag = Some tag; code = None; text = "CREATE completed" });
471471- state
472472- | Error Storage_types.Mailbox_already_exists ->
473473- send_response flow (No {
474474- tag = Some tag;
475475- code = Some Code_alreadyexists;
476476- text = "Mailbox already exists"
477477- });
478478- state
479479- | Error _ ->
480480- send_response flow (No { tag = Some tag; code = None; text = "CREATE failed" });
481481- state
482482-483483- (* Process DELETE command *)
484484- let handle_delete t flow tag mailbox state =
485485- (* Security: Validate mailbox name *)
486486- if not (Protocol.is_safe_mailbox_name mailbox) then begin
487487- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
488488- state
489489- end else
490490- let username = match state with
491491- | Authenticated { username; _ } -> Some username
492492- | Selected { username; _ } -> Some username
493493- | _ -> None
494494- in
495495- match username with
496496- | None ->
497497- send_response flow (Bad {
498498- tag = Some tag;
499499- code = None;
500500- text = "Command not valid in this state"
501501- });
502502- state
503503- | Some username ->
504504- match Storage.delete_mailbox t.storage ~username mailbox with
505505- | Ok () ->
506506- send_response flow (Ok { tag = Some tag; code = None; text = "DELETE completed" });
507507- state
508508- | Error Storage_types.Permission_denied ->
509509- send_response flow (No {
510510- tag = Some tag;
511511- code = Some Code_cannot;
512512- text = "Cannot delete INBOX"
513513- });
514514- state
515515- | Error _ ->
516516- send_response flow (No { tag = Some tag; code = None; text = "DELETE failed" });
517517- state
518518-519519- (* Process RENAME command *)
520520- let handle_rename t flow tag ~old_name ~new_name state =
521521- (* Security: Validate both mailbox names *)
522522- if not (Protocol.is_safe_mailbox_name old_name) ||
523523- not (Protocol.is_safe_mailbox_name new_name) then begin
524524- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
525525- state
526526- end else
527527- let username = match state with
528528- | Authenticated { username; _ } -> Some username
529529- | Selected { username; _ } -> Some username
530530- | _ -> None
531531- in
532532- match username with
533533- | None ->
534534- send_response flow (Bad {
535535- tag = Some tag;
536536- code = None;
537537- text = "Command not valid in this state"
538538- });
539539- state
540540- | Some username ->
541541- match Storage.rename_mailbox t.storage ~username ~old_name ~new_name with
542542- | Result.Ok () ->
543543- send_response flow (Ok { tag = Some tag; code = None; text = "RENAME completed" });
544544- state
545545- | Result.Error Storage_types.Mailbox_not_found ->
546546- send_response flow (No {
547547- tag = Some tag;
548548- code = Some Code_nonexistent;
549549- text = "Mailbox does not exist"
550550- });
551551- state
552552- | Result.Error Storage_types.Mailbox_already_exists ->
553553- send_response flow (No {
554554- tag = Some tag;
555555- code = Some Code_alreadyexists;
556556- text = "Target mailbox already exists"
557557- });
558558- state
559559- | Result.Error _ ->
560560- send_response flow (No { tag = Some tag; code = None; text = "RENAME failed" });
561561- state
562562-563563- (* Process COPY command *)
564564- let handle_copy t flow tag ~sequence ~mailbox state =
565565- (* Security: Validate destination mailbox name *)
566566- if not (Protocol.is_safe_mailbox_name mailbox) then begin
567567- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
568568- state
569569- end else
570570- match state with
571571- | Selected { username; mailbox = src_mailbox; _ } ->
572572- (match Storage.copy t.storage ~username ~src_mailbox ~sequence ~dst_mailbox:mailbox with
573573- | Result.Error Storage_types.Mailbox_not_found ->
574574- send_response flow (No {
575575- tag = Some tag;
576576- code = Some Code_trycreate;
577577- text = "Destination mailbox does not exist"
578578- })
579579- | Result.Error _ ->
580580- send_response flow (No { tag = Some tag; code = None; text = "COPY failed" })
581581- | Result.Ok dst_uids ->
582582- (* UIDPLUS: include COPYUID response code *)
583583- let uidvalidity = match Storage.select_mailbox t.storage ~username mailbox ~readonly:true with
584584- | Result.Ok mb -> mb.uidvalidity
585585- | Result.Error _ -> 1l
586586- in
587587- let dst_set = List.map (fun uid -> Single (Int32.to_int uid)) dst_uids in
588588- send_response flow (Ok {
589589- tag = Some tag;
590590- code = Some (Code_copyuid (uidvalidity, sequence, dst_set));
591591- text = "COPY completed"
592592- }));
593593- state
594594- | _ ->
595595- send_response flow (Bad {
596596- tag = Some tag;
597597- code = None;
598598- text = "Command not valid in this state"
599599- });
600600- state
601601-602602- (* Process MOVE command - RFC 6851 *)
603603- let handle_move t flow tag ~sequence ~mailbox state =
604604- (* Security: Validate destination mailbox name *)
605605- if not (Protocol.is_safe_mailbox_name mailbox) then begin
606606- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
607607- state
608608- end else
609609- match state with
610610- | Selected { username; mailbox = src_mailbox; readonly; _ } ->
611611- if readonly then begin
612612- send_response flow (No { tag = Some tag; code = None; text = "Mailbox is read-only" });
613613- state
614614- end else begin
615615- match Storage.move t.storage ~username ~src_mailbox ~sequence ~dst_mailbox:mailbox with
616616- | Result.Error Storage_types.Mailbox_not_found ->
617617- send_response flow (No {
618618- tag = Some tag;
619619- code = Some Code_trycreate;
620620- text = "Destination mailbox does not exist"
621621- });
622622- state
623623- | Result.Error _ ->
624624- send_response flow (No { tag = Some tag; code = None; text = "MOVE failed" });
625625- state
626626- | Result.Ok dst_uids ->
627627- (* UIDPLUS: include COPYUID response code for MOVE as well *)
628628- let uidvalidity = match Storage.select_mailbox t.storage ~username mailbox ~readonly:true with
629629- | Result.Ok mb -> mb.uidvalidity
630630- | Result.Error _ -> 1l
631631- in
632632- let dst_set = List.map (fun uid -> Single (Int32.to_int uid)) dst_uids in
633633- send_response flow (Ok {
634634- tag = Some tag;
635635- code = Some (Code_copyuid (uidvalidity, sequence, dst_set));
636636- text = "MOVE completed"
637637- });
638638- state
639639- end
640640- | _ ->
641641- send_response flow (Bad {
642642- tag = Some tag;
643643- code = None;
644644- text = "Command not valid in this state"
645645- });
646646- state
647647-648648- (** Process SEARCH command.
649649- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3
650650- After ENABLE UTF8=ACCEPT, SEARCH with CHARSET is rejected. *)
651651- let handle_search t flow tag ~charset ~criteria state =
652652- match state with
653653- | Selected { username; mailbox; utf8_enabled; _ } ->
654654- (* RFC 6855 Section 3: After ENABLE UTF8=ACCEPT, reject SEARCH with CHARSET *)
655655- if utf8_enabled && Option.is_some charset then begin
656656- send_response flow (Bad {
657657- tag = Some tag;
658658- code = None;
659659- text = "CHARSET not allowed after ENABLE UTF8=ACCEPT"
660660- });
661661- state
662662- end else begin
663663- match Storage.search t.storage ~username ~mailbox ~criteria with
664664- | Result.Error _ ->
665665- send_response flow (No { tag = Some tag; code = None; text = "SEARCH failed" });
666666- state
667667- | Result.Ok uids ->
668668- (* Send ESEARCH response per RFC 9051 *)
669669- let results = if List.length uids > 0 then
670670- [Esearch_count (List.length uids); Esearch_all (List.map (fun uid -> Single (Int32.to_int uid)) uids)]
671671- else
672672- [Esearch_count 0]
673673- in
674674- send_response flow (Esearch { tag = Some tag; uid = false; results });
675675- send_response flow (Ok { tag = Some tag; code = None; text = "SEARCH completed" });
676676- state
677677- end
678678- | _ ->
679679- send_response flow (Bad {
680680- tag = Some tag;
681681- code = None;
682682- text = "Command not valid in this state"
683683- });
684684- state
685685-686686- (** Process THREAD command - RFC 5256.
687687-688688- The THREAD command is used to retrieve message threads from a mailbox.
689689- It takes an algorithm, charset, and search criteria, returning threads
690690- of messages matching the criteria.
691691-692692- Note: This is a basic stub implementation that returns empty threads.
693693- A full implementation would require:
694694- - ORDEREDSUBJECT: subject.ml for base subject extraction (RFC 5256 Section 2.1)
695695- - REFERENCES: Message-ID/In-Reply-To/References header parsing
696696-697697- @see <https://datatracker.ietf.org/doc/html/rfc5256#section-3> RFC 5256 Section 3 *)
698698- let handle_thread _t flow tag ~algorithm ~charset:_ ~criteria:_ state =
699699- match state with
700700- | Selected { username = _; mailbox = _; _ } ->
701701- (* TODO: Implement actual threading algorithms.
702702- For now, return empty thread result.
703703- Full implementation would:
704704- 1. Search for messages matching criteria
705705- 2. Apply ORDEREDSUBJECT or REFERENCES algorithm
706706- 3. Build thread tree structure *)
707707- let _ = algorithm in (* Acknowledge the algorithm parameter *)
708708- send_response flow (Thread_response []);
709709- send_response flow (Ok { tag = Some tag; code = None; text = "THREAD completed" });
710710- state
711711- | _ ->
712712- send_response flow (Bad {
713713- tag = Some tag;
714714- code = None;
715715- text = "THREAD requires selected state"
716716- });
717717- state
718718-719719- (* Process APPEND command *)
720720- let handle_append t flow tag ~mailbox ~flags ~date ~message state =
721721- (* Security: Validate mailbox name *)
722722- if not (Protocol.is_safe_mailbox_name mailbox) then begin
723723- send_response flow (No { tag = Some tag; code = None; text = "Invalid mailbox name" });
724724- state
725725- end else
726726- let username = match state with
727727- | Authenticated { username; _ } -> Some username
728728- | Selected { username; _ } -> Some username
729729- | _ -> None
730730- in
731731- match username with
732732- | None ->
733733- send_response flow (Bad {
734734- tag = Some tag;
735735- code = None;
736736- text = "Command not valid in this state"
737737- });
738738- state
739739- | Some username ->
740740- match Storage.append t.storage ~username ~mailbox ~flags ~date ~message with
741741- | Result.Error Storage_types.Mailbox_not_found ->
742742- send_response flow (No {
743743- tag = Some tag;
744744- code = Some Code_trycreate;
745745- text = "Mailbox does not exist"
746746- });
747747- state
748748- | Result.Error _ ->
749749- send_response flow (No { tag = Some tag; code = None; text = "APPEND failed" });
750750- state
751751- | Result.Ok uid ->
752752- (* Get UIDVALIDITY for response *)
753753- let uidvalidity = match Storage.select_mailbox t.storage ~username mailbox ~readonly:true with
754754- | Result.Ok mb -> mb.uidvalidity
755755- | Result.Error _ -> 1l
756756- in
757757- send_response flow (Ok {
758758- tag = Some tag;
759759- code = Some (Code_appenduid (uidvalidity, uid));
760760- text = "APPEND completed"
761761- });
762762- state
763763-764764- (* Process NAMESPACE command - RFC 2342 *)
765765- let handle_namespace flow tag state =
766766- match state with
767767- | Authenticated _ | Selected _ ->
768768- send_response flow (Namespace_response {
769769- personal = Some [{ prefix = ""; delimiter = Some '/' }];
770770- other = None;
771771- shared = None;
772772- });
773773- send_response flow (Ok { tag = Some tag; code = None; text = "NAMESPACE completed" });
774774- state
775775- | _ ->
776776- send_response flow (Bad {
777777- tag = Some tag;
778778- code = None;
779779- text = "Command not valid in this state"
780780- });
781781- state
782782-783783- (** Process ENABLE command - RFC 5161, RFC 6855.
784784- After ENABLE UTF8=ACCEPT, the session accepts UTF-8 in quoted-strings.
785785- @see <https://datatracker.ietf.org/doc/html/rfc5161> RFC 5161: ENABLE Extension
786786- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3 *)
787787- let handle_enable flow tag ~capabilities state =
788788- match state with
789789- | Authenticated { username; utf8_enabled } ->
790790- (* Filter to capabilities we actually support *)
791791- let enabled = List.filter (fun cap ->
792792- let cap_upper = String.uppercase_ascii cap in
793793- cap_upper = "IMAP4REV2" || cap_upper = "UTF8=ACCEPT"
794794- ) capabilities in
795795- (* Check if UTF8=ACCEPT was requested and enabled *)
796796- let new_utf8_enabled = utf8_enabled || List.exists (fun cap ->
797797- String.uppercase_ascii cap = "UTF8=ACCEPT"
798798- ) enabled in
799799- if List.length enabled > 0 then
800800- send_response flow (Enabled enabled);
801801- send_response flow (Ok { tag = Some tag; code = None; text = "ENABLE completed" });
802802- Authenticated { username; utf8_enabled = new_utf8_enabled }
803803- | _ ->
804804- send_response flow (Bad {
805805- tag = Some tag;
806806- code = None;
807807- text = "ENABLE only valid in authenticated state before SELECT"
808808- });
809809- state
810810-811811- (* Process SUBSCRIBE/UNSUBSCRIBE - simplified, just succeed *)
812812- let handle_subscribe flow tag _mailbox state =
813813- match state with
814814- | Authenticated _ | Selected _ ->
815815- send_response flow (Ok { tag = Some tag; code = None; text = "SUBSCRIBE completed" });
816816- state
817817- | _ ->
818818- send_response flow (Bad {
819819- tag = Some tag;
820820- code = None;
821821- text = "Command not valid in this state"
822822- });
823823- state
824824-825825- let handle_unsubscribe flow tag _mailbox state =
826826- match state with
827827- | Authenticated _ | Selected _ ->
828828- send_response flow (Ok { tag = Some tag; code = None; text = "UNSUBSCRIBE completed" });
829829- state
830830- | _ ->
831831- send_response flow (Bad {
832832- tag = Some tag;
833833- code = None;
834834- text = "Command not valid in this state"
835835- });
836836- state
837837-838838- (* Process IDLE command - RFC 2177 *)
839839- let handle_idle flow tag read_line_fn state =
840840- match state with
841841- | Authenticated _ | Selected _ ->
842842- send_response flow (Continuation (Some "idling"));
843843- (* Wait for DONE from client *)
844844- let rec wait_for_done () =
845845- match read_line_fn () with
846846- | None -> () (* Connection closed *)
847847- | Some line ->
848848- let trimmed = String.trim (String.uppercase_ascii line) in
849849- if trimmed = "DONE" then
850850- send_response flow (Ok { tag = Some tag; code = None; text = "IDLE terminated" })
851851- else
852852- wait_for_done ()
853853- in
854854- wait_for_done ();
855855- state
856856- | _ ->
857857- send_response flow (Bad {
858858- tag = Some tag;
859859- code = None;
860860- text = "Command not valid in this state"
861861- });
862862- state
863863-864864- (* Main command dispatcher *)
865865- let rec handle_command t flow ~read_line_fn ~tls_active cmd state =
866866- let tag = cmd.tag in
867867- match cmd.command with
868868- | Capability -> handle_capability t flow tag ~tls_active; (state, Continue)
869869- | Noop -> handle_noop flow tag; (state, Continue)
870870- | Id params -> handle_id flow tag params; (state, Continue)
871871- | Logout -> (handle_logout flow tag, Continue)
872872- | Login { username; password } -> (handle_login t flow tag ~username ~password ~tls_active state, Continue)
873873- | Select mailbox -> (handle_select t flow tag mailbox ~readonly:false state, Continue)
874874- | Examine mailbox -> (handle_select t flow tag mailbox ~readonly:true state, Continue)
875875- | List list_cmd -> (handle_list t flow tag list_cmd state, Continue)
876876- | Status { mailbox; items } -> (handle_status t flow tag mailbox ~items state, Continue)
877877- | Fetch { sequence; items } -> (handle_fetch t flow tag ~sequence ~items state, Continue)
878878- | Store { sequence; silent; action; flags } -> (handle_store t flow tag ~sequence ~silent ~action ~flags state, Continue)
879879- | Expunge -> (handle_expunge t flow tag state, Continue)
880880- | Close -> (handle_close t flow tag state, Continue)
881881- | Unselect -> (handle_unselect flow tag state, Continue)
882882- | Create mailbox -> (handle_create t flow tag mailbox state, Continue)
883883- | Delete mailbox -> (handle_delete t flow tag mailbox state, Continue)
884884- | Rename { old_name; new_name } -> (handle_rename t flow tag ~old_name ~new_name state, Continue)
885885- | Copy { sequence; mailbox } -> (handle_copy t flow tag ~sequence ~mailbox state, Continue)
886886- | Move { sequence; mailbox } -> (handle_move t flow tag ~sequence ~mailbox state, Continue)
887887- | Search { charset; criteria } -> (handle_search t flow tag ~charset ~criteria state, Continue)
888888- | Thread { algorithm; charset; criteria } -> (handle_thread t flow tag ~algorithm ~charset ~criteria state, Continue)
889889- | Append { mailbox; flags; date; message } -> (handle_append t flow tag ~mailbox ~flags ~date ~message state, Continue)
890890- | Namespace -> (handle_namespace flow tag state, Continue)
891891- | Enable caps -> (handle_enable flow tag ~capabilities:caps state, Continue)
892892- | Subscribe mailbox -> (handle_subscribe flow tag mailbox state, Continue)
893893- | Unsubscribe mailbox -> (handle_unsubscribe flow tag mailbox state, Continue)
894894- | Idle -> (handle_idle flow tag read_line_fn state, Continue)
895895- | Uid uid_cmd -> (handle_uid_command t flow tag ~read_line_fn uid_cmd state, Continue)
896896- | Starttls ->
897897- (match t.config.tls_config with
898898- | None ->
899899- send_response flow (Bad { tag = Some tag; code = None; text = "STARTTLS not available" });
900900- (state, Continue)
901901- | Some _ when tls_active ->
902902- send_response flow (Bad { tag = Some tag; code = None; text = "TLS already active" });
903903- (state, Continue)
904904- | Some _ when state <> Not_authenticated ->
905905- send_response flow (Bad { tag = Some tag; code = None; text = "STARTTLS only valid before authentication" });
906906- (state, Continue)
907907- | Some _ ->
908908- (* Signal to connection handler to upgrade *)
909909- (state, Upgrade_tls tag))
910910- | Authenticate _ ->
911911- send_response flow (No { tag = Some tag; code = None; text = "Use LOGIN instead" });
912912- (state, Continue)
913913- (* QUOTA extension - RFC 9208 *)
914914- | Getquota root ->
915915- (* GETQUOTA returns quota information for a quota root *)
916916- (* For now, return empty quota - storage backend would provide real data *)
917917- send_response flow (Quota_response { root; resources = [] });
918918- send_response flow (Ok { tag = Some tag; code = None; text = "GETQUOTA completed" });
919919- (state, Continue)
920920- | Getquotaroot mailbox ->
921921- (* GETQUOTAROOT returns the quota roots for a mailbox *)
922922- (* Typically the user's root is the quota root *)
923923- let roots = [mailbox] in (* Simplified: use mailbox as its own quota root *)
924924- send_response flow (Quotaroot_response { mailbox; roots });
925925- (* Also send QUOTA responses for each root *)
926926- List.iter (fun root ->
927927- send_response flow (Quota_response { root; resources = [] })
928928- ) roots;
929929- send_response flow (Ok { tag = Some tag; code = None; text = "GETQUOTAROOT completed" });
930930- (state, Continue)
931931- | Setquota { root; limits = _ } ->
932932- (* SETQUOTA is admin-only in most implementations *)
933933- send_response flow (No {
934934- tag = Some tag;
935935- code = Some Code_noperm;
936936- text = Printf.sprintf "Cannot set quota for %s" root
937937- });
938938- (state, Continue)
939939-940940- (* Handle UID prefixed commands *)
941941- and handle_uid_command t flow tag ~read_line_fn:_ uid_cmd state =
942942- match uid_cmd with
943943- | Uid_fetch { sequence; items } ->
944944- (* For UID FETCH, sequence is UIDs not sequence numbers *)
945945- handle_fetch t flow tag ~sequence ~items state
946946- | Uid_store { sequence; silent; action; flags } ->
947947- handle_store t flow tag ~sequence ~silent ~action ~flags state
948948- | Uid_copy { sequence; mailbox } ->
949949- handle_copy t flow tag ~sequence ~mailbox state
950950- | Uid_move { sequence; mailbox } ->
951951- handle_move t flow tag ~sequence ~mailbox state
952952- | Uid_search { charset; criteria } ->
953953- handle_search t flow tag ~charset ~criteria state
954954- | Uid_expunge _sequence ->
955955- (* UID EXPUNGE only expunges messages in the given UID set *)
956956- handle_expunge t flow tag state
957957- | Uid_thread { algorithm; charset; criteria } ->
958958- (* UID THREAD returns UIDs instead of sequence numbers *)
959959- handle_thread t flow tag ~algorithm ~charset ~criteria state
960960-961961- (* Maximum line length to prevent DoS attacks via memory exhaustion.
962962- RFC 9051 Section 4 recommends supporting lines up to 8192 octets. *)
963963- let max_line_length = 65536
964964-965965- (* Read a line from the client *)
966966- let read_line flow =
967967- let buf = Buffer.create 256 in
968968- let cs = Cstruct.create 1 in
969969- let rec loop () =
970970- try
971971- (* Security: Prevent memory exhaustion from unbounded line length *)
972972- if Buffer.length buf > max_line_length then
973973- None (* Reject overly long lines *)
974974- else
975975- let n = Eio.Flow.single_read flow cs in
976976- if n = 0 then
977977- None
978978- else begin
979979- let c = Cstruct.get_char cs 0 in
980980- Buffer.add_char buf c;
981981- if c = '\n' && Buffer.length buf >= 2 &&
982982- Buffer.nth buf (Buffer.length buf - 2) = '\r' then
983983- Some (Buffer.contents buf)
984984- else
985985- loop ()
986986- end
987987- with End_of_file ->
988988- if Buffer.length buf > 0 then Some (Buffer.contents buf) else None
989989- in
990990- loop ()
991991-992992- (* Main command loop - returns when connection should close or upgrade TLS *)
993993- let rec command_loop t flow state tls_active =
994994- let read_line_fn () = read_line flow in
995995- match state with
996996- | Logout -> `Done
997997- | _ ->
998998- match read_line flow with
999999- | None -> `Done (* Connection closed *)
10001000- | Some line ->
10011001- match parse_command line with
10021002- | Error _msg ->
10031003- send_response flow (Bad {
10041004- tag = None;
10051005- code = None;
10061006- text = "Invalid command syntax"
10071007- });
10081008- command_loop t flow state tls_active
10091009- | Result.Ok cmd ->
10101010- let (new_state, action) = handle_command t flow ~read_line_fn ~tls_active cmd state in
10111011- match action with
10121012- | Continue -> command_loop t flow new_state tls_active
10131013- | Upgrade_tls tag -> `Upgrade_tls (tag, new_state)
10141014-10151015- (* Internal connection handler with TLS state *)
10161016- let handle_connection_internal t (flow : _ Eio.Flow.two_way) ~tls_active ~send_greeting:should_greet ~initial_state =
10171017- (* Send greeting only for new connections *)
10181018- if should_greet then send_greeting t flow ~tls_active;
10191019- command_loop t flow initial_state tls_active
10201020-10211021- (* Connection handler for cleartext connections (may upgrade to TLS) *)
10221022- let handle_connection t flow _addr =
10231023- match handle_connection_internal t flow ~tls_active:false ~send_greeting:true ~initial_state:Not_authenticated with
10241024- | `Done -> ()
10251025- | `Upgrade_tls (tag, state) ->
10261026- (* Upgrade to TLS *)
10271027- match t.config.tls_config with
10281028- | None ->
10291029- send_response flow (Bad { tag = Some tag; code = None; text = "TLS not configured" })
10301030- | Some tls_config ->
10311031- (* Send OK before upgrading *)
10321032- send_response flow (Ok { tag = Some tag; code = None; text = "Begin TLS negotiation" });
10331033- (* Upgrade the connection to TLS *)
10341034- let tls_flow = Tls_eio.server_of_flow tls_config flow in
10351035- (* Continue with TLS-wrapped flow, preserving state (which is Not_authenticated after STARTTLS) *)
10361036- (* Per RFC 3501, STARTTLS does not send a new greeting, session continues *)
10371037- ignore (handle_connection_internal t (tls_flow :> _ Eio.Flow.two_way) ~tls_active:true ~send_greeting:false ~initial_state:state)
10381038-10391039- (* Connection handler for already-TLS connections (implicit TLS on port 993) *)
10401040- let handle_connection_tls t tls_flow _addr =
10411041- ignore (handle_connection_internal t (tls_flow :> _ Eio.Flow.two_way) ~tls_active:true ~send_greeting:true ~initial_state:Not_authenticated)
10421042-10431043- (* Run server on cleartext port - single process mode (no privilege separation) *)
10441044- let run t ~sw ~net ~addr ?(after_bind = fun () -> ()) () =
10451045- let socket = Eio.Net.listen net ~sw ~reuse_addr:true ~backlog:128 addr in
10461046- after_bind ();
10471047- let rec accept_loop () =
10481048- Eio.Net.accept_fork socket ~sw
10491049- ~on_error:(fun exn -> Eio.traceln "Connection error: %a" Fmt.exn exn)
10501050- (fun flow addr -> handle_connection t flow addr);
10511051- accept_loop ()
10521052- in
10531053- accept_loop ()
10541054-10551055- (* Run server on TLS port - single process mode (no privilege separation) *)
10561056- let run_tls t ~sw ~net ~addr ~tls_config ?(after_bind = fun () -> ()) () =
10571057- let socket = Eio.Net.listen net ~sw ~reuse_addr:true ~backlog:128 addr in
10581058- after_bind ();
10591059- let rec accept_loop () =
10601060- Eio.Net.accept_fork socket ~sw
10611061- ~on_error:(fun exn -> Eio.traceln "Connection error: %a" Fmt.exn exn)
10621062- (fun flow addr ->
10631063- let tls_flow = Tls_eio.server_of_flow tls_config flow in
10641064- handle_connection_tls t tls_flow addr);
10651065- accept_loop ()
10661066- in
10671067- accept_loop ()
10681068-10691069- (* Drop privileges to the authenticated user *)
10701070- let drop_to_user username =
10711071- try
10721072- let pw = Unix.getpwnam username in
10731073- (* Set supplementary groups first *)
10741074- Unix.initgroups username pw.Unix.pw_gid;
10751075- (* Set GID before UID (can't change GID after dropping root) *)
10761076- Unix.setgid pw.Unix.pw_gid;
10771077- Unix.setuid pw.Unix.pw_uid;
10781078- true
10791079- with
10801080- | Not_found -> false
10811081- | Unix.Unix_error _ -> false
10821082-10831083- (* Fork-based connection handler for privilege separation.
10841084- Each connection runs in its own process as the authenticated user. *)
10851085- let handle_connection_forked t flow _addr ~tls_active =
10861086- send_greeting t flow ~tls_active;
10871087- (* Authentication loop - runs as root *)
10881088- let rec auth_loop () =
10891089- match read_line flow with
10901090- | None -> () (* Connection closed *)
10911091- | Some line ->
10921092- match parse_command line with
10931093- | Error _ ->
10941094- send_response flow (Bad { tag = None; code = None; text = "Invalid command syntax" });
10951095- auth_loop ()
10961096- | Result.Ok cmd ->
10971097- match cmd.command with
10981098- | Login { username; password } ->
10991099- if not (Protocol.is_safe_username username) then begin
11001100- send_response flow (No {
11011101- tag = Some cmd.tag;
11021102- code = Some Code_authenticationfailed;
11031103- text = "LOGIN failed"
11041104- });
11051105- auth_loop ()
11061106- end else if Auth.authenticate t.auth ~username ~password then begin
11071107- (* Authentication succeeded - drop privileges to this user *)
11081108- if drop_to_user username then begin
11091109- let caps = all_capabilities t ~tls_active in
11101110- send_response flow (Ok {
11111111- tag = Some cmd.tag;
11121112- code = Some (Code_capability caps);
11131113- text = "LOGIN completed"
11141114- });
11151115- (* Continue session as authenticated user *)
11161116- let state = Authenticated { username; utf8_enabled = false } in
11171117- ignore (command_loop t flow state tls_active)
11181118- end else begin
11191119- (* Failed to drop privileges *)
11201120- send_response flow (No {
11211121- tag = Some cmd.tag;
11221122- code = Some Code_authenticationfailed;
11231123- text = "LOGIN failed"
11241124- });
11251125- auth_loop ()
11261126- end
11271127- end else begin
11281128- send_response flow (No {
11291129- tag = Some cmd.tag;
11301130- code = Some Code_authenticationfailed;
11311131- text = "LOGIN failed"
11321132- });
11331133- auth_loop ()
11341134- end
11351135- | Capability ->
11361136- handle_capability t flow cmd.tag ~tls_active;
11371137- auth_loop ()
11381138- | Noop ->
11391139- handle_noop flow cmd.tag;
11401140- auth_loop ()
11411141- | Id params ->
11421142- handle_id flow cmd.tag params;
11431143- auth_loop ()
11441144- | Logout ->
11451145- ignore (handle_logout flow cmd.tag);
11461146- () (* Exit *)
11471147- | Starttls ->
11481148- (* STARTTLS not supported in forked mode - would need to pass TLS state *)
11491149- send_response flow (Bad { tag = Some cmd.tag; code = None; text = "STARTTLS not supported in this mode" });
11501150- auth_loop ()
11511151- | _ ->
11521152- send_response flow (Bad {
11531153- tag = Some cmd.tag;
11541154- code = None;
11551155- text = "Please authenticate first"
11561156- });
11571157- auth_loop ()
11581158- in
11591159- auth_loop ()
11601160-11611161- (* Run server with fork-per-connection privilege separation.
11621162- Requires running as root. Each connection forks and drops to the authenticated user. *)
11631163- let run_forked t ~sw:_ ~net:_ ~addr ~tls_config =
11641164- (* Extract port and address for raw socket operations *)
11651165- let port, bind_addr = match addr with
11661166- | `Tcp (ip, port) ->
11671167- let addr_str =
11681168- if ip = Eio.Net.Ipaddr.V4.loopback then "127.0.0.1"
11691169- else if ip = Eio.Net.Ipaddr.V4.any then "0.0.0.0"
11701170- else
11711171- (* Format IP address to string *)
11721172- Format.asprintf "%a" Eio.Net.Ipaddr.pp ip
11731173- in
11741174- (port, Unix.inet_addr_of_string addr_str)
11751175- | _ -> failwith "Only TCP addresses supported"
11761176- in
11771177- (* Create listening socket *)
11781178- let sock = Unix.socket Unix.PF_INET Unix.SOCK_STREAM 0 in
11791179- Unix.setsockopt sock Unix.SO_REUSEADDR true;
11801180- Unix.bind sock (Unix.ADDR_INET (bind_addr, port));
11811181- Unix.listen sock 128;
11821182-11831183- (* Reap zombie children *)
11841184- Sys.set_signal Sys.sigchld (Sys.Signal_handle (fun _ ->
11851185- try while fst (Unix.waitpid [Unix.WNOHANG] (-1)) > 0 do () done
11861186- with Unix.Unix_error (Unix.ECHILD, _, _) -> ()
11871187- ));
11881188-11891189- (* Accept loop - handle EINTR from signal handlers *)
11901190- let rec accept_with_retry () =
11911191- try Unix.accept sock
11921192- with Unix.Unix_error (Unix.EINTR, _, _) -> accept_with_retry ()
11931193- in
11941194- while true do
11951195- let client_sock, _client_addr = accept_with_retry () in
11961196- match Unix.fork () with
11971197- | 0 ->
11981198- (* Child process *)
11991199- Unix.close sock; (* Close listening socket in child *)
12001200- (* Run EIO for this connection *)
12011201- Eio_main.run @@ fun _env ->
12021202- Eio.Switch.run @@ fun sw ->
12031203- let flow = Eio_unix.Net.import_socket_stream ~sw ~close_unix:true client_sock in
12041204- (match tls_config with
12051205- | None ->
12061206- handle_connection_forked t flow () ~tls_active:false
12071207- | Some tls_cfg ->
12081208- let tls_flow = Tls_eio.server_of_flow tls_cfg flow in
12091209- handle_connection_forked t (tls_flow :> _ Eio.Flow.two_way) () ~tls_active:true);
12101210- exit 0
12111211- | _pid ->
12121212- (* Parent process *)
12131213- Unix.close client_sock (* Close client socket in parent *)
12141214- done
12151215-end
-120
lib/imapd/server.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP4rev2 Server
77-88- This module implements the IMAP server connection handler and state machine
99- as specified in {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051}.
1010-1111- {2 References}
1212- {ul
1313- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
1414- {- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-3}RFC 9051 Section 3} - State and Flow Diagram}
1515- {- {{:https://datatracker.ietf.org/doc/html/rfc8314}RFC 8314} - Use of TLS for Email}} *)
1616-1717-(** {1 Server Configuration} *)
1818-1919-type config = {
2020- hostname : string;
2121- (** Server hostname for greeting. *)
2222-2323- capabilities : string list;
2424- (** Additional capabilities to advertise. *)
2525-2626- greeting : string option;
2727- (** Custom greeting message. *)
2828-2929- autologout_timeout : float;
3030- (** Inactivity timeout in seconds. Default 1800 (30 minutes) per RFC 9051. *)
3131-3232- tls_config : Tls.Config.server option;
3333- (** TLS configuration for STARTTLS support. If provided, STARTTLS capability
3434- is advertised and clients can upgrade to TLS mid-connection. *)
3535-}
3636-3737-val default_config : config
3838-(** Default server configuration. *)
3939-4040-(** {1 Server Functor}
4141-4242- Create a server instance with a specific storage backend. *)
4343-4444-module Make
4545- (Storage : Storage.STORAGE)
4646- (Auth : Auth.AUTH) : sig
4747-4848- type t
4949- (** Server instance. *)
5050-5151- val create : config:config -> storage:Storage.t -> auth:Auth.t -> t
5252- (** Create a new server instance. *)
5353-5454- (** {2 Connection Handling} *)
5555-5656- val handle_connection :
5757- t ->
5858- [> `Close | `Flow | `R | `Shutdown | `W ] Eio.Resource.t ->
5959- _ ->
6060- unit
6161- (** Handle a single client connection.
6262-6363- This implements the IMAP state machine:
6464- - Sends greeting
6565- - Processes commands
6666- - Manages state transitions
6767- - Handles logout/disconnect *)
6868-6969- (** {2 Running the Server} *)
7070-7171- val run :
7272- t ->
7373- sw:Eio.Switch.t ->
7474- net:'a Eio.Net.t ->
7575- addr:Eio.Net.Sockaddr.stream ->
7676- ?after_bind:(unit -> unit) ->
7777- unit ->
7878- unit
7979- (** Run the server on a cleartext port (143).
8080-8181- @param after_bind Optional callback invoked after binding but before
8282- accepting connections. Use for privilege dropping.
8383-8484- Implements {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.1}RFC 9051 Section 2.1}. *)
8585-8686- val run_tls :
8787- t ->
8888- sw:Eio.Switch.t ->
8989- net:'a Eio.Net.t ->
9090- addr:Eio.Net.Sockaddr.stream ->
9191- tls_config:Tls.Config.server ->
9292- ?after_bind:(unit -> unit) ->
9393- unit ->
9494- unit
9595- (** Run the server on an implicit TLS port (993).
9696-9797- @param after_bind Optional callback invoked after binding but before
9898- accepting connections. Use for privilege dropping.
9999-100100- Implements {{:https://datatracker.ietf.org/doc/html/rfc8314#section-3.2}RFC 8314 Section 3.2}. *)
101101-102102- val run_forked :
103103- t ->
104104- sw:Eio.Switch.t ->
105105- net:'a Eio.Net.t ->
106106- addr:Eio.Net.Sockaddr.stream ->
107107- tls_config:Tls.Config.server option ->
108108- unit
109109- (** Run the server with fork-per-connection privilege separation.
110110-111111- Each incoming connection forks a child process. After successful
112112- authentication, the child drops privileges to the authenticated user
113113- via setuid/setgid. This provides strong isolation between users.
114114-115115- Requires running as root. STARTTLS is not supported in this mode
116116- (use implicit TLS instead).
117117-118118- This is the recommended mode for production deployments. *)
119119-end
120120-
-1154
lib/imapd/storage.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Storage Backends
77-88- Implements storage for {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051}. *)
99-1010-open Protocol
1111-1212-(* Storage errors *)
1313-type error =
1414- | Mailbox_not_found
1515- | Mailbox_already_exists
1616- | Message_not_found
1717- | Permission_denied
1818- | Storage_error of string
1919- | Quota_exceeded
2020-2121-let error_to_string = function
2222- | Mailbox_not_found -> "Mailbox not found"
2323- | Mailbox_already_exists -> "Mailbox already exists"
2424- | Message_not_found -> "Message not found"
2525- | Permission_denied -> "Permission denied"
2626- | Storage_error msg -> "Storage error: " ^ msg
2727- | Quota_exceeded -> "Quota exceeded"
2828-2929-(** Format month name for IMAP internal date format *)
3030-let month_name = function
3131- | 0 -> "Jan" | 1 -> "Feb" | 2 -> "Mar" | 3 -> "Apr"
3232- | 4 -> "May" | 5 -> "Jun" | 6 -> "Jul" | 7 -> "Aug"
3333- | 8 -> "Sep" | 9 -> "Oct" | 10 -> "Nov" | _ -> "Dec"
3434-3535-(** Format Unix time as IMAP internal date string *)
3636-let format_internal_date tm =
3737- Printf.sprintf "%02d-%s-%04d %02d:%02d:%02d +0000"
3838- tm.Unix.tm_mday
3939- (month_name tm.Unix.tm_mon)
4040- (1900 + tm.Unix.tm_year)
4141- tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec
4242-4343-(** Check if UID matches a sequence range *)
4444-let uid_in_range uid range =
4545- let uid_int = Int32.to_int uid in
4646- match range with
4747- | Single n -> uid_int = n
4848- | Range (a, b) -> uid_int >= a && uid_int <= b
4949- | From n -> uid_int >= n
5050- | All -> true
5151-5252-(** Check if UID matches any range in sequence set *)
5353-let uid_matches_set uid seqs =
5454- List.exists (uid_in_range uid) seqs
5555-5656-(* Mailbox information *)
5757-type mailbox_info = {
5858- name : mailbox_name;
5959- delimiter : char option;
6060- flags : list_flag list;
6161-}
6262-6363-(** Get SPECIAL-USE flags for a mailbox based on its name (RFC 6154) *)
6464-let get_special_use_for_mailbox name =
6565- match String.lowercase_ascii name with
6666- | "drafts" -> [List_drafts]
6767- | "sent" | "sent messages" | "sent items" -> [List_sent]
6868- | "trash" | "deleted messages" | "deleted items" -> [List_trash]
6969- | "junk" | "spam" -> [List_junk]
7070- | "archive" -> [List_archive]
7171- | _ -> []
7272-7373-(* Storage backend signature *)
7474-module type STORAGE = sig
7575- type t
7676-7777- val create : unit -> t
7878-7979- val list_mailboxes :
8080- t -> username:string -> reference:string -> pattern:string -> mailbox_info list
8181-8282- val create_mailbox : t -> username:string -> mailbox_name -> (unit, error) result
8383- val delete_mailbox : t -> username:string -> mailbox_name -> (unit, error) result
8484- val rename_mailbox : t -> username:string -> old_name:mailbox_name -> new_name:mailbox_name -> (unit, error) result
8585- val select_mailbox : t -> username:string -> mailbox_name -> readonly:bool -> (mailbox_state, error) result
8686- val status_mailbox : t -> username:string -> mailbox_name -> items:status_item list -> ((status_item * int64) list, error) result
8787-8888- val fetch_messages : t -> username:string -> mailbox:mailbox_name -> sequence:sequence_set -> items:fetch_item list -> (message list, error) result
8989- val fetch_by_uid : t -> username:string -> mailbox:mailbox_name -> uids:sequence_set -> items:fetch_item list -> (message list, error) result
9090- val store_flags : t -> username:string -> mailbox:mailbox_name -> sequence:sequence_set -> action:store_action -> flags:flag list -> (message list, error) result
9191- val expunge : t -> username:string -> mailbox:mailbox_name -> (uid list, error) result
9292- val append : t -> username:string -> mailbox:mailbox_name -> flags:flag list -> date:string option -> message:string -> (uid, error) result
9393- val copy : t -> username:string -> src_mailbox:mailbox_name -> sequence:sequence_set -> dst_mailbox:mailbox_name -> (uid list, error) result
9494- val move : t -> username:string -> src_mailbox:mailbox_name -> sequence:sequence_set -> dst_mailbox:mailbox_name -> (uid list, error) result
9595- val search : t -> username:string -> mailbox:mailbox_name -> criteria:search_key -> (uid list, error) result
9696-end
9797-9898-(* ===== In-Memory Storage ===== *)
9999-100100-module Memory_storage = struct
101101- (* Internal mailbox representation *)
102102- type mailbox = {
103103- mutable messages : message list;
104104- uidvalidity : uidvalidity;
105105- mutable uidnext : uid;
106106- flags : flag list;
107107- }
108108-109109- (* User data *)
110110- type user_data = {
111111- mailboxes : (mailbox_name, mailbox) Hashtbl.t;
112112- subscriptions : mailbox_name list; [@warning "-69"]
113113- (** Placeholder for LSUB/SUBSCRIBE support per RFC 9051 Section 6.3.6 *)
114114- }
115115-116116- type t = {
117117- users : (string, user_data) Hashtbl.t;
118118- lock : unit; [@warning "-69"]
119119- (** Placeholder for future Eio mutex for concurrent access *)
120120- }
121121-122122- let create () = {
123123- users = Hashtbl.create 16;
124124- lock = ();
125125- }
126126-127127- let get_user t ~username =
128128- match Hashtbl.find_opt t.users username with
129129- | Some u -> u
130130- | None ->
131131- let u = {
132132- mailboxes = Hashtbl.create 8;
133133- subscriptions = [];
134134- } in
135135- Hashtbl.add t.users username u;
136136- u
137137-138138- let ensure_inbox user =
139139- if not (Hashtbl.mem user.mailboxes "INBOX") then begin
140140- let inbox = {
141141- messages = [];
142142- uidvalidity = Int32.of_float (Unix.time ());
143143- uidnext = 1l;
144144- flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
145145- } in
146146- Hashtbl.add user.mailboxes "INBOX" inbox
147147- end
148148-149149- let add_test_user t ~username =
150150- let user = get_user t ~username in
151151- ensure_inbox user
152152-153153- let add_test_message t ~username ~mailbox ~message =
154154- let user = get_user t ~username in
155155- match Hashtbl.find_opt user.mailboxes mailbox with
156156- | Some mb ->
157157- mb.messages <- mb.messages @ [message];
158158- if message.uid >= mb.uidnext then
159159- mb.uidnext <- Int32.succ message.uid
160160- | None -> ()
161161-162162- (* Pattern matching for LIST command *)
163163- let matches_pattern ~pattern name =
164164- if pattern = "*" then true
165165- else if pattern = "%" then not (String.contains name '/')
166166- else
167167- (* Simple prefix matching - full glob support would be more complex *)
168168- let pattern_len = String.length pattern in
169169- if pattern_len > 0 && pattern.[pattern_len - 1] = '*' then
170170- let prefix = String.sub pattern 0 (pattern_len - 1) in
171171- String.length name >= String.length prefix &&
172172- String.sub name 0 (String.length prefix) = prefix
173173- else
174174- name = pattern
175175-176176- let list_mailboxes t ~username ~reference:_ ~pattern =
177177- let user = get_user t ~username in
178178- ensure_inbox user;
179179- Hashtbl.fold (fun name _mb acc ->
180180- if matches_pattern ~pattern name then
181181- let flags = get_special_use_for_mailbox name in
182182- { name; delimiter = Some '/'; flags } :: acc
183183- else acc
184184- ) user.mailboxes []
185185-186186- let create_mailbox t ~username name =
187187- let name = normalize_mailbox_name name in
188188- let user = get_user t ~username in
189189- if Hashtbl.mem user.mailboxes name then
190190- Result.Error Mailbox_already_exists
191191- else begin
192192- let mb = {
193193- messages = [];
194194- uidvalidity = Int32.of_float (Unix.time ());
195195- uidnext = 1l;
196196- flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
197197- } in
198198- Hashtbl.add user.mailboxes name mb;
199199- Result.Ok ()
200200- end
201201-202202- let delete_mailbox t ~username name =
203203- let name = normalize_mailbox_name name in
204204- if is_inbox name then
205205- Result.Error Permission_denied (* Cannot delete INBOX *)
206206- else
207207- let user = get_user t ~username in
208208- if Hashtbl.mem user.mailboxes name then begin
209209- Hashtbl.remove user.mailboxes name;
210210- Result.Ok ()
211211- end else
212212- Result.Error Mailbox_not_found
213213-214214- let rename_mailbox t ~username ~old_name ~new_name =
215215- let old_name = normalize_mailbox_name old_name in
216216- let new_name = normalize_mailbox_name new_name in
217217- if is_inbox old_name then
218218- Result.Error Permission_denied (* Cannot rename INBOX directly *)
219219- else
220220- let user = get_user t ~username in
221221- match Hashtbl.find_opt user.mailboxes old_name with
222222- | None -> Result.Error Mailbox_not_found
223223- | Some mb ->
224224- if Hashtbl.mem user.mailboxes new_name then
225225- Result.Error Mailbox_already_exists
226226- else begin
227227- Hashtbl.remove user.mailboxes old_name;
228228- Hashtbl.add user.mailboxes new_name mb;
229229- Result.Ok ()
230230- end
231231-232232- let select_mailbox t ~username name ~readonly =
233233- let name = normalize_mailbox_name name in
234234- let user = get_user t ~username in
235235- ensure_inbox user;
236236- match Hashtbl.find_opt user.mailboxes name with
237237- | None -> Result.Error Mailbox_not_found
238238- | Some mb ->
239239- Result.Ok {
240240- name;
241241- exists = List.length mb.messages;
242242- uidvalidity = mb.uidvalidity;
243243- uidnext = mb.uidnext;
244244- flags = mb.flags;
245245- permanent_flags = mb.flags;
246246- readonly;
247247- }
248248-249249- let status_mailbox t ~username name ~items =
250250- let name = normalize_mailbox_name name in
251251- let user = get_user t ~username in
252252- match Hashtbl.find_opt user.mailboxes name with
253253- | None -> Result.Error Mailbox_not_found
254254- | Some mb ->
255255- let results = List.map (fun item ->
256256- let value = match item with
257257- | Status_messages -> Int64.of_int (List.length mb.messages)
258258- | Status_uidnext -> Int64.of_int32 mb.uidnext
259259- | Status_uidvalidity -> Int64.of_int32 mb.uidvalidity
260260- | Status_unseen ->
261261- Int64.of_int (List.length (List.filter (fun (m : message) ->
262262- not (List.mem (System Seen) m.flags)
263263- ) mb.messages))
264264- | Status_deleted ->
265265- Int64.of_int (List.length (List.filter (fun (m : message) ->
266266- List.mem (System Deleted) m.flags
267267- ) mb.messages))
268268- | Status_size ->
269269- List.fold_left (fun acc (m : message) -> Int64.add acc m.size) 0L mb.messages
270270- in
271271- (item, value)
272272- ) items in
273273- Result.Ok results
274274-275275- (* Check if sequence number matches a range *)
276276- let seq_in_range seq range max_seq =
277277- match range with
278278- | Single n -> seq = n
279279- | Range (a, b) -> seq >= a && seq <= b
280280- | From n -> seq >= n && seq <= max_seq
281281- | All -> seq <= max_seq
282282-283283- let seq_matches sequence seq max_seq =
284284- List.exists (fun range -> seq_in_range seq range max_seq) sequence
285285-286286- let fetch_messages t ~username ~mailbox ~sequence ~items:_ =
287287- let mailbox = normalize_mailbox_name mailbox in
288288- let user = get_user t ~username in
289289- match Hashtbl.find_opt user.mailboxes mailbox with
290290- | None -> Result.Error Mailbox_not_found
291291- | Some mb ->
292292- let max_seq = List.length mb.messages in
293293- let results = List.filteri (fun i _ ->
294294- seq_matches sequence (i + 1) max_seq
295295- ) mb.messages in
296296- Result.Ok results
297297-298298- let fetch_by_uid t ~username ~mailbox ~uids ~items:_ =
299299- let mailbox = normalize_mailbox_name mailbox in
300300- let user = get_user t ~username in
301301- match Hashtbl.find_opt user.mailboxes mailbox with
302302- | None -> Result.Error Mailbox_not_found
303303- | Some mb ->
304304- let results = List.filter (fun m -> uid_matches_set m.uid uids) mb.messages in
305305- Result.Ok results
306306-307307- let apply_flags_action action existing new_flags =
308308- match action with
309309- | Store_set -> new_flags
310310- | Store_add ->
311311- List.fold_left (fun acc f ->
312312- if List.mem f acc then acc else f :: acc
313313- ) existing new_flags
314314- | Store_remove ->
315315- List.filter (fun f -> not (List.mem f new_flags)) existing
316316-317317- let store_flags t ~username ~mailbox ~sequence ~action ~flags =
318318- let mailbox = normalize_mailbox_name mailbox in
319319- let user = get_user t ~username in
320320- match Hashtbl.find_opt user.mailboxes mailbox with
321321- | None -> Result.Error Mailbox_not_found
322322- | Some mb ->
323323- let max_seq = List.length mb.messages in
324324- let updated = List.mapi (fun i (m : message) ->
325325- if seq_matches sequence (i + 1) max_seq then
326326- { m with flags = apply_flags_action action m.flags flags }
327327- else m
328328- ) mb.messages in
329329- mb.messages <- updated;
330330- let results = List.filteri (fun i _ ->
331331- seq_matches sequence (i + 1) max_seq
332332- ) updated in
333333- Result.Ok results
334334-335335- let expunge t ~username ~mailbox =
336336- let mailbox = normalize_mailbox_name mailbox in
337337- let user = get_user t ~username in
338338- match Hashtbl.find_opt user.mailboxes mailbox with
339339- | None -> Result.Error Mailbox_not_found
340340- | Some mb ->
341341- let deleted, remaining = List.partition (fun (m : message) ->
342342- List.mem (System Deleted) m.flags
343343- ) mb.messages in
344344- mb.messages <- remaining;
345345- (* Renumber remaining messages *)
346346- mb.messages <- List.mapi (fun i (m : message) -> { m with seq = i + 1 }) mb.messages;
347347- Result.Ok (List.map (fun (m : message) -> m.uid) deleted)
348348-349349- let current_date () =
350350- format_internal_date (Unix.gmtime (Unix.time ()))
351351-352352- let append t ~username ~mailbox ~flags ~date ~message =
353353- let mailbox = normalize_mailbox_name mailbox in
354354- let user = get_user t ~username in
355355- ensure_inbox user;
356356- match Hashtbl.find_opt user.mailboxes mailbox with
357357- | None -> Result.Error Mailbox_not_found
358358- | Some mb ->
359359- let uid = mb.uidnext in
360360- mb.uidnext <- Int32.succ mb.uidnext;
361361- let msg = {
362362- uid;
363363- seq = List.length mb.messages + 1;
364364- flags;
365365- internal_date = (match date with Some d -> d | None -> current_date ());
366366- size = Int64.of_int (String.length message);
367367- envelope = None;
368368- body_structure = None;
369369- raw_headers = None;
370370- raw_body = Some message;
371371- } in
372372- mb.messages <- mb.messages @ [msg];
373373- Result.Ok uid
374374-375375- let copy t ~username ~src_mailbox ~sequence ~dst_mailbox =
376376- let src_mailbox = normalize_mailbox_name src_mailbox in
377377- let dst_mailbox = normalize_mailbox_name dst_mailbox in
378378- let user = get_user t ~username in
379379- match Hashtbl.find_opt user.mailboxes src_mailbox,
380380- Hashtbl.find_opt user.mailboxes dst_mailbox with
381381- | None, _ -> Result.Error Mailbox_not_found
382382- | _, None -> Result.Error Mailbox_not_found
383383- | Some src_mb, Some dst_mb ->
384384- let max_seq = List.length src_mb.messages in
385385- let to_copy = List.filteri (fun i _ ->
386386- seq_matches sequence (i + 1) max_seq
387387- ) src_mb.messages in
388388- let new_uids = List.map (fun m ->
389389- let uid = dst_mb.uidnext in
390390- dst_mb.uidnext <- Int32.succ dst_mb.uidnext;
391391- let new_msg = {
392392- m with
393393- uid;
394394- seq = List.length dst_mb.messages + 1;
395395- } in
396396- dst_mb.messages <- dst_mb.messages @ [new_msg];
397397- uid
398398- ) to_copy in
399399- Result.Ok new_uids
400400-401401- let move t ~username ~src_mailbox ~sequence ~dst_mailbox =
402402- match copy t ~username ~src_mailbox ~sequence ~dst_mailbox with
403403- | Result.Error e -> Result.Error e
404404- | Result.Ok uids ->
405405- (* Mark source messages as deleted and expunge *)
406406- let src_mailbox = normalize_mailbox_name src_mailbox in
407407- let user = get_user t ~username in
408408- (match Hashtbl.find_opt user.mailboxes src_mailbox with
409409- | None -> ()
410410- | Some src_mb ->
411411- let max_seq = List.length src_mb.messages in
412412- src_mb.messages <- List.filteri (fun i _ ->
413413- not (seq_matches sequence (i + 1) max_seq)
414414- ) src_mb.messages;
415415- src_mb.messages <- List.mapi (fun i (m : message) -> { m with seq = i + 1 }) src_mb.messages);
416416- Result.Ok uids
417417-418418- let search t ~username ~mailbox ~criteria =
419419- let mailbox = normalize_mailbox_name mailbox in
420420- let user = get_user t ~username in
421421- match Hashtbl.find_opt user.mailboxes mailbox with
422422- | None -> Result.Error Mailbox_not_found
423423- | Some mb ->
424424- let rec matches (m : message) = function
425425- | Search_all -> true
426426- | Search_seen -> List.mem (System Seen) m.flags
427427- | Search_unseen -> not (List.mem (System Seen) m.flags)
428428- | Search_answered -> List.mem (System Answered) m.flags
429429- | Search_unanswered -> not (List.mem (System Answered) m.flags)
430430- | Search_deleted -> List.mem (System Deleted) m.flags
431431- | Search_undeleted -> not (List.mem (System Deleted) m.flags)
432432- | Search_flagged -> List.mem (System Flagged) m.flags
433433- | Search_unflagged -> not (List.mem (System Flagged) m.flags)
434434- | Search_draft -> List.mem (System Draft) m.flags
435435- | Search_new -> not (List.mem (System Seen) m.flags) (* Simplified *)
436436- | Search_old -> List.mem (System Seen) m.flags (* Simplified *)
437437- | Search_not k -> not (matches m k)
438438- | Search_or (k1, k2) -> matches m k1 || matches m k2
439439- | Search_and ks -> List.for_all (matches m) ks
440440- | Search_larger n -> m.size > n
441441- | Search_smaller n -> m.size < n
442442- | Search_uid seqs -> uid_matches_set m.uid seqs
443443- | _ -> true (* TODO: Implement remaining search keys *)
444444- in
445445- let results = List.filter_map (fun (m : message) ->
446446- if matches m criteria then Some m.uid else None
447447- ) mb.messages in
448448- Result.Ok results
449449-end
450450-451451-(* ===== Maildir Storage ===== *)
452452-453453-(** Maildir storage backend.
454454-455455- Implements the Maildir format as specified by D.J. Bernstein.
456456- See {{:https://cr.yp.to/proto/maildir.html}Maildir specification}.
457457-458458- Directory structure:
459459- - {i base_path}/{i username}/ - User's INBOX
460460- - {i base_path}/{i username}/.{i folder}/ - Subfolders
461461-462462- Each mailbox contains:
463463- - cur/ - Messages that have been seen by MUA
464464- - new/ - Newly delivered messages
465465- - tmp/ - Temporary files during delivery
466466-467467- Filename format: {i unique}:2,{i flags}
468468- - unique: {i timestamp}.{i pid}.{i hostname}
469469- - flags: D=Draft, F=Flagged, R=Replied, S=Seen, T=Trashed *)
470470-471471-module Maildir_storage = struct
472472- type path_mode =
473473- | Shared_base of string (* Shared base path: /var/mail/<username> *)
474474- | Home_directory (* User home: ~<username>/Maildir *)
475475-476476- type t = {
477477- path_mode : path_mode;
478478- hostname : string;
479479- mutable delivery_counter : int;
480480- }
481481-482482- (* Helper: check if string ends with suffix *)
483483- let ends_with ~suffix s =
484484- let len = String.length suffix in
485485- String.length s >= len && String.sub s (String.length s - len) len = suffix
486486-487487- (* UID mapping file stores: filename -> uid *)
488488- type uid_map = {
489489- mutable next_uid : int32;
490490- uidvalidity : int32;
491491- entries : (string, int32) Hashtbl.t; (* filename -> uid *)
492492- }
493493-494494- let create () = {
495495- path_mode = Home_directory;
496496- hostname = Unix.gethostname ();
497497- delivery_counter = 0;
498498- }
499499-500500- let create_with_path ~base_path = {
501501- path_mode = Shared_base base_path;
502502- hostname = Unix.gethostname ();
503503- delivery_counter = 0;
504504- }
505505-506506- let create_home_directory () = {
507507- path_mode = Home_directory;
508508- hostname = Unix.gethostname ();
509509- delivery_counter = 0;
510510- }
511511-512512- let user_path t ~username =
513513- match t.path_mode with
514514- | Shared_base base_path -> Filename.concat base_path username
515515- | Home_directory ->
516516- try
517517- let pw = Unix.getpwnam username in
518518- Filename.concat pw.Unix.pw_dir "Maildir"
519519- with Not_found ->
520520- (* Fallback if user lookup fails *)
521521- Filename.concat "/var/mail" username
522522-523523- (* Recursive mkdir -p - declared early for ensure_user *)
524524- let rec mkdir_p path =
525525- if not (Sys.file_exists path) then begin
526526- mkdir_p (Filename.dirname path);
527527- try Sys.mkdir path 0o700 with Sys_error _ -> ()
528528- end
529529-530530- let ensure_maildir_structure path =
531531- mkdir_p path;
532532- mkdir_p (Filename.concat path "cur");
533533- mkdir_p (Filename.concat path "new");
534534- mkdir_p (Filename.concat path "tmp")
535535-536536- (* Ensure user's INBOX exists *)
537537- let ensure_user t ~username =
538538- let inbox_path = user_path t ~username in
539539- ensure_maildir_structure inbox_path
540540-541541- let mailbox_path t ~username ~mailbox =
542542- let base = user_path t ~username in
543543- if is_inbox mailbox then base
544544- else Filename.concat base ("." ^ String.map (fun c -> if c = '/' then '.' else c) mailbox)
545545-546546- (* UID map file - use a name that won't be confused with a mailbox *)
547547- let uid_map_path path = Filename.concat path ".imapd-uidmap"
548548-549549- (* Parse Maildir flags from filename info part *)
550550- let parse_maildir_flags info =
551551- let flags = ref [] in
552552- String.iter (fun c ->
553553- match c with
554554- | 'S' -> flags := System Seen :: !flags
555555- | 'R' -> flags := System Answered :: !flags
556556- | 'F' -> flags := System Flagged :: !flags
557557- | 'T' -> flags := System Deleted :: !flags
558558- | 'D' -> flags := System Draft :: !flags
559559- | _ -> ()
560560- ) info;
561561- !flags
562562-563563- (* Convert IMAP flags to Maildir flag string *)
564564- let flags_to_maildir_string flags =
565565- let buf = Buffer.create 8 in
566566- (* Maildir flags must be in ASCII order: DFPRST *)
567567- if List.mem (System Draft) flags then Buffer.add_char buf 'D';
568568- if List.mem (System Flagged) flags then Buffer.add_char buf 'F';
569569- (* P = Passed (forwarded), not in IMAP *)
570570- if List.mem (System Answered) flags then Buffer.add_char buf 'R';
571571- if List.mem (System Seen) flags then Buffer.add_char buf 'S';
572572- if List.mem (System Deleted) flags then Buffer.add_char buf 'T';
573573- Buffer.contents buf
574574-575575- (* Parse filename to extract base name and flags *)
576576- let parse_filename filename =
577577- match String.index_opt filename ':' with
578578- | None -> (filename, [])
579579- | Some colon_pos ->
580580- let base = String.sub filename 0 colon_pos in
581581- let rest = String.sub filename (colon_pos + 1) (String.length filename - colon_pos - 1) in
582582- if String.length rest >= 2 && rest.[0] = '2' && rest.[1] = ',' then
583583- let info = String.sub rest 2 (String.length rest - 2) in
584584- (base, parse_maildir_flags info)
585585- else
586586- (base, [])
587587-588588- (* Build filename with flags *)
589589- let build_filename base flags =
590590- let flag_str = flags_to_maildir_string flags in
591591- if flag_str = "" then base ^ ":2,"
592592- else base ^ ":2," ^ flag_str
593593-594594- (* Generate unique filename for new message *)
595595- let generate_unique_name t =
596596- let timestamp = Unix.gettimeofday () in
597597- let sec = int_of_float timestamp in
598598- let usec = int_of_float ((timestamp -. float_of_int sec) *. 1000000.0) in
599599- t.delivery_counter <- t.delivery_counter + 1;
600600- Printf.sprintf "%d.M%dP%dQ%d.%s" sec usec (Unix.getpid ()) t.delivery_counter t.hostname
601601-602602- (* Load or create UID map for a mailbox *)
603603- let load_uid_map path =
604604- let map_file = uid_map_path path in
605605- if Sys.file_exists map_file then begin
606606- let ic = open_in_bin map_file in
607607- try
608608- let next_uid = Int32.of_string (input_line ic) in
609609- let uidvalidity = Int32.of_string (input_line ic) in
610610- let entries = Hashtbl.create 256 in
611611- (try
612612- while true do
613613- let line = input_line ic in
614614- match String.index_opt line '\t' with
615615- | Some tab ->
616616- let filename = String.sub line 0 tab in
617617- let uid = Int32.of_string (String.sub line (tab + 1) (String.length line - tab - 1)) in
618618- Hashtbl.add entries filename uid
619619- | None -> ()
620620- done
621621- with End_of_file -> ());
622622- close_in ic;
623623- { next_uid; uidvalidity; entries }
624624- with _ ->
625625- close_in ic;
626626- { next_uid = 1l; uidvalidity = Int32.of_float (Unix.time ()); entries = Hashtbl.create 256 }
627627- end else
628628- { next_uid = 1l; uidvalidity = Int32.of_float (Unix.time ()); entries = Hashtbl.create 256 }
629629-630630- let save_uid_map path map =
631631- let map_file = uid_map_path path in
632632- let oc = open_out_bin map_file in
633633- Printf.fprintf oc "%ld\n%ld\n" map.next_uid map.uidvalidity;
634634- Hashtbl.iter (fun filename uid ->
635635- Printf.fprintf oc "%s\t%ld\n" filename uid
636636- ) map.entries;
637637- close_out oc
638638-639639- (* Get or assign UID for a filename *)
640640- let get_or_assign_uid map filename =
641641- let base, _ = parse_filename filename in
642642- match Hashtbl.find_opt map.entries base with
643643- | Some uid -> uid
644644- | None ->
645645- let uid = map.next_uid in
646646- map.next_uid <- Int32.succ map.next_uid;
647647- Hashtbl.add map.entries base uid;
648648- uid
649649-650650- (* List all messages in a maildir *)
651651- let list_messages path =
652652- let messages = ref [] in
653653- let add_from_dir subdir in_new =
654654- let dir = Filename.concat path subdir in
655655- if Sys.file_exists dir then begin
656656- let entries = Sys.readdir dir in
657657- Array.iter (fun filename ->
658658- if filename.[0] <> '.' then
659659- messages := (Filename.concat dir filename, filename, in_new) :: !messages
660660- ) entries
661661- end
662662- in
663663- add_from_dir "new" true;
664664- add_from_dir "cur" false;
665665- !messages
666666-667667- (* Read message from file *)
668668- let read_message_file filepath =
669669- try
670670- let ic = open_in_bin filepath in
671671- let size = in_channel_length ic in
672672- let content = really_input_string ic size in
673673- close_in ic;
674674- Some content
675675- with _ -> None
676676-677677- (* Parse internal date from message or use file mtime *)
678678- let get_internal_date filepath =
679679- try
680680- let stats = Unix.stat filepath in
681681- format_internal_date (Unix.gmtime stats.Unix.st_mtime)
682682- with _ -> Memory_storage.current_date ()
683683-684684- (* Check if a file is a metadata file that should not be listed as a mailbox *)
685685- let is_metadata_file name =
686686- ends_with ~suffix:"-uidmap" name ||
687687- ends_with ~suffix:".uidmap" name ||
688688- name = ".imapd-uidmap" ||
689689- name = ".subscriptions"
690690-691691- let list_mailboxes t ~username ~reference:_ ~pattern =
692692- let base = user_path t ~username in
693693- if not (Sys.file_exists base) then
694694- (* Create user directory with INBOX *)
695695- (ensure_maildir_structure base;
696696- if Memory_storage.matches_pattern ~pattern "INBOX" then
697697- [{ name = "INBOX"; delimiter = Some '/'; flags = [] }]
698698- else [])
699699- else begin
700700- let entries = Sys.readdir base in
701701- let mailboxes = Array.fold_left (fun acc entry ->
702702- if String.length entry > 0 && entry.[0] = '.' && entry <> ".." then
703703- (* Skip hidden files that aren't mailboxes *)
704704- if String.length entry > 1 && entry.[1] <> '.' then
705705- (* Skip metadata files *)
706706- if is_metadata_file entry then acc
707707- else
708708- (* Only include directories (mailboxes), not regular files *)
709709- let entry_path = Filename.concat base entry in
710710- if Sys.is_directory entry_path then
711711- let name = String.sub entry 1 (String.length entry - 1) in
712712- let name = String.map (fun c -> if c = '.' then '/' else c) name in
713713- if Memory_storage.matches_pattern ~pattern name then
714714- let flags = get_special_use_for_mailbox name in
715715- { name; delimiter = Some '/'; flags } :: acc
716716- else acc
717717- else acc
718718- else acc
719719- else acc
720720- ) [] entries in
721721- (* Always include INBOX if it matches *)
722722- if Memory_storage.matches_pattern ~pattern "INBOX" then
723723- { name = "INBOX"; delimiter = Some '/'; flags = [] } :: mailboxes
724724- else mailboxes
725725- end
726726-727727- let create_mailbox t ~username name =
728728- let name = normalize_mailbox_name name in
729729- let path = mailbox_path t ~username ~mailbox:name in
730730- if Sys.file_exists path then
731731- Result.Error Mailbox_already_exists
732732- else begin
733733- try
734734- ensure_maildir_structure path;
735735- Result.Ok ()
736736- with Sys_error msg -> Result.Error (Storage_error msg)
737737- end
738738-739739- let delete_mailbox t ~username name =
740740- let name = normalize_mailbox_name name in
741741- if is_inbox name then
742742- Result.Error Permission_denied
743743- else
744744- let path = mailbox_path t ~username ~mailbox:name in
745745- if not (Sys.file_exists path) then
746746- Result.Error Mailbox_not_found
747747- else begin
748748- try
749749- (* Remove all files in subdirectories first *)
750750- List.iter (fun subdir ->
751751- let dir = Filename.concat path subdir in
752752- if Sys.file_exists dir then begin
753753- Array.iter (fun f ->
754754- Sys.remove (Filename.concat dir f)
755755- ) (Sys.readdir dir);
756756- Sys.rmdir dir
757757- end
758758- ) ["cur"; "new"; "tmp"];
759759- (* Remove UID map if exists *)
760760- let uid_file = uid_map_path path in
761761- if Sys.file_exists uid_file then Sys.remove uid_file;
762762- (* Remove mailbox directory *)
763763- Sys.rmdir path;
764764- Result.Ok ()
765765- with Sys_error msg -> Result.Error (Storage_error msg)
766766- end
767767-768768- let rename_mailbox t ~username ~old_name ~new_name =
769769- let old_name = normalize_mailbox_name old_name in
770770- let new_name = normalize_mailbox_name new_name in
771771- if is_inbox old_name then
772772- Result.Error Permission_denied
773773- else
774774- let old_path = mailbox_path t ~username ~mailbox:old_name in
775775- let new_path = mailbox_path t ~username ~mailbox:new_name in
776776- if not (Sys.file_exists old_path) then
777777- Result.Error Mailbox_not_found
778778- else if Sys.file_exists new_path then
779779- Result.Error Mailbox_already_exists
780780- else begin
781781- try
782782- Sys.rename old_path new_path;
783783- Result.Ok ()
784784- with Sys_error msg -> Result.Error (Storage_error msg)
785785- end
786786-787787- let select_mailbox t ~username name ~readonly =
788788- let name = normalize_mailbox_name name in
789789- let path = mailbox_path t ~username ~mailbox:name in
790790- if not (Sys.file_exists path) then begin
791791- (* Auto-create INBOX *)
792792- if is_inbox name then begin
793793- ensure_maildir_structure path;
794794- let map = load_uid_map path in
795795- save_uid_map path map;
796796- Result.Ok {
797797- name;
798798- exists = 0;
799799- uidvalidity = map.uidvalidity;
800800- uidnext = map.next_uid;
801801- flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
802802- permanent_flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
803803- readonly;
804804- }
805805- end else
806806- Result.Error Mailbox_not_found
807807- end else begin
808808- let messages = list_messages path in
809809- let map = load_uid_map path in
810810- (* Assign UIDs to any new messages *)
811811- List.iter (fun (_, filename, _) ->
812812- ignore (get_or_assign_uid map filename)
813813- ) messages;
814814- save_uid_map path map;
815815- Result.Ok {
816816- name;
817817- exists = List.length messages;
818818- uidvalidity = map.uidvalidity;
819819- uidnext = map.next_uid;
820820- flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
821821- permanent_flags = [System Seen; System Answered; System Flagged; System Deleted; System Draft];
822822- readonly;
823823- }
824824- end
825825-826826- let status_mailbox t ~username name ~items =
827827- let name = normalize_mailbox_name name in
828828- let path = mailbox_path t ~username ~mailbox:name in
829829- if not (Sys.file_exists path) then
830830- Result.Error Mailbox_not_found
831831- else begin
832832- let messages = list_messages path in
833833- let map = load_uid_map path in
834834- let total_size = ref 0L in
835835- let unseen_count = ref 0 in
836836- List.iter (fun (filepath, filename, in_new) ->
837837- let _, flags = parse_filename filename in
838838- if in_new || not (List.mem (System Seen) flags) then
839839- incr unseen_count;
840840- try
841841- let stats = Unix.stat filepath in
842842- total_size := Int64.add !total_size (Int64.of_int stats.Unix.st_size)
843843- with _ -> ()
844844- ) messages;
845845- let results = List.map (fun item ->
846846- let value = match item with
847847- | Status_messages -> Int64.of_int (List.length messages)
848848- | Status_uidnext -> Int64.of_int32 map.next_uid
849849- | Status_uidvalidity -> Int64.of_int32 map.uidvalidity
850850- | Status_unseen -> Int64.of_int !unseen_count
851851- | Status_deleted -> 0L (* Would need to scan for T flag *)
852852- | Status_size -> !total_size
853853- in
854854- (item, value)
855855- ) items in
856856- Result.Ok results
857857- end
858858-859859- let fetch_messages t ~username ~mailbox ~sequence ~items:_ =
860860- let mailbox = normalize_mailbox_name mailbox in
861861- let path = mailbox_path t ~username ~mailbox in
862862- if not (Sys.file_exists path) then
863863- Result.Error Mailbox_not_found
864864- else begin
865865- let messages = list_messages path in
866866- let map = load_uid_map path in
867867- let max_seq = List.length messages in
868868- let results = List.mapi (fun i (filepath, filename, _in_new) ->
869869- let seq = i + 1 in
870870- if Memory_storage.seq_matches sequence seq max_seq then begin
871871- let _base, flags = parse_filename filename in
872872- let uid = get_or_assign_uid map filename in
873873- let size = try
874874- let stats = Unix.stat filepath in
875875- Int64.of_int stats.Unix.st_size
876876- with _ -> 0L in
877877- Some {
878878- uid;
879879- seq;
880880- flags;
881881- internal_date = get_internal_date filepath;
882882- size;
883883- envelope = None;
884884- body_structure = None;
885885- raw_headers = None;
886886- raw_body = (match read_message_file filepath with Some s -> Some s | None -> None);
887887- }
888888- end else None
889889- ) messages in
890890- save_uid_map path map;
891891- Result.Ok (List.filter_map Fun.id results)
892892- end
893893-894894- let fetch_by_uid t ~username ~mailbox ~uids ~items:_ =
895895- let mailbox = normalize_mailbox_name mailbox in
896896- let path = mailbox_path t ~username ~mailbox in
897897- if not (Sys.file_exists path) then
898898- Result.Error Mailbox_not_found
899899- else begin
900900- let messages = list_messages path in
901901- let map = load_uid_map path in
902902- let results = List.mapi (fun i (filepath, filename, _in_new) ->
903903- let uid = get_or_assign_uid map filename in
904904- if uid_matches_set uid uids then begin
905905- let _, flags = parse_filename filename in
906906- let size = try
907907- let stats = Unix.stat filepath in
908908- Int64.of_int stats.Unix.st_size
909909- with _ -> 0L in
910910- Some {
911911- uid;
912912- seq = i + 1;
913913- flags;
914914- internal_date = get_internal_date filepath;
915915- size;
916916- envelope = None;
917917- body_structure = None;
918918- raw_headers = None;
919919- raw_body = read_message_file filepath;
920920- }
921921- end else None
922922- ) messages in
923923- save_uid_map path map;
924924- Result.Ok (List.filter_map Fun.id results)
925925- end
926926-927927- let store_flags t ~username ~mailbox ~sequence ~action ~flags =
928928- let mailbox = normalize_mailbox_name mailbox in
929929- let path = mailbox_path t ~username ~mailbox in
930930- if not (Sys.file_exists path) then
931931- Result.Error Mailbox_not_found
932932- else begin
933933- let messages = list_messages path in
934934- let map = load_uid_map path in
935935- let max_seq = List.length messages in
936936- let results = List.mapi (fun i (filepath, filename, _in_new) ->
937937- let seq = i + 1 in
938938- if Memory_storage.seq_matches sequence seq max_seq then begin
939939- let base, old_flags = parse_filename filename in
940940- let new_flags = Memory_storage.apply_flags_action action old_flags flags in
941941- let uid = get_or_assign_uid map filename in
942942- (* Rename file with new flags *)
943943- let new_filename = build_filename base new_flags in
944944- let dir = Filename.dirname filepath in
945945- (* Move to cur/ if in new/ *)
946946- let new_dir = if ends_with ~suffix:"/new" dir then
947947- String.sub dir 0 (String.length dir - 4) ^ "/cur"
948948- else dir in
949949- let new_filepath = Filename.concat new_dir new_filename in
950950- (try
951951- if filepath <> new_filepath then
952952- Sys.rename filepath new_filepath
953953- with _ -> ());
954954- let size = try
955955- let stats = Unix.stat new_filepath in
956956- Int64.of_int stats.Unix.st_size
957957- with _ -> 0L in
958958- Some {
959959- uid;
960960- seq;
961961- flags = new_flags;
962962- internal_date = get_internal_date new_filepath;
963963- size;
964964- envelope = None;
965965- body_structure = None;
966966- raw_headers = None;
967967- raw_body = None;
968968- }
969969- end else None
970970- ) messages in
971971- save_uid_map path map;
972972- Result.Ok (List.filter_map Fun.id results)
973973- end
974974-975975- let expunge t ~username ~mailbox =
976976- let mailbox = normalize_mailbox_name mailbox in
977977- let path = mailbox_path t ~username ~mailbox in
978978- if not (Sys.file_exists path) then
979979- Result.Error Mailbox_not_found
980980- else begin
981981- let messages = list_messages path in
982982- let map = load_uid_map path in
983983- let deleted_uids = ref [] in
984984- List.iter (fun (filepath, filename, _) ->
985985- let base, flags = parse_filename filename in
986986- if List.mem (System Deleted) flags then begin
987987- let uid = get_or_assign_uid map filename in
988988- deleted_uids := uid :: !deleted_uids;
989989- (* Remove from UID map *)
990990- Hashtbl.remove map.entries base;
991991- (* Delete file *)
992992- (try Sys.remove filepath with _ -> ())
993993- end
994994- ) messages;
995995- save_uid_map path map;
996996- Result.Ok (List.rev !deleted_uids)
997997- end
998998-999999- let append t ~username ~mailbox ~flags ~date:_ ~message =
10001000- let mailbox = normalize_mailbox_name mailbox in
10011001- let path = mailbox_path t ~username ~mailbox in
10021002- (* Create INBOX if it doesn't exist, error for other mailboxes *)
10031003- if not (Sys.file_exists path) then begin
10041004- if is_inbox mailbox then
10051005- ensure_maildir_structure path
10061006- else
10071007- (* Return error for non-existent non-INBOX mailboxes *)
10081008- ()
10091009- end;
10101010- if not (Sys.file_exists path) then
10111011- Result.Error Mailbox_not_found
10121012- else
10131013- try
10141014- let map = load_uid_map path in
10151015- let unique_name = generate_unique_name t in
10161016- let filename = build_filename unique_name flags in
10171017- (* Write to tmp first, then move to new or cur *)
10181018- let tmp_path = Filename.concat (Filename.concat path "tmp") filename in
10191019- let oc = open_out_bin tmp_path in
10201020- output_string oc message;
10211021- close_out oc;
10221022- (* Move to cur if Seen flag set, otherwise new *)
10231023- let dest_dir = if List.mem (System Seen) flags then "cur" else "new" in
10241024- let dest_path = Filename.concat (Filename.concat path dest_dir) filename in
10251025- Sys.rename tmp_path dest_path;
10261026- (* Assign UID *)
10271027- let uid = map.next_uid in
10281028- map.next_uid <- Int32.succ map.next_uid;
10291029- Hashtbl.add map.entries unique_name uid;
10301030- save_uid_map path map;
10311031- Result.Ok uid
10321032- with Sys_error msg -> Result.Error (Storage_error msg)
10331033-10341034- let copy t ~username ~src_mailbox ~sequence ~dst_mailbox =
10351035- let src_mailbox = normalize_mailbox_name src_mailbox in
10361036- let dst_mailbox = normalize_mailbox_name dst_mailbox in
10371037- let src_path = mailbox_path t ~username ~mailbox:src_mailbox in
10381038- let dst_path = mailbox_path t ~username ~mailbox:dst_mailbox in
10391039- if not (Sys.file_exists src_path) then
10401040- Result.Error Mailbox_not_found
10411041- else if not (Sys.file_exists dst_path) then
10421042- Result.Error Mailbox_not_found
10431043- else begin
10441044- let messages = list_messages src_path in
10451045- let src_map = load_uid_map src_path in
10461046- let dst_map = load_uid_map dst_path in
10471047- let max_seq = List.length messages in
10481048- let new_uids = ref [] in
10491049- List.iteri (fun i (filepath, filename, _) ->
10501050- let seq = i + 1 in
10511051- if Memory_storage.seq_matches sequence seq max_seq then begin
10521052- match read_message_file filepath with
10531053- | Some content ->
10541054- let _, flags = parse_filename filename in
10551055- let unique_name = generate_unique_name t in
10561056- let new_filename = build_filename unique_name flags in
10571057- let dest_dir = if List.mem (System Seen) flags then "cur" else "new" in
10581058- let dest_path = Filename.concat (Filename.concat dst_path dest_dir) new_filename in
10591059- let oc = open_out_bin dest_path in
10601060- output_string oc content;
10611061- close_out oc;
10621062- let uid = dst_map.next_uid in
10631063- dst_map.next_uid <- Int32.succ dst_map.next_uid;
10641064- Hashtbl.add dst_map.entries unique_name uid;
10651065- new_uids := uid :: !new_uids
10661066- | None -> ()
10671067- end
10681068- ) messages;
10691069- save_uid_map src_path src_map;
10701070- save_uid_map dst_path dst_map;
10711071- Result.Ok (List.rev !new_uids)
10721072- end
10731073-10741074- let move t ~username ~src_mailbox ~sequence ~dst_mailbox =
10751075- match copy t ~username ~src_mailbox ~sequence ~dst_mailbox with
10761076- | Result.Error e -> Result.Error e
10771077- | Result.Ok new_uids ->
10781078- (* Mark source messages as deleted and expunge *)
10791079- let src_mailbox = normalize_mailbox_name src_mailbox in
10801080- let src_path = mailbox_path t ~username ~mailbox:src_mailbox in
10811081- let messages = list_messages src_path in
10821082- let max_seq = List.length messages in
10831083- List.iteri (fun i (filepath, _filename, _) ->
10841084- let seq = i + 1 in
10851085- if Memory_storage.seq_matches sequence seq max_seq then
10861086- (try Sys.remove filepath with _ -> ())
10871087- ) messages;
10881088- Result.Ok new_uids
10891089-10901090- let search t ~username ~mailbox ~criteria =
10911091- let mailbox = normalize_mailbox_name mailbox in
10921092- let path = mailbox_path t ~username ~mailbox in
10931093- if not (Sys.file_exists path) then
10941094- Result.Error Mailbox_not_found
10951095- else begin
10961096- let messages = list_messages path in
10971097- let map = load_uid_map path in
10981098- let rec matches (filepath, filename, in_new) = function
10991099- | Search_all -> true
11001100- | Search_seen ->
11011101- let _, flags = parse_filename filename in
11021102- not in_new && List.mem (System Seen) flags
11031103- | Search_unseen ->
11041104- let _, flags = parse_filename filename in
11051105- in_new || not (List.mem (System Seen) flags)
11061106- | Search_flagged ->
11071107- let _, flags = parse_filename filename in
11081108- List.mem (System Flagged) flags
11091109- | Search_unflagged ->
11101110- let _, flags = parse_filename filename in
11111111- not (List.mem (System Flagged) flags)
11121112- | Search_deleted ->
11131113- let _, flags = parse_filename filename in
11141114- List.mem (System Deleted) flags
11151115- | Search_undeleted ->
11161116- let _, flags = parse_filename filename in
11171117- not (List.mem (System Deleted) flags)
11181118- | Search_answered ->
11191119- let _, flags = parse_filename filename in
11201120- List.mem (System Answered) flags
11211121- | Search_unanswered ->
11221122- let _, flags = parse_filename filename in
11231123- not (List.mem (System Answered) flags)
11241124- | Search_draft ->
11251125- let _, flags = parse_filename filename in
11261126- List.mem (System Draft) flags
11271127- | Search_not k -> not (matches (filepath, filename, in_new) k)
11281128- | Search_or (k1, k2) ->
11291129- matches (filepath, filename, in_new) k1 || matches (filepath, filename, in_new) k2
11301130- | Search_and ks -> List.for_all (matches (filepath, filename, in_new)) ks
11311131- | Search_larger n ->
11321132- (try
11331133- let stats = Unix.stat filepath in
11341134- Int64.of_int stats.Unix.st_size > n
11351135- with _ -> false)
11361136- | Search_smaller n ->
11371137- (try
11381138- let stats = Unix.stat filepath in
11391139- Int64.of_int stats.Unix.st_size < n
11401140- with _ -> false)
11411141- | Search_uid seqs ->
11421142- let uid = get_or_assign_uid map filename in
11431143- uid_matches_set uid seqs
11441144- | _ -> true (* Simplified: other criteria match all *)
11451145- in
11461146- let results = List.filter_map (fun (filepath, filename, in_new) ->
11471147- if matches (filepath, filename, in_new) criteria then
11481148- Some (get_or_assign_uid map filename)
11491149- else None
11501150- ) messages in
11511151- save_uid_map path map;
11521152- Result.Ok results
11531153- end
11541154-end
-215
lib/imapd/storage.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Storage Backends
77-88- This module provides pluggable storage backends for the IMAP server.
99-1010- {2 References}
1111- {ul
1212- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
1313- {- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-2.3}RFC 9051 Section 2.3} - Message Attributes}} *)
1414-1515-open Protocol
1616-1717-(** {1 Storage Errors} *)
1818-1919-type error =
2020- | Mailbox_not_found
2121- | Mailbox_already_exists
2222- | Message_not_found
2323- | Permission_denied
2424- | Storage_error of string
2525- | Quota_exceeded
2626-2727-val error_to_string : error -> string
2828-2929-(** {1 Mailbox Information} *)
3030-3131-type mailbox_info = {
3232- name : mailbox_name;
3333- delimiter : char option;
3434- flags : list_flag list;
3535-}
3636-3737-(** {1 Storage Backend Signature} *)
3838-3939-module type STORAGE = sig
4040- type t
4141-4242- (** {2 Lifecycle} *)
4343-4444- val create : unit -> t
4545- (** Create a new storage instance. *)
4646-4747- (** {2 Mailbox Operations} *)
4848-4949- val list_mailboxes :
5050- t ->
5151- username:string ->
5252- reference:string ->
5353- pattern:string ->
5454- mailbox_info list
5555- (** List mailboxes matching the pattern.
5656- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.9}RFC 9051 Section 6.3.9}. *)
5757-5858- val create_mailbox :
5959- t ->
6060- username:string ->
6161- mailbox_name ->
6262- (unit, error) result
6363- (** Create a new mailbox.
6464- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.4}RFC 9051 Section 6.3.4}. *)
6565-6666- val delete_mailbox :
6767- t ->
6868- username:string ->
6969- mailbox_name ->
7070- (unit, error) result
7171- (** Delete a mailbox.
7272- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.5}RFC 9051 Section 6.3.5}. *)
7373-7474- val rename_mailbox :
7575- t ->
7676- username:string ->
7777- old_name:mailbox_name ->
7878- new_name:mailbox_name ->
7979- (unit, error) result
8080- (** Rename a mailbox.
8181- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.6}RFC 9051 Section 6.3.6}. *)
8282-8383- val select_mailbox :
8484- t ->
8585- username:string ->
8686- mailbox_name ->
8787- readonly:bool ->
8888- (mailbox_state, error) result
8989- (** Select a mailbox for access.
9090- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.2}RFC 9051 Section 6.3.2}. *)
9191-9292- val status_mailbox :
9393- t ->
9494- username:string ->
9595- mailbox_name ->
9696- items:status_item list ->
9797- ((status_item * int64) list, error) result
9898- (** Get mailbox status.
9999- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.11}RFC 9051 Section 6.3.11}. *)
100100-101101- (** {2 Message Operations} *)
102102-103103- val fetch_messages :
104104- t ->
105105- username:string ->
106106- mailbox:mailbox_name ->
107107- sequence:sequence_set ->
108108- items:fetch_item list ->
109109- (message list, error) result
110110- (** Fetch messages.
111111- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5}RFC 9051 Section 6.4.5}. *)
112112-113113- val fetch_by_uid :
114114- t ->
115115- username:string ->
116116- mailbox:mailbox_name ->
117117- uids:sequence_set ->
118118- items:fetch_item list ->
119119- (message list, error) result
120120- (** Fetch messages by UID. *)
121121-122122- val store_flags :
123123- t ->
124124- username:string ->
125125- mailbox:mailbox_name ->
126126- sequence:sequence_set ->
127127- action:store_action ->
128128- flags:flag list ->
129129- (message list, error) result
130130- (** Store flags on messages.
131131- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.6}RFC 9051 Section 6.4.6}. *)
132132-133133- val expunge :
134134- t ->
135135- username:string ->
136136- mailbox:mailbox_name ->
137137- (uid list, error) result
138138- (** Expunge deleted messages.
139139- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.3}RFC 9051 Section 6.4.3}. *)
140140-141141- val append :
142142- t ->
143143- username:string ->
144144- mailbox:mailbox_name ->
145145- flags:flag list ->
146146- date:string option ->
147147- message:string ->
148148- (uid, error) result
149149- (** Append a message.
150150- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.12}RFC 9051 Section 6.3.12}. *)
151151-152152- val copy :
153153- t ->
154154- username:string ->
155155- src_mailbox:mailbox_name ->
156156- sequence:sequence_set ->
157157- dst_mailbox:mailbox_name ->
158158- (uid list, error) result
159159- (** Copy messages.
160160- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.7}RFC 9051 Section 6.4.7}. *)
161161-162162- val move :
163163- t ->
164164- username:string ->
165165- src_mailbox:mailbox_name ->
166166- sequence:sequence_set ->
167167- dst_mailbox:mailbox_name ->
168168- (uid list, error) result
169169- (** Move messages.
170170- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.8}RFC 9051 Section 6.4.8}. *)
171171-172172- val search :
173173- t ->
174174- username:string ->
175175- mailbox:mailbox_name ->
176176- criteria:search_key ->
177177- (uid list, error) result
178178- (** Search messages.
179179- See {{:https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.4}RFC 9051 Section 6.4.4}. *)
180180-end
181181-182182-(** {1 In-Memory Storage}
183183-184184- Simple in-memory storage for development and testing. *)
185185-186186-module Memory_storage : sig
187187- include STORAGE
188188-189189- val add_test_user : t -> username:string -> unit
190190- (** Add a test user with default INBOX. *)
191191-192192- val add_test_message : t -> username:string -> mailbox:mailbox_name -> message:message -> unit
193193- (** Add a test message directly. *)
194194-end
195195-196196-(** {1 Maildir Storage}
197197-198198- Maildir-based storage for production use.
199199- See {{:https://cr.yp.to/proto/maildir.html}Maildir specification}. *)
200200-201201-module Maildir_storage : sig
202202- include STORAGE
203203-204204- val create_with_path : base_path:string -> t
205205- (** Create storage with a specific base path for Maildir directories.
206206- Mail is stored at {i base_path}/{i username}/. *)
207207-208208- val create_home_directory : unit -> t
209209- (** Create storage using users' home directories.
210210- Mail is stored at ~{i username}/Maildir/ (the traditional Unix location).
211211- This is the recommended mode for fork-per-connection with setuid. *)
212212-213213- val ensure_user : t -> username:string -> unit
214214- (** Ensure user's INBOX exists (creates Maildir structure). *)
215215-end
-181
lib/imapd/utf8.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** UTF-8 validation per RFC 3629 for RFC 6855 IMAP UTF-8 support.
77- @see <https://datatracker.ietf.org/doc/html/rfc6855> RFC 6855: IMAP Support for UTF-8
88- @see <https://datatracker.ietf.org/doc/html/rfc3629> RFC 3629: UTF-8 encoding *)
99-1010-(** Check if a string contains any non-ASCII characters (bytes >= 128). *)
1111-let has_non_ascii s =
1212- let len = String.length s in
1313- let rec loop i =
1414- if i >= len then false
1515- else if Char.code s.[i] >= 128 then true
1616- else loop (i + 1)
1717- in
1818- loop 0
1919-2020-(** Validate UTF-8 encoding per RFC 3629 Section 4.
2121-2222- UTF-8 encoding (RFC 3629):
2323- - 1-byte: 0xxxxxxx (U+0000..U+007F)
2424- - 2-byte: 110xxxxx 10xxxxxx (U+0080..U+07FF)
2525- - 3-byte: 1110xxxx 10xxxxxx 10xxxxxx (U+0800..U+FFFF)
2626- - 4-byte: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (U+10000..U+10FFFF)
2727-2828- Continuation bytes always have form 10xxxxxx.
2929-3030- @see <https://datatracker.ietf.org/doc/html/rfc3629#section-4> RFC 3629 Section 4 *)
3131-let is_valid_utf8 s =
3232- let len = String.length s in
3333- let rec loop i =
3434- if i >= len then true
3535- else
3636- let b0 = Char.code s.[i] in
3737- if b0 <= 0x7F then
3838- (* 1-byte sequence: ASCII *)
3939- loop (i + 1)
4040- else if b0 land 0xE0 = 0xC0 then begin
4141- (* 2-byte sequence: 110xxxxx 10xxxxxx *)
4242- if i + 1 >= len then false
4343- else
4444- let b1 = Char.code s.[i + 1] in
4545- (* Check continuation byte *)
4646- if b1 land 0xC0 <> 0x80 then false
4747- else
4848- (* Check for overlong encoding: must encode U+0080 or higher *)
4949- let codepoint = ((b0 land 0x1F) lsl 6) lor (b1 land 0x3F) in
5050- if codepoint < 0x80 then false
5151- else loop (i + 2)
5252- end
5353- else if b0 land 0xF0 = 0xE0 then begin
5454- (* 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx *)
5555- if i + 2 >= len then false
5656- else
5757- let b1 = Char.code s.[i + 1] in
5858- let b2 = Char.code s.[i + 2] in
5959- (* Check continuation bytes *)
6060- if b1 land 0xC0 <> 0x80 || b2 land 0xC0 <> 0x80 then false
6161- else
6262- let codepoint =
6363- ((b0 land 0x0F) lsl 12) lor
6464- ((b1 land 0x3F) lsl 6) lor
6565- (b2 land 0x3F)
6666- in
6767- (* Check for overlong encoding: must encode U+0800 or higher *)
6868- if codepoint < 0x800 then false
6969- (* Check for surrogate pairs (U+D800..U+DFFF are invalid) *)
7070- else if codepoint >= 0xD800 && codepoint <= 0xDFFF then false
7171- else loop (i + 3)
7272- end
7373- else if b0 land 0xF8 = 0xF0 then begin
7474- (* 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx *)
7575- if i + 3 >= len then false
7676- else
7777- let b1 = Char.code s.[i + 1] in
7878- let b2 = Char.code s.[i + 2] in
7979- let b3 = Char.code s.[i + 3] in
8080- (* Check continuation bytes *)
8181- if b1 land 0xC0 <> 0x80 ||
8282- b2 land 0xC0 <> 0x80 ||
8383- b3 land 0xC0 <> 0x80 then false
8484- else
8585- let codepoint =
8686- ((b0 land 0x07) lsl 18) lor
8787- ((b1 land 0x3F) lsl 12) lor
8888- ((b2 land 0x3F) lsl 6) lor
8989- (b3 land 0x3F)
9090- in
9191- (* Check for overlong encoding: must encode U+10000 or higher *)
9292- if codepoint < 0x10000 then false
9393- (* Check valid Unicode range: max is U+10FFFF *)
9494- else if codepoint > 0x10FFFF then false
9595- else loop (i + 4)
9696- end
9797- else
9898- (* Invalid start byte *)
9999- false
100100- in
101101- loop 0
102102-103103-(** Decode a single UTF-8 codepoint at position [i] in string [s].
104104- Returns the codepoint and the number of bytes consumed, or None if invalid.
105105- Assumes is_valid_utf8 has already passed. *)
106106-let decode_codepoint s i =
107107- let len = String.length s in
108108- if i >= len then None
109109- else
110110- let b0 = Char.code s.[i] in
111111- if b0 <= 0x7F then
112112- Some (b0, 1)
113113- else if b0 land 0xE0 = 0xC0 && i + 1 < len then
114114- let b1 = Char.code s.[i + 1] in
115115- let cp = ((b0 land 0x1F) lsl 6) lor (b1 land 0x3F) in
116116- Some (cp, 2)
117117- else if b0 land 0xF0 = 0xE0 && i + 2 < len then
118118- let b1 = Char.code s.[i + 1] in
119119- let b2 = Char.code s.[i + 2] in
120120- let cp =
121121- ((b0 land 0x0F) lsl 12) lor
122122- ((b1 land 0x3F) lsl 6) lor
123123- (b2 land 0x3F)
124124- in
125125- Some (cp, 3)
126126- else if b0 land 0xF8 = 0xF0 && i + 3 < len then
127127- let b1 = Char.code s.[i + 1] in
128128- let b2 = Char.code s.[i + 2] in
129129- let b3 = Char.code s.[i + 3] in
130130- let cp =
131131- ((b0 land 0x07) lsl 18) lor
132132- ((b1 land 0x3F) lsl 12) lor
133133- ((b2 land 0x3F) lsl 6) lor
134134- (b3 land 0x3F)
135135- in
136136- Some (cp, 4)
137137- else
138138- None
139139-140140-(** Check if a codepoint is disallowed in mailbox names per RFC 6855 Section 3.
141141-142142- Disallowed characters:
143143- - U+0000..U+001F: C0 control characters
144144- - U+007F: DELETE
145145- - U+0080..U+009F: C1 control characters
146146- - U+2028: LINE SEPARATOR
147147- - U+2029: PARAGRAPH SEPARATOR
148148-149149- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3
150150- @see <https://datatracker.ietf.org/doc/html/rfc5198#section-2> RFC 5198 Section 2 *)
151151-let is_disallowed_mailbox_codepoint cp =
152152- (* C0 control characters U+0000..U+001F *)
153153- (cp >= 0x0000 && cp <= 0x001F) ||
154154- (* DELETE U+007F *)
155155- cp = 0x007F ||
156156- (* C1 control characters U+0080..U+009F *)
157157- (cp >= 0x0080 && cp <= 0x009F) ||
158158- (* LINE SEPARATOR U+2028 *)
159159- cp = 0x2028 ||
160160- (* PARAGRAPH SEPARATOR U+2029 *)
161161- cp = 0x2029
162162-163163-(** Validate a mailbox name for UTF-8 compliance per RFC 6855 Section 3.
164164-165165- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3 *)
166166-let is_valid_utf8_mailbox_name s =
167167- (* First check basic UTF-8 validity *)
168168- if not (is_valid_utf8 s) then false
169169- else
170170- (* Then check for disallowed codepoints *)
171171- let len = String.length s in
172172- let rec loop i =
173173- if i >= len then true
174174- else
175175- match decode_codepoint s i with
176176- | None -> false
177177- | Some (cp, bytes) ->
178178- if is_disallowed_mailbox_codepoint cp then false
179179- else loop (i + bytes)
180180- in
181181- loop 0
-33
lib/imapd/utf8.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** UTF-8 validation per RFC 3629 for RFC 6855 IMAP UTF-8 support.
77- @see <https://datatracker.ietf.org/doc/html/rfc6855> RFC 6855: IMAP Support for UTF-8
88- @see <https://datatracker.ietf.org/doc/html/rfc3629> RFC 3629: UTF-8 encoding *)
99-1010-(** {1 UTF-8 Validation} *)
1111-1212-val is_valid_utf8 : string -> bool
1313-(** [is_valid_utf8 s] returns [true] if [s] contains only valid UTF-8 sequences
1414- per RFC 3629. Returns [true] for empty strings and pure ASCII strings.
1515- @see <https://datatracker.ietf.org/doc/html/rfc3629#section-4> RFC 3629 Section 4 *)
1616-1717-val has_non_ascii : string -> bool
1818-(** [has_non_ascii s] returns [true] if [s] contains any bytes with value >= 128.
1919- This is useful for detecting when UTF-8 validation is needed. *)
2020-2121-(** {1 Mailbox Name Validation} *)
2222-2323-val is_valid_utf8_mailbox_name : string -> bool
2424-(** [is_valid_utf8_mailbox_name s] validates a mailbox name for UTF-8 compliance
2525- per RFC 6855 Section 3. Mailbox names must:
2626- - Contain only valid UTF-8 sequences
2727- - Comply with Net-Unicode (RFC 5198 Section 2)
2828- - Not contain control characters U+0000-U+001F, U+0080-U+009F
2929- - Not contain delete U+007F
3030- - Not contain line separator U+2028 or paragraph separator U+2029
3131-3232- @see <https://datatracker.ietf.org/doc/html/rfc6855#section-3> RFC 6855 Section 3
3333- @see <https://datatracker.ietf.org/doc/html/rfc5198#section-2> RFC 5198 Section 2 *)
-554
lib/imapd/write.ml
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-open Protocol
77-module W = Eio.Buf_write
88-99-let sp w = W.char w ' '
1010-let crlf w = W.string w "\r\n"
1111-1212-(* Check if character is safe for atoms (not a special) *)
1313-let is_atom_char = function
1414- | '(' | ')' | '{' | ' ' | '\x00' .. '\x1f' | '\x7f' | '%' | '*' | '"' | '\\'
1515- | ']' ->
1616- false
1717- | c -> c >= '\x21' && c <= '\x7e'
1818-1919-(* Check if string can be written as atom *)
2020-let is_atom s = String.length s > 0 && String.for_all is_atom_char s
2121-2222-let atom w s = W.string w s
2323-2424-let quoted_string w s =
2525- W.char w '"';
2626- String.iter
2727- (fun c ->
2828- match c with
2929- | '"' | '\\' ->
3030- W.char w '\\';
3131- W.char w c
3232- | _ -> W.char w c)
3333- s;
3434- W.char w '"'
3535-3636-let literal w s =
3737- W.char w '{';
3838- W.string w (string_of_int (String.length s));
3939- W.string w "}\r\n";
4040- W.string w s
4141-4242-let literal_plus w s =
4343- W.char w '{';
4444- W.string w (string_of_int (String.length s));
4545- W.string w "+}\r\n";
4646- W.string w s
4747-4848-(* Check if string needs to be a literal (contains CR, LF, or NUL) *)
4949-let needs_literal s =
5050- String.exists (function '\r' | '\n' | '\x00' -> true | _ -> false) s
5151-5252-let astring w s =
5353- if is_atom s then atom w s
5454- else if needs_literal s then literal w s
5555- else quoted_string w s
5656-5757-let nstring w = function
5858- | None -> W.string w "NIL"
5959- | Some s -> if needs_literal s then literal w s else quoted_string w s
6060-let number w n = W.string w (string_of_int n)
6161-let number32 w n = W.string w (Int32.to_string n)
6262-let number64 w n = W.string w (Int64.to_string n)
6363-6464-let sequence_range w = function
6565- | Single n -> number w n
6666- | Range (a, b) ->
6767- number w a;
6868- W.char w ':';
6969- number w b
7070- | From n ->
7171- number w n;
7272- W.string w ":*"
7373- | All -> W.char w '*'
7474-7575-let sequence_set w set =
7676- List.iteri
7777- (fun i r ->
7878- if i > 0 then W.char w ',';
7979- sequence_range w r)
8080- set
8181-8282-let system_flag w = function
8383- | Seen -> W.string w "\\Seen"
8484- | Answered -> W.string w "\\Answered"
8585- | Flagged -> W.string w "\\Flagged"
8686- | Deleted -> W.string w "\\Deleted"
8787- | Draft -> W.string w "\\Draft"
8888-8989-let flag w = function
9090- | System f -> system_flag w f
9191- | Keyword k ->
9292- W.char w '$';
9393- W.string w k
9494-9595-let flag_list w flags =
9696- W.char w '(';
9797- List.iteri
9898- (fun i f ->
9999- if i > 0 then sp w;
100100- flag w f)
101101- flags;
102102- W.char w ')'
103103-104104-let rec search_key w = function
105105- | Search_all -> W.string w "ALL"
106106- | Search_answered -> W.string w "ANSWERED"
107107- | Search_bcc s ->
108108- W.string w "BCC ";
109109- astring w s
110110- | Search_before s ->
111111- W.string w "BEFORE ";
112112- atom w s
113113- | Search_body s ->
114114- W.string w "BODY ";
115115- astring w s
116116- | Search_cc s ->
117117- W.string w "CC ";
118118- astring w s
119119- | Search_deleted -> W.string w "DELETED"
120120- | Search_flagged -> W.string w "FLAGGED"
121121- | Search_from s ->
122122- W.string w "FROM ";
123123- astring w s
124124- | Search_keyword s ->
125125- W.string w "KEYWORD ";
126126- atom w s
127127- | Search_new -> W.string w "NEW"
128128- | Search_not k ->
129129- W.string w "NOT ";
130130- search_key w k
131131- | Search_old -> W.string w "OLD"
132132- | Search_on s ->
133133- W.string w "ON ";
134134- atom w s
135135- | Search_or (k1, k2) ->
136136- W.string w "OR ";
137137- search_key w k1;
138138- sp w;
139139- search_key w k2
140140- | Search_seen -> W.string w "SEEN"
141141- | Search_since s ->
142142- W.string w "SINCE ";
143143- atom w s
144144- | Search_subject s ->
145145- W.string w "SUBJECT ";
146146- astring w s
147147- | Search_text s ->
148148- W.string w "TEXT ";
149149- astring w s
150150- | Search_to s ->
151151- W.string w "TO ";
152152- astring w s
153153- | Search_unanswered -> W.string w "UNANSWERED"
154154- | Search_undeleted -> W.string w "UNDELETED"
155155- | Search_unflagged -> W.string w "UNFLAGGED"
156156- | Search_unkeyword s ->
157157- W.string w "UNKEYWORD ";
158158- atom w s
159159- | Search_unseen -> W.string w "UNSEEN"
160160- | Search_draft -> W.string w "DRAFT"
161161- | Search_undraft -> W.string w "UNDRAFT"
162162- | Search_header (field, value) ->
163163- W.string w "HEADER ";
164164- astring w field;
165165- sp w;
166166- astring w value
167167- | Search_larger n ->
168168- W.string w "LARGER ";
169169- number64 w n
170170- | Search_smaller n ->
171171- W.string w "SMALLER ";
172172- number64 w n
173173- | Search_uid set ->
174174- W.string w "UID ";
175175- sequence_set w set
176176- | Search_sequence_set set -> sequence_set w set
177177- | Search_and keys ->
178178- W.char w '(';
179179- List.iteri
180180- (fun i k ->
181181- if i > 0 then sp w;
182182- search_key w k)
183183- keys;
184184- W.char w ')'
185185- | Search_sentbefore s ->
186186- W.string w "SENTBEFORE ";
187187- atom w s
188188- | Search_senton s ->
189189- W.string w "SENTON ";
190190- atom w s
191191- | Search_sentsince s ->
192192- W.string w "SENTSINCE ";
193193- atom w s
194194-195195-let fetch_item w = function
196196- | Fetch_envelope -> W.string w "ENVELOPE"
197197- | Fetch_flags -> W.string w "FLAGS"
198198- | Fetch_internaldate -> W.string w "INTERNALDATE"
199199- | Fetch_rfc822 -> W.string w "RFC822"
200200- | Fetch_rfc822_size -> W.string w "RFC822.SIZE"
201201- | Fetch_rfc822_header -> W.string w "RFC822.HEADER"
202202- | Fetch_rfc822_text -> W.string w "RFC822.TEXT"
203203- | Fetch_uid -> W.string w "UID"
204204- | Fetch_body -> W.string w "BODY"
205205- | Fetch_bodystructure -> W.string w "BODYSTRUCTURE"
206206- | Fetch_body_section (section, partial) ->
207207- W.string w "BODY[";
208208- W.string w section;
209209- W.char w ']';
210210- (match partial with
211211- | Some (offset, len) ->
212212- W.char w '<';
213213- number w offset;
214214- W.char w '.';
215215- number w len;
216216- W.char w '>'
217217- | None -> ())
218218- | Fetch_body_peek (section, partial) ->
219219- W.string w "BODY.PEEK[";
220220- W.string w section;
221221- W.char w ']';
222222- (match partial with
223223- | Some (offset, len) ->
224224- W.char w '<';
225225- number w offset;
226226- W.char w '.';
227227- number w len;
228228- W.char w '>'
229229- | None -> ())
230230- | Fetch_binary (section, partial) ->
231231- W.string w "BINARY[";
232232- W.string w section;
233233- W.char w ']';
234234- (match partial with
235235- | Some (offset, len) ->
236236- W.char w '<';
237237- number w offset;
238238- W.char w '.';
239239- number w len;
240240- W.char w '>'
241241- | None -> ())
242242- | Fetch_binary_peek (section, partial) ->
243243- W.string w "BINARY.PEEK[";
244244- W.string w section;
245245- W.char w ']';
246246- (match partial with
247247- | Some (offset, len) ->
248248- W.char w '<';
249249- number w offset;
250250- W.char w '.';
251251- number w len;
252252- W.char w '>'
253253- | None -> ())
254254- | Fetch_binary_size section ->
255255- W.string w "BINARY.SIZE[";
256256- W.string w section;
257257- W.char w ']'
258258-259259-let fetch_items w = function
260260- | [ item ] -> fetch_item w item
261261- | items ->
262262- W.char w '(';
263263- List.iteri
264264- (fun i item ->
265265- if i > 0 then sp w;
266266- fetch_item w item)
267267- items;
268268- W.char w ')'
269269-270270-let status_item w = function
271271- | Status_messages -> W.string w "MESSAGES"
272272- | Status_uidnext -> W.string w "UIDNEXT"
273273- | Status_uidvalidity -> W.string w "UIDVALIDITY"
274274- | Status_unseen -> W.string w "UNSEEN"
275275- | Status_deleted -> W.string w "DELETED"
276276- | Status_size -> W.string w "SIZE"
277277-278278-let status_items w items =
279279- W.char w '(';
280280- List.iteri
281281- (fun i item ->
282282- if i > 0 then sp w;
283283- status_item w item)
284284- items;
285285- W.char w ')'
286286-287287-let store_action w = function
288288- | Store_set -> W.string w "FLAGS"
289289- | Store_add -> W.string w "+FLAGS"
290290- | Store_remove -> W.string w "-FLAGS"
291291-292292-let id_params w = function
293293- | None -> W.string w "NIL"
294294- | Some pairs ->
295295- W.char w '(';
296296- List.iteri
297297- (fun i (k, v) ->
298298- if i > 0 then sp w;
299299- quoted_string w k;
300300- sp w;
301301- quoted_string w v)
302302- pairs;
303303- W.char w ')'
304304-305305-let command_body w = function
306306- | Capability -> W.string w "CAPABILITY"
307307- | Noop -> W.string w "NOOP"
308308- | Logout -> W.string w "LOGOUT"
309309- | Starttls -> W.string w "STARTTLS"
310310- | Login { username; password } ->
311311- W.string w "LOGIN ";
312312- astring w username;
313313- sp w;
314314- astring w password
315315- | Authenticate { mechanism; initial_response } -> (
316316- W.string w "AUTHENTICATE ";
317317- atom w mechanism;
318318- match initial_response with
319319- | Some r ->
320320- sp w;
321321- W.string w r
322322- | None -> ())
323323- | Enable caps ->
324324- W.string w "ENABLE";
325325- List.iter
326326- (fun c ->
327327- sp w;
328328- atom w c)
329329- caps
330330- | Select mailbox ->
331331- W.string w "SELECT ";
332332- astring w mailbox
333333- | Examine mailbox ->
334334- W.string w "EXAMINE ";
335335- astring w mailbox
336336- | Create mailbox ->
337337- W.string w "CREATE ";
338338- astring w mailbox
339339- | Delete mailbox ->
340340- W.string w "DELETE ";
341341- astring w mailbox
342342- | Rename { old_name; new_name } ->
343343- W.string w "RENAME ";
344344- astring w old_name;
345345- sp w;
346346- astring w new_name
347347- | Subscribe mailbox ->
348348- W.string w "SUBSCRIBE ";
349349- astring w mailbox
350350- | Unsubscribe mailbox ->
351351- W.string w "UNSUBSCRIBE ";
352352- astring w mailbox
353353- | List list_cmd ->
354354- W.string w "LIST ";
355355- (match list_cmd with
356356- | List_basic { reference; pattern } ->
357357- astring w reference;
358358- sp w;
359359- astring w pattern
360360- | List_extended { selection; reference; patterns; return_opts } ->
361361- (* Selection options - RFC 5258 Section 3.1 *)
362362- W.char w '(';
363363- List.iteri (fun i opt ->
364364- if i > 0 then sp w;
365365- match opt with
366366- | List_select_subscribed -> W.string w "SUBSCRIBED"
367367- | List_select_remote -> W.string w "REMOTE"
368368- | List_select_recursivematch -> W.string w "RECURSIVEMATCH"
369369- | List_select_special_use -> W.string w "SPECIAL-USE"
370370- ) selection;
371371- W.char w ')';
372372- sp w;
373373- astring w reference;
374374- sp w;
375375- (* Patterns - multiple patterns in parentheses *)
376376- (match patterns with
377377- | [p] -> astring w p
378378- | ps ->
379379- W.char w '(';
380380- List.iteri (fun i p ->
381381- if i > 0 then sp w;
382382- astring w p
383383- ) ps;
384384- W.char w ')');
385385- (* Return options - RFC 5258 Section 3.2 *)
386386- (match return_opts with
387387- | [] -> ()
388388- | opts ->
389389- sp w;
390390- W.string w "RETURN (";
391391- List.iteri (fun i opt ->
392392- if i > 0 then sp w;
393393- match opt with
394394- | List_return_subscribed -> W.string w "SUBSCRIBED"
395395- | List_return_children -> W.string w "CHILDREN"
396396- | List_return_special_use -> W.string w "SPECIAL-USE"
397397- ) opts;
398398- W.char w ')'))
399399- | Namespace -> W.string w "NAMESPACE"
400400- | Status { mailbox; items } ->
401401- W.string w "STATUS ";
402402- astring w mailbox;
403403- sp w;
404404- status_items w items
405405- | Append { mailbox; flags; date; message } ->
406406- W.string w "APPEND ";
407407- astring w mailbox;
408408- (match flags with
409409- | [] -> ()
410410- | flags ->
411411- sp w;
412412- flag_list w flags);
413413- (match date with
414414- | Some d ->
415415- sp w;
416416- quoted_string w d
417417- | None -> ());
418418- sp w;
419419- literal w message
420420- | Idle -> W.string w "IDLE"
421421- | Close -> W.string w "CLOSE"
422422- | Unselect -> W.string w "UNSELECT"
423423- | Expunge -> W.string w "EXPUNGE"
424424- | Search { charset; criteria } -> (
425425- W.string w "SEARCH";
426426- (match charset with
427427- | Some cs ->
428428- W.string w " CHARSET ";
429429- astring w cs
430430- | None -> ());
431431- sp w;
432432- search_key w criteria)
433433- | Fetch { sequence; items } ->
434434- W.string w "FETCH ";
435435- sequence_set w sequence;
436436- sp w;
437437- fetch_items w items
438438- | Store { sequence; silent; action; flags } ->
439439- W.string w "STORE ";
440440- sequence_set w sequence;
441441- sp w;
442442- store_action w action;
443443- if silent then W.string w ".SILENT";
444444- sp w;
445445- flag_list w flags
446446- | Copy { sequence; mailbox } ->
447447- W.string w "COPY ";
448448- sequence_set w sequence;
449449- sp w;
450450- astring w mailbox
451451- | Move { sequence; mailbox } ->
452452- W.string w "MOVE ";
453453- sequence_set w sequence;
454454- sp w;
455455- astring w mailbox
456456- | Uid cmd -> (
457457- W.string w "UID ";
458458- match cmd with
459459- | Uid_fetch { sequence; items } ->
460460- W.string w "FETCH ";
461461- sequence_set w sequence;
462462- sp w;
463463- fetch_items w items
464464- | Uid_store { sequence; silent; action; flags } ->
465465- W.string w "STORE ";
466466- sequence_set w sequence;
467467- sp w;
468468- store_action w action;
469469- if silent then W.string w ".SILENT";
470470- sp w;
471471- flag_list w flags
472472- | Uid_copy { sequence; mailbox } ->
473473- W.string w "COPY ";
474474- sequence_set w sequence;
475475- sp w;
476476- astring w mailbox
477477- | Uid_move { sequence; mailbox } ->
478478- W.string w "MOVE ";
479479- sequence_set w sequence;
480480- sp w;
481481- astring w mailbox
482482- | Uid_search { charset; criteria } ->
483483- W.string w "SEARCH";
484484- (match charset with
485485- | Some cs ->
486486- W.string w " CHARSET ";
487487- astring w cs
488488- | None -> ());
489489- sp w;
490490- search_key w criteria
491491- | Uid_expunge set ->
492492- W.string w "EXPUNGE ";
493493- sequence_set w set
494494- | Uid_thread { algorithm; charset; criteria } ->
495495- W.string w "THREAD ";
496496- (match algorithm with
497497- | Thread_orderedsubject -> W.string w "ORDEREDSUBJECT"
498498- | Thread_references -> W.string w "REFERENCES"
499499- | Thread_extension ext -> astring w ext);
500500- sp w;
501501- astring w charset;
502502- sp w;
503503- search_key w criteria)
504504- | Id params ->
505505- W.string w "ID ";
506506- id_params w params
507507- (* QUOTA extension - RFC 9208 *)
508508- | Getquota root ->
509509- W.string w "GETQUOTA ";
510510- astring w root
511511- | Getquotaroot mailbox ->
512512- W.string w "GETQUOTAROOT ";
513513- astring w mailbox
514514- | Setquota { root; limits } ->
515515- W.string w "SETQUOTA ";
516516- astring w root;
517517- sp w;
518518- W.char w '(';
519519- List.iteri (fun i (res, limit) ->
520520- if i > 0 then sp w;
521521- (match res with
522522- | Quota_storage -> W.string w "STORAGE"
523523- | Quota_message -> W.string w "MESSAGE"
524524- | Quota_mailbox -> W.string w "MAILBOX"
525525- | Quota_annotation_storage -> W.string w "ANNOTATION-STORAGE");
526526- sp w;
527527- W.string w (Int64.to_string limit)
528528- ) limits;
529529- W.char w ')'
530530- (* THREAD extension - RFC 5256 *)
531531- | Thread { algorithm; charset; criteria } ->
532532- W.string w "THREAD ";
533533- (match algorithm with
534534- | Thread_orderedsubject -> W.string w "ORDEREDSUBJECT"
535535- | Thread_references -> W.string w "REFERENCES"
536536- | Thread_extension ext -> astring w ext);
537537- sp w;
538538- astring w charset;
539539- sp w;
540540- search_key w criteria
541541-542542-let command w ~tag cmd =
543543- atom w tag;
544544- sp w;
545545- command_body w cmd;
546546- crlf w
547547-548548-let idle_done w =
549549- W.string w "DONE";
550550- crlf w
551551-552552-let authenticate_response w data =
553553- W.string w data;
554554- crlf w
-122
lib/imapd/write.mli
···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** IMAP Command Serialization
77-88- This module serializes IMAP commands to the wire format for client-side use.
99- Uses [Eio.Buf_write] for efficient buffered output.
1010-1111- {2 Wire Format}
1212-1313- IMAP commands are serialized according to
1414- {{:https://datatracker.ietf.org/doc/html/rfc9051#section-9}RFC 9051 Section 9}.
1515- Each command is tagged and terminated with CRLF.
1616-1717- {2 Example}
1818-1919- {[
2020- Eio.Buf_write.with_flow flow @@ fun w ->
2121- Write.command w ~tag:"A001" Protocol.Capability;
2222- Write.command w ~tag:"A002"
2323- (Protocol.Login { username = "user"; password = "pass" })
2424- ]}
2525-2626- {2 References}
2727- {ul
2828- {- {{:https://datatracker.ietf.org/doc/html/rfc9051}RFC 9051} - IMAP4rev2}
2929- {- {{:https://datatracker.ietf.org/doc/html/rfc7888}RFC 7888} - LITERAL+}} *)
3030-3131-(** {1 Primitive Writers}
3232-3333- Low-level writers for IMAP data types. *)
3434-3535-val atom : Eio.Buf_write.t -> string -> unit
3636-(** [atom w s] writes an atom (unquoted string).
3737- Atoms must contain only printable US-ASCII characters excluding
3838- specials like [(], [)], [open brace], [%], [*], [double-quote], [backslash]. *)
3939-4040-val quoted_string : Eio.Buf_write.t -> string -> unit
4141-(** [quoted_string w s] writes a quoted string with proper escaping.
4242- Backslash and double-quote are escaped with backslash. *)
4343-4444-val literal : Eio.Buf_write.t -> string -> unit
4545-(** [literal w s] writes a synchronizing literal [{n}CRLF...].
4646- Note: The server must send a continuation before the data. *)
4747-4848-val literal_plus : Eio.Buf_write.t -> string -> unit
4949-(** [literal_plus w s] writes a non-synchronizing literal [{n+}CRLF...].
5050- Requires LITERAL+ capability. Does not wait for server continuation. *)
5151-5252-val astring : Eio.Buf_write.t -> string -> unit
5353-(** [astring w s] writes an astring (atom or string).
5454- Chooses atom format if safe, otherwise quoted string. *)
5555-5656-val nstring : Eio.Buf_write.t -> string option -> unit
5757-(** [nstring w s] writes NIL or a string. *)
5858-5959-val number : Eio.Buf_write.t -> int -> unit
6060-(** [number w n] writes a decimal number. *)
6161-6262-val number32 : Eio.Buf_write.t -> int32 -> unit
6363-(** [number32 w n] writes a 32-bit decimal number (for UIDs). *)
6464-6565-val number64 : Eio.Buf_write.t -> int64 -> unit
6666-(** [number64 w n] writes a 64-bit decimal number. *)
6767-6868-val sp : Eio.Buf_write.t -> unit
6969-(** [sp w] writes a single space. *)
7070-7171-val crlf : Eio.Buf_write.t -> unit
7272-(** [crlf w] writes CRLF line terminator. *)
7373-7474-(** {1 Structured Writers}
7575-7676- Writers for IMAP structured data types. *)
7777-7878-val sequence_set : Eio.Buf_write.t -> Protocol.sequence_set -> unit
7979-(** [sequence_set w set] writes a sequence set like [1,3:5,10:*]. *)
8080-8181-val flag : Eio.Buf_write.t -> Protocol.flag -> unit
8282-(** [flag w f] writes a message flag like [\Seen] or [$Forwarded]. *)
8383-8484-val flag_list : Eio.Buf_write.t -> Protocol.flag list -> unit
8585-(** [flag_list w flags] writes a parenthesized flag list. *)
8686-8787-val search_key : Eio.Buf_write.t -> Protocol.search_key -> unit
8888-(** [search_key w key] writes a search criterion. *)
8989-9090-val fetch_item : Eio.Buf_write.t -> Protocol.fetch_item -> unit
9191-(** [fetch_item w item] writes a fetch data item. *)
9292-9393-val fetch_items : Eio.Buf_write.t -> Protocol.fetch_item list -> unit
9494-(** [fetch_items w items] writes a parenthesized list of fetch items,
9595- or a single item without parentheses. *)
9696-9797-val status_item : Eio.Buf_write.t -> Protocol.status_item -> unit
9898-(** [status_item w item] writes a STATUS data item. *)
9999-100100-val status_items : Eio.Buf_write.t -> Protocol.status_item list -> unit
101101-(** [status_items w items] writes a parenthesized list of STATUS items. *)
102102-103103-(** {1 Command Writers}
104104-105105- High-level command serialization. *)
106106-107107-val command : Eio.Buf_write.t -> tag:string -> Protocol.command -> unit
108108-(** [command w ~tag cmd] writes a complete tagged command with CRLF.
109109-110110- Example:
111111- {[
112112- command w ~tag:"A001" Protocol.Capability
113113- (* writes: "A001 CAPABILITY\r\n" *)
114114- ]} *)
115115-116116-val idle_done : Eio.Buf_write.t -> unit
117117-(** [idle_done w] writes "DONE" to exit IDLE mode.
118118- Must be sent after receiving IDLE continuation. *)
119119-120120-val authenticate_response : Eio.Buf_write.t -> string -> unit
121121-(** [authenticate_response w data] writes a base64-encoded SASL response
122122- for AUTHENTICATE continuation. *)