diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a4b06d2..debe5d6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,29 +1,23 @@ -FROM ocaml/opam:debian-12-ocaml-4.14 +FROM hadolint/hadolint:latest-alpine AS hadolint +FROM ocaml/opam:debian-12-ocaml-5.3 USER root # copy hadolint -COPY --from=hadolint/hadolint:latest-alpine /bin/hadolint /bin/hadolint +COPY --from=hadolint /bin/hadolint /bin/hadolint # Avoid warnings by switching to noninteractive -ENV DEBIAN_FRONTEND noninteractive -ENV SIHL_ENV development +ENV DEBIAN_FRONTEND=noninteractive # install packages -RUN apt-get update -q && apt-get install -yqq --no-install-recommends \ - # development dependencies +# hadolint ignore=DL3008 +RUN apt-get update --allow-releaseinfo-change -q \ + && apt-get install -yqq --no-install-recommends \ inotify-tools \ - zsh \ - m4 \ - wget \ - # - # build dependencies (would also be installed by opam depext) - gcc \ - jq \ - libev-dev \ libgmp-dev \ - libssl-dev \ pkg-config \ + wget \ + zsh \ # # cleanup installations && apt-get autoremove -y \ @@ -33,9 +27,8 @@ RUN apt-get update -q && apt-get install -yqq --no-install-recommends \ # add timezone RUN ln -fs /usr/share/zoneinfo/Europe/Zurich /etc/localtime -# WTF: https://github.com/mirage/ocaml-cohttp/issues/675 -RUN bash -c 'echo "http 80/tcp www # WorldWideWeb HTTP" >> /etc/services' \ - && bash -c 'echo "https 443/tcp www # WorldWideWeb HTTPS" >> /etc/services' +# link opam version +RUN ln -fs /usr/bin/opam-2.3 /usr/bin/opam USER opam @@ -43,10 +36,7 @@ USER opam SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN wget https://github.com/robbyrussell/oh-my-zsh/raw/master/tools/install.sh -q -O - | zsh \ && cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc \ - && sed -i "/^plugins=/c\plugins=(git dotenv)" ~/.zshrc \ - # - # link make to devcontainer makefile - && echo 'alias make="make -f /workspace/.devcontainer/Makefile"' >> ~/.zshrc + && sed -i "/^plugins=/c\plugins=(git dotenv)" ~/.zshrc # Switch back to dialog for any ad-hoc use of apt-get ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/Makefile b/.devcontainer/Makefile deleted file mode 100644 index 741a9ef..0000000 --- a/.devcontainer/Makefile +++ /dev/null @@ -1,3 +0,0 @@ -include ./Makefile - -SHELL = bash diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 19e844d..1e9f728 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,14 +1,10 @@ -// For format details, see https://aka.ms/vscode-remote/devcontainer.json or the definition README at -// https://github.com/microsoft/vscode-dev-containers/tree/master/containers/python-3 or the -// devcontainer docu https://code.visualstudio.com/docs/remote/containers#_devcontainerjson-reference { "name": "letters", "dockerComposeFile": "./docker-compose.yml", "service": "dev", "workspaceFolder": "/workspace", "privileged": true, - "postCreateCommand": "/bin/bash .devcontainer/postCreate.sh", - // Supported customizations: https://containers.dev/supporting + "postCreateCommand": ".devcontainer/postCreate.sh", "customizations": { "vscode": { "settings": { @@ -27,7 +23,6 @@ "kind": "global" } }, - // Add the IDs of extensions you want installed when the container is created in the array below. "extensions": [ "donjayamanne.githistory", "eamodio.gitlens", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index fb29637..5aaa42e 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -10,14 +10,19 @@ services: - ..:/workspace:cached - opam:/home/opam/.opam:cached - build:/workspace/_build:cached - - ${HOME}${USERPROFILE}/.ssh:/home/opam/.ssh - - ${HOME}${USERPROFILE}/.gitconfig:/home/opam/.gitconfig - - ${HOME}${USERPROFILE}/.gitignore_global:/home/opam/.gitignore_global environment: - - OPAMSOLVERTIMEOUT=180 - - VERSION=dev + OPAMSOLVERTIMEOUT: 180 + VERSION: dev command: sleep infinity + mailtrap: + image: mailhog/mailhog + container_name: mailtrap-letters + hostname: mailtrap-letters + ports: + - "1025:1025" # SMTP server + - "8025:8025" # Web UI + volumes: opam: build: diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh old mode 100644 new mode 100755 index 6c7a1f2..7592e39 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -3,19 +3,13 @@ # immediately when a command fails and print each command set -ex -# When possible to create cached docker volume, sudo chown -R opam: _build opam init -a --shell=zsh -# get newest opam packages opam remote remove --all default -opam remote add default https://opam.ocaml.org +opam repository add default --all-switches --set-default https://opam.ocaml.org -opam pin add -yn letters . -opam depext -y letters +opam install --with-test --with-doc --deps-only -y . -# install opam packages used for vscode ocaml platform package -# e.g. when developing with emax, add also: utop merlin ocamlformat -opam install -y ocaml-lsp-server make deps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a300fb0..a9e6caa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,48 +4,60 @@ on: [push, pull_request] jobs: build: - name: Build and test runs-on: ${{ matrix.os }} + strategy: - fail-fast: false matrix: os: - # Disable macos build for now since it keeps failing with dune not found - # - macos-latest - ubuntu-latest ocaml-compiler: - - 4.14 - - 4.13 - - 4.12 - - 4.11 - - 4.10.1 - - 4.09 - - 4.08 + - "5.3.0" + fail-fast: false + steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Retrieve opam cache - uses: actions/cache@v4 - if: runner.os != 'Windows' - id: cache-opam - with: - path: ~/.opam - key: v1-${{ runner.os }}-opam-${{ matrix.ocaml-compiler }}-${{ hashFiles('*.opam.locked') }} - - name: Use OCaml ${{ matrix.ocaml-compiler }} + - name: Checkout tree + uses: actions/checkout@v6 + + - name: Set-up OCaml uses: ocaml/setup-ocaml@v3 with: ocaml-compiler: ${{ matrix.ocaml-compiler }} - - name: Install dependencies - if: steps.cache-opam.outputs.cache-hit != 'true' - run: | - opam install -y dune - opam install -y . --deps-only --with-doc --with-test --locked --update-invariant - - name: Recover from an Opam broken state - if: steps.cache-opam.outputs.cache-hit == 'true' - run: | - opam install -y dune - opam upgrade --fixup - - name: Build - run: make build - - name: Run tests - run: make test + + - run: opam install . --deps-only --with-test + + - run: opam exec -- dune build + + - run: opam exec -- dune runtest + + lint-doc: + runs-on: ubuntu-latest + steps: + - name: Checkout tree + uses: actions/checkout@v6 + - name: Set-up OCaml + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: 5 + - uses: ocaml/setup-ocaml/lint-doc@v3 + + lint-fmt: + runs-on: ubuntu-latest + steps: + - name: Checkout tree + uses: actions/checkout@v6 + - name: Set-up OCaml + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: 5 + - uses: ocaml/setup-ocaml/lint-fmt@v3 + + lint-opam: + runs-on: ubuntu-latest + steps: + - name: Checkout tree + uses: actions/checkout@v6 + - name: Set-up OCaml + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: 5 + - uses: ocaml/setup-ocaml/lint-opam@v3 diff --git a/.gitignore b/.gitignore index 04c7029..ea34577 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .merlin *_account.json .vscode +.env +.DS_Store diff --git a/.ocamlformat b/.ocamlformat index 3df0f20..70df4a1 100644 --- a/.ocamlformat +++ b/.ocamlformat @@ -1,3 +1,4 @@ +version = 0.29.0 profile = janestreet parse-docstrings = true -wrap-comments = true \ No newline at end of file +wrap-comments = true diff --git a/Makefile b/Makefile index 1f745db..70161cb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: deps deps: ## Install development dependencies - opam install --deps-only --with-test --with-doc -y . + opam install --working-dir --with-dev-setup --with-test --with-doc --update-invariant -y . eval $(opam env) .PHONY: create_switch @@ -20,6 +20,10 @@ watch: clean: opam exec -- dune clean +.PHONY: format +format: + opam exec -- dune build --root . --auto-promote @fmt + .PHONY: test-all test-all: curl -s -d '{ "requestor": "letters", "version": "0.1.0" }' "https://api.nodemailer.com/user" -X POST -H "Content-Type: application/json" > ethereal_account.json diff --git a/dune-project b/dune-project index c099a60..b6875e7 100644 --- a/dune-project +++ b/dune-project @@ -1,4 +1,4 @@ -(lang dune 2.7) +(lang dune 3.7) (name letters) @@ -29,23 +29,27 @@ (ca-certs (>= 0.2.1)) (colombe - (>= 0.7.0)) + (>= 0.13.0)) (containers (>= 3.13.1)) + (domain-name + (>= 0.3.0)) + (emile + (>= 0.8)) (fmt (>= 0.8.8)) - (fpath + (logs (>= 0.7.0)) (lwt (>= 5.2.0)) (mrmime (>= 0.3.1)) (ocaml - (>= 4.08.1)) + (>= 5.2.0)) (ptime (>= 0.8.5)) (sendmail - (>= 0.7.0)) + (>= 0.13.0)) (tls-lwt (>= 1.0.4)) (tls @@ -68,6 +72,7 @@ ;; Documentation dependencies (odoc :with-doc) ;; Dev dependencies - (ocamlformat :dev))) + (ocamlformat :with-dev-setup) + (ocaml-lsp-server :with-dev-setup))) (implicit_transitive_deps false) diff --git a/letters.opam b/letters.opam index 6186b24..5a8d1fd 100644 --- a/letters.opam +++ b/letters.opam @@ -10,17 +10,19 @@ homepage: "https://github.com/oxidizing/letters/" doc: "https://oxidizing.github.io/letters/" bug-reports: "https://github.com/oxidizing/letters/issues" depends: [ - "dune" {>= "2.7"} + "dune" {>= "3.7"} "ca-certs" {>= "0.2.1"} - "colombe" {>= "0.7.0"} + "colombe" {>= "0.13.0"} "containers" {>= "3.13.1"} + "domain-name" {>= "0.3.0"} + "emile" {>= "0.8"} "fmt" {>= "0.8.8"} - "fpath" {>= "0.7.0"} + "logs" {>= "0.7.0"} "lwt" {>= "5.2.0"} "mrmime" {>= "0.3.1"} - "ocaml" {>= "4.08.1"} + "ocaml" {>= "5.2.0"} "ptime" {>= "0.8.5"} - "sendmail" {>= "0.7.0"} + "sendmail" {>= "0.13.0"} "tls-lwt" {>= "1.0.4"} "tls" {>= "1.0.4"} "x509" {>= "0.9.0"} @@ -28,7 +30,8 @@ depends: [ "alcotest-lwt" {>= "1.1.0" & with-test} "yojson" {>= "1.7.0" & with-test} "odoc" {with-doc} - "ocamlformat" {dev} + "ocamlformat" {with-dev-setup} + "ocaml-lsp-server" {with-dev-setup} ] build: [ ["dune" "subst"] {dev} diff --git a/letters.opam.locked b/letters.opam.locked deleted file mode 100644 index 75e4376..0000000 --- a/letters.opam.locked +++ /dev/null @@ -1,97 +0,0 @@ -opam-version: "2.0" -version: "0.4.0" -synopsis: "Client library for sending emails over SMTP" -description: "Simple to use SMTP client implementation for OCaml" -maintainer: ["Miko Nieminen "] -authors: ["Miko Nieminen"] -license: "MIT" -homepage: "https://github.com/oxidizing/letters/" -doc: "https://oxidizing.github.io/letters/" -bug-reports: "https://github.com/oxidizing/letters/issues" -depends: [ - "angstrom" {= "0.16.1"} - "asn1-combinators" {= "0.3.2"} - "astring" {= "0.8.5"} - "base-bytes" {= "base"} - "base-threads" {= "base"} - "base-unix" {= "base"} - "base64" {= "3.5.1"} - "bigarray-overlap" {= "0.2.1"} - "bigstringaf" {= "0.10.0"} - "bos" {= "0.2.1"} - "ca-certs" {= "1.0.0"} - "cmdliner" {= "1.3.0"} - "coin" {= "0.1.4"} - "colombe" {= "0.11.0"} - "conf-gmp" {= "4"} - "conf-gmp-powm-sec" {= "3"} - "conf-pkg-config" {= "3"} - "containers" {= "3.15"} - "cppo" {= "1.8.0"} - "csexp" {= "1.5.2"} - "cstruct" {= "6.2.0"} - "digestif" {= "1.2.0"} - "domain-name" {= "0.4.0"} - "dune" {= "3.17.0"} - "dune-configurator" {= "3.17.0"} - "duration" {= "0.2.1"} - "either" {= "1.0.0"} - "emile" {= "1.1"} - "eqaf" {= "0.10"} - "fmt" {= "0.9.0"} - "fpath" {= "0.7.3"} - "gmap" {= "0.3.0"} - "hxd" {= "0.3.3"} - "ipaddr" {= "5.6.0"} - "kdf" {= "1.0.0"} - "ke" {= "0.6"} - "logs" {= "0.7.0"} - "lwt" {= "5.9.0"} - "macaddr" {= "5.6.0"} - "mirage-crypto" {= "1.1.0"} - "mirage-crypto-ec" {= "1.1.0"} - "mirage-crypto-pk" {= "1.1.0"} - "mirage-crypto-rng" {= "1.1.0"} - "mirage-crypto-rng-lwt" {= "1.1.0"} - "mrmime" {= "0.6.1"} - "mtime" {= "2.1.0"} - "ocaml" {= "4.14.2"} - "ocaml-syntax-shims" {= "1.0.0"} - "ocamlbuild" {= "0.15.0"} - "ocamlfind" {= "1.9.6"} - "ocplib-endian" {= "1.2"} - "ohex" {= "0.2.0"} - "pecu" {= "0.7"} - "prettym" {= "0.0.3"} - "ptime" {= "1.2.0"} - "re" {= "1.12.0"} - "rosetta" {= "0.3.0"} - "rresult" {= "0.7.0"} - "sendmail" {= "0.11.0"} - "seq" {= "base"} - "tls" {= "1.0.4"} - "tls-lwt" {= "1.0.4"} - "topkg" {= "1.0.7"} - "unstrctrd" {= "0.4"} - "uutf" {= "1.0.3"} - "uuuu" {= "0.3.0"} - "x509" {= "1.0.5"} - "yuscii" {= "0.3.0"} - "zarith" {= "1.14"} -] -build: [ - ["dune" "subst"] {dev} - [ - "dune" - "build" - "-p" - name - "-j" - jobs - "@install" - "@runtest" {with-test} - "@doc" {with-doc} - ] -] -dev-repo: "git+https://github.com/oxidizing/letters.git" -name: "letters" diff --git a/lib/dune b/lib/dune index 03df11e..a4d0a31 100644 --- a/lib/dune +++ b/lib/dune @@ -14,14 +14,9 @@ lwt.unix mrmime ptime.clock.os - rresult sendmail sendmail.starttls tls tls-lwt + unix x509)) - -(env - (dev - (flags - (:standard -w -16)))) diff --git a/lib/letters.ml b/lib/letters.ml index d30781b..d4ef231 100644 --- a/lib/letters.ml +++ b/lib/letters.ml @@ -67,17 +67,14 @@ let stream_of_string s = Some (s, 0, String.length s)) ;; -let str_to_colombe_address str_address = +let str_to_colombe_address (str_address : string) : Colombe.Forward_path.t = match Emile.of_string str_address with - | Ok mailbox -> - (match Colombe_emile.to_forward_path mailbox with - | Ok address -> address - | Error _ -> raise (Invalid_email_address str_address)) + | Ok mailbox -> Colombe_emile.to_forward_path mailbox | Error _ -> raise (Invalid_email_address str_address) ;; let domain_of_reverse_path = function - | None -> Rresult.R.error_msgf "reverse-path is empty" + | None -> Error (`Msg "reverse-path is empty") | Some { Colombe.Path.domain; _ } -> Ok domain ;; @@ -235,11 +232,7 @@ let send = | Ok v -> v | Error (`Invalid (_, _)) -> failwith "Invalid sender address" in - let from_addr = - match Colombe_emile.to_reverse_path from_mailbox with - | Ok v -> v - | Error (`Msg msg) -> failwith msg - in + let from_addr = Colombe_emile.to_reverse_path from_mailbox in let recipients = List.map (fun recipient -> @@ -294,6 +287,7 @@ let send = ~from:from_addr ~recipients ~mail + () in match res with | Ok () -> Lwt.return () @@ -311,6 +305,7 @@ let send = ~from:from_addr ~recipients ~mail + () in match res with | Ok () -> Lwt.return () diff --git a/lib/sendmail_handler.ml b/lib/sendmail_handler.ml index b646ef3..febf6c2 100644 --- a/lib/sendmail_handler.ml +++ b/lib/sendmail_handler.ml @@ -46,6 +46,7 @@ let run_with_starttls ~from ~recipients ~mail + () = let ( let* ) = Lwt_result.bind in let port = @@ -96,11 +97,23 @@ let run_with_starttls recipients mail_stream in - Lwt_scheduler.prj fiber) + Lwt.finalize + (fun () -> Lwt_scheduler.prj fiber) + (fun () -> Lwt_io.close ic >>= fun () -> Lwt_io.close oc)) ;; -let run ~hostname ?port ~domain ?authentication ~tls_authenticator ~from ~recipients ~mail +let run + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + () = + let open Lwt.Infix in let ( let* ) = Lwt_result.bind in let port = match port with @@ -129,5 +142,51 @@ let run ~hostname ?port ~domain ?authentication ~tls_authenticator ~from ~recipi recipients mail_stream in - Lwt_scheduler.prj fiber + Lwt.finalize + (fun () -> Lwt_scheduler.prj fiber) + (fun () -> Lwt_io.close ic >>= fun () -> Lwt_io.close oc) +;; + +let[@warning "-16"] run_with_starttls_legacy + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + = + run_with_starttls + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + () +;; + +let[@warning "-16"] run_legacy + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + = + run + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + () ;; diff --git a/lib/sendmail_handler.mli b/lib/sendmail_handler.mli index ab5209e..514d6ad 100644 --- a/lib/sendmail_handler.mli +++ b/lib/sendmail_handler.mli @@ -7,6 +7,7 @@ val run_with_starttls -> from:Colombe.Reverse_path.t -> recipients:Colombe.Forward_path.t list -> mail:Mrmime.Mt.buffer Mrmime.Mt.stream + -> unit -> (unit, Sendmail_with_starttls.error) Lwt_result.t val run @@ -18,4 +19,73 @@ val run -> from:Colombe.Reverse_path.t -> recipients:Colombe.Forward_path.t list -> mail:Mrmime.Mt.buffer Mrmime.Mt.stream + -> unit -> (unit, Sendmail.error) Lwt_result.t + +(** @deprecated Use {!run_with_starttls} with a trailing [unit] argument. *) +val run_with_starttls_legacy + : hostname:'a Domain_name.t + -> ?port:int + -> domain:Colombe.Domain.t + -> ?authentication:Sendmail.authentication + -> tls_authenticator:X509.Authenticator.t + -> from:Colombe.Reverse_path.t + -> recipients:Colombe.Forward_path.t list + -> mail:Mrmime.Mt.buffer Mrmime.Mt.stream + -> (unit, Sendmail_with_starttls.error) Lwt_result.t +[@@deprecated "Use run_with_starttls with a trailing unit argument"] +[@@migrate + { repl = + (fun ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail -> + run_with_starttls + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + ()) + }] + +(** @deprecated Use {!run} with a trailing [unit] argument. *) +val run_legacy + : hostname:'a Domain_name.t + -> ?port:int + -> domain:Colombe.Domain.t + -> ?authentication:Sendmail.authentication + -> tls_authenticator:X509.Authenticator.t + -> from:Colombe.Reverse_path.t + -> recipients:Colombe.Forward_path.t list + -> mail:Mrmime.Mt.buffer Mrmime.Mt.stream + -> (unit, Sendmail.error) Lwt_result.t +[@@deprecated "Use run with a trailing unit argument"] +[@@migrate + { repl = + (fun ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail -> + run + ~hostname + ?port + ~domain + ?authentication + ~tls_authenticator + ~from + ~recipients + ~mail + ()) + }] diff --git a/service-test/test.ml b/service-test/test.ml index 3ad0d31..71271f0 100644 --- a/service-test/test.ml +++ b/service-test/test.ml @@ -5,8 +5,8 @@ let ( let* ) = Lwt.bind let get_ethereal_account_details () = let open Yojson.Basic.Util in (* see the README.md how to generate the account file and the path - * below is relative to the location of the executable under _build - *) + * below is relative to the location of the executable under _build + *) let json = Yojson.Basic.from_file "../../../ethereal_account.json" in let username = json |> member "username" |> to_string in let password = json |> member "password" |> to_string in @@ -21,8 +21,8 @@ let get_ethereal_account_details () = let get_mailtrap_account_details () = let open Yojson.Basic.Util in (* see the README.md how to generate the account file and the path - * below is relative to the location of the executable under _build - *) + * below is relative to the location of the executable under _build + *) let json = Yojson.Basic.from_file "../../../mailtrap_account.json" in let username = json |> member "username" |> to_string in let password = json |> member "password" |> to_string in