#lang syndicate

(provide (struct-out/defaults [make-smtp-account-config smtp-account-config])
         (rename-out [smtp-account-config <smtp-account-config>])
         (struct-out smtp-account)
         (struct-out smtp-delivery)
         (struct-out smtp-delivery-complete)

         spawn-smtp-driver
         smtp-deliver!)

(define-logger syndicate/drivers/smtp)

(require racket/exn)
(require racket/tcp)
(require (only-in racket/list flatten))
(require net/head)
(require net/smtp)
(require openssl)
(require struct-defaults)

;; An SSLMode is one of
;; - #f: Use unencrypted SMTP, by default at port 587
;; - 'ssl: Use TLS-tunneled SMTP, by default at port 465 (!)
;; - 'starttls: Use STARTTLS SMTP upgrade to TLS encryption, by default at port 587

;; (smtp-account-config Symbol String Number (Option String) (Option String) SSLMode)
(struct smtp-account-config (id host port user password ssl-mode) #:prefab) ;; ASSERTION

;; (smtp-account Any)
(struct smtp-account (id) #:prefab) ;; ASSERTION

(struct smtp-delivery (account-id ;; Any
                       delivery-id ;; Any
                       from ;; String -- *envelope* FROM
                       to ;; (Listof String) -- *envelope* RCPT
                       header ;; (Listof (Cons Symbol String))
                       lines ;; (Listof (U String Bytes))
                       ) #:prefab) ;; MESSAGE

;; (smtp-delivery-complete Any Boolean)
(struct smtp-delivery-complete (delivery-id ok?) #:prefab) ;; MESSAGE

;; On SMTP ports.
;;
;; 25: MTA-to-MTA; message transfer
;; 465: Legacy SSL SMTP, prefer not to use
;; 587: MUA-to-MTA; message submission; STARTTLS for upgrade to TLS

(begin-for-declarations
  (define-struct-defaults make-smtp-account-config smtp-account-config
    (#:ssl-mode [smtp-account-config-ssl-mode 'starttls]
     #:port [smtp-account-config-port (case smtp-account-config-ssl-mode
                                        [(#f starttls) 587]
                                        [(ssl) 465]
                                        [else (error 'smtp-account-config
                                                     "Invalid smtp-account-config-ssl-mode ~v"
                                                     smtp-account-config-ssl-mode)])]
     #:user [smtp-account-config-user #f]
     #:password [smtp-account-config-password #f])))

(define (spawn-smtp-driver)
  (spawn #:name 'smtp-account-driver
         (during/spawn (smtp-account-config $id $host $port $user $password $ssl-mode)
           #:name (list 'smtp-account id)
           (on-start
            (log-syndicate/drivers/smtp-info "~v starting: ~a:~a ~s ~s" id host port user ssl-mode))
           (on-stop
            (log-syndicate/drivers/smtp-info "~v stopping: ~a:~a ~s ~s" id host port user ssl-mode))
           (assert (smtp-account id))
           (on (message (smtp-delivery id $delivery-id $from $to $header $lines))
               (with-handlers [(exn:fail?
                                (lambda (e)
                                  (log-syndicate/drivers/smtp-error "smtp-delivery ~a ~a: ~a"
                                                                    id
                                                                    delivery-id
                                                                    (exn->string e))
                                  (send-ground-message
                                   (smtp-delivery-complete delivery-id #f))))]
                 (parameterize ((smtp-sending-end-of-message
                                 (lambda ()
                                   (send-ground-message
                                    (smtp-delivery-complete delivery-id #t)))))
                   (log-syndicate/drivers/smtp-info "account ~a delivery ~a: ~s -> ~s"
                                                    id
                                                    delivery-id
                                                    from
                                                    to)
                   (smtp-send-message host
                                      from
                                      to
                                      (construct-header header)
                                      lines
                                      #:port-no port
                                      #:auth-user user
                                      #:auth-passwd password
                                      #:tcp-connect (case ssl-mode
                                                      [(ssl) ssl-connect]
                                                      [else tcp-connect])
                                      #:tls-encode (case ssl-mode
                                                     [(starttls) ports->ssl-ports]
                                                     [else #f]))))))
         (during/spawn (smtp-account-config _ _ _ _ _ _)
           ;; By *conditionally* paying attention to inbound messages
           ;; from ground, we ensure that we don't unnecessarily hold
           ;; up ground termination.
           (on (message (inbound (smtp-delivery-complete $delivery-id $ok?)))
               (send! (smtp-delivery-complete delivery-id ok?))))))

(define (construct-header hs)
  (for/fold [(acc empty-header)] [(h (reverse hs))]
    (match-define (cons key val) h)
    (insert-field (symbol->string key) val acc)))

(define (smtp-deliver! account-id from to header lines)
  (define delivery-id (gensym 'smtp-delivery))
  (react/suspend (k)
                 (on (asserted (smtp-account account-id))
                     (send! (smtp-delivery account-id delivery-id from to header lines)))
                 (stop-when (message (smtp-delivery-complete delivery-id $status))
                            (k status))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(spawn-smtp-driver)
