Repository: awdeorio/mailmerge (python)

Issue:
Resume option
It would be nice to have a `mailmerge --resume` option to resume sending emails in the event of a failure.

Along with this, it would makes sense to report the database row number of a sent message.  It should correspond to row number when viewing the database CSV file in a spreadsheet program.

Pull request description:
Resume option
Add `--resume` option.  This PR also includes some minor test case refactoring.

Closes #35 

Patch:
diff --git a/mailmerge/__main__.py b/mailmerge/__main__.py
index c17bcbe..935d862 100644
--- a/mailmerge/__main__.py
+++ b/mailmerge/__main__.py
@@ -28,11 +28,15 @@ except ImportError:
 @click.version_option()  # Auto detect version from setup.py
 @click.option(
     "--sample", is_flag=True, default=False,
-    help="Create sample database, template email, and config",
+    help="Create sample template, database, and config",
 )
 @click.option(
     "--dry-run/--no-dry-run", default=True,
-    help="Don't send email, just print (True)",
+    help="Don't send email, just print (dry-run)",
+)
+@click.option(
+    "--no-limit", is_flag=True, default=False,
+    help="Do not limit the number of messages",
 )
 @click.option(
     "--limit", is_flag=False, default=1,
@@ -40,29 +44,30 @@ except ImportError:
     help="Limit the number of messages (1)",
 )
 @click.option(
-    "--no-limit", is_flag=True, default=False,
-    help="Do not limit the number of messages",
+    "--resume", is_flag=False, default=1,
+    type=click.IntRange(1, None),
+    help="Start on message number INTEGER",
 )
 @click.option(
     "--template", "template_path",
     default="mailmerge_template.txt",
     type=click.Path(),
-    help="template email file name (mailmerge_template.txt)"
+    help="template email (mailmerge_template.txt)"
 )
 @click.option(
     "--database", "database_path",
     default="mailmerge_database.csv",
     type=click.Path(),
-    help="database CSV file name (mailmerge_database.csv)",
+    help="database CSV (mailmerge_database.csv)",
 )
 @click.option(
     "--config", "config_path",
     default="mailmerge_server.conf",
     type=click.Path(),
-    help="configuration file name (mailmerge_server.conf)",
+    help="server configuration (mailmerge_server.conf)",
 )
-def main(sample, dry_run, limit, no_limit,
-         database_path, template_path, config_path):
+def main(sample, dry_run, limit, no_limit, resume,
+         template_path, database_path, config_path):
     """
     Mailmerge is a simple, command line mail merge tool.
 
@@ -79,26 +84,36 @@ def main(sample, dry_run, limit, no_limit,
     database_path = Path(database_path)
     config_path = Path(config_path)
 
+    # Make sure input files exist and provide helpful prompts
     check_input_files(template_path, database_path, config_path, sample)
 
-    # No limit is an alias for limit=-1
-    if no_limit:
-        limit = -1
+    # Calculate start and stop indexes.  Start and stop are zero-based.  The
+    # input --resume is one-based.
+    start = resume - 1
+    stop = None if no_limit else resume - 1 + limit
 
+    # Run
+    message_num = 1 + start
     try:
         template_message = TemplateMessage(template_path)
         csv_database = read_csv_database(database_path)
         sendmail_client = SendmailClient(config_path, dry_run)
-        for i, row in enumerate_limit(csv_database, limit):
+        for _, row in enumerate_range(csv_database, start, stop):
             sender, recipients, message = template_message.render(row)
             sendmail_client.sendmail(sender, recipients, message)
-            print(">>> message {}".format(i + 1))
+            print(">>> message {}".format(message_num))
             print(message.as_string())
             for filename in get_attachment_filenames(message):
                 print(">>> attached {}".format(filename))
-            print(">>> sent message {}".format(i + 1))
-    except MailmergeError as err:
-        sys.exit(">>> {}".format(err))
+            print(">>> sent message {}".format(message_num))
+            message_num += 1
+    except MailmergeError as error:
+        sys.exit(
+            "Error on message {message_num}\n"
+            "{error}\n"
+            'Hint: "--resume {message_num}"'
+            .format(message_num=message_num, error=error)
+        )
 
     # Hints for user
     if not no_limit:
@@ -128,27 +143,27 @@ def check_input_files(template_path, database_path, config_path, sample):
 
     if not template_path.exists():
         sys.exit(textwrap.dedent(u"""\
