mailrows 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. mailrows-0.1.0/.gitattributes +4 -0
  2. mailrows-0.1.0/.github/workflows/publish.yml +71 -0
  3. mailrows-0.1.0/.github/workflows/tests.yml +33 -0
  4. mailrows-0.1.0/.gitignore +27 -0
  5. mailrows-0.1.0/CHANGELOG.md +35 -0
  6. mailrows-0.1.0/LICENSE +21 -0
  7. mailrows-0.1.0/PKG-INFO +321 -0
  8. mailrows-0.1.0/README.md +293 -0
  9. mailrows-0.1.0/SECURITY.md +42 -0
  10. mailrows-0.1.0/config.example.ini +87 -0
  11. mailrows-0.1.0/examples/events.csv +6 -0
  12. mailrows-0.1.0/examples/events.xlsx +0 -0
  13. mailrows-0.1.0/examples/template.html +13 -0
  14. mailrows-0.1.0/examples/template.txt +14 -0
  15. mailrows-0.1.0/pyproject.toml +56 -0
  16. mailrows-0.1.0/src/mailrows/__init__.py +3 -0
  17. mailrows-0.1.0/src/mailrows/__main__.py +5 -0
  18. mailrows-0.1.0/src/mailrows/cli.py +153 -0
  19. mailrows-0.1.0/src/mailrows/config.py +118 -0
  20. mailrows-0.1.0/src/mailrows/hooks/__init__.py +94 -0
  21. mailrows-0.1.0/src/mailrows/hooks/airtable.py +134 -0
  22. mailrows-0.1.0/src/mailrows/hooks/csv_append.py +44 -0
  23. mailrows-0.1.0/src/mailrows/hooks/http.py +103 -0
  24. mailrows-0.1.0/src/mailrows/hooks/webhook.py +66 -0
  25. mailrows-0.1.0/src/mailrows/ledger.py +210 -0
  26. mailrows-0.1.0/src/mailrows/mailer.py +205 -0
  27. mailrows-0.1.0/src/mailrows/run.py +569 -0
  28. mailrows-0.1.0/src/mailrows/smtpcheck.py +138 -0
  29. mailrows-0.1.0/src/mailrows/table.py +226 -0
  30. mailrows-0.1.0/src/mailrows/template.py +126 -0
  31. mailrows-0.1.0/tests/conftest.py +313 -0
  32. mailrows-0.1.0/tests/test_hooks.py +183 -0
  33. mailrows-0.1.0/tests/test_review_fixes.py +215 -0
  34. mailrows-0.1.0/tests/test_sending.py +265 -0
  35. mailrows-0.1.0/tests/test_units.py +118 -0
  36. mailrows-0.1.0/windows/1 check mail settings.bat +7 -0
  37. mailrows-0.1.0/windows/2 dry run.bat +7 -0
  38. mailrows-0.1.0/windows/3 send a test to me.bat +9 -0
  39. mailrows-0.1.0/windows/4 send for real.bat +7 -0
  40. mailrows-0.1.0/windows/_python.cmd +29 -0
  41. mailrows-0.1.0/windows/install.bat +30 -0
