~fabionatali/org-batch-email

Programmatically send emails from Emacs Org Mode

5ce5c71 Typos

2 years ago

1e3d3e2 Initial commit

2 years ago
# -*- mode: org -*-

#+title: Org Batch Email
#+author: Fabio Natali
#+email: me@fabionatali.com
#+description: Programmatically send emails from Emacs Org Mode
#+keywords: email emacs org-mode

* Org Batch Email

Org Batch Email is a small Emacs package to send emails in batches from a Org
file.

** Installation

This package follows the [[https://en.wikipedia.org/wiki/Literate_programming][literate programming]] paradigm, its source code is
completely contained in this documentation file. From within Emacs Org Mode, the
code part can be extracted with the =org-babel-tangle= command, which will
create a separate =org-batch-email.el= Emacs Lisp file.

In order to use the package, evaluate the file from within a Emacs session or
add the following line (or a variation thereof) to your Emacs configuration:

#+begin_src emacs-lisp :eval never
(load "~/.emacs.d/org-batch-email.el")
#+end_src

This package assumes a fully-configured [[https://notmuchmail.org/notmuch-emacs/][notmuch]]-based email setup. It also
depends on the [[https://github.com/magnars/s.el][s.el]] Emacs library. Make sure these dependencies are met before
using Org Batch Email.

** Basic usage

Compose an email body and place it within a Org text block. Name the block so
that it can be referred to later.

#+begin_src text
,#+name: my-body
,#+begin_src text
Dear Recipient, this is the email body. Greetings, the Sender.
,#+end_src
#+end_src

List the intended recipients and the subject lines in a Org table. Add
references to the Org block containing the body. Name the table with a unique
identifier.

#+begin_src text
,#+name: my-table
| block   | to               | subject |
|---------+------------------+---------|
| my-body | jane@example.com | Foo!    |
| my-body | john@example.com | Bar!    |
#+end_src

Everything is now in place. Evaluating the following Emacs Lisp function will
send two separate emails, one to =jane@example.com= with subject =Foo!= and one
to =john@example.com= with subject =Bar!=.

#+begin_src emacs-lisp :eval never
(org-batch-email/send-emails "my-table")
#+end_src

** Templating mechanism

A simple templating mechanism is provided to allow for some variability in the
emails. Consider the following email body:

#+begin_src text
,#+name: my-body
,#+begin_src text
Dear ${recipient}, this is the email body. Greetings, ${sender}.
,#+end_src
#+end_src

Now modify the email table by providing values for the above template variables
=${recipient}= and =${sender}=.

#+begin_src text
,#+name: table
| block   | to               | subject | recipient | sender |
|---------+------------------+---------+-----------+--------|
| my-body | jane@example.com | Foo!    | Jane      | Alice  |
| my-body | john@example.com | Bar!    | John      | Bob    |
#+end_src

Evaluating =(org-batch-email/send-emails table)= will now result in two
personalised emails being sent to Jane and John.

The subject field takes advantage of the same templating mechanism, as
demonstrated in the following example:

#+begin_src text
,#+name: table
| block   | to               | subject               | recipient | sender |
|---------+------------------+-----------------------+-----------+--------|
| my-body | jane@example.com | Foo, for ${recipient} | Jane      | Alice  |
| my-body | john@example.com | Bar, for ${recipient} | John      | Bob    |
#+end_src

** Org formatting

Any Org markup in the email body will be interpreted as such and converted to
ASCII. For example, consider the following block:

#+begin_src text
,#+name: my-body
,#+begin_src text
,,* Project Foo

This is the email body.

| User | Id | Project |
|------+----+---------|
| Jane |  0 | Foo     |
| John |  1 | Bar     |
,#+end_src
#+end_src

Note that some Org formatting (like =*= symbols indicating headings) need to be
escaped with a leading =,=, as explained in [[https://orgmode.org/manual/Literal-Examples.html][this section]] of the Org
documentation. The above block will be rendered as follows:

#+begin_src text
Project Foo
===========

This is the email body.

 User  Id  Project
-------------------
 Jane   0  Foo
 John   1  Bar
#+end_src

* Email configuration

Org Batch Email expects Emacs to be configured to send emails via [[https://notmuchmail.org/notmuch-emacs/][Notmuch]]. This
may be made slightly more general in the future, to allow for different email
setups.

Notmuch can be configured in a variety of ways. In the example below, Notmuch is
configured to work in combination with the [[https://marlam.de/msmtp/][msmtp]] SMTP client. For further
details please refer to the official Notmuch documentation.

#+begin_src emacs-lisp
(require 'notmuch)
(setq user-full-name "Jane Doe")
(setq user-mail-address "j.doe@example.com")
(setq send-mail-function 'sendmail-send-it)
(setq sendmail-program "msmtp")
(setq message-confirm-send nil)
(setq message-default-mail-headers "Cc: \n")
(setq message-kill-buffer-on-exit t)
(setq message-sendmail-envelope-from 'header)

;; Fcc configuration, see 'M-x describe-variable notmuch-fcc-dirs'
(setq notmuch-maildir-use-notmuch-insert t)
(setq notmuch-fcc-dirs "sent +sent -inbox -unread")
#+end_src

* Todos

** Org-to-ASCII conversion

Before being sent, emails go through an automatic Org-to-ASCII conversion
step. This should be off by default. Users should be explicitly made aware that
Org markup (e.g. a =*= at the beginning of a line) will be interpreted and have
specific consequences on the structure and look of the final email.

** Email setup

This is probably a poorly-put-together hack with little hope of
redemption. Still, we may want to make things slightly more flexible and accept
a broader variety of email setups beyond Notmuch.

** Lexical binding

A few tests fail when lexical binding is activated, e.g. when placing =-*-
lexical-binding: t -*-= at the top of the source code.

* Code

#+begin_src emacs-lisp :tangle org-batch-email.el :results silent :exports code
;;; org-batch-email.el --- Send emails in batches from a Org file

;; Copyright (C) 2022 Fabio Natali <me@fabionatali.com>

;; Author: Fabio Natali <me@fabionatali.com>
;; Version: 0.0
;; Package-Requires: ((emacs "28.1")(notmuch "0.36")(s "1.12.0"))
;; Keywords: email, automation
;; URL: https://fabionatali.com/org-batch-email/

;; This file is not part of GNU Emacs.

;; This program 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.
;;
;; This program 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
;; this program. If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; This package provides a function that can be used to send emails in batches
;; from a Org file. This package assumes a fully-configured notmuch-based email
;; setup. It also depends on the s.el Emacs library. See the README for more
;; details.

;;; Code:
(setq org-batch-email/text-width 72)
(define-error 'org-batch-email/error "Org Batch Email error" 'error)
(define-error 'missing-field-error "Missing field" 'org-batch-email/error)
(define-error 'block-not-found-error "Block not found" 'org-batch-email/error)

(defun org-batch-email/send-email (to cc subject attachments body)
  "Send an email."
  (progn
    (notmuch-mua-mail)
    (message-goto-to)(insert to)
    (message-goto-cc)(insert cc)
    (message-goto-subject)(insert subject)
    (message-goto-body)(insert body)
    (mapc 'mml-attach-file attachments)
    (notmuch-mua-send-and-exit)))

(defun org-batch-email/get-block-content (block)
  "Get the content of a given Org source block."
  (if (org-babel-find-named-block block)
      (save-excursion
        (org-babel-goto-named-src-block block)
        (org-element-property :value (org-element-at-point)))
    (signal 'block-not-found-error `(,block))))

(defun org-batch-email/table->list (table)
  "Convert a table to a list of associative lists.

The input table is expected to be a list of lists. The table's first element is
its header, the following elements are its rows.

>> (org-batch-email/table->list '((x y)(0 0)(1 1)))
=> '(((x . 0)(y . 0))((x . 1)(y . 1)))"
  (let ((header (car table))
        (rows (cdr table)))
    (mapcar (lambda (row) (cl-mapcar #'cons header row)) rows)))

(defun org-batch-email/get-table-data (table)
  "Get data from a given Org table.

Return data as a list of assoc lists, whose keys are the header's elements and
whose values are the elements of the different rows.

E.g. if

,#+name: test-table
| x | y |
|---|---|
| 0 | 0 |
| 1 | 1 |

then the function returns '(((x . 0)(y . 0))((x . 1)(y . 1)))"
  (org-batch-email/table->list
   (ignore-errors (delete 'hline (org-babel-ref-resolve table)))))

(defun org-batch-email/expand-template (template vars)
  "Expand the given template with the given variables."
  (s-format template 'aget vars))

(defun org-batch-email/org->ascii (text)
  "Convert a Org-formatted text to ASCII."
  (let* ((org-export-with-title nil)
         (org-export-with-author nil)
         (org-export-with-toc nil)
         (org-export-with-section-numbers nil)
         (org-ascii-inner-margin 0)
         (org-ascii-text-width org-batch-email/text-width))
    (with-temp-buffer (insert text)(org-export-as 'ascii))))

(defun org-batch-email/email-alist->data (lst)
  "Get email data from the given associative list."
  (let* ((to (or (cdr (assoc "to" lst)) (signal 'missing-field-error '("to"))))
         (cc (cdr (assoc "cc" lst)))
         (attachments (split-string
                       (or (cdr (assoc "attachments" lst)) "") "," t " "))
         (subject-template (or (cdr (assoc "subject" lst))
                               (signal 'missing-field-error '("subject"))))
         (block (or (cdr (assoc "block" lst))
                    (signal 'missing-field-error '("block"))))
         (subject (org-batch-email/expand-template subject-template lst))
         (body-template (org-batch-email/get-block-content block))
         (body-org (org-batch-email/expand-template body-template lst))
         (body (org-batch-email/org->ascii body-org)))
    (list to cc subject attachments body)))

(defun org-batch-email/send-email-alist (lst)
  "Send an email, given an associative list with the email header and body."
  (apply 'org-batch-email/send-email (org-batch-email/email-alist->data lst)))

(defun org-batch-email/send-emails (table)
  "Send a series of emails, as per data from the given Org table."
  (mapc 'org-batch-email/send-email-alist
        (org-batch-email/get-table-data table)))
;;; org-batch-email.el ends here
#+end_src

* Tests

The provided test suite can be run with the following command:

#+begin_src shell :eval never
emacs --batch \
      --load ert \
      --load org \
      --load s \
      --load org-batch-email.el \
      --load tests.el \
      --funcall ert-run-tests-batch-and-exit
#+end_src

#+begin_src emacs-lisp :tangle tests.el :results silent :exports code
;;------------------------------------------------------------------------------
;; Tests

(defun org-batch-email/fixture (body)
  (with-temp-buffer
    (insert "#+name: mail-table\n")
    (insert "| block  | to            | cc            | subject   | attachments | var |\n")
    (insert "|--------+---------------+---------------+-----------+-------------+-----|\n")
    (insert "| test-0 | a@example.com | b@example.com | My ${var} | test-0.pdf  | foo |\n")
    (insert "| test-1 | c@example.com | d@example.com | My ${var} | test-1.pdf  | bar |\n")
    (insert "\n")
    (insert "#+name: test-0\n")
    (insert "#+begin_src text\n")
    (insert ",* Test title\n")
    (insert "\n")
    (insert "Test content ${var}\n")
    (insert "#+end_src\n")
    (insert "\n")
    (insert "#+name: test-1\n")
    (insert "#+begin_src text\n")
    (insert ",* Other test title\n")
    (insert "\n")
    (insert "Other test content ${var}\n")
    (insert "#+end_src\n")
    (funcall body)))

(ert-deftest test/org-batch-email/get-block-content ()
  (org-batch-email/fixture
   (lambda ()
     (should (string-equal
              (org-batch-email/get-block-content "test-0")
              "* Test title\n\nTest content ${var}\n")))))

(ert-deftest test/org-batch-email/table->list ()
  (let ((input '(("foo0" "foo1")("bar0" "bar1")("car0" "car1")))
        (expected '((("foo0" . "bar0")("foo1" . "bar1"))
                    (("foo0" . "car0")("foo1" . "car1")))))
    (should (equal (org-batch-email/table->list input)
                   expected))))

(ert-deftest test/org-batch-email/get-table-data/success ()
  (org-batch-email/fixture
   (lambda ()
     (should (equal (org-batch-email/get-table-data "mail-table")
                    '((("block" . "test-0")
                       ("to" . "a@example.com")
                       ("cc" . "b@example.com")
                       ("subject" . "My ${var}")
                       ("attachments" . "test-0.pdf")
                       ("var" . "foo"))
                      (("block" . "test-1")
                       ("to" . "c@example.com")
                       ("cc" . "d@example.com")
                       ("subject" . "My ${var}")
                       ("attachments" . "test-1.pdf")
                       ("var" . "bar"))))))))

(ert-deftest test/org-batch-email/get-table-data/table-not-found ()
  (org-batch-email/fixture
   (lambda ()
     (should-not (org-batch-email/get-table-data "NO-TABLE-NAMED-LIKE-THIS")))))

(ert-deftest test/org-batch-email/expand-template ()
  (let ((template "Test template: ${var0}${var1}")
        (vars '(("var0" . "foo")("var1" . "bar")))
        (expected "Test template: foobar"))
    (should (string-equal
             (org-batch-email/expand-template template vars)
             expected))))

(ert-deftest test/org-batch-email/org->ascii ()
  (let ((input "* Test title\n\nTest content")
        (expected "Test title\n==========\n\nTest content\n"))
    (should (string-equal
             (org-batch-email/org->ascii input)
             expected))))

(ert-deftest test/org-batch-email/email-alist->data/missing-field ()
  (org-batch-email/fixture
   (lambda ()
     (should-error (org-batch-email/email-alist->data nil)
                   :type 'missing-field-error))))

(ert-deftest test/org-batch-email/email-alist->data/block-not-found ()
  (org-batch-email/fixture
   (lambda ()
     (let ((input '(("to" . "a@example.com,b@example.com")
                    ("cc" . "c@example.com")
                    ("subject" . "Re ${var0} and ${var1}")
                    ("attachments" . "/foo,/bar")
                    ("block" . "NO-BLOCK-NAMED-LIKE-THIS")
                    ("var0" . "foo")
                    ("var1" . "bar"))))
       (should-error (org-batch-email/email-alist->data input)
                     :type 'block-not-found-error)))))

(ert-deftest test/org-batch-email/email-alist->data/missing-template-var ()
  (org-batch-email/fixture
   (lambda ()
     (let ((input '(("to" . "a@example.com,b@example.com")
                    ("cc" . "c@example.com")
                    ("subject" . "Re ${var0} and ${var1}")
                    ("attachments" . "/foo,/bar")
                    ("block" . "NO-BLOCK-NAMED-LIKE-THIS"))))
       (should-error (org-batch-email/email-alist->data input)
                     :type 's-format-resolve)))))

(ert-deftest test/org-batch-email/email-alist->data/success-0 ()
  (org-batch-email/fixture
   (lambda ()
     (let ((input '(("to" . "a@example.com")
                    ("subject" . "Subject")
                    ("block" . "test-0")
                    ("var" . "foobar")))
           (expected '("a@example.com"
                       nil
                       "Subject"
                       nil
                       "Test title\n==========\n\nTest content foobar\n")))
       (should (equal (org-batch-email/email-alist->data input)
                      expected))))))

(ert-deftest test/org-batch-email/email-alist->data/success-1 ()
  (org-batch-email/fixture
   (lambda ()
     (let ((input '(("to" . "a@example.com,b@example.com")
                    ("cc" . "c@example.com")
                    ("subject" . "Subject ${var}")
                    ("attachments" . "/foo,/bar")
                    ("block" . "test-0")
                    ("var" . "foobar")))
           (expected '("a@example.com,b@example.com"
                       "c@example.com"
                       "Subject foobar"
                       ("/foo" "/bar")
                       "Test title\n==========\n\nTest content foobar\n")))
       (should (equal (org-batch-email/email-alist->data input)
                      expected))))))

(defun mock/org-batch-email/send-email (to cc subject attachments body)
  "Pretend to be sending an email."
  (let* ((attachments (string-join attachments ",")))
    (format "%s\n%s\n%s\n%s\n%s" to cc subject body attachments)))

(ert-deftest test/org-batch-email/send-emails ()
  (org-batch-email/fixture
   (lambda ()
     (cl-letf (((symbol-function 'org-batch-email/send-email)
                'mock/org-batch-email/send-email))
       (should (equal (org-batch-email/send-emails "mail-table")
                      '((("block" . "test-0")
                         ("to" . "a@example.com")
                         ("cc" . "b@example.com")
                         ("subject" . "My ${var}")
                         ("attachments" . "test-0.pdf")
                         ("var" . "foo"))
                        (("block" . "test-1")
                         ("to" . "c@example.com")
                         ("cc" . "d@example.com")
                         ("subject" . "My ${var}")
                         ("attachments" . "test-1.pdf")
                         ("var" . "bar")))))))))
#+end_src