mailrows 0.1.0__py3-none-any.whl
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.
- mailrows/__init__.py +3 -0
- mailrows/__main__.py +5 -0
- mailrows/_starter/config.example.ini +87 -0
- mailrows/_starter/examples/events.csv +6 -0
- mailrows/_starter/examples/events.xlsx +0 -0
- mailrows/_starter/examples/template.html +13 -0
- mailrows/_starter/examples/template.txt +14 -0
- mailrows/cli.py +153 -0
- mailrows/config.py +118 -0
- mailrows/hooks/__init__.py +94 -0
- mailrows/hooks/airtable.py +134 -0
- mailrows/hooks/csv_append.py +44 -0
- mailrows/hooks/http.py +103 -0
- mailrows/hooks/webhook.py +66 -0
- mailrows/ledger.py +210 -0
- mailrows/mailer.py +205 -0
- mailrows/run.py +569 -0
- mailrows/smtpcheck.py +138 -0
- mailrows/table.py +226 -0
- mailrows/template.py +126 -0
- mailrows-0.1.0.dist-info/METADATA +321 -0
- mailrows-0.1.0.dist-info/RECORD +25 -0
- mailrows-0.1.0.dist-info/WHEEL +4 -0
- mailrows-0.1.0.dist-info/entry_points.txt +2 -0
- mailrows-0.1.0.dist-info/licenses/LICENSE +21 -0
mailrows/run.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Checking the rows, showing the preview, and sending."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mailrows import ledger as ledger_mod
|
|
11
|
+
from mailrows.config import ConfigError, Settings
|
|
12
|
+
from mailrows.hooks import Hook, HookError, HookRow, load_hooks
|
|
13
|
+
from mailrows.mailer import (Mailer, Refused, Sender, SmtpSettings, StoppedBeforeSending, Uncertain,
|
|
14
|
+
build_message, pause)
|
|
15
|
+
from mailrows.table import FileLocked, Row, Table, TableError
|
|
16
|
+
from mailrows.template import Rendered, Template, TemplateError
|
|
17
|
+
|
|
18
|
+
EMAIL = re.compile(r"^[^@\s<>()\[\],;:\"]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$")
|
|
19
|
+
|
|
20
|
+
REAL, DRY, TEST = "real", "dry-run", "test"
|
|
21
|
+
|
|
22
|
+
EXIT_OK, EXIT_PROBLEMS, EXIT_SETUP, EXIT_CANCELLED = 0, 1, 2, 3
|
|
23
|
+
GIVE_UP_AFTER = 2 # mail server unreachable this many times in a row: stop instead of waiting on every row
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SetupError(Exception):
|
|
27
|
+
"""Something to fix before anything can be sent. Nothing has been sent."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Item:
|
|
32
|
+
row: Row
|
|
33
|
+
row_id: str
|
|
34
|
+
recipient: str = ""
|
|
35
|
+
action: str = "send" # send | hooks | skip | bad
|
|
36
|
+
reason: str = ""
|
|
37
|
+
rendered: Rendered | None = None
|
|
38
|
+
hooks: list[str] = field(default_factory=list) # hooks still to run, for action "hooks"
|
|
39
|
+
hook_row: HookRow | None = None
|
|
40
|
+
sent_at: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Job:
|
|
45
|
+
settings: Settings
|
|
46
|
+
table: Table
|
|
47
|
+
columns: list[str]
|
|
48
|
+
template: Template
|
|
49
|
+
id_col: str
|
|
50
|
+
email_col: str
|
|
51
|
+
status_col: str
|
|
52
|
+
status_sent: str
|
|
53
|
+
status_pending: str
|
|
54
|
+
status_unknown: str
|
|
55
|
+
log_path: Path
|
|
56
|
+
hooks: list[Hook]
|
|
57
|
+
items: list[Item]
|
|
58
|
+
|
|
59
|
+
def of(self, action: str) -> list[Item]:
|
|
60
|
+
return [i for i in self.items if i.action == action]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def valid_email(addr: str) -> bool:
|
|
64
|
+
return bool(EMAIL.match(addr)) and len(addr) <= 254 and ".." not in addr
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _out(*parts, end="\n"):
|
|
68
|
+
print(*parts, end=end, flush=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# == planning ==
|
|
72
|
+
|
|
73
|
+
def prepare(settings: Settings, mode: str, out=_out) -> Job:
|
|
74
|
+
try:
|
|
75
|
+
table = Table.open(settings.resolve(settings.get("input", "file")),
|
|
76
|
+
sheet=settings.get("input", "sheet", ""), delimiter=settings.get("input", "delimiter", ""))
|
|
77
|
+
columns, rows = table.read()
|
|
78
|
+
html = settings.get("template", "html", "")
|
|
79
|
+
template = Template.load(settings.resolve(settings.get("template", "text")),
|
|
80
|
+
settings.resolve(html) if html else None)
|
|
81
|
+
except (TableError, TemplateError, ConfigError) as exc:
|
|
82
|
+
raise SetupError(str(exc)) from None
|
|
83
|
+
|
|
84
|
+
id_col = settings.get("input", "id_column")
|
|
85
|
+
email_col = settings.get("input", "email_column")
|
|
86
|
+
status_col = settings.get("input", "status_column", "")
|
|
87
|
+
required = settings.get_list("input", "required_columns")
|
|
88
|
+
for label, col in (("id_column", id_col), ("email_column", email_col), ("status_column", status_col)):
|
|
89
|
+
if col and col not in columns:
|
|
90
|
+
raise SetupError(f"[input] {label} is '{col}', but the spreadsheet has no such column. "
|
|
91
|
+
f"Columns are: {', '.join(columns)}.")
|
|
92
|
+
missing = [c for c in required if c not in columns]
|
|
93
|
+
if missing:
|
|
94
|
+
raise SetupError(f"[input] required_columns lists {', '.join(missing)}, not in the spreadsheet.")
|
|
95
|
+
try:
|
|
96
|
+
template.check_columns(columns)
|
|
97
|
+
hooks = load_hooks(settings) if mode != TEST else []
|
|
98
|
+
for h in hooks:
|
|
99
|
+
h.check_columns(columns)
|
|
100
|
+
except (TemplateError, ConfigError) as exc:
|
|
101
|
+
raise SetupError(str(exc)) from None
|
|
102
|
+
|
|
103
|
+
job = Job(settings=settings, table=table, columns=columns, template=template, id_col=id_col,
|
|
104
|
+
email_col=email_col, status_col=status_col,
|
|
105
|
+
status_sent=settings.get("status", "sent", "sent"),
|
|
106
|
+
status_pending=settings.get("status", "pending", "sent, update pending"),
|
|
107
|
+
status_unknown=settings.get("status", "unknown", "check: may have been sent"),
|
|
108
|
+
log_path=settings.resolve(settings.get("log", "path", "mailrows-log.sqlite3")),
|
|
109
|
+
hooks=hooks, items=[])
|
|
110
|
+
log = ledger_mod.Ledger.read_only(job.log_path)
|
|
111
|
+
try:
|
|
112
|
+
job.items = _classify(job, rows, required, log, mode)
|
|
113
|
+
finally:
|
|
114
|
+
log.close()
|
|
115
|
+
return job
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _classify(job: Job, rows: list[Row], required: list[str], log, mode: str) -> list[Item]:
|
|
119
|
+
counts: dict[str, int] = {}
|
|
120
|
+
for r in rows:
|
|
121
|
+
rid = r.values.get(job.id_col, "").strip()
|
|
122
|
+
counts[rid] = counts.get(rid, 0) + 1
|
|
123
|
+
enabled = [h.name for h in job.hooks]
|
|
124
|
+
markers = {m.lower() for m in (job.status_sent, job.status_pending, job.status_unknown)}
|
|
125
|
+
seen_fp: dict[str, str] = {}
|
|
126
|
+
items = []
|
|
127
|
+
for r in rows:
|
|
128
|
+
rid = r.values.get(job.id_col, "").strip()
|
|
129
|
+
it = Item(row=r, row_id=rid, recipient=r.values.get(job.email_col, "").strip())
|
|
130
|
+
items.append(it)
|
|
131
|
+
if not rid:
|
|
132
|
+
it.action, it.reason = "bad", f"'{job.id_col}' is empty"
|
|
133
|
+
continue
|
|
134
|
+
if counts[rid] > 1:
|
|
135
|
+
others = [str(o.line) for o in rows if o.values.get(job.id_col, "").strip() == rid and o is not r]
|
|
136
|
+
it.action, it.reason = "bad", f"ID {rid} is also on line {', '.join(others)}. IDs must be unique"
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
entry = log.entry(rid)
|
|
140
|
+
if entry is not None:
|
|
141
|
+
it.sent_at = entry.sent_at or ""
|
|
142
|
+
if entry.state == "sending":
|
|
143
|
+
it.action = "bad"
|
|
144
|
+
it.reason = (f"may have been sent: the run stopped while it was going. Check your Sent folder, "
|
|
145
|
+
f"then run 'mailrows resolve {rid} --sent' or '--not-sent'")
|
|
146
|
+
continue
|
|
147
|
+
pending = entry.pending_hooks
|
|
148
|
+
if pending and mode != TEST:
|
|
149
|
+
it.action, it.hooks = "hooks", pending
|
|
150
|
+
gone = [h for h in pending if h not in enabled]
|
|
151
|
+
if gone:
|
|
152
|
+
it.action = "bad"
|
|
153
|
+
it.reason = (f"the email went out, but the {', '.join(gone)} update is pending and that hook is not "
|
|
154
|
+
"enabled under [hooks]")
|
|
155
|
+
continue
|
|
156
|
+
it.action = "skip"
|
|
157
|
+
it.reason = f"already sent {entry.sent_at[:16].replace('T', ' ') if entry.sent_at else ''}".strip()
|
|
158
|
+
if pending:
|
|
159
|
+
it.reason += f" ({', '.join(pending)} update pending)"
|
|
160
|
+
try:
|
|
161
|
+
if job.template.render(entry.recipient, r.values).fingerprint() != entry.fingerprint:
|
|
162
|
+
it.reason += "; the row has changed since, but it won't be sent again"
|
|
163
|
+
except KeyError:
|
|
164
|
+
pass
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
status = r.values.get(job.status_col, "").strip().lower() if job.status_col else ""
|
|
168
|
+
if status and status in markers:
|
|
169
|
+
if status == job.status_unknown.lower():
|
|
170
|
+
it.action, it.reason = "bad", (f"status says '{job.status_unknown}'. Check your Sent folder, "
|
|
171
|
+
"then clear the status if it did not go")
|
|
172
|
+
else:
|
|
173
|
+
it.action, it.reason = "skip", f"status column already says '{r.values.get(job.status_col)}'"
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
empty = [c for c in required if not r.values.get(c, "").strip()]
|
|
177
|
+
if empty:
|
|
178
|
+
it.action, it.reason = "bad", f"empty: {', '.join(empty)}"
|
|
179
|
+
continue
|
|
180
|
+
if not valid_email(it.recipient):
|
|
181
|
+
it.action, it.reason = "bad", f"'{it.recipient}' is not a valid email address" if it.recipient \
|
|
182
|
+
else f"'{job.email_col}' is empty"
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
it.rendered = job.template.render(it.recipient, r.values)
|
|
186
|
+
fp = it.rendered.fingerprint()
|
|
187
|
+
earlier = log.by_fingerprint(fp)
|
|
188
|
+
if earlier:
|
|
189
|
+
it.action, it.reason = "skip", f"exactly this email already went to this address as ID {earlier}"
|
|
190
|
+
continue
|
|
191
|
+
if fp in seen_fp:
|
|
192
|
+
it.action, it.reason = "bad", f"exactly the same email as ID {seen_fp[fp]}, to the same address"
|
|
193
|
+
continue
|
|
194
|
+
seen_fp[fp] = rid
|
|
195
|
+
it.hooks = list(enabled)
|
|
196
|
+
return items
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def hook_rows(job: Job) -> None:
|
|
200
|
+
"""Run every hook's pre-check for the rows that will need it. Rows that fail are taken out."""
|
|
201
|
+
work = [i for i in job.items if i.action in ("send", "hooks")]
|
|
202
|
+
if not work or not job.hooks:
|
|
203
|
+
return
|
|
204
|
+
for it in work:
|
|
205
|
+
subject = it.rendered.subject if it.rendered else ""
|
|
206
|
+
it.hook_row = HookRow(row_id=it.row_id, values=dict(it.row.values), recipient=it.recipient,
|
|
207
|
+
subject=subject, sent_at=it.sent_at)
|
|
208
|
+
for hook in job.hooks:
|
|
209
|
+
needs = [i for i in work if hook.name in i.hooks and i.action in ("send", "hooks")]
|
|
210
|
+
if not needs:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
problems = hook.prepare([i.hook_row for i in needs])
|
|
214
|
+
except HookError as exc:
|
|
215
|
+
raise SetupError(f"{exc}. Nothing has been sent.") from None
|
|
216
|
+
for it in needs:
|
|
217
|
+
why = problems.get(it.row_id)
|
|
218
|
+
if not why:
|
|
219
|
+
continue
|
|
220
|
+
if it.action == "send":
|
|
221
|
+
it.action, it.reason = "bad", why
|
|
222
|
+
else:
|
|
223
|
+
it.action, it.reason = "bad", f"the email went out and its update is pending, but {why}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# == showing ==
|
|
227
|
+
|
|
228
|
+
def _cut(text: str, width: int) -> str:
|
|
229
|
+
text = " ".join(str(text).split())
|
|
230
|
+
return text if len(text) <= width else text[: width - 1] + "~"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def show(job: Job, mode: str, test_to: str = "", out=_out) -> None:
|
|
234
|
+
send, upd, bad, skip = job.of("send"), job.of("hooks"), job.of("bad"), job.of("skip")
|
|
235
|
+
rows = send + upd
|
|
236
|
+
if rows:
|
|
237
|
+
title = "Will be emailed" if mode != TEST else f"Test emails (all go to {test_to})"
|
|
238
|
+
out(f"\n{title}:\n")
|
|
239
|
+
w_id = max(4, min(16, max(len(i.row_id) for i in rows)))
|
|
240
|
+
out(f" {'Line':>5} {'ID':<{w_id}} {'To':<34} What happens")
|
|
241
|
+
out(" " + "-" * (5 + 2 + w_id + 2 + 34 + 2 + 30))
|
|
242
|
+
for it in rows:
|
|
243
|
+
if it.action == "hooks":
|
|
244
|
+
what = f"retry {', '.join(it.hooks)} update only (email already sent)"
|
|
245
|
+
elif mode == TEST:
|
|
246
|
+
what = "test email"
|
|
247
|
+
else:
|
|
248
|
+
what = "send" + (f" + {', '.join(it.hooks)}" if it.hooks else "")
|
|
249
|
+
out(f" {it.row.line:>5} {_cut(it.row_id, w_id):<{w_id}} {_cut(it.recipient, 34):<34} {what}")
|
|
250
|
+
if bad:
|
|
251
|
+
out(f"\nCan't be sent ({len(bad)}), skipped:\n")
|
|
252
|
+
for it in bad:
|
|
253
|
+
out(f" line {it.row.line:<5} {_cut(it.row_id or '-', 16):<16} {it.reason}")
|
|
254
|
+
if skip:
|
|
255
|
+
out(f"\nAlready done ({len(skip)}), skipped:")
|
|
256
|
+
for it in skip[:10]:
|
|
257
|
+
out(f" line {it.row.line:<5} {_cut(it.row_id, 16):<16} {it.reason}")
|
|
258
|
+
if len(skip) > 10:
|
|
259
|
+
out(f" ... and {len(skip) - 10} more")
|
|
260
|
+
if send:
|
|
261
|
+
sample(job, send[0], mode, test_to, out)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sample(job: Job, it: Item, mode: str, test_to: str, out=_out) -> None:
|
|
265
|
+
sender = Sender.from_settings(job.settings)
|
|
266
|
+
r = it.rendered
|
|
267
|
+
out(f"\nSample email (line {it.row.line}, ID {it.row_id}):")
|
|
268
|
+
out(" " + "=" * 70)
|
|
269
|
+
frm = f"{sender.from_name} <{sender.from_email}>" if sender.from_name else sender.from_email
|
|
270
|
+
out(f" From: {frm}")
|
|
271
|
+
out(f" To: {test_to if mode == TEST else r.to}")
|
|
272
|
+
if sender.reply_to:
|
|
273
|
+
out(f" Reply-To: {sender.reply_to}")
|
|
274
|
+
if sender.bcc and mode != TEST:
|
|
275
|
+
out(f" Bcc: {sender.bcc}")
|
|
276
|
+
out(f" Subject: {'[TEST] ' if mode == TEST else ''}{r.subject}")
|
|
277
|
+
out(" " + "-" * 70)
|
|
278
|
+
for line in r.text.rstrip("\n").split("\n"):
|
|
279
|
+
out(f" {line}")
|
|
280
|
+
out(" " + "=" * 70)
|
|
281
|
+
if r.html is not None:
|
|
282
|
+
out(f" (also has an HTML version, {len(r.html)} characters)")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# == sending ==
|
|
286
|
+
|
|
287
|
+
class Statuses:
|
|
288
|
+
"""Writes the status column. If the file is locked, warns once and carries on: the send log,
|
|
289
|
+
not the spreadsheet, is what prevents a second email.
|
|
290
|
+
|
|
291
|
+
Writes can be grouped ([input] status_save_every = N) so a large .xlsx isn't re-saved after
|
|
292
|
+
every single email. Whatever is waiting is always written at the end of the run."""
|
|
293
|
+
|
|
294
|
+
def __init__(self, job: Job, out):
|
|
295
|
+
self.job, self.out, self.failed = job, out, False
|
|
296
|
+
self.every = max(1, job.settings.get_int("input", "status_save_every", 1))
|
|
297
|
+
self.waiting: dict[str, str] = {}
|
|
298
|
+
|
|
299
|
+
def set(self, updates: dict[str, str]) -> None:
|
|
300
|
+
if not self.job.status_col or not updates:
|
|
301
|
+
return
|
|
302
|
+
self.waiting.update(updates)
|
|
303
|
+
if len(self.waiting) >= self.every:
|
|
304
|
+
self.flush()
|
|
305
|
+
|
|
306
|
+
def flush(self) -> None:
|
|
307
|
+
if not self.waiting:
|
|
308
|
+
return
|
|
309
|
+
updates, self.waiting = self.waiting, {}
|
|
310
|
+
try:
|
|
311
|
+
self.job.table.set_values(self.job.id_col, self.job.status_col, updates)
|
|
312
|
+
except (FileLocked, TableError, OSError) as exc:
|
|
313
|
+
if not self.failed:
|
|
314
|
+
self.out(f" ! couldn't update the status column ({exc}). Carrying on: the send log "
|
|
315
|
+
"already records every email, and the next run fixes the column.")
|
|
316
|
+
self.failed = True
|
|
317
|
+
|
|
318
|
+
def sync(self, log: ledger_mod.Ledger) -> None:
|
|
319
|
+
"""Make the status column agree with the send log."""
|
|
320
|
+
if not self.job.status_col:
|
|
321
|
+
return
|
|
322
|
+
self.flush()
|
|
323
|
+
try:
|
|
324
|
+
_, rows = self.job.table.read()
|
|
325
|
+
except (TableError, OSError) as exc:
|
|
326
|
+
if not self.failed:
|
|
327
|
+
self.out(f" ! couldn't read the spreadsheet to update the status column ({exc}).")
|
|
328
|
+
self.failed = True
|
|
329
|
+
return
|
|
330
|
+
entries = log.all_entries()
|
|
331
|
+
updates = {}
|
|
332
|
+
for r in rows:
|
|
333
|
+
rid = r.values.get(self.job.id_col, "").strip()
|
|
334
|
+
e = entries.get(rid)
|
|
335
|
+
if e is None:
|
|
336
|
+
continue
|
|
337
|
+
want = (self.job.status_unknown if e.state == "sending"
|
|
338
|
+
else self.job.status_pending if e.pending_hooks else self.job.status_sent)
|
|
339
|
+
if r.values.get(self.job.status_col, "") != want:
|
|
340
|
+
updates[rid] = want
|
|
341
|
+
self.set(updates)
|
|
342
|
+
self.flush()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def run_hooks(job: Job, it: Item, log: ledger_mod.Ledger, out) -> bool:
|
|
346
|
+
ok = True
|
|
347
|
+
by_name = {h.name: h for h in job.hooks}
|
|
348
|
+
it.hook_row.sent_at = it.sent_at
|
|
349
|
+
for name in it.hooks:
|
|
350
|
+
try:
|
|
351
|
+
by_name[name].run(it.hook_row)
|
|
352
|
+
except Exception as exc: # any failure leaves the update pending for the next run
|
|
353
|
+
ok = False
|
|
354
|
+
log.note(it.row_id, f"hook {name} failed", str(exc))
|
|
355
|
+
out(f" {name} update failed: {exc}. It will be retried on the next run; "
|
|
356
|
+
"the email will not be sent again.")
|
|
357
|
+
continue
|
|
358
|
+
log.mark_hook_done(it.row_id, name)
|
|
359
|
+
return ok
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def execute(job: Job, mode: str, test_to: str = "", out=_out) -> int:
|
|
363
|
+
"""Send. The caller has already shown the preview and had 'yes' typed."""
|
|
364
|
+
if mode == TEST:
|
|
365
|
+
return _execute_test(job, test_to, out)
|
|
366
|
+
|
|
367
|
+
log = ledger_mod.Ledger(job.log_path)
|
|
368
|
+
status = Statuses(job, out)
|
|
369
|
+
problems = len(job.of("bad"))
|
|
370
|
+
sent = updated = 0
|
|
371
|
+
try:
|
|
372
|
+
status.sync(log)
|
|
373
|
+
send, upd = job.of("send"), job.of("hooks")
|
|
374
|
+
for it in upd:
|
|
375
|
+
if run_hooks(job, it, log, out):
|
|
376
|
+
updated += 1
|
|
377
|
+
status.set({it.row_id: job.status_sent})
|
|
378
|
+
out(f" updated {it.row_id}")
|
|
379
|
+
else:
|
|
380
|
+
problems += 1
|
|
381
|
+
if send:
|
|
382
|
+
smtp, sender = SmtpSettings.from_settings(job.settings), Sender.from_settings(job.settings)
|
|
383
|
+
delay = job.settings.get_float("sender", "delay_seconds", 2)
|
|
384
|
+
unreachable = attempted = 0
|
|
385
|
+
with Mailer(smtp, sender) as mailer:
|
|
386
|
+
for n, it in enumerate(send):
|
|
387
|
+
if unreachable >= GIVE_UP_AFTER:
|
|
388
|
+
left = len(send) - n
|
|
389
|
+
problems += left
|
|
390
|
+
out(f"\n Stopped: the mail server could not be reached {unreachable} times in a row. "
|
|
391
|
+
f"{left} email(s) were not tried and will go on the next run.")
|
|
392
|
+
break
|
|
393
|
+
if attempted:
|
|
394
|
+
pause(delay)
|
|
395
|
+
# everything that can fail before the claim is done first, so a claim is
|
|
396
|
+
# only ever left behind when the server may really have the email
|
|
397
|
+
message = build_message(sender, it.rendered)
|
|
398
|
+
envelope = [it.recipient] + ([sender.bcc] if sender.bcc else [])
|
|
399
|
+
try:
|
|
400
|
+
log.claim(it.row_id, it.rendered.fingerprint(), it.recipient, it.hooks)
|
|
401
|
+
except ledger_mod.ClaimTaken:
|
|
402
|
+
out(f" skipped {it.row_id}: another run has already sent it")
|
|
403
|
+
continue
|
|
404
|
+
attempted += 1
|
|
405
|
+
try:
|
|
406
|
+
mailer.send(message, envelope)
|
|
407
|
+
except StoppedBeforeSending:
|
|
408
|
+
log.release(it.row_id, "stopped with Ctrl+C before the email was handed over")
|
|
409
|
+
raise
|
|
410
|
+
except Refused as exc:
|
|
411
|
+
log.release(it.row_id, str(exc))
|
|
412
|
+
unreachable = unreachable + 1 if exc.unreachable else 0
|
|
413
|
+
problems += 1
|
|
414
|
+
out(f" FAILED {it.row_id} ({it.recipient}): {exc}. Not sent; a rerun will try again.")
|
|
415
|
+
continue
|
|
416
|
+
except Uncertain as exc:
|
|
417
|
+
log.note(it.row_id, "uncertain", str(exc))
|
|
418
|
+
status.set({it.row_id: job.status_unknown})
|
|
419
|
+
problems += 1
|
|
420
|
+
out(f" UNKNOWN {it.row_id} ({it.recipient}): {exc}. It won't be sent again "
|
|
421
|
+
f"automatically: check your Sent folder, then run 'mailrows resolve {it.row_id}'.")
|
|
422
|
+
continue
|
|
423
|
+
unreachable = 0
|
|
424
|
+
log.mark_sent(it.row_id)
|
|
425
|
+
sent += 1
|
|
426
|
+
it.sent_at = (log.entry(it.row_id).sent_at or "")
|
|
427
|
+
if it.hooks:
|
|
428
|
+
status.set({it.row_id: job.status_pending})
|
|
429
|
+
if run_hooks(job, it, log, out):
|
|
430
|
+
status.set({it.row_id: job.status_sent})
|
|
431
|
+
out(f" sent {it.row_id} -> {it.recipient} (+ {', '.join(it.hooks)})")
|
|
432
|
+
else:
|
|
433
|
+
problems += 1
|
|
434
|
+
out(f" sent {it.row_id} -> {it.recipient} (update pending)")
|
|
435
|
+
else:
|
|
436
|
+
status.set({it.row_id: job.status_sent})
|
|
437
|
+
out(f" sent {it.row_id} -> {it.recipient}")
|
|
438
|
+
status.flush()
|
|
439
|
+
if status.failed:
|
|
440
|
+
status.failed = False
|
|
441
|
+
status.sync(log)
|
|
442
|
+
finally:
|
|
443
|
+
try:
|
|
444
|
+
status.flush() # even when stopped early, write what is waiting
|
|
445
|
+
finally:
|
|
446
|
+
log.close()
|
|
447
|
+
out(f"\nDone. Emailed: {sent} Updates retried: {updated} Problems: {problems}")
|
|
448
|
+
if status.failed:
|
|
449
|
+
out("The status column could not be fully updated. Close the spreadsheet and run again to fix it; "
|
|
450
|
+
"nothing will be sent twice.")
|
|
451
|
+
return EXIT_PROBLEMS if problems else EXIT_OK
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _execute_test(job: Job, test_to: str, out) -> int:
|
|
455
|
+
smtp, sender = SmtpSettings.from_settings(job.settings), Sender.from_settings(job.settings)
|
|
456
|
+
delay = job.settings.get_float("sender", "delay_seconds", 2)
|
|
457
|
+
sent = failed = unreachable = 0
|
|
458
|
+
rows = job.of("send")
|
|
459
|
+
with Mailer(smtp, sender) as mailer:
|
|
460
|
+
for n, it in enumerate(rows):
|
|
461
|
+
if unreachable >= GIVE_UP_AFTER:
|
|
462
|
+
failed += len(rows) - n
|
|
463
|
+
out(f"\n Stopped: the mail server could not be reached {unreachable} times in a row.")
|
|
464
|
+
break
|
|
465
|
+
if n:
|
|
466
|
+
pause(delay)
|
|
467
|
+
r = it.rendered
|
|
468
|
+
test = Rendered(to=test_to, subject="[TEST] " + r.subject, text=r.text, html=r.html)
|
|
469
|
+
try:
|
|
470
|
+
mailer.send(build_message(sender, test), [test_to])
|
|
471
|
+
unreachable = 0
|
|
472
|
+
sent += 1
|
|
473
|
+
out(f" test sent {it.row_id} (would go to {it.recipient})")
|
|
474
|
+
except (Refused, Uncertain) as exc:
|
|
475
|
+
unreachable = unreachable + 1 if getattr(exc, "unreachable", False) else 0
|
|
476
|
+
failed += 1
|
|
477
|
+
out(f" FAILED {it.row_id}: {exc}")
|
|
478
|
+
out(f"\nTest emails sent to {test_to}: {sent} Failed: {failed}. No files or logs were changed.")
|
|
479
|
+
return EXIT_PROBLEMS if failed else EXIT_OK
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# == the whole thing ==
|
|
483
|
+
|
|
484
|
+
def send_command(settings: Settings, mode: str, test_to: str = "", ask=input, out=_out) -> int:
|
|
485
|
+
if mode == TEST and not valid_email(test_to):
|
|
486
|
+
raise SetupError(f"--test-to needs an email address, not '{test_to}'.")
|
|
487
|
+
job = prepare(settings, mode, out)
|
|
488
|
+
if mode != TEST:
|
|
489
|
+
hook_rows(job)
|
|
490
|
+
for h in job.hooks:
|
|
491
|
+
out(f"Hook: {h.describe()}")
|
|
492
|
+
if mode == REAL and job.status_col and (job.of("send") or job.of("hooks")):
|
|
493
|
+
try:
|
|
494
|
+
job.table.check_writable()
|
|
495
|
+
except FileLocked as exc:
|
|
496
|
+
raise SetupError(str(exc)) from None
|
|
497
|
+
show(job, mode, test_to, out)
|
|
498
|
+
send, upd = job.of("send"), job.of("hooks")
|
|
499
|
+
if mode == TEST:
|
|
500
|
+
upd = []
|
|
501
|
+
bad = len(job.of("bad"))
|
|
502
|
+
if not send and not upd:
|
|
503
|
+
out("\nNothing to send." + (" Fix the rows listed above and run again." if bad else ""))
|
|
504
|
+
return EXIT_PROBLEMS if bad else EXIT_OK
|
|
505
|
+
if mode == DRY:
|
|
506
|
+
out(f"\nDry run: {len(send)} email(s) would be sent"
|
|
507
|
+
+ (f" and {len(upd)} pending update(s) retried" if upd else "")
|
|
508
|
+
+ ". Nothing was sent and no file was changed.")
|
|
509
|
+
return EXIT_PROBLEMS if bad else EXIT_OK
|
|
510
|
+
|
|
511
|
+
if mode == TEST:
|
|
512
|
+
question = f"\nSend {len(send)} TEST email(s) to {test_to}? Type yes to continue: "
|
|
513
|
+
else:
|
|
514
|
+
bits = []
|
|
515
|
+
if send:
|
|
516
|
+
bits.append(f"send {len(send)} email(s)")
|
|
517
|
+
if upd:
|
|
518
|
+
bits.append(f"retry {len(upd)} pending update(s)")
|
|
519
|
+
question = f"\n{' and '.join(bits).capitalize()}? Type yes to continue: "
|
|
520
|
+
try:
|
|
521
|
+
answer = ask(question)
|
|
522
|
+
except EOFError:
|
|
523
|
+
answer = ""
|
|
524
|
+
if answer.strip().lower() != "yes":
|
|
525
|
+
out("Cancelled. Nothing was sent.")
|
|
526
|
+
return EXIT_CANCELLED
|
|
527
|
+
return execute(job, mode, test_to, out)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def resolve_command(settings: Settings, row_id: str, was_sent: bool, out=_out) -> int:
|
|
531
|
+
path = settings.resolve(settings.get("log", "path", "mailrows-log.sqlite3"))
|
|
532
|
+
if not path.exists():
|
|
533
|
+
raise SetupError(f"No send log at {path}.")
|
|
534
|
+
log = ledger_mod.Ledger(path)
|
|
535
|
+
try:
|
|
536
|
+
before = log.entry(row_id)
|
|
537
|
+
if before is None:
|
|
538
|
+
out(f"ID {row_id} is not in the send log, so there is nothing to resolve.")
|
|
539
|
+
return EXIT_PROBLEMS
|
|
540
|
+
if before.state != "sending":
|
|
541
|
+
out(f"ID {row_id} is already recorded as sent. Nothing changed.")
|
|
542
|
+
return EXIT_OK
|
|
543
|
+
log.resolve(row_id, was_sent)
|
|
544
|
+
finally:
|
|
545
|
+
log.close()
|
|
546
|
+
status_col = settings.get("input", "status_column", "")
|
|
547
|
+
if status_col:
|
|
548
|
+
try:
|
|
549
|
+
table = Table.open(settings.resolve(settings.get("input", "file")),
|
|
550
|
+
sheet=settings.get("input", "sheet", ""),
|
|
551
|
+
delimiter=settings.get("input", "delimiter", ""))
|
|
552
|
+
sent_value = settings.get("status", "pending" if before.pending_hooks else "sent",
|
|
553
|
+
"sent, update pending" if before.pending_hooks else "sent")
|
|
554
|
+
table.set_values(settings.get("input", "id_column"), status_col, {row_id: sent_value if was_sent else ""})
|
|
555
|
+
except (TableError, OSError) as exc:
|
|
556
|
+
out(f"(The status column could not be updated: {exc}. Do it by hand, or the next run will.)")
|
|
557
|
+
if was_sent:
|
|
558
|
+
out(f"Recorded ID {row_id} as sent. It won't be sent again; pending updates run on the next run.")
|
|
559
|
+
else:
|
|
560
|
+
out(f"Recorded ID {row_id} as not sent. The next run will send it.")
|
|
561
|
+
return EXIT_OK
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def setup_stdout() -> None:
|
|
565
|
+
for stream in (sys.stdout, sys.stderr):
|
|
566
|
+
try:
|
|
567
|
+
stream.reconfigure(errors="replace")
|
|
568
|
+
except (AttributeError, ValueError):
|
|
569
|
+
pass
|