# -*- 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