Emails in development, or how Mailcatcher saves the day

By:

on

April 27, 2015

Most developers that have worked on web applications or websites have had to implement email sending functionality. Emails are extremely useful, as it allows your application to reach its users, even when they haven't visited it in a while, or to let them know that something important has happened. Unfortunately, testing email delivery is, or was, a harder problem to solve.

Now that professional Drupal shops are increasingly using multiple environments (dev, staging, live, etc.), it is important to have facilties to test email delivery. In the past, the recommended method was add to code that would either reroute emails sent from your system to a developer's email address or some "dev-null" type of endpoint (shout-out to /dev/null as a Service). However, this introduces a lot of complexity and introduces a whole to kind of issue: what if the reroute system gets misconfigured and the test email entitled "Test I am a hax0r!!!11!one!" (please don't do that) actually gets sent? Someone will be getting a stern talk-to, at the very least.

What if we could instead replace the email delivery system with something that accepts deliveries, but holds them for review? We could make sure that the email sending code is correct, we could inspect the sent emails, and we could do so without fear that the end-user will mistakenly receive one of these emails. Enter MailCatcher. MailCatcher presents, on one side, an SMTP server, and on the other, an HTTP interface to view and inspect submitted emails.

For a basic installation, the only thing you require is ruby and rubygems. On Ubuntu, this is as simple as issuing the following commands as root (or sudo):

$ apt-get install ruby rubygems
$ gem install mailcatcher

Once you have that, you are ready to start using MailCatcher! If you want to start and stop MailCatcher for every project, you can just call mailcatcher to start a daemonized process, which by default listens on 1025 for SMTP and 1080 for the HTTP UI. However, if you wish to have a server/computer-wide instance of MailCatcher, read on. The first step is to create an init file to start MailCatcher on boot. We built a plain old init shell script, as there's some movement in the Linux init daemons, and shell scripts are a common denominator. The following init script is based on the skeleton script, available on most distros at /etc/init.d/skeleton:

#!/bin/sh
### BEGIN INIT INFO
# Provides:          mailcatcher
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: MailCatcher, an SMTP server that holds your mail
# Description:       MailCatcher provides an SMTP server that will hold
#    all sent mail for later inspection.
### END INIT INFO
 
# Do NOT "set -e"
 
# PATH should only include /usr/* if it runs after the mountnfs.sh script
PATH=/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin
DESC="MailCatcher SMTP server"
NAME=mailcatcher
DAEMON=/usr/local/bin/$NAME
DAEMON_ARGS=" --http-ip 0.0.0.0"
PIDFILE=/var/run/$NAME.pid
SCRIPTNAME=/etc/init.d/$NAME
 
# Exit if the package is not installed
[ -x "$DAEMON" ] || exit 0
 
# Read configuration variable file if it is present
[ -r /etc/default/$NAME ] && . /etc/default/$NAME
 
# Load the VERBOSE setting and other rcS variables
. /lib/init/vars.sh
 
# Define LSB log_* functions.
# Depend on lsb-base (>= 3.2-14) to ensure that this file is present
# and status_of_proc is working.
. /lib/lsb/init-functions
 
#
# Function that starts the daemon/service
#
do_start()
{
    # Return
    #   0 if daemon has been started
    #   1 if daemon was already running
    #   2 if daemon could not be started
    start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
        || return 1
    start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile --background --exec $DAEMON -- -f \
        $DAEMON_ARGS \
        || return 2
    # Add code here, if necessary, that waits for the process to be ready
    # to handle requests from services started subsequently which depend
    # on this one.  As a last resort, sleep for some time.
}
 
#
# Function that stops the daemon/service
#
do_stop()
{
    # Return
    #   0 if daemon has been stopped
    #   1 if daemon was already stopped
    #   2 if daemon could not be stopped
    #   other if a failure occurred
    start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME
    RETVAL="$?"
    [ "$RETVAL" = 2 ] && return 2
    # Wait for children to finish too if this is a daemon that forks
    # and if the daemon is only ever run from this initscript.
    # If the above conditions are not satisfied then add some other code
    # that waits for the process to drop all resources that could be
    # needed by services started subsequently.  A last resort is to
    # sleep for some time.
    start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
    [ "$?" = 2 ] && return 2
    # Many daemons don't delete their pidfiles when they exit.
    rm -f $PIDFILE
    return "$RETVAL"
}
 
#
# Function that sends a SIGHUP to the daemon/service
#
do_reload() {
    #
    # If the daemon can reload its configuration without
    # restarting (for example, when it is sent a SIGHUP),
    # then implement that here.
    #
    start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE --name $NAME
    return 0
}
 
case "$1" in
  start)
    [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
    do_start
    case "$?" in
        0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
        2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
    esac
    ;;
  stop)
    [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
    do_stop
    case "$?" in
        0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
        2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
    esac
    ;;
  status)
    status_of_proc -p $PIDFILE "$DAEMON" "$NAME" && exit 0 || exit $?
    ;;
  #reload|force-reload)
    #
    # If do_reload() is not implemented then leave this commented out
    # and leave 'force-reload' as an alias for 'restart'.
    #
    #log_daemon_msg "Reloading $DESC" "$NAME"
    #do_reload
    #log_end_msg $?
    #;;
  restart|force-reload)
    #
    # If the "reload" option is implemented then remove the
    # 'force-reload' alias
    #
    log_daemon_msg "Restarting $DESC" "$NAME"
    do_stop
    case "$?" in
      0|1)
        do_start
        case "$?" in
            0) log_end_msg 0 ;;
            1) log_end_msg 1 ;; # Old process is still running
            *) log_end_msg 1 ;; # Failed to start
        esac
        ;;
      *)
        # Failed to stop
        log_end_msg 1
        ;;
    esac
    ;;
  *)
    #echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2
    echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2
    exit 3
    ;;
esac
 
exit 0
 
# vim: syntax=sh ts=4 sw=4 sts=4 et

The only point of value in this script is the do_start function. Because MailCatcher forks when started as a daemon and there is no support for having it write its own pid to a file, we have to make start-stop-daemon fork and write its PID, then have it run MailCatcher in the foreground. There is an issue open on github to add support for a --pidfile option, but until then, this is the best method to start and control the service.

To enable the service on boot, use your init system's install command, which for Ubuntu, is update-rc.d (run as root/sudo):

$ update-rc.d mailcatcher defaults

Using this script, you now have an SMTP server that starts on boot, is controlled with the usual daemon utilities, will never deliver mail to the recipients, but still allows you to inspect sent emails, and does so without requiring any changes within the application itself. Woohoo!

EDIT: MailCatcher has a nice script, catchmail that can replace sendmail for the default mail() calls in drupal. Simply set the sendmail_path to "/usr/local/bin/catchmail" in your php.ini, and you're done.

Add new comment

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.
CAPTCHA
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.