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/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