;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2016 Sou Bunnbu <iyzsong@member.fsf.org>
;;; Copyright © 2017 Carlo Zancanaro <carlo@zancanaro.id.au>
;;; Copyright © 2017, 2020, 2021 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2018 Oleg Pykhalov <go.wigust@gmail.com>
;;; Copyright © 2018 Clément Lassieur <clement@lassieur.org>
;;; Copyright © 2019 Christopher Baines <mail@cbaines.net>
;;; Copyright © 2019, 2020 Tobias Geerinckx-Rice <me@tobias.gr>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (gnu tests mail)
  #:use-module (gnu tests)
  #:use-module (gnu packages mail)
  #:use-module (gnu system)
  #:use-module (gnu system accounts)
  #:use-module (gnu system shadow)
  #:use-module (gnu system vm)
  #:use-module (gnu services)
  #:use-module (gnu services base)
  #:use-module (gnu services getmail)
  #:use-module (gnu services mail)
  #:use-module (gnu services networking)
  #:use-module (guix gexp)
  #:use-module (guix store)
  #:use-module (ice-9 ftw)
  #:use-module (srfi srfi-64)
  #:export (%test-opensmtpd
            %test-exim
            %test-dovecot
            %test-getmail))

(define %opensmtpd-os
  (simple-operating-system
   (service dhcp-client-service-type)
   (service opensmtpd-service-type
            (opensmtpd-configuration
             (config-file
              (plain-file "smtpd.conf" "
listen on 0.0.0.0
action inbound mbox
match from any for local action inbound
"))))))

(define (run-opensmtpd-test)
  "Return a test of an OS running OpenSMTPD service."
  (define vm
    (virtual-machine
     (operating-system (marionette-operating-system
                        %opensmtpd-os
                        #:imported-modules '((gnu services herd))))
     (port-forwardings '((1025 . 25)))))

  (define test
    (with-imported-modules '((gnu build marionette))
      #~(begin
          (use-modules (rnrs base)
                       (srfi srfi-64)
                       (ice-9 rdelim)
                       (ice-9 regex)
                       (gnu build marionette))

          (define marionette
            (make-marionette '(#$vm)))

          (define (read-reply-code port)
            "Read a SMTP reply from PORT and return its reply code."
            (let* ((line      (read-line port))
                   (mo        (string-match "([0-9]+)([ -]).*" line))
                   (code      (string->number (match:substring mo 1)))
                   (finished? (string= " " (match:substring mo 2))))
              (if finished?
                  code
                  (read-reply-code port))))

          (test-runner-current (system-test-runner #$output))
          (test-begin "opensmptd")

          (test-assert "service is running"
            (marionette-eval
             '(begin
                (use-modules (gnu services herd))
                (start-service 'smtpd))
             marionette))

          (test-assert "mbox is empty"
            (marionette-eval
             '(and (file-exists? "/var/spool/mail")
                   (not (file-exists? "/var/spool/mail/root")))
             marionette))

          (test-eq "accept an email"
            #t
            (let* ((smtp (socket AF_INET SOCK_STREAM 0))
                   (addr (make-socket-address AF_INET INADDR_LOOPBACK 1025)))
              (connect smtp addr)
              ;; Be greeted.
              (read-reply-code smtp)              ;220
              ;; Greet the server.
              (write-line "EHLO somehost" smtp)
              (read-reply-code smtp)              ;250
              ;; Set sender email.
              (write-line "MAIL FROM: <someone>" smtp)
              (read-reply-code smtp)              ;250
              ;; Set recipient email.
              (write-line "RCPT TO: <root>" smtp)
              (read-reply-code smtp)              ;250
              ;; Send message.
              (write-line "DATA" smtp)
              (read-reply-code smtp)              ;354
              (write-line "Subject: Hello" smtp)
              (newline smtp)
              (write-line "Nice to meet you!" smtp)
              (write-line "." smtp)
              (read-reply-code smtp)              ;250
              ;; Say goodbye.
              (write-line "QUIT" smtp)
              (read-reply-code smtp)              ;221
              (close smtp)
              #t))

          (test-assert "mail arrived"
            (marionette-eval
             '(begin
                (use-modules (ice-9 popen)
                             (ice-9 rdelim))

                (define (queue-empty?)
                  (let* ((pipe (open-pipe* OPEN_READ
                                           #$(file-append opensmtpd
                                                          "/sbin/smtpctl")
                                           "show" "queue"))
                         (line (read-line pipe)))
                    (close-pipe pipe)
                    (eof-object? line)))

                (let wait ((n 20))
                  (cond ((queue-empty?)
                         (file-exists? "/var/spool/mail/root"))
                        ((zero? n)
                         (error "root mailbox didn't show up"))
                        (else
                         (sleep 1) (wait (- n 1))))))
             marionette))

          (test-end))))

  (gexp->derivation "opensmtpd-test" test))

(define %test-opensmtpd
  (system-test
   (name "opensmtpd")
   (description "Send an email to a running OpenSMTPD server.")
   (value (run-opensmtpd-test))))

;; trying to create a bad record, should result in an error.
;; this function should not be able return, instead it should throw an error
;(define (create-bad-record record)
  ;; TODO why is this not working
;  (with-output-to-port (%make-void-port "w")
;    (lambda () (when record #f))))

;; if this caller function is reached, then trying to create the bad record
;; resulted in an error.  So return true.
(define (return-true error arg)
  #t)

(define (return-false error arg)
  #f)

(define (test-good-record func)
  (catch #t func return-false))

(define (run-opensmtpd-record-sanitation-test)
  ;;(with-output-to-port (%make-void-port "w")
  ;;  (lambda ()
  (test-begin "run-opensmtpd-record-sanitation-test")

  (test-error "(interface (filters ...)) has two filters with the same name."
              #t
              (let ((dkimsign
                     (opensmtpd-filter
                      (name "dkimsign")
                      (exec #t)
                      (proc
                       (list
                        (file-append
                         opensmtpd-filter-dkimsign
                         "/libexec/opensmtpd/filter-dkimsign")
                             " -d gnucode.me -s 2021-09-22 -c "
                             "relaxed/relaxed -k "
                             "rando string"
                             "/etc/dkim/private.key "
                             "user nobody group nogroup")))))
                (opensmtpd-interface
                  (interface "lo")
                  (filters (list
                            dkimsign
                            dkimsign)))))

  ;; duplicate filter names.  The filters are different, the fix
  ;; is to change one filter's 'name' field.
  (test-error "Test <interface> cannot have two filters with the same name."
              #t
              (opensmtpd-interface
               (filters (list
                         (opensmtpd-filter-phase
                          (name "src")
                          (phase "connect")
                          (options
                           (list
                            (opensmtpd-option
                             (option "fcrdns")
                             (bool #f))))
                          (decision "junk"))
                         (opensmtpd-filter-phase
                          (name "src")
                          (phase "helo")
                          (options
                           (list
                            (opensmtpd-option
                             (option "rdns")
                             (bool #f))))
                          (decision "reject")
                          (message "433 no rdns."))))))

  (test-error "(filter (proc ...)) is a list of strings or gexps, NOT numbers."
              #t
              (opensmtpd-filter
               (name "dkimsign")
               (proc
                (list
                 (file-append opensmtpd-filter-dkimsign
                              "/libexec/opensmtpd/filter-dkimsign")
                 " -d gnucode.me -s 2021-09-22 -c relaxed/relaxed -k "
                 5
                 "/etc/dkim/private.key "
                 "user nobody group nogroup"))
               (exec #t)))

  (test-error "Test <filter-phase> fieldname 'phase' has an wrong phase name."
              #t
              (opensmtpd-filter-phase
               (name "filter")
               (phase "wrongString")
               (decision "bypass")
               (options
                (list
                 (opensmtpd-option
                  (option "auth"))))))

  (test-error
   "(filter-phase (decision \"reject\")) requires a 'message'."
   #t
   (opensmtpd-interface
    (filters (list
              (opensmtpd-filter-phase
               (name "src")
               (phase "connect")
               (options
                (list
                 (opensmtpd-option
                  (option "src")
                  (data (opensmtpd-table
                         (name "src-table")
                         (data (list "cat" "hat")))))))
               (decision "reject"))))))

  (test-error (string-append
               "Test <filter-phase> fieldname 'decision' "
               "w/ value \"reject\" and \"disconnect\" requires a 'message'."
               " The message must begin with 4xx or 5xx.")
    #t
    (opensmtpd-interface
     (filters (list
               (opensmtpd-filter-phase
                (name "src")
                (phase "connect")
                (options
                 (list
                  (opensmtpd-option
                   (option "src")
                   (data (opensmtpd-table
                          (name "src-table")
                          (data (list "cat" "hat")))))))
                (decision "reject")
                (message "322 Bad data!"))))))

  (test-error
   "(filter-phase \"rewrite\") requires fieldname 'value' to have a number."
   #t
   (opensmtpd-interface
    (filters
     (list
      (opensmtpd-filter-phase
       (name "noFRDNS")
       (phase "commit")
       (options (list (opensmtpd-option
                       (option "fcrdns")
                       (bool #f))))
       (decision "rewrite"))))))

  (define option-string1/2 (list-ref (list "junk" "bypass")
                                     (random 2)))
  (test-error
   (string-append "(filter-phase \"decision\" with values 'junk' or 'bypass',"
                  " then fieldname 'message' and 'value' must be blank.")
              #t
              (opensmtpd-interface
               (filters
                (list
                 (opensmtpd-filter-phase
                  (name "noFRDNS")
                  (phase "commit")
                  (options (list (opensmtpd-option
                                  (option "fcrdns")
                                  )))
                  (decision option-string1/2)
                  (message "This is not a good email."))))))

  (test-error "You cannot junk an email on phase commit."
              #t
    (opensmtpd-interface
    (filters
     (list
      (opensmtpd-filter-phase
       (name "junk-after-commit")
       (options (list (opensmtpd-option
                       (option "fcrdns"))))
       (phase "commit")
       (decision "junk"))))))

  (define (good-interface1)
    (opensmtpd-interface
     (senders
      (opensmtpd-table
       (name "senders")
       (data '(("joshua" . "joshua@dismail.de")))))
     (masquerade #t)))

  (test-assert "good interface" (test-good-record good-interface1))

  (test-error "Test <filter-phase> has 2 duplicate options."
              #t
              (opensmtpd-filter-phase
               (name "invalid-fcrdns")
               (phase "connect")
               (options
                (list (opensmtpd-option
                       (option "fcrdns"))
                      (opensmtpd-option
                       (option "fcrdns"))))
               (decision "reject")
               (message "422 No valid fcrdns.")))

  (test-error "Test <filter-phase> option 'src' requires a table."
              #t
              (opensmtpd-filter-phase
               (name "filter")
               (phase "helo")
               (decision "bypass")
               (options
                (list
                 (opensmtpd-option
                  (option "src"))))))

  (test-error "Test <filter-phase> option 'fcrdns' cannot have a table."
              #t
              (opensmtpd-filter-phase
               (name "filter")
               (phase "helo")
               (decision "bypass")
               (options
                (list
                 (opensmtpd-option
                  (option "fcrdns")
                  (data (opensmtpd-table
                         (name "table")
                         (data (list "hello" "cat")))))))))

  (test-error "Test <filter-phase> must have at least one option."
              #t
              (opensmtpd-filter-phase
               (name "filter")
               (phase "helo")
               (decision "bypass")))

  (test-error
   "(filter-phase (options ...)) must be a list of <opensmtpd-option>s."
              #t
              (opensmtpd-filter-phase
               (name "rdns")
               (decision "junk")
               (phase "helo")
               (options
                (list 5))))

  (test-error "Test (decision \"junks\") is invalid."
              #t
              (opensmtpd-filter-phase
               (name "this")
               (options (list
                         (opensmtpd-option
                          (option "auth"))))
               (phase "helo")
               (decision "junks")))

  (test-error
   (string-append
    "(local-delivery (virtual ...) must be an <opensmtp-table> "
    "whose 'data' is an alist.")
              #t
              (opensmtpd-local-delivery
               (name "receive")
               (method (opensmtpd-maildir
                        (pathname "/home/%{rcpt.user}/Maildir")))
               (virtual (opensmtpd-table
                         (name "virt")
                         (data (list "jbranso@dismail.de"))))))

  (test-error "(opensmtpd-match (options ...)) should not be quoted."
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options
                '((opensmtpd-option
                   (option "for any"))))))

  (test-error "Test <opensmtpd-match> has duplicate 'for' options."
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option "for any"))
                         (opensmtpd-option
                          (option "for local"))))))

  (test-error "Test <opensmtpd-match> has duplicate 'from' options."
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option "from any"))
                         (opensmtpd-option
                          (option "from auth"))))))

  (define option-string1
    (list-ref (list "helo" "rcpt-to" "mail-from")
              (random 3)))
  ;; rcpt-to, mail-from, and helo must have a data field.
  (test-error (string-append "Test (opensmtpd-option (option \""
                             option-string1
                             "\")) must also define fieldname 'data'.")
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option option-string1))))))
  (define option-string2
    (list-ref (list "for local" "for any" "from any"
                    "from local" "from socket" "tls")
              (random 6)))
  ;; "for local" "for any" "from any" "from local" "from socket" "tls"
  ;; cannot have data or regex defined
  (test-error (string-append "Test (opensmtpd-option (option \""
                             option-string2
                             "\")) cannot have fieldname 'data' defined.")
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option option-string2)
                          (regex #t))))))

  (test-error "(opensmtpd-match (action ...)) needs to be defined."
              #t
              (opensmtpd-match
               (options (list
                         (opensmtpd-option
                          (option "from auth"))))))

  (test-error "(opensmtpd-match (options ...)) has duplicate 'helo's."
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option "helo")
                          (bool #f))
                         (opensmtpd-option
                          (option "helo"))))))

  (test-error "(opensmtpd-match (options ...)) has duplicate 'mail-from's."
              #t
              (opensmtpd-match
               (action (opensmtpd-relay
                        (name "relay")))
               (options (list
                         (opensmtpd-option
                          (option "mail-from")
                          (data "hello"))
                         (opensmtpd-option
                          (option "mail-from")
                          (data "world"))))))

  (test-error
   "(opensmtpd-match (options ...)) has an invalid option name: fcrdns."
   #t
   (opensmtpd-match
    (options (list
              (opensmtpd-option
               (option "fcrdns"))))
    (action (opensmtpd-relay
             (name "relay")))))

  (test-error
   "(opensmtpd-match (options ...)) has an invalid option name: rdns."
   #t
   (opensmtpd-match
    (options (list
              (opensmtpd-option
               (option "rdns"))))
    (action (opensmtpd-relay
             (name "relay")))))

  (test-error
   (string-append
    "(opensmtpd-match (options ...)) option-name 'tag' must "
    "also have 'data' be a string.")
              #t
              (opensmtpd-match
               (options (list
                         (opensmtpd-option
                          (option "tag"))))
               (action (opensmtpd-relay
                        (name "relay")))))

  (define option-string3
    (list-ref (list "for domain" "for rcpt-to"
                    "from mail-from" "from src")
              (random 4)))
  ;; the options in this list:
  ;; (list "for domain" "for rcpt-to" "from mail-from" "from src")
  ;; must have a data field.
  (test-error
   (string-append "Test (opensmtpd-option (option \""
                  option-string3 "\"))"
                  " must define fieldname 'data'.")
              #t
              (opensmtpd-match
               (options (list
                         (opensmtpd-option
                          (option option-string3))))
               (action (opensmtpd-relay
                        (name "relay")))))

  (define option-string4
    (list-ref (list "for local" "for any" "from any"
                    "from local" "from socket" "tls")
              (random 6)))
  ;; the options in this list cannot have a data or regex field defined.
  ;; (list "for local" "for any" "from any" "from local" "from socket" "tls")
  (test-error (string-append "Test (opensmtpd-option (option \""
                             option-string4 "\"))"
                             " cannot define fieldname 'data'.")
              #t
              (opensmtpd-match
               (options (list
                         (opensmtpd-option
                          (regex #t)
                          (option option-string4))))
               (action (opensmtpd-relay
                        (name "relay")))))


  (define option-string5
    (list-ref (list "for domain" "for rcpt-to"
                    "from mail-from" "from src")
              (random 4)))
  (test-error (string-append "Test (opensmtpd-option (option \""
                             option-string5 "\"))"
                             " must define fieldname 'data' as a string or "
                             "an <opensmtpd-table>, whose 'data' \n"
                             "is a list of strings.")
              #t
              (opensmtpd-match
               (options
                (list (opensmtpd-option
                       (option option-string5)
                       (data (opensmtpd-table
                              (name "src-table")
                              (data '(("127.0.0.1" . "374.394.405.23"))))))))
               (action (opensmtpd-relay
                        (name "relay")))))

  ;; match must have at least one option.
  (test-error
   "(opensmtpd-match (options ...)) must have at least one <opensmtpd-option>."
   #t
   (opensmtpd-match
    (action
     (opensmtpd-local-delivery
      (name "mail")))))

  ;; you cannot have strings of length 0.
  (test-error
   (string-append
    "(opensmtpd-table (name \"table\") (data '((\"james\" . \"\")))) "
    "has an empty string.")
   #t
   (opensmtpd-table
    (name "mytable")
    (data '(("hello" . "")))))

  (define good-match1
    (opensmtpd-match
     (action (opensmtpd-relay
              (name "relay")))
     (options (list (opensmtpd-option
                     (option "for any"))
                    (opensmtpd-option
                     (option "from any"))
                    (opensmtpd-option
                     (option "auth"))))))

  (test-assert "good match" (test-good-record good-match1))
  ;;(test-assert "Test <opensmtpd-match> is valid.")

  ;; I used to have some code that assumed if you have
  ;; an "<interface>" and an "<socket>" using the same filter, then that
  ;; was a misconfiguration.  It's not.
  (define (good-opensmtpd-configuration1)
    (let ([interface "lo"]
          [filter-dkimsign
           (opensmtpd-filter
            (name "dkimsign")
            (exec #t)
            (proc (list (file-append opensmtpd-filter-dkimsign
                                     "/libexec/opensmtpd/filter-dkimsign")
                        " -d gnucode.me -s 2021-09-22 -c relaxed/relaxed -k "
                        "/etc/dkim/private.key "
                        "user nobody group nogroup")))])
      (opensmtpd-configuration
       (interfaces
        (list
         ;; send out emails and be sure to dkimsign them.
         (opensmtpd-interface
          (interface interface)
          (filters (list filter-dkimsign)))))
       (socket
        (opensmtpd-socket
         (filters (list filter-dkimsign))))
       (matches (list
                 (opensmtpd-match
                  (action (opensmtpd-relay
                           (name "relay")))
                  (options (list (opensmtpd-option
                                  (option "for any"))
                                 (opensmtpd-option
                                  (option "from any"))
                                 (opensmtpd-option
                                  (option "auth"))))))))))

  (test-assert
      (string-append "opensmtpd-configuration may use the same dkimsign "
                     "filter on <opensmtpd-socket> and <opensmtpd-interface>.")
    (test-good-record good-opensmtpd-configuration1))

  ;; this is just the largest configuration that I can test.
  (define (good-opensmtpd-configuration2)
    (let ([interface "lo"]
          [creds-table
           (opensmtpd-table
            (name "creds")
            (data
             (list
              (cons "joshua"
                    "$6$Ec4m8FgKjT2F/03Y$k66ABdse9TzCX6qaALB3WBL9GC1rmAWJmaoSjFMpbhzat7DOpFqpnOwpbZ34wwsQYIK8RQlqwM1I/v6vsRq86."))))]
          [receive-action
           (opensmtpd-local-delivery
                           (name "receive")
                           (method (opensmtpd-maildir
                                    (pathname "/home/%{rcpt.user}/Maildir")
                                    (junk #t)))
                           (alias (opensmtpd-table
                                   (name "aliases")
                                   (data '(("joshua@gnucode.me" . "joshua")))))
                           (virtual (opensmtpd-table
                                     (name "virt")
                                     (data '(("josh"
                                              . "jbranso@dismail.de"))))))]
          ;; as of 7-24-22 this proc fieldname does not actually work, but
          ;; is proper syntax.
          [filter-dkimsign (opensmtpd-filter
                            (name "dkimsign")
                            (exec #t)
                            (proc (list
                                   (file-append
                                    opensmtpd-filter-dkimsign
                                    "/libexec/opensmtpd/filter-dkimsign")
                                   " -d gnucode.me -s 2021-09-22 -c "
                                   "relaxed/relaxed -k "
                                   "/etc/dkim/private.key "
                                   "user nobody group nogroup")))]
          [filter-invalid-fcrdns (opensmtpd-filter-phase
                                  (name "invalid-fcrdns")
                                  (phase "connect")
                                  (options
                                   (list (opensmtpd-option
                                          (option "fcrdns")
                                          (bool #f))))
                                  (decision "reject")
                                  (message "422 No valid fcrdns."))]
          [filter-invalid-rdns (opensmtpd-filter-phase
                                (name "invalid-rdns")
                                (phase "connect")
                                (options
                                 (list (opensmtpd-option
                                        (option "rdns")
                                        (bool #f))))
                                (decision "junk"))]
          [smtp.gnucode.me (opensmtpd-pki
                            (domain "smtp.gnucode.me")
                            (cert "guix.scm")
                            (key "guix.scm"))])
      (opensmtpd-configuration
       (mta-max-deferred 50)
       (queue
        (opensmtpd-queue
         (compression #t)))
       (smtp
        (opensmtpd-smtp
         (max-message-size "10M")))
       (srs
        (opensmtpd-srs
         (ttl-delay "5d")))
       (interfaces
        (list
         (opensmtpd-interface
          (interface interface)
          (port 25)
          (secure-connection "tls")
          (filters (list filter-invalid-fcrdns
                         filter-invalid-rdns))
          (pki smtp.gnucode.me))
         ;; this lets local users logged into the system via ssh send email
         ;; be sure to dkimsign them.
         (opensmtpd-interface
          (interface interface)
          (port 465)
          (secure-connection "smtps")
          (pki smtp.gnucode.me)
          (auth creds-table)
          (filters (list filter-dkimsign)))
         ;; if you uncomment this next line, then you get issues.
         ;;(opensmtpd-socket
         ;; (filters (list filter-dkimsign)))
         ;; send out emails and be sure to dkimsign them.
         (opensmtpd-interface
          (interface interface)
          (port 587)
          (secure-connection "tls-require")
          (pki smtp.gnucode.me)
          (auth creds-table)
          (filters (list filter-dkimsign)))))
       (socket
        (opensmtpd-socket
         (filters (list filter-dkimsign))
         (tag "socket")))
       (matches (list
                 (opensmtpd-match
                  (action (opensmtpd-relay
                           (name "relay")))
                  (options (list (opensmtpd-option
                                  (option "for any"))
                                 (opensmtpd-option
                                  (option "from any"))
                                 (opensmtpd-option
                                  (option "auth")))))
                 (opensmtpd-match
                  (action receive-action)
                  (options (list (opensmtpd-option
                                  (option "from any"))
                                 (opensmtpd-option
                                  (option "for domain")
                                  (data (opensmtpd-table
                                         (name "domain-table")
                                         (data (list "gnucode.me"
                                                     "gnu-hurd.com"))))))))
                 (opensmtpd-match
                  (action receive-action)
                  (options (list (opensmtpd-option
                                  (option "for local"))))))))))

  (test-assert "Test my largish example <opensmtpd-configuration>."
    (test-good-record good-opensmtpd-configuration2))

  ;; the matches have two actions with the same name,
  ;; but are different actions.
  (test-error
   (string-append "Test <opensmtpd-configuration> fieldname 'matches' has "
                  "two actions with the same name, but the actions are "
                  "different.")
   #t
   (opensmtpd-configuration
    (matches
     (list (opensmtpd-match
            (options
             (list
              (opensmtpd-option
               (option "auth"))))
            (action
             (opensmtpd-local-delivery
              (name "my-local-delivery")
              (ttl "50m"))))
           (opensmtpd-match
            (options
             (list
              (opensmtpd-option
               (option "auth"))))
            (action
             (opensmtpd-local-delivery
              (name "my-local-delivery")
              (ttl "50h"))))))))

  ;; you can only have 1 opensmtpd-socket.
  (test-error
   (string-append
    "(opensmtpd-configuration> (listen-on ...)) may only have "
    "one <opensmtpd-socket>.")
   #t
   (let ([interface "lo"])
     (opensmtpd-configuration
      (socket
       (list
        (opensmtpd-socket)
        (opensmtpd-socket)))
      (matches (list
                (opensmtpd-match
                 (options
                  (list
                   (opensmtpd-option
                    (option "auth"))))
                 (action (opensmtpd-relay
                          (name "relay")))))))))

  (test-end "run-opensmtpd-record-sanitation-test"))

(define %test-opensmtpd-record-sanitation
  (system-test
   (name "opensmtpdRecordSanitation")
   (description
    (string-append "<opensmtpd> has numerous sanity checks.\n"
                   "This checks that invalid configurations, return an\n"
                   "appropriate error.\n"))
   (value (run-opensmtpd-record-sanitation-test))))


(define %exim-os
  (simple-operating-system
   (service dhcp-client-service-type)
   (service mail-aliases-service-type '())
   (service exim-service-type
            (exim-configuration
             (config-file
              (plain-file "exim.conf" "
primary_hostname = komputilo
domainlist local_domains = @
domainlist relay_to_domains =
hostlist   relay_from_hosts = localhost

never_users =

acl_smtp_rcpt = acl_check_rcpt
acl_smtp_data = acl_check_data

begin acl

acl_check_rcpt:
  accept
acl_check_data:
  accept
"))))))

(define (run-exim-test)
  "Return a test of an OS running an Exim service."
  (define vm
    (virtual-machine
     (operating-system (marionette-operating-system
                        %exim-os
                        #:imported-modules '((gnu services herd))))
     (port-forwardings '((1025 . 25)))))

  (define test
    (with-imported-modules '((gnu build marionette))
      #~(begin
          (use-modules (rnrs base)
                       (srfi srfi-64)
                       (ice-9 ftw)
                       (ice-9 rdelim)
                       (ice-9 regex)
                       (gnu build marionette))

          (define marionette
            (make-marionette '(#$vm)))

          (define (read-reply-code port)
            "Read a SMTP reply from PORT and return its reply code."
            (let* ((line      (read-line port))
                   (mo        (string-match "([0-9]+)([ -]).*" line))
                   (code      (string->number (match:substring mo 1)))
                   (finished? (string= " " (match:substring mo 2))))
              (if finished?
                  code
                  (read-reply-code port))))

          (define smtp (socket AF_INET SOCK_STREAM 0))
          (define addr (make-socket-address AF_INET INADDR_LOOPBACK 1025))

          (test-runner-current (system-test-runner #$output))
          (test-begin "exim")

          (test-assert "service is running"
            (marionette-eval
             '(begin
                (use-modules (gnu services herd))
                (start-service 'exim))
             marionette))

          (sleep 1) ;; give the service time to start talking

          (connect smtp addr)
          ;; Be greeted.
          (test-eq "greeting received"
            220 (read-reply-code smtp))
          ;; Greet the server.
          (write-line "EHLO somehost" smtp)
          (test-eq "greeting successful"
            250 (read-reply-code smtp))
          ;; Set sender email.
          (write-line "MAIL FROM: test@example.com" smtp)
          (test-eq "sender set"
            250 (read-reply-code smtp))           ;250
          ;; Set recipient email.
          (write-line "RCPT TO: root@komputilo" smtp)
          (test-eq "recipient set"
            250 (read-reply-code smtp))           ;250
          ;; Send message.
          (write-line "DATA" smtp)
          (test-eq "data begun"
            354 (read-reply-code smtp))           ;354
          (write-line "Subject: Hello" smtp)
          (newline smtp)
          (write-line "Nice to meet you!" smtp)
          (write-line "." smtp)
          (test-eq "message sent"
            250 (read-reply-code smtp))           ;250
          ;; Say goodbye.
          (write-line "QUIT" smtp)
          (test-eq "quit successful"
            221 (read-reply-code smtp))           ;221
          (close smtp)

          (test-eq "the email is received"
            1
            (marionette-eval
             '(begin
                (use-modules (ice-9 ftw))
                (length (scandir "/var/spool/exim/msglog"
                                 (lambda (x) (not (string-prefix? "." x))))))
             marionette))

          (test-end))))

  (gexp->derivation "exim-test" test))

(define %test-exim
  (system-test
   (name "exim")
   (description "Send an email to a running an Exim server.")
   (value (run-exim-test))))

(define %dovecot-os
  (simple-operating-system
   (service dhcp-client-service-type)
   (dovecot-service #:config
                    (dovecot-configuration
                     (disable-plaintext-auth? #f)
                     (ssl? "no")
                     (auth-mechanisms '("anonymous"))
                     (auth-anonymous-username "alice")
                     (mail-location
                      (string-append "maildir:~/Maildir"
                                     ":INBOX=~/Maildir/INBOX"
                                     ":LAYOUT=fs"))))))

(define (run-dovecot-test)
  "Return a test of an OS running Dovecot service."
  (define vm
    (virtual-machine
     (operating-system (marionette-operating-system
                        %dovecot-os
                        #:imported-modules '((gnu services herd))))
     (port-forwardings '((8143 . 143)))))

  (define test
    (with-imported-modules '((gnu build marionette))
      #~(begin
          (use-modules (gnu build marionette)
                       (ice-9 iconv)
                       (ice-9 rdelim)
                       (rnrs base)
                       (rnrs bytevectors)
                       (srfi srfi-64))

          (define marionette
            (make-marionette '(#$vm)))

          (define* (message-length message #:key (encoding "iso-8859-1"))
            (bytevector-length (string->bytevector message encoding)))

          (define message "From: test@example.com\n\
Subject: Hello Nice to meet you!")

          (test-runner-current (system-test-runner #$output))
          (test-begin "dovecot")

          ;; Wait for dovecot to be up and running.
          (test-assert "dovecot running"
            (marionette-eval
             '(begin
                (use-modules (gnu services herd))
                (start-service 'dovecot))
             marionette))

          ;; Check Dovecot service's PID.
          (test-assert "service process id"
            (let ((pid
                   (number->string (wait-for-file "/var/run/dovecot/master.pid"
                                                  marionette))))
              (marionette-eval `(file-exists? (string-append "/proc/" ,pid))
                               marionette)))

          (test-assert "accept an email"
            (let ((imap (socket AF_INET SOCK_STREAM 0))
                  (addr (make-socket-address AF_INET INADDR_LOOPBACK 8143)))
              (connect imap addr)
              ;; Be greeted.
              (read-line imap) ;OK
              ;; Authenticate
              (write-line "a AUTHENTICATE ANONYMOUS" imap)
              (read-line imap) ;+
              (write-line "c2lyaGM=" imap)
              (read-line imap) ;OK
              ;; Create a TESTBOX mailbox
              (write-line "a CREATE TESTBOX" imap)
              (read-line imap) ;OK
              ;; Append a message to a TESTBOX mailbox
              (write-line (format #f "a APPEND TESTBOX {~a}"
                                  (number->string (message-length message)))
                          imap)
              (read-line imap) ;+
              (write-line message imap)
              (read-line imap) ;OK
              ;; Logout
              (write-line "a LOGOUT" imap)
              (close imap)
              #t))

          (test-equal "mail arrived"
            message
            (marionette-eval
             '(begin
                (use-modules (ice-9 ftw)
                             (ice-9 match))
                (let ((TESTBOX/new "/home/alice/Maildir/TESTBOX/new/"))
                  (match (scandir TESTBOX/new)
                    (("." ".." message-file)
                     (call-with-input-file
                         (string-append TESTBOX/new message-file)
                       get-string-all)))))
             marionette))

          (test-end))))

  (gexp->derivation "dovecot-test" test))

(define %test-dovecot
  (system-test
   (name "dovecot")
   (description "Connect to a running Dovecot server.")
   (value (run-dovecot-test))))

(define %getmail-os
  (operating-system
    (inherit (simple-operating-system))

    ;; Set a password for the user account; the test needs it.
    (users (cons (user-account
                  (name "alice")
                  (password (crypt "testpass" "$6$abc"))
                  (comment "Bob's sister")
                  (group "users")
                  (supplementary-groups '("wheel" "audio" "video")))
                 %base-user-accounts))

    (services (cons* (service dhcp-client-service-type)
                     (service dovecot-service-type
                              (dovecot-configuration
                               (disable-plaintext-auth? #f)
                               (ssl? "no")
                               (auth-mechanisms '("anonymous" "plain"))
                               (auth-anonymous-username "alice")
                               (mail-location
                                (string-append "maildir:~/Maildir"
                                               ":INBOX=~/Maildir/INBOX"
                                               ":LAYOUT=fs"))))
                     (service getmail-service-type
                              (list
                               (getmail-configuration
                                (name 'test)
                                (user "alice")
                                (directory "/var/lib/getmail/alice")
                                (idle '("TESTBOX"))
                                (rcfile
                                 (getmail-configuration-file
                                  (retriever
                                   (getmail-retriever-configuration
                                    (type "SimpleIMAPRetriever")
                                    (server "localhost")
                                    (username "alice")
                                    (port 143)
                                    (extra-parameters
                                     '((password . "testpass")
                                       (mailboxes . ("TESTBOX"))))))
                                  (destination
                                   (getmail-destination-configuration
                                    (type "Maildir")
                                    (path "/home/alice/TestMaildir/")))
                                  (options
                                   (getmail-options-configuration
                                    (read-all #f))))))))
                     %base-services))))

(define (run-getmail-test)
  "Return a test of an OS running Getmail service."
  (define vm
    (virtual-machine
     (operating-system (marionette-operating-system
                        %getmail-os
                        #:imported-modules '((gnu services herd))))
     (port-forwardings '((8143 . 143)))))

  (define test
    (with-imported-modules '((gnu build marionette))
      #~(begin
          (use-modules (gnu build marionette)
                       (ice-9 iconv)
                       (ice-9 rdelim)
                       (rnrs base)
                       (rnrs bytevectors)
                       (srfi srfi-64))

          (define marionette
            (make-marionette '(#$vm)))

          (define* (message-length message #:key (encoding "iso-8859-1"))
            (bytevector-length (string->bytevector message encoding)))

          (define message "From: test@example.com\n\
Subject: Hello Nice to meet you!")

          (test-runner-current (system-test-runner #$output))
          (test-begin "getmail")

          ;; Wait for dovecot to be up and running.
          (test-assert "dovecot running"
            (marionette-eval
             '(begin
                (use-modules (gnu services herd))
                (start-service 'dovecot))
             marionette))

          ;; Wait for getmail to be up and running.
          (test-assert "getmail-test running"
            (marionette-eval
             '(let* ((pw (getpw "alice"))
                     (uid (passwd:uid pw))
                     (gid (passwd:gid pw)))
                (use-modules (gnu services herd))

                (for-each
                 (lambda (dir)
                   (mkdir dir)
                   (chown dir uid gid))
                 '("/home/alice/TestMaildir"
                   "/home/alice/TestMaildir/cur"
                   "/home/alice/TestMaildir/new"
                   "/home/alice/TestMaildir/tmp"
                   "/home/alice/TestMaildir/TESTBOX"
                   "/home/alice/TestMaildir/TESTBOX/cur"
                   "/home/alice/TestMaildir/TESTBOX/new"
                   "/home/alice/TestMaildir/TESTBOX/tmp"))

                (start-service 'getmail-test))
             marionette))

          ;; Check Dovecot service's PID.
          (test-assert "service process id"
            (let ((pid
                   (number->string (wait-for-file "/var/run/dovecot/master.pid"
                                                  marionette))))
              (marionette-eval `(file-exists? (string-append "/proc/" ,pid))
                               marionette)))

          (test-assert "accept an email"
            (let ((imap (socket AF_INET SOCK_STREAM 0))
                  (addr (make-socket-address AF_INET INADDR_LOOPBACK 8143)))
              (connect imap addr)
              ;; Be greeted.
              (read-line imap) ;OK
              ;; Authenticate
              (write-line "a AUTHENTICATE ANONYMOUS" imap)
              (read-line imap) ;+
              (write-line "c2lyaGM=" imap)
              (read-line imap) ;OK
              ;; Create a TESTBOX mailbox
              (write-line "a CREATE TESTBOX" imap)
              (read-line imap) ;OK
              ;; Append a message to a TESTBOX mailbox
              (write-line (format #f "a APPEND TESTBOX {~a}"
                                  (number->string (message-length message)))
                          imap)
              (read-line imap) ;+
              (write-line message imap)
              (read-line imap) ;OK
              ;; Logout
              (write-line "a LOGOUT" imap)
              (close imap)
              #t))

          (sleep 1)

          (test-assert "mail arrived"
            (string-contains
             (marionette-eval
              '(begin
                 (use-modules (ice-9 ftw)
                              (ice-9 match))
                 (let ((TESTBOX/new "/home/alice/TestMaildir/new/"))
                   (match (scandir TESTBOX/new)
                     (("." ".." message-file)
                      (call-with-input-file
                          (string-append TESTBOX/new message-file)
                        get-string-all)))))
              marionette)
             message))

          (test-end))))

  (gexp->derivation "getmail-test" test))

(define %test-getmail
  (system-test
   (name "getmail")
   (description "Connect to a running Getmail server.")
   (value (run-getmail-test))))