-            Error: can't find template {template_path}
+            Error: can't find template "{template_path}".
 
-            Create a sample (--sample) or specify a file (--template)
+            Create a sample (--sample) or specify a file (--template).
 
             See https://github.com/awdeorio/mailmerge for examples.\
         """.format(template_path=template_path)))
 
     if not database_path.exists():
         sys.exit(textwrap.dedent(u"""\
-            Error: can't find database {database_path}
+            Error: can't find database "{database_path}".
 
-            Create a sample (--sample) or specify a file (--database)
+            Create a sample (--sample) or specify a file (--database).
 
             See https://github.com/awdeorio/mailmerge for examples.\
         """.format(database_path=database_path)))
 
     if not config_path.exists():
         sys.exit(textwrap.dedent(u"""\
-            Error: can't find config {config_path}
+            Error: can't find config "{config_path}".
 
-            Create a sample (--sample) or specify a file (--config)
+            Create a sample (--sample) or specify a file (--config).
 
             See https://github.com/awdeorio/mailmerge for examples.\
         """.format(config_path=config_path)))
@@ -239,13 +254,17 @@ def read_csv_database(database_path):
             )
 
 
-def enumerate_limit(iterable, limit):
-    """Enumerate iterable, stopping after limit iterations.
+def enumerate_range(iterable, start=0, stop=None):
+    """Enumerate iterable, starting at index "start", stopping before "stop".
 
-    When limit == -1, enumerate entire iterable.
+    To enumerate the entire iterable, start=0 and stop=None.
     """
+    assert start >= 0
+    assert stop is None or stop >= 0
     for i, value in enumerate(iterable):
-        if limit != -1 and i >= limit:
+        if i < start:
+            continue
+        if stop is not None and i >= stop:
             return
         yield i, value
 


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 21e6e7b..d5fb037 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -7,7 +7,7 @@ Andrew DeOrio <awdeorio@umich.edu>
 """
 import textwrap
 import pytest
-import mailmerge.__main__
+from mailmerge.__main__ import enumerate_range, read_csv_database
 from mailmerge import MailmergeError
 
 # Python 2 pathlib support requires backport
@@ -16,36 +16,75 @@ try:
 except ImportError:
     from pathlib import Path
 
+# Every test_enumerate_range_* unit test uses a list comprehension to yield all
+# the values from the generator implementation.
+# pylint: disable=unnecessary-comprehension
 
-def test_enumerate_limit_no_limit():
-    """Verify limit=-1 results in no early termination."""
-    iterations = 0
-    for _, _ in mailmerge.__main__.enumerate_limit(["a", "b", "c"], -1):
-        iterations += 1
-    assert iterations == 3
 
+def test_enumerate_range_default():
+    """Verify default start and stop."""
+    output = [i for i in enumerate_range(["a", "b", "c"])]
+    assert output == [(0, "a"), (1, "b"), (2, "c")]
 
-def test_enumerate_limit_values():
-    """Verify limit=-1 results in no early termination."""
-    values = ["a", "b", "c"]
-    for i, value in mailmerge.__main__.enumerate_limit(values, -1):
-        assert value == values[i]
 
+def test_enumerate_range_stop_none():
+    """Verify stop=None."""
+    output = [i for i in enumerate_range(["a", "b", "c"], stop=None)]
+    assert output == [(0, "a"), (1, "b"), (2, "c")]
 
-def test_enumerate_limit_stop_early():
-    """Verify limit results in early termination."""
-    iterations = 0
-    for _, _ in mailmerge.__main__.enumerate_limit(["a", "b", "c"], 2):
-        iterations += 1
-    assert iterations == 2
 
+def test_enumerate_range_stop_value():
+    """Verify stop=value."""
+    output = [i for i in enumerate_range(["a", "b", "c"], stop=1)]
+    assert output == [(0, "a")]
 
-def test_enumerate_limit_zero():
-    """Verify limit results in early termination."""
-    iterations = 0
-    for _, _ in mailmerge.__main__.enumerate_limit(["a", "b", "c"], 0):
-        iterations += 1
-    assert iterations == 0
+
+def test_enumerate_range_stop_zero():
+    """Verify stop=0."""
+    output = [i for i in enumerate_range(["a", "b", "c"], stop=0)]
+    assert output == []
+
+
+def test_enumerate_range_stop_too_big():
+    """Verify stop when value is greater than length."""
+    output = [i for i in enumerate_range(["a", "b", "c"], stop=10)]
+    assert output == [(0, "a"), (1, "b"), (2, "c")]
+
+
+def test_enumerate_range_start_zero():
+    """Verify start=0."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=0)]
+    assert output == [(0, "a"), (1, "b"), (2, "c")]
+
+
+def test_enumerate_range_start_value():
+    """Verify start=1."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=1)]
+    assert output == [(1, "b"), (2, "c")]
+
+
+def test_enumerate_range_start_last_one():
+    """Verify start=length - 1."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=2)]
+    assert output == [(2, "c")]
+
+
+def test_enumerate_range_start_length():
+    """Verify start=length."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=3)]
+    assert output == []
+
+
+def test_enumerate_range_start_too_big():
+    """Verify start past the end."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=10)]
+    assert output == []
+
+
+def test_enumerate_range_start_stop():
+    """Verify start and stop together."""
+    output = [i for i in enumerate_range(["a", "b", "c"], start=1, stop=2)]
+    assert output == [(1, "b")]
 
 
 def test_csv_bad(tmpdir):