@@ -0,0 +1,4 @@
1
+ * text=auto eol=lf
2
+ *.bat text eol=crlf
3
+ *.cmd text eol=crlf
4
+ *.xlsx binary
@@ -0,0 +1,71 @@
1
+ name: publish
2
+
3
+ # Runs when a GitHub release is published: tests, builds, and uploads to PyPI.
4
+ # Uses PyPI trusted publishing, so no API token is stored in the repository.
5
+
6
+ on:
7
+ release:
8
+ types: [published]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ strategy:
16
+ fail-fast: true
17
+ matrix:
18
+ os: [ubuntu-latest, windows-latest]
19
+ python: ["3.10", "3.11", "3.12", "3.13"]
20
+ runs-on: ${{ matrix.os }}
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ - uses: actions/setup-python@v7
24
+ with:
25
+ python-version: ${{ matrix.python }}
26
+ - name: Install
27
+ run: python -m pip install ".[test]"
28
+ - name: Test
29
+ run: python -m pytest -q
30
+
31
+ build:
32
+ needs: test
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v7
36
+ - uses: actions/setup-python@v7
37
+ with:
38
+ python-version: "3.13"
39
+ - name: Check the release tag matches the package version
40
+ shell: bash
41
+ run: |
42
+ version=$(grep -Po '__version__ = "\K[^"]+' src/mailrows/__init__.py)
43
+ tag="${GITHUB_REF_NAME#v}"
44
+ if [ "$version" != "$tag" ]; then
45
+ echo "Release tag $GITHUB_REF_NAME does not match __version__ $version" >&2
46
+ exit 1
47
+ fi
48
+ - name: Build
49
+ run: |
50
+ python -m pip install build twine
51
+ python -m build
52
+ python -m twine check --strict dist/*
53
+ - uses: actions/upload-artifact@v7
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+
58
+ publish:
59
+ needs: build
60
+ runs-on: ubuntu-latest
61
+ environment:
62
+ name: pypi
63
+ url: https://pypi.org/project/mailrows/
64
+ permissions:
65
+ id-token: write # lets PyPI verify this workflow; no token is stored
66
+ steps:
67
+ - uses: actions/download-artifact@v8
68
+ with:
69
+ name: dist
70
+ path: dist/
71
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,33 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os: [ubuntu-latest, windows-latest]
16
+ python: ["3.10", "3.11", "3.12", "3.13"]
17
+ runs-on: ${{ matrix.os }}
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: actions/setup-python@v7
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+ - name: Install
24
+ run: python -m pip install ".[test]"
25
+ - name: Test
26
+ run: python -m pytest -q
27
+ - name: Smoke test the installed command
28
+ shell: bash
29
+ run: |
30
+ cd "$RUNNER_TEMP"
31
+ mailrows init starter
32
+ mailrows send --dry-run -c starter/config.ini || test $? -eq 1
33
+ python -m mailrows --version
@@ -0,0 +1,27 @@
1
+ # your settings and secrets: never commit these
2
+ config.ini
3
+ *.ini
4
+ !config.example.ini
5
+ .env
6
+
7
+ # send logs and anything mailrows writes while running
8
+ *.sqlite3
9
+ *.sqlite3-journal
10
+ sent-copy.csv
11
+
12
+ # Python
13
+ __pycache__/
14
+ *.py[cod]
15
+ *.egg-info/
16
+ build/
17
+ dist/
18
+ .venv/
19
+ venv/
20
+ .pytest_cache/
21
+
22
+ # editors and systems
23
+ .vscode/
24
+ .idea/
25
+ .DS_Store
26
+ Thumbs.db
27
+ ~$*.xlsx
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/),
4
+ and versions follow [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] - 2026-11-07
7
+
8
+ First release: mail merge from Excel or CSV through your own SMTP server, with a preview,
9
+ a typed confirmation, and a send log that never sends the same email twice.
10
+
11
+ ### Added
12
+ - `mailrows send`: one templated email per row of an `.xlsx` or `.csv` file, with a preview
13
+ table, a fully rendered sample, and a typed `yes` before anything is sent.
14
+ - `--dry-run` (changes nothing) and `--test-to ADDRESS` (everything to one address, `[TEST]`
15
+ subject, no files or logs changed).
16
+ - Row checks: valid address, required fields, unique IDs. Bad rows are listed and skipped.
17
+ - Templates with `{column}` placeholders, a `Subject:` first line and an optional HTML version.
18
+ Unknown placeholders stop the run before sending.
19
+ - SQLite send log keyed by row ID and message hash. Reruns never send twice, including after a
20
+ crash or a cleared status column. `mailrows resolve` for emails whose outcome is unknown.
21
+ - Post-send hooks: Airtable (records checked before sending, under 5 calls per second, retries
22
+ with backoff, "sent, update pending" state), webhook and CSV append.
23
+ - `mailrows smtp-check`: finds the working port and security setting without sending.
24
+ - `mailrows init`: a starter folder with a config, a sample sheet and templates.
25
+ - Settings from `config.ini` or `MAILROWS_*` environment variables.
26
+ - Double-click `.bat` helpers for Windows.
27
+ - `[input] status_save_every` to group status-column saves for large spreadsheets.
28
+
29
+ ### Safety details
30
+ - Ctrl+C before an email starts going releases the row, so it isn't held as "may have been sent".
31
+ - A run stops after the mail server is unreachable twice in a row.
32
+ - HTTP redirects are never followed with credentials; webhook secrets require https.
33
+ - CSV files keep their original line endings when the status column is written.
34
+
35
+ [0.1.0]: https://github.com/munimh/mailrows/releases/tag/v0.1.0
mailrows-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The mailrows contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.5
2
+ Name: mailrows
3
+ Version: 0.1.0
4
+ Summary: Mail merge from Excel or CSV through your own SMTP server: preview, confirm, never send twice.
5
+ Project-URL: Homepage, https://github.com/munimh/mailrows
6
+ Project-URL: Source, https://github.com/munimh/mailrows
7
+ Project-URL: Issues, https://github.com/munimh/mailrows/issues
8
+ Project-URL: Changelog, https://github.com/munimh/mailrows/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: airtable,bulk email,cli,csv,email,excel,mail merge,smtp,xlsx
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Email
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: openpyxl>=3.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=7; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # mailrows
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/mailrows)](https://pypi.org/project/mailrows/)
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/mailrows)](https://pypi.org/project/mailrows/)
33
+ [![License: MIT](https://img.shields.io/pypi/l/mailrows)](https://github.com/munimh/mailrows/blob/main/LICENSE)
34
+ [![tests](https://github.com/munimh/mailrows/actions/workflows/tests.yml/badge.svg)](https://github.com/munimh/mailrows/actions/workflows/tests.yml)
35
+
36
+ Mail merge from Excel or CSV in Python: send one templated email per row of a spreadsheet
37
+ through your own SMTP server, safely, from your own computer.
38
+
39
+ ![Terminal recording: mailrows init creates a starter folder, then mailrows send --dry-run shows the table of who will be emailed, the row skipped for a bad address, and one fully rendered sample email](https://raw.githubusercontent.com/munimh/mailrows/main/docs/demo.gif)
40
+
41
+ - **Preview first.** Every row is checked, a table shows who will get an email, one email is
42
+ shown in full, and nothing goes until you type `yes`.
43
+ - **Never twice.** A local send log records every email before and after it goes. Reruns skip
44
+ anything already sent, even if you cleared the status column, edited the sheet or the
45
+ process was killed halfway.
46
+ - **Bad rows are skipped, not sent.** Invalid addresses, empty required fields and duplicate
47
+ IDs are listed with the reason.
48
+ - **Test and dry-run modes** that change nothing.
49
+ - **Optional follow-up step** after each email: update an Airtable record, call a webhook, or
50
+ append to a CSV. If the email went but the update failed, only the update is retried.
51
+ - Plain Python 3.10+, one dependency (`openpyxl`). Windows, macOS and Linux.
52
+
53
+ ## Quick start (5 minutes)
54
+
55
+ **1. Install** (needs Python 3.10 or newer):
56
+
57
+ ```bash
58
+ pipx install mailrows # or: python -m pip install mailrows
59
+ ```
60
+
61
+ **2. Make a starter folder** with a config file, a sample sheet and a template:
62
+
63
+ ```bash
64
+ mailrows init my-mailing
65
+ cd my-mailing
66
+ ```
67
+
68
+ **3. Fill in your mail server** in `config.ini`, under `[smtp]` and `[sender]`. Not sure which
69
+ port or security setting to use? Ask the tool; it tries the usual ones and sends nothing:
70
+
71
+ ```bash
72
+ mailrows smtp-check
73
+ ```
74
+
75
+ **4. See what would happen.** Nothing is sent and no file changes:
76
+
77
+ ```bash
78
+ mailrows send --dry-run
79
+ ```
80
+
81
+ The sample sheet has one row with a broken address on purpose, so you can see how bad rows
82
+ are reported.
83
+
84
+ **5. Send every email to yourself first**, marked `[TEST]`. No files or logs change:
85
+
86
+ ```bash
87
+ mailrows send --test-to your.name@example.com
88
+ ```
89
+
90
+ **6. Send for real.** Put your own rows in `events.csv` (or point `[input] file` at an `.xlsx`;
91
+ [`examples/events.xlsx`](https://github.com/munimh/mailrows/blob/main/examples/events.xlsx) is the same sample as a spreadsheet), then:
92
+
93
+ ```bash
94
+ mailrows send
95
+ ```
96
+
97
+ `python -m mailrows ...` works everywhere `mailrows ...` does.
98
+
99
+ ### On Windows without a terminal
100
+
101
+ The `windows` folder has double-click helpers. Run `install.bat` once, copy the four numbered
102
+ `.bat` files and `_python.cmd` into the folder that holds your `config.ini`, then use
103
+ `1 check mail settings`, `2 dry run`, `3 send a test to me` and `4 send for real`.
104
+
105
+ ## The spreadsheet
106
+
107
+ Any `.xlsx` or `.csv` file with a heading row. The config says which column is which:
108
+
109
+ ```ini
110
+ [input]
111
+ file = events.csv
112
+ id_column = id ; unique per row, never reused
113
+ email_column = email
114
+ status_column = status ; mailrows writes progress here (optional)
115
+ required_columns = first_name, email
116
+ ```
117
+
118
+ Every column can be used in the template. The ID is what makes reruns safe, so give each row
119
+ an ID that stays with it (a booking number, a member number). Don't renumber rows.
120
+
121
+ ## The template
122
+
123
+ A text file. The first line is the subject, the rest is the body. `{column name}` is replaced
124
+ with that row's value, matching the heading exactly. `{{` and `}}` give a literal brace.
125
+
126
+ ```text
127
+ Subject: Your place at the Riverside Maker Day, {first_name}
128
+ Hi {first_name},
129
+
130
+ You're booked into: {session}.
131
+ ```
132
+
133
+ If the template uses a placeholder that isn't a column, the run stops before anything is sent
134
+ and tells you which one. For an HTML version too, set `[template] html = template.html`; it
135
+ uses the same placeholders, and values are HTML-escaped. The plain text is always included.
136
+
137
+ ## What a run does
138
+
139
+ 1. Reads the sheet and the template; stops on any unknown placeholder or missing column.
140
+ 2. Checks every row: an ID, no duplicate IDs, a valid address, required fields filled, not
141
+ already sent. Problems are listed and those rows are skipped.
142
+ 3. If a follow-up step is enabled, checks it can be done for every row (for Airtable, that the
143
+ matching record exists). Rows that fail the check are skipped.
144
+ 4. Shows the preview table and one fully rendered email, then asks you to type `yes`.
145
+ 5. Sends one by one, `delay_seconds` apart, writing the send log and the status column after
146
+ each email.
147
+
148
+ ### Modes
149
+
150
+ | Command | Sends | Changes files or logs | Follow-up step |
151
+ |---|---|---|---|
152
+ | `mailrows send` | to each row | yes | runs |
153
+ | `mailrows send --dry-run` | nothing | no | only its read-only check |
154
+ | `mailrows send --test-to ADDRESS` | all to ADDRESS, subject starts `[TEST]`, no BCC | no | skipped |
155
+
156
+ ### How "never twice" works
157
+
158
+ The send log (`mailrows-log.sqlite3` next to your config) is the final word, not the
159
+ spreadsheet. For each row it keeps the row ID and a hash of the exact message (recipient,
160
+ subject and body).
161
+
162
+ - Before an email is handed to the server, a "sending" record is saved. After the server
163
+ accepts it, the record becomes "sent". Then the status column is updated.
164
+ - A rerun skips every row ID the log knows, whatever the status column says.
165
+ - It also skips a row whose message is identical to one already sent under another ID (for
166
+ example, a row copied with a new ID).
167
+ - If the server refuses an email, the record is removed and the next run tries again. The same
168
+ happens if you press Ctrl+C before the email has started going.
169
+ - If the mail server can't be reached twice in a row, the run stops instead of waiting on every
170
+ remaining row. Those rows go on the next run.
171
+ - If the process is killed, or the connection drops while the server is receiving the email,
172
+ the record stays "sending". mailrows can't know whether it arrived, so it doesn't guess: the
173
+ row is marked `check: may have been sent` and skipped. Look in your Sent folder, then:
174
+
175
+ ```bash
176
+ mailrows resolve E-1001 --sent # it arrived: never send it
177
+ mailrows resolve E-1001 --not-sent # it didn't: send it on the next run
178
+ ```
179
+
180
+ If the spreadsheet is open in Excel when a status needs writing, mailrows carries on (the log
181
+ already protects you) and the next run fixes the status column. For a very large `.xlsx`, set
182
+ `[input] status_save_every = 20` to save the status column every 20 emails instead of after
183
+ each one; the send log is still updated after every email. Close the file before a run to
184
+ avoid this; a real run refuses to start if it can't write to the sheet.
185
+
186
+ To send to a row again on purpose, give it a new ID.
187
+
188
+ ## Settings
189
+
190
+ All settings live in `config.ini` (copy it from `config.example.ini`). The main ones:
191
+
192
+ ```ini
193
+ [smtp]
194
+ host = smtp.example.com
195
+ port = 587
196
+ security = starttls ; ssl, starttls or none
197
+ username = your.name@example.com
198
+ password = CHANGE_ME ; or the MAILROWS_SMTP_PASSWORD environment variable
199
+
200
+ [sender]
201
+ from_name = Example Community Events
202
+ from_email = events@example.com
203
+ reply_to = hello@example.com
204
+ bcc = ; optional; real sends only, never shown to recipients
205
+ delay_seconds = 2
206
+ ```
207
+
208
+ Any setting can come from an environment variable named `MAILROWS_<SECTION>_<KEY>`, which wins
209
+ over the file: `MAILROWS_SMTP_PASSWORD`, `MAILROWS_AIRTABLE_TOKEN`, and so on.
210
+
211
+ ## Follow-up steps (hooks)
212
+
213
+ Off by default. Turn them on with a comma-separated list:
214
+
215
+ ```ini
216
+ [hooks]
217
+ enabled = airtable
218
+ ```
219
+
220
+ ### Airtable
221
+
222
+ Updates one record per sent email.
223
+
224
+ ```ini
225
+ [airtable]
226
+ base_id = appXXXXXXXXXXXXXX
227
+ table = Attendees ; name or ID
228
+ match_field = Ticket ID ; the Airtable field to find the record by
229
+ match_column = id ; the spreadsheet column with the same value
230
+ token = CHANGE_ME ; or MAILROWS_AIRTABLE_TOKEN
231
+
232
+ [airtable.fields]
233
+ Invite status = Invited ; a fixed value
234
+ Session = {session} ; from the row
235
+ Invite sent on = {_sent_date}
236
+ ```
237
+
238
+ - Every record is looked up **before any email is sent**. A row with no matching record, or
239
+ more than one, is skipped and listed. A bad token or table name stops the run.
240
+ - Calls stay under 5 per second and are retried with backoff when Airtable is busy.
241
+ - If an email went but its update failed, the row is marked `sent, update pending`. The next
242
+ run retries only the update. The email is never sent again.
243
+
244
+ Built-in values for any hook: `{_sent_at}`, `{_sent_date}`, `{_row_id}`, `{_recipient}`,
245
+ `{_subject}`.
246
+
247
+ Create the token at airtable.com/create/tokens with the `data.records:read` and
248
+ `data.records:write` scopes, limited to the one base.
249
+
250
+ ### Webhook
251
+
252
+ Posts `{"row_id", "recipient", "subject", "sent_at", "row": {...}}` as JSON after each email.
253
+
254
+ ```ini
255
+ [webhook]
256
+ url = https://hooks.example.com/mailrows
257
+ header_name = X-Mailrows-Token ; optional shared secret
258
+ header_value = CHANGE_ME ; or MAILROWS_WEBHOOK_HEADER_VALUE
259
+ columns = id, email ; default: all columns
260
+ ```
261
+
262
+ A retry can deliver the same row twice, so treat `row_id` as an idempotency key.
263
+
264
+ ### CSV append
265
+
266
+ ```ini
267
+ [csv_append]
268
+ path = sent-copy.csv
269
+ columns = id, email, _sent_at
270
+ ```
271
+
272
+ ### Writing your own
273
+
274
+ Subclass `mailrows.hooks.Hook`: `prepare(rows)` runs before any email and returns the rows it
275
+ can't handle; `run(row)` runs after each email and raises on failure. Register it in
276
+ `load_hooks`. The pending/retry bookkeeping is handled for you.
277
+
278
+ ## smtp-check
279
+
280
+ ```bash
281
+ mailrows smtp-check # uses [smtp] from config.ini
282
+ mailrows smtp-check --host smtp.example.com --username your.name@example.com
283
+ ```
284
+
285
+ Tries port 465 with SSL, 587 with STARTTLS, 25 and 2525, connects, tests the login without
286
+ sending, and tells you which `port` and `security` to use. It never sends a password over an
287
+ unencrypted connection. If every port times out, something on your computer or network (a
288
+ firewall, an antivirus "mail shield", an office or hotel network) is blocking outgoing mail.
289
+
290
+ ## Why not Zapier or Make?
291
+
292
+ They are great for many things, but for "email everyone on this list":
293
+
294
+ - **Cost.** Each row is a billed task. A few hundred emails a month can need a paid plan.
295
+ - **No real preview.** You find out what the 200th email looks like after it's sent.
296
+ - **Reruns are risky.** Replaying a failed run can email people twice; a half-finished run is
297
+ hard to pick up safely.
298
+ - **Your data stays with you.** The spreadsheet, the addresses and the send log stay on your
299
+ computer, and mail goes straight from your own mail server.
300
+
301
+ mailrows is not a newsletter platform: there is no unsubscribe handling, open tracking or
302
+ bounce processing. For marketing email to large lists, use a service built for that.
303
+
304
+ ## Exit codes
305
+
306
+ `0` everything went, `1` some rows had problems (listed), `2` stopped before sending (setup
307
+ problem), `3` cancelled at the prompt.
308
+
309
+ ## Development
310
+
311
+ ```bash
312
+ python -m pip install -e ".[test]"
313
+ python -m pytest
314
+ ```
315
+
316
+ The tests run against a fake SMTP server and a fake Airtable API on `127.0.0.1`; nothing
317
+ leaves your machine.
318
+
319
+ ## Licence
320
+
321
+ MIT. See [LICENSE](https://github.com/munimh/mailrows/blob/main/LICENSE).