@@ -56,7 +95,7 @@ def test_csv_bad(tmpdir):
         1,"2
     """))
     with pytest.raises(MailmergeError):
-        next(mailmerge.__main__.read_csv_database(database_path))
+        next(read_csv_database(database_path))
 
 
 def test_csv_quotes_commas(tmpdir):
@@ -70,7 +109,7 @@ def test_csv_quotes_commas(tmpdir):
         email,message
         one@test.com,"Hello, ""world"""
     '''))
-    row = next(mailmerge.__main__.read_csv_database(database_path))
+    row = next(read_csv_database(database_path))
     assert row["email"] == u"one@test.com"
     assert row["message"] == 'Hello, "world"'
 
@@ -82,6 +121,6 @@ def test_csv_utf8(tmpdir):
         email,message
         Laȝamon <lam@test.com>,Laȝamon emoji \xf0\x9f\x98\x80 klâwen
     """))
-    row = next(mailmerge.__main__.read_csv_database(database_path))
+    row = next(read_csv_database(database_path))
     assert row["email"] == u"Laȝamon <lam@test.com>"
     assert row["message"] == u"Laȝamon emoji \xf0\x9f\x98\x80 klâwen"
diff --git a/tests/test_main.py b/tests/test_main.py
index 8451d82..0ee9df9 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -4,10 +4,14 @@
 System tests.
 
 Andrew DeOrio <awdeorio@umich.edu>
+
+pytest tmpdir docs:
+http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture
 """
 import re
 import textwrap
 import sh
+import pytest
 
 # Python 2 pathlib support requires backport
 try:
@@ -152,19 +156,13 @@ def test_no_options(tmpdir):
 
     Run mailmerge at the CLI with no options.  Do this in an empty temporary
     directory to ensure that mailmerge doesn't find any default input files.
-
-    pytest tmpdir docs:
-    http://doc.pytest.org/en/latest/tmpdir.html#the-tmpdir-fixture
-
-    sh _ok_code docs
-    https://amoffat.github.io/sh/sections/special_arguments.html#ok-code
     """
-    with tmpdir.as_cwd():
-        output = sh.mailmerge(_ok_code=1)  # expect non-zero exit
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge()
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
-    assert "Error: can't find template mailmerge_template.txt" in stderr
+    assert 'Error: can\'t find template "mailmerge_template.txt"' in stderr
     assert "https://github.com/awdeorio/mailmerge" in stderr
 
 
@@ -172,9 +170,9 @@ def test_sample(tmpdir):
     """Verify --sample creates sample input files."""
     with tmpdir.as_cwd():
         output = sh.mailmerge("--sample")
-        assert Path("mailmerge_template.txt").exists()
-        assert Path("mailmerge_database.csv").exists()
-        assert Path("mailmerge_server.conf").exists()
+    assert Path(tmpdir/"mailmerge_template.txt").exists()
+    assert Path(tmpdir/"mailmerge_database.csv").exists()
+    assert Path(tmpdir/"mailmerge_server.conf").exists()
     assert output.stderr.decode("utf-8") == ""
     assert "Created sample template" in output
     assert "Created sample database" in output
@@ -183,33 +181,33 @@ def test_sample(tmpdir):
 
 def test_sample_clobber_template(tmpdir):
     """Verify --sample won't clobber template if it already exists."""
-    with tmpdir.as_cwd():
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
         Path("mailmerge_template.txt").touch()
-        output = sh.mailmerge("--sample", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        sh.mailmerge("--sample")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: file exists: mailmerge_template.txt" in stderr
 
 
 def test_sample_clobber_database(tmpdir):
     """Verify --sample won't clobber database if it already exists."""
-    with tmpdir.as_cwd():
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
         Path("mailmerge_database.csv").touch()
-        output = sh.mailmerge("--sample", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        sh.mailmerge("--sample")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: file exists: mailmerge_database.csv" in stderr
 
 
 def test_sample_clobber_config(tmpdir):
     """Verify --sample won't clobber config if it already exists."""
-    with tmpdir.as_cwd():
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
         Path("mailmerge_server.conf").touch()
-        output = sh.mailmerge("--sample", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        sh.mailmerge("--sample")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: file exists: mailmerge_server.conf" in stderr
 
@@ -228,7 +226,7 @@ def test_defaults(tmpdir):
 def test_bad_limit(tmpdir):
     """Verify --limit with bad value."""
     # Simple template
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: {{email}}
         FROM: from@test.com
@@ -237,7 +235,7 @@ def test_bad_limit(tmpdir):
     """))
 
     # Simple database with two entries
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         email
         one@test.com
@@ -245,7 +243,7 @@ def test_bad_limit(tmpdir):
     """))
 
     # Simple unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -253,21 +251,16 @@ def test_bad_limit(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        "--dry-run",
-        "--limit", "-1",
-        _ok_code=2,
-    )
-    assert "Error: Invalid value" in output.stderr.decode("utf-8")
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
+        sh.mailmerge("--dry-run", "--limit", "-1")
+    stderr = error.value.stderr.decode("utf-8")
+    assert "Error: Invalid value" in stderr
 
 
 def test_limit_combo(tmpdir):
     """Verify --limit 1 --no-limit results in no limit."""
     # Simple template
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: {{email}}
         FROM: from@test.com
@@ -276,7 +269,7 @@ def test_limit_combo(tmpdir):
     """))
 
     # Simple database with two entries
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         email
         one@test.com
@@ -284,7 +277,7 @@ def test_limit_combo(tmpdir):
     """))
 
     # Simple unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -292,14 +285,8 @@ def test_limit_combo(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        "--dry-run",
-        "--no-limit",
-        "--limit", "1",
-    )
+    with tmpdir.as_cwd():
+        output = sh.mailmerge("--no-limit", "--limit", "1")
     assert output.stderr.decode("utf-8") == ""
     assert "sent message 1" in output
     assert "sent message 2" in output
@@ -308,33 +295,33 @@ def test_limit_combo(tmpdir):
 
 def test_template_not_found(tmpdir):
     """Verify error when template input file not found."""
-    with tmpdir.as_cwd():
-        output = sh.mailmerge("--template", "notfound.txt", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge("--template", "notfound.txt")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: can't find template" in stderr
 
 
 def test_database_not_found(tmpdir):
     """Verify error when database input file not found."""
-    with tmpdir.as_cwd():
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
         Path("mailmerge_template.txt").touch()
-        output = sh.mailmerge("--database", "notfound.csv", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        sh.mailmerge("--database", "notfound.csv")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: can't find database" in stderr
 
 
 def test_config_not_found(tmpdir):
     """Verify error when config input file not found."""
-    with tmpdir.as_cwd():
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
         Path("mailmerge_template.txt").touch()
         Path("mailmerge_database.csv").touch()
-        output = sh.mailmerge("--config", "notfound.conf", _ok_code=1)
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+        sh.mailmerge("--config", "notfound.conf")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "Error: can't find config" in stderr
 
@@ -360,7 +347,7 @@ def test_version():
 def test_bad_template(tmpdir):
     """Template mismatch with database header should produce an error."""
     # Template has a bad key
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: {{error_not_in_database}}
         SUBJECT: Testing mailmerge
@@ -370,14 +357,14 @@ def test_bad_template(tmpdir):
     """))
 
     # Normal database
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         email
         to@test.com
     """))
 
     # Normal, unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -385,16 +372,12 @@ def test_bad_template(tmpdir):
     """))
 
     # Run mailmerge, which should exit 1
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        _ok_code=1,
-    )
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge()
 
     # Verify output
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "template.txt: 'error_not_in_database' is undefined" in stderr
 
@@ -402,7 +385,7 @@ def test_bad_template(tmpdir):
 def test_bad_database(tmpdir):
     """Database read error should produce a sane error."""
     # Normal template
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: to@test.com
         FROM: from@test.com
@@ -411,14 +394,14 @@ def test_bad_database(tmpdir):
     """))
 
     # Database with unmatched quote
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         message
         "hello world
     """))
 
     # Normal, unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -426,16 +409,12 @@ def test_bad_database(tmpdir):
     """))
 
     # Run mailmerge, which should exit 1
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        _ok_code=1,
-    )
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge()
 
     # Verify output
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
     assert stdout == ""
     assert "database.csv:1: unexpected end of data" in stderr
 
@@ -443,37 +422,33 @@ def test_bad_database(tmpdir):
 def test_bad_config(tmpdir):
     """Config containing an error should produce an error."""
     # Normal template
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: to@test.com
         FROM: from@test.com
     """))
 
     # Normal database
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         dummy
         asdf
     """))
 
     # Server config is missing host
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         port = 25
     """))
 
     # Run mailmerge, which should exit 1
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        _ok_code=1,
-    )
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge()
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
 
     # Verify output
-    stdout = output.stdout.decode("utf-8")
-    stderr = output.stderr.decode("utf-8")
     assert stdout == ""
     assert "server.conf: No option 'host' in section: 'smtp_server'" in stderr
 
@@ -489,7 +464,7 @@ def test_attachment(tmpdir):
     attachment2_path.write_text(u"Hello mailmerge\n")
 
     # Template with attachment header
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: {{email}}
         FROM: from@test.com
@@ -500,14 +475,14 @@ def test_attachment(tmpdir):
     """))
 
     # Simple database
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         email
         to@test.com
     """))
 
     # Simple unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -515,11 +490,8 @@ def test_attachment(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-    )
+    with tmpdir.as_cwd():
+        output = sh.mailmerge()
 
     # Verify output
     assert output.stderr.decode("utf-8") == ""
@@ -533,7 +505,7 @@ def test_attachment(tmpdir):
 def test_utf8_template(tmpdir):
     """Message is utf-8 encoded when only the template contains utf-8 chars."""
     # Template with UTF-8 characters and emoji
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: {{email}}
         FROM: from@test.com
@@ -542,14 +514,14 @@ def test_utf8_template(tmpdir):
     """))
 
     # Simple database without utf-8 characters
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         email
         to@test.com
     """))
 
     # Simple unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -572,7 +544,7 @@ def test_utf8_template(tmpdir):
 def test_utf8_database(tmpdir):
     """Message is utf-8 encoded when only the databse contains utf-8 chars."""
     # Simple template without UTF-8 characters
-    template_path = Path(tmpdir/"template.txt")
+    template_path = Path(tmpdir/"mailmerge_template.txt")
     template_path.write_text(textwrap.dedent(u"""\
         TO: to@test.com
         FROM: from@test.com
@@ -581,14 +553,14 @@ def test_utf8_database(tmpdir):
     """))
 
     # Database with utf-8 characters and emoji
-    database_path = Path(tmpdir/"database.csv")
+    database_path = Path(tmpdir/"mailmerge_database.csv")
     database_path.write_text(textwrap.dedent(u"""\
         message
         Laȝamon 😀 klâwen
     """))
 
     # Simple unsecure server config
-    config_path = Path(tmpdir/"server.conf")
+    config_path = Path(tmpdir/"mailmerge_server.conf")
     config_path.write_text(textwrap.dedent(u"""\
         [smtp_server]
         host = open-smtp.example.com
@@ -596,12 +568,8 @@ def test_utf8_database(tmpdir):
     """))
 
     # Run mailmerge
-    output = sh.mailmerge(
-        "--template", template_path,
-        "--database", database_path,
-        "--config", config_path,
-        "--dry-run",
-    )
+    with tmpdir.as_cwd():
+        output = sh.mailmerge()
 
     # Verify output
     assert output.stderr.decode("utf-8") == ""
@@ -835,3 +803,195 @@ def test_english(tmpdir):
         assert "Limit was 1 message." in output
         output = sh.mailmerge("--limit", "2")
         assert "Limit was 2 messages." in output
+
+
+def test_resume(tmpdir):
+    """Verify --resume option starts "in the middle" of the database."""
+    # Simple template
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: to@test.com
+        FROM: from@test.com
+
+        {{message}}
+    """))
+
+    # Database with two entries
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u"""\
+        message
+        hello
+        world
+    """))
+
+    # Simple unsecure server config
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+        port = 25
+    """))
+
+    # Run mailmerge
+    with tmpdir.as_cwd():
+        output = sh.mailmerge("--resume", "2", "--no-limit")
+
+    # Verify only second message was sent
+    stdout = output.stdout.decode("utf-8")
+    stderr = output.stderr.decode("utf-8")
+    assert stderr == ""
+    assert "hello" not in stdout
+    assert "sent message 1" not in stdout
+    assert "world" in stdout
+    assert "sent message 2" in stdout
+
+
+def test_resume_too_small(tmpdir):
+    """Verify --resume <= 0 prints an error message."""
+    # Simple template
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: to@test.com
+        FROM: from@test.com
+
+        {{message}}
+    """))
+
+    # Database with two entries
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u"""\
+        message
+        hello
+        world
+    """))
+
+    # Simple unsecure server config
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+        port = 25
+    """))
+
+    # Run "mailmerge --resume 0" and check output
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
+        sh.mailmerge("--resume", "0")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
+    assert stdout == ""
+    assert "Invalid value" in stderr
+
+    # Run "mailmerge --resume -1" and check output
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_2) as error:
+        sh.mailmerge("--resume", "-1")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
+    assert stdout == ""
+    assert "Invalid value" in stderr
+
+
+def test_resume_too_big(tmpdir):
+    """Verify --resume > database does nothing."""
+    # Simple template
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: to@test.com
+        FROM: from@test.com
+
+        {{message}}
+    """))
+
+    # Database with two entries
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u"""\
+        message
+        hello
+        world
+    """))
+
+    # Simple unsecure server config
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+        port = 25
+    """))
+
+    # Run and check output
+    with tmpdir.as_cwd():
+        output = sh.mailmerge("--resume", "3", "--no-limit")
+    stdout = output.stdout.decode("utf-8")
+    stderr = output.stderr.decode("utf-8")
+    assert "sent message" not in stdout
+    assert stderr == ""
+
+
+def test_resume_hint_on_config_error(tmpdir):
+    """Verify --resume hint after config file read error."""
+    # Simple template
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: to@test.com
+        FROM: from@test.com
+
+        {{message}}
+    """))
+
+    # Database with error on second entry
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u"""\
+        message
+        hello
+        "world
+    """))
+
+    # Server config missing port
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+    """))
+
+    # Run and check output
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge()
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
+    assert stdout == ""
+    assert "--resume 1" in stderr
+
+
+def test_resume_hint_on_csv_error(tmpdir):
+    """Verify --resume hint after CSV error."""
+    # Simple template
+    template_path = Path(tmpdir/"mailmerge_template.txt")
+    template_path.write_text(textwrap.dedent(u"""\
+        TO: to@test.com
+        FROM: from@test.com
+
+        {{message}}
+    """))
+
+    # Database with unmatched quote on second entry
+    database_path = Path(tmpdir/"mailmerge_database.csv")
+    database_path.write_text(textwrap.dedent(u"""\
+        message
+        hello
+        "world
+    """))
+
+    # Simple unsecure server config
+    config_path = Path(tmpdir/"mailmerge_server.conf")
+    config_path.write_text(textwrap.dedent(u"""\
+        [smtp_server]
+        host = open-smtp.example.com
+        port = 25
+    """))
+
+    # Run and check output
+    with tmpdir.as_cwd(), pytest.raises(sh.ErrorReturnCode_1) as error:
+        sh.mailmerge("--resume", "2", "--no-limit")
+    stdout = error.value.stdout.decode("utf-8")
+    stderr = error.value.stderr.decode("utf-8")
+    assert stdout == ""
+    assert "--resume 2" in stderr