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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Append a line to a CSV file after each email (a simple local record for another tool).
|
|
2
|
+
|
|
3
|
+
[csv_append]
|
|
4
|
+
path = sent.csv
|
|
5
|
+
columns = id, email, _sent_at
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
|
|
12
|
+
from mailrows.config import ConfigError
|
|
13
|
+
from mailrows.hooks import EXTRA_VALUES, Hook, HookRow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CsvAppender(Hook):
|
|
17
|
+
name = "csv_append"
|
|
18
|
+
|
|
19
|
+
def __init__(self, settings):
|
|
20
|
+
super().__init__(settings)
|
|
21
|
+
self.path = settings.resolve(settings.get("csv_append", "path"))
|
|
22
|
+
self.columns = settings.get_list("csv_append", "columns") or ["_row_id", "_recipient", "_sent_at"]
|
|
23
|
+
|
|
24
|
+
def describe(self):
|
|
25
|
+
return f"csv_append: add a line to {self.path.name}"
|
|
26
|
+
|
|
27
|
+
def check_columns(self, columns):
|
|
28
|
+
unknown = [c for c in self.columns if c not in columns and c not in EXTRA_VALUES]
|
|
29
|
+
if unknown:
|
|
30
|
+
raise ConfigError(f"[csv_append] columns lists {', '.join(unknown)}, not in the spreadsheet.")
|
|
31
|
+
|
|
32
|
+
def run(self, row: HookRow) -> None:
|
|
33
|
+
merged = row.merged()
|
|
34
|
+
new = not self.path.exists() or self.path.stat().st_size == 0
|
|
35
|
+
with open(self.path, "a", encoding="utf-8", newline="") as f:
|
|
36
|
+
w = csv.writer(f)
|
|
37
|
+
if new:
|
|
38
|
+
w.writerow(self.columns)
|
|
39
|
+
w.writerow([_safe(merged.get(c, "")) for c in self.columns])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe(value: str) -> str:
|
|
43
|
+
"""Stop a spreadsheet program from running a value as a formula."""
|
|
44
|
+
return "'" + value if value[:1] in ("=", "+", "-", "@") else value
|
mailrows/hooks/http.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""A tiny JSON-over-HTTP client with a rate limit and retries, on the standard library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
RETRY_CODES = {429, 500, 502, 503, 504}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
16
|
+
"""urllib would follow a redirect and resend our Authorization and secret headers to
|
|
17
|
+
whatever host it points at. An API call never needs a redirect, so refuse them all."""
|
|
18
|
+
|
|
19
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
20
|
+
raise urllib.error.HTTPError(req.full_url, code,
|
|
21
|
+
f"refused to follow a redirect to {newurl.split('?')[0]}", headers, fp)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_OPENER = urllib.request.build_opener(_NoRedirects)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpError(Exception):
|
|
28
|
+
def __init__(self, message: str, status: int | None = None, body: str = ""):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status = status
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RateLimiter:
|
|
35
|
+
"""Never more than `per_second` calls in any one second (spaced evenly)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, per_second: float, clock=time.monotonic, sleep=time.sleep):
|
|
38
|
+
self.gap = 1.0 / per_second if per_second > 0 else 0.0
|
|
39
|
+
self.clock = clock
|
|
40
|
+
self.sleep = sleep
|
|
41
|
+
self.last = None
|
|
42
|
+
|
|
43
|
+
def wait(self) -> None:
|
|
44
|
+
if self.last is not None:
|
|
45
|
+
left = self.last + self.gap - self.clock()
|
|
46
|
+
if left > 0:
|
|
47
|
+
self.sleep(left)
|
|
48
|
+
self.last = self.clock()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JsonClient:
|
|
52
|
+
def __init__(self, headers: dict[str, str], per_second: float = 4.5, retries: int = 5,
|
|
53
|
+
backoff: float = 1.0, max_wait: float = 30.0, timeout: float = 30.0, sleep=time.sleep):
|
|
54
|
+
self.headers = headers
|
|
55
|
+
self.limiter = RateLimiter(per_second, sleep=sleep)
|
|
56
|
+
self.retries = retries
|
|
57
|
+
self.backoff = backoff
|
|
58
|
+
self.max_wait = max_wait
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
self.sleep = sleep
|
|
61
|
+
|
|
62
|
+
def call(self, method: str, url: str, body=None):
|
|
63
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
64
|
+
headers = dict(self.headers)
|
|
65
|
+
if data is not None:
|
|
66
|
+
headers["Content-Type"] = "application/json"
|
|
67
|
+
last: HttpError | None = None
|
|
68
|
+
for attempt in range(self.retries + 1):
|
|
69
|
+
self.limiter.wait()
|
|
70
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
71
|
+
try:
|
|
72
|
+
with _OPENER.open(req, timeout=self.timeout) as resp:
|
|
73
|
+
raw = resp.read()
|
|
74
|
+
return json.loads(raw.decode("utf-8")) if raw.strip() else {}
|
|
75
|
+
except urllib.error.HTTPError as exc:
|
|
76
|
+
if 300 <= exc.code < 400:
|
|
77
|
+
raise HttpError(f"HTTP {exc.code}: {exc.reason}", exc.code) from None
|
|
78
|
+
text = exc.read().decode("utf-8", "replace")[:500] if exc.fp else ""
|
|
79
|
+
last = HttpError(f"HTTP {exc.code}", exc.code, text)
|
|
80
|
+
if exc.code not in RETRY_CODES:
|
|
81
|
+
raise last from None
|
|
82
|
+
wait = _retry_after(exc.headers.get("Retry-After") if exc.headers else None)
|
|
83
|
+
if wait is not None:
|
|
84
|
+
wait = min(wait, self.max_wait * 2)
|
|
85
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
|
86
|
+
reason = getattr(exc, "reason", exc)
|
|
87
|
+
last = HttpError(f"could not reach the server ({reason})")
|
|
88
|
+
wait = None
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise HttpError("the server sent back a reply that isn't JSON") from None
|
|
91
|
+
if attempt == self.retries:
|
|
92
|
+
break
|
|
93
|
+
if wait is None:
|
|
94
|
+
wait = min(self.max_wait, self.backoff * (2 ** attempt)) * random.uniform(0.8, 1.2)
|
|
95
|
+
self.sleep(wait)
|
|
96
|
+
raise last
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _retry_after(value):
|
|
100
|
+
try:
|
|
101
|
+
return max(0.0, float(value)) if value else None
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""POST a small JSON document to a URL after each email.
|
|
2
|
+
|
|
3
|
+
[webhook]
|
|
4
|
+
url = https://hooks.example.com/mailrows
|
|
5
|
+
; optional header, e.g. for a shared secret. The value can come from MAILROWS_WEBHOOK_HEADER_VALUE
|
|
6
|
+
header_name = X-Mailrows-Token
|
|
7
|
+
header_value = CHANGE_ME
|
|
8
|
+
; which columns to include (default: all)
|
|
9
|
+
columns = id, email
|
|
10
|
+
|
|
11
|
+
The body is {"row_id", "recipient", "subject", "sent_at", "row": {column: value}}.
|
|
12
|
+
A receiver should treat row_id as the idempotency key: a retry can deliver the same row twice.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import urllib.parse
|
|
18
|
+
|
|
19
|
+
from mailrows.config import ConfigError, looks_unset
|
|
20
|
+
from mailrows.hooks import Hook, HookError, HookRow
|
|
21
|
+
from mailrows.hooks.http import HttpError, JsonClient
|
|
22
|
+
|
|
23
|
+
def _is_local(url: str) -> bool:
|
|
24
|
+
return urllib.parse.urlsplit(url).hostname in ("localhost", "127.0.0.1", "::1")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PostWebhook(Hook):
|
|
28
|
+
name = "webhook"
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings, sleep=None):
|
|
31
|
+
super().__init__(settings)
|
|
32
|
+
s = settings
|
|
33
|
+
self.url = s.get("webhook", "url")
|
|
34
|
+
if not self.url.lower().startswith(("https://", "http://")):
|
|
35
|
+
raise ConfigError("[webhook] url must start with https:// (or http:// for a local test).")
|
|
36
|
+
headers = {"User-Agent": "mailrows"}
|
|
37
|
+
name = s.get("webhook", "header_name", "")
|
|
38
|
+
if name:
|
|
39
|
+
value = s.get("webhook", "header_value", "")
|
|
40
|
+
if looks_unset(value):
|
|
41
|
+
raise ConfigError("[webhook] header_value is not set (or set MAILROWS_WEBHOOK_HEADER_VALUE).")
|
|
42
|
+
headers[name] = value
|
|
43
|
+
if self.url.lower().startswith("http://") and not _is_local(self.url):
|
|
44
|
+
raise ConfigError("[webhook] url uses http://, which would send header_value unencrypted. "
|
|
45
|
+
"Use https:// (http:// is only allowed for localhost).")
|
|
46
|
+
self.columns = s.get_list("webhook", "columns")
|
|
47
|
+
extra = {"sleep": sleep} if sleep else {}
|
|
48
|
+
self.client = JsonClient(headers, per_second=s.get_float("webhook", "per_second", 5),
|
|
49
|
+
retries=s.get_int("webhook", "retries", 4), **extra)
|
|
50
|
+
|
|
51
|
+
def describe(self):
|
|
52
|
+
return f"webhook: POST to {self.url.split('?')[0]}"
|
|
53
|
+
|
|
54
|
+
def check_columns(self, columns):
|
|
55
|
+
unknown = [c for c in self.columns if c not in columns]
|
|
56
|
+
if unknown:
|
|
57
|
+
raise ConfigError(f"[webhook] columns lists {', '.join(unknown)}, not in the spreadsheet.")
|
|
58
|
+
|
|
59
|
+
def run(self, row: HookRow) -> None:
|
|
60
|
+
values = {c: row.values.get(c, "") for c in self.columns} if self.columns else dict(row.values)
|
|
61
|
+
body = {"row_id": row.row_id, "recipient": row.recipient, "subject": row.subject,
|
|
62
|
+
"sent_at": row.sent_at, "row": values}
|
|
63
|
+
try:
|
|
64
|
+
self.client.call("POST", self.url, body)
|
|
65
|
+
except HttpError as exc:
|
|
66
|
+
raise HookError(f"webhook: {exc}") from None
|
mailrows/ledger.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""The send log: a small SQLite file that is the final word on what has been sent.
|
|
2
|
+
|
|
3
|
+
The order of events for one email is what makes reruns safe:
|
|
4
|
+
|
|
5
|
+
1. claim(): a 'sending' record is committed BEFORE the email is handed to the server.
|
|
6
|
+
2. the email is sent.
|
|
7
|
+
3. mark_sent(): the record becomes 'sent'.
|
|
8
|
+
|
|
9
|
+
If the process dies between 1 and 3, the record stays 'sending'. A rerun sees it and does
|
|
10
|
+
not send again; a person checks their Sent folder and runs 'mailrows resolve'. If the
|
|
11
|
+
server clearly refused the email, release() removes the claim so a rerun can try again.
|
|
12
|
+
|
|
13
|
+
Each sent email also remembers which post-send hooks it still owes (an Airtable update,
|
|
14
|
+
for example). Those are retried on later runs without sending the email again.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import sqlite3
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
SCHEMA = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sends (
|
|
26
|
+
row_id TEXT PRIMARY KEY,
|
|
27
|
+
fingerprint TEXT NOT NULL,
|
|
28
|
+
recipient TEXT NOT NULL,
|
|
29
|
+
state TEXT NOT NULL CHECK (state IN ('sending', 'sent')),
|
|
30
|
+
hooks TEXT NOT NULL DEFAULT '',
|
|
31
|
+
claimed_at TEXT NOT NULL,
|
|
32
|
+
sent_at TEXT
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS sends_by_fingerprint ON sends (fingerprint);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS hooks_done (
|
|
36
|
+
row_id TEXT NOT NULL,
|
|
37
|
+
hook TEXT NOT NULL,
|
|
38
|
+
done_at TEXT NOT NULL,
|
|
39
|
+
PRIMARY KEY (row_id, hook)
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
42
|
+
at TEXT NOT NULL,
|
|
43
|
+
row_id TEXT NOT NULL,
|
|
44
|
+
event TEXT NOT NULL,
|
|
45
|
+
detail TEXT NOT NULL DEFAULT ''
|
|
46
|
+
);
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def now() -> str:
|
|
51
|
+
return dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Entry:
|
|
56
|
+
row_id: str
|
|
57
|
+
fingerprint: str
|
|
58
|
+
recipient: str
|
|
59
|
+
state: str
|
|
60
|
+
hooks: tuple[str, ...]
|
|
61
|
+
done: frozenset[str]
|
|
62
|
+
sent_at: str | None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pending_hooks(self) -> list[str]:
|
|
66
|
+
return [h for h in self.hooks if h not in self.done]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClaimTaken(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Ledger:
|
|
74
|
+
def __init__(self, path: Path):
|
|
75
|
+
self.path = path
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self.db = sqlite3.connect(str(path), isolation_level=None, timeout=30)
|
|
78
|
+
self.db.execute("PRAGMA journal_mode=DELETE")
|
|
79
|
+
self.db.execute("PRAGMA synchronous=FULL")
|
|
80
|
+
self.db.executescript(SCHEMA)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def read_only(cls, path: Path) -> "ReadOnlyLedger":
|
|
84
|
+
return ReadOnlyLedger(path)
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self.db.close()
|
|
88
|
+
|
|
89
|
+
# ---- reading
|
|
90
|
+
|
|
91
|
+
def entry(self, row_id: str) -> Entry | None:
|
|
92
|
+
r = self.db.execute(
|
|
93
|
+
"SELECT row_id, fingerprint, recipient, state, hooks, sent_at FROM sends WHERE row_id = ?", (row_id,)
|
|
94
|
+
).fetchone()
|
|
95
|
+
if r is None:
|
|
96
|
+
return None
|
|
97
|
+
done = {h for (h,) in self.db.execute("SELECT hook FROM hooks_done WHERE row_id = ?", (row_id,))}
|
|
98
|
+
hooks = tuple(h for h in r[4].split(",") if h)
|
|
99
|
+
return Entry(r[0], r[1], r[2], r[3], hooks, frozenset(done), r[5])
|
|
100
|
+
|
|
101
|
+
def all_entries(self) -> dict[str, Entry]:
|
|
102
|
+
ids = [r for (r,) in self.db.execute("SELECT row_id FROM sends")]
|
|
103
|
+
return {i: self.entry(i) for i in ids}
|
|
104
|
+
|
|
105
|
+
def by_fingerprint(self, fingerprint: str) -> str | None:
|
|
106
|
+
r = self.db.execute("SELECT row_id FROM sends WHERE fingerprint = ? LIMIT 1", (fingerprint,)).fetchone()
|
|
107
|
+
return r[0] if r else None
|
|
108
|
+
|
|
109
|
+
# ---- writing
|
|
110
|
+
|
|
111
|
+
def claim(self, row_id: str, fingerprint: str, recipient: str, hooks: list[str]) -> None:
|
|
112
|
+
"""Record the intent to send, atomically. Raises ClaimTaken if any run got there first."""
|
|
113
|
+
try:
|
|
114
|
+
self.db.execute("BEGIN IMMEDIATE")
|
|
115
|
+
exists = self.db.execute(
|
|
116
|
+
"SELECT 1 FROM sends WHERE row_id = ? OR fingerprint = ?", (row_id, fingerprint)
|
|
117
|
+
).fetchone()
|
|
118
|
+
if exists:
|
|
119
|
+
self.db.execute("ROLLBACK")
|
|
120
|
+
raise ClaimTaken(row_id)
|
|
121
|
+
self.db.execute(
|
|
122
|
+
"INSERT INTO sends (row_id, fingerprint, recipient, state, hooks, claimed_at) VALUES (?,?,?,?,?,?)",
|
|
123
|
+
(row_id, fingerprint, recipient, "sending", ",".join(hooks), now()),
|
|
124
|
+
)
|
|
125
|
+
self._event(row_id, "claimed")
|
|
126
|
+
self.db.execute("COMMIT")
|
|
127
|
+
except sqlite3.Error:
|
|
128
|
+
if self.db.in_transaction:
|
|
129
|
+
self.db.execute("ROLLBACK")
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
def mark_sent(self, row_id: str) -> None:
|
|
133
|
+
with self._tx():
|
|
134
|
+
self.db.execute("UPDATE sends SET state = 'sent', sent_at = ? WHERE row_id = ?", (now(), row_id))
|
|
135
|
+
self._event(row_id, "sent")
|
|
136
|
+
|
|
137
|
+
def release(self, row_id: str, why: str) -> None:
|
|
138
|
+
"""The server refused the email, so it did not go: allow a later retry."""
|
|
139
|
+
with self._tx():
|
|
140
|
+
self.db.execute("DELETE FROM sends WHERE row_id = ? AND state = 'sending'", (row_id,))
|
|
141
|
+
self._event(row_id, "refused", why)
|
|
142
|
+
|
|
143
|
+
def mark_hook_done(self, row_id: str, hook: str) -> None:
|
|
144
|
+
with self._tx():
|
|
145
|
+
self.db.execute(
|
|
146
|
+
"INSERT OR REPLACE INTO hooks_done (row_id, hook, done_at) VALUES (?,?,?)", (row_id, hook, now())
|
|
147
|
+
)
|
|
148
|
+
self._event(row_id, f"hook {hook} done")
|
|
149
|
+
|
|
150
|
+
def note(self, row_id: str, event: str, detail: str = "") -> None:
|
|
151
|
+
with self._tx():
|
|
152
|
+
self._event(row_id, event, detail)
|
|
153
|
+
|
|
154
|
+
def resolve(self, row_id: str, was_sent: bool) -> Entry | None:
|
|
155
|
+
"""A person checked their Sent folder for an email whose outcome wasn't known."""
|
|
156
|
+
e = self.entry(row_id)
|
|
157
|
+
if e is None or e.state != "sending":
|
|
158
|
+
return e
|
|
159
|
+
with self._tx():
|
|
160
|
+
if was_sent:
|
|
161
|
+
self.db.execute("UPDATE sends SET state = 'sent', sent_at = ? WHERE row_id = ?", (now(), row_id))
|
|
162
|
+
self._event(row_id, "resolved as sent")
|
|
163
|
+
else:
|
|
164
|
+
self.db.execute("DELETE FROM sends WHERE row_id = ?", (row_id,))
|
|
165
|
+
self._event(row_id, "resolved as not sent")
|
|
166
|
+
return self.entry(row_id)
|
|
167
|
+
|
|
168
|
+
# ---- helpers
|
|
169
|
+
|
|
170
|
+
def _event(self, row_id: str, event: str, detail: str = "") -> None:
|
|
171
|
+
self.db.execute("INSERT INTO events (at, row_id, event, detail) VALUES (?,?,?,?)",
|
|
172
|
+
(now(), row_id, event, detail[:500]))
|
|
173
|
+
|
|
174
|
+
def _tx(self):
|
|
175
|
+
db = self.db
|
|
176
|
+
|
|
177
|
+
class _T:
|
|
178
|
+
def __enter__(self):
|
|
179
|
+
db.execute("BEGIN IMMEDIATE")
|
|
180
|
+
|
|
181
|
+
def __exit__(self, kind, *_):
|
|
182
|
+
db.execute("COMMIT" if kind is None else "ROLLBACK")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
return _T()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ReadOnlyLedger:
|
|
189
|
+
"""For dry runs and test sends: reads the log if it exists, never creates or changes it."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, path: Path):
|
|
192
|
+
self._inner = None
|
|
193
|
+
if path.exists():
|
|
194
|
+
uri = path.resolve().as_uri() + "?mode=ro"
|
|
195
|
+
self._inner = Ledger.__new__(Ledger)
|
|
196
|
+
self._inner.path = path
|
|
197
|
+
self._inner.db = sqlite3.connect(uri, uri=True, isolation_level=None, timeout=30)
|
|
198
|
+
|
|
199
|
+
def entry(self, row_id):
|
|
200
|
+
return self._inner.entry(row_id) if self._inner else None
|
|
201
|
+
|
|
202
|
+
def all_entries(self):
|
|
203
|
+
return self._inner.all_entries() if self._inner else {}
|
|
204
|
+
|
|
205
|
+
def by_fingerprint(self, fingerprint):
|
|
206
|
+
return self._inner.by_fingerprint(fingerprint) if self._inner else None
|
|
207
|
+
|
|
208
|
+
def close(self):
|
|
209
|
+
if self._inner:
|
|
210
|
+
self._inner.db.close()
|
mailrows/mailer.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Sending over SMTP, with a clear answer to "did that email go or not?".
|
|
2
|
+
|
|
3
|
+
The SMTP conversation is driven step by step (MAIL, RCPT, DATA) instead of through
|
|
4
|
+
smtplib's sendmail(), so it is known whether the server could already have the message
|
|
5
|
+
when something goes wrong:
|
|
6
|
+
|
|
7
|
+
* a refusal or a dropped connection before DATA: the email certainly did not go (Refused);
|
|
8
|
+
* a dropped connection or timeout after DATA started: it may have gone (Uncertain).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import smtplib
|
|
14
|
+
import ssl
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from email.message import EmailMessage
|
|
18
|
+
from email.policy import SMTP as SMTP_POLICY
|
|
19
|
+
from email.utils import formataddr, formatdate, make_msgid
|
|
20
|
+
|
|
21
|
+
from mailrows.config import ConfigError, Settings
|
|
22
|
+
from mailrows.template import Rendered
|
|
23
|
+
|
|
24
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Refused(Exception):
|
|
28
|
+
"""The email was not accepted. Safe to try again later."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, unreachable: bool = False):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.unreachable = unreachable # True when the server could not be reached at all
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StoppedBeforeSending(KeyboardInterrupt):
|
|
36
|
+
"""Ctrl+C arrived before any of the message reached the server: it certainly did not go."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Uncertain(Exception):
|
|
40
|
+
"""The connection broke while the server was receiving the email. It may have been delivered."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SmtpSettings:
|
|
45
|
+
host: str
|
|
46
|
+
port: int
|
|
47
|
+
security: str # "ssl", "starttls" or "none"
|
|
48
|
+
username: str
|
|
49
|
+
password: str
|
|
50
|
+
timeout: float = 30
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_settings(cls, s: Settings, need_password: bool = True) -> "SmtpSettings":
|
|
54
|
+
security = s.get("smtp", "security", "starttls").lower()
|
|
55
|
+
if security not in ("ssl", "starttls", "none"):
|
|
56
|
+
raise ConfigError("[smtp] security must be ssl, starttls or none.")
|
|
57
|
+
username = s.get("smtp", "username", "")
|
|
58
|
+
password = s.secret("smtp", "password") if (username and need_password) else s.get("smtp", "password", "")
|
|
59
|
+
host = s.get("smtp", "host")
|
|
60
|
+
if security == "none" and username and host.lower() not in LOCAL_HOSTS:
|
|
61
|
+
raise ConfigError(
|
|
62
|
+
"[smtp] security = none would send your password unencrypted. Use ssl or starttls "
|
|
63
|
+
"(run 'mailrows smtp-check' to find the right setting)."
|
|
64
|
+
)
|
|
65
|
+
return cls(host=host, port=s.get_int("smtp", "port", 587), security=security,
|
|
66
|
+
username=username, password=password, timeout=s.get_float("smtp", "timeout", 30))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Sender:
|
|
71
|
+
from_email: str
|
|
72
|
+
from_name: str = ""
|
|
73
|
+
reply_to: str = ""
|
|
74
|
+
bcc: str = ""
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_settings(cls, s: Settings) -> "Sender":
|
|
78
|
+
return cls(from_email=s.get("sender", "from_email"), from_name=s.get("sender", "from_name", ""),
|
|
79
|
+
reply_to=s.get("sender", "reply_to", ""), bcc=s.get("sender", "bcc", ""))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def connect(cfg: SmtpSettings) -> smtplib.SMTP:
|
|
83
|
+
context = ssl.create_default_context()
|
|
84
|
+
if cfg.security == "ssl":
|
|
85
|
+
conn = smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=cfg.timeout, context=context)
|
|
86
|
+
else:
|
|
87
|
+
conn = smtplib.SMTP(cfg.host, cfg.port, timeout=cfg.timeout)
|
|
88
|
+
conn.ehlo()
|
|
89
|
+
if cfg.security == "starttls":
|
|
90
|
+
conn.starttls(context=context)
|
|
91
|
+
conn.ehlo()
|
|
92
|
+
if cfg.username:
|
|
93
|
+
conn.login(cfg.username, cfg.password)
|
|
94
|
+
return conn
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_message(sender: Sender, msg: Rendered, to: str | None = None) -> EmailMessage:
|
|
98
|
+
m = EmailMessage()
|
|
99
|
+
m["From"] = formataddr((sender.from_name, sender.from_email)) if sender.from_name else sender.from_email
|
|
100
|
+
m["To"] = to or msg.to
|
|
101
|
+
m["Subject"] = msg.subject
|
|
102
|
+
m["Date"] = formatdate(localtime=True)
|
|
103
|
+
m["Message-ID"] = make_msgid(domain=sender.from_email.rpartition("@")[2] or None)
|
|
104
|
+
if sender.reply_to:
|
|
105
|
+
m["Reply-To"] = sender.reply_to
|
|
106
|
+
m.set_content(msg.text, cte=_cte(msg.text))
|
|
107
|
+
if msg.html is not None:
|
|
108
|
+
m.add_alternative(msg.html, subtype="html", cte=_cte(msg.html))
|
|
109
|
+
return m
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cte(text: str) -> str:
|
|
113
|
+
"""7bit when that is safe, else quoted-printable, so no server needs 8BITMIME support."""
|
|
114
|
+
if text.isascii() and all(len(line) <= 900 for line in text.splitlines()):
|
|
115
|
+
return "7bit"
|
|
116
|
+
return "quoted-printable"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Mailer:
|
|
120
|
+
"""One SMTP connection, reopened if the server drops it between emails."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, cfg: SmtpSettings, sender: Sender):
|
|
123
|
+
self.cfg = cfg
|
|
124
|
+
self.sender = sender
|
|
125
|
+
self.conn: smtplib.SMTP | None = None
|
|
126
|
+
|
|
127
|
+
def __enter__(self):
|
|
128
|
+
self.conn = connect(self.cfg)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, kind, value, tb):
|
|
132
|
+
if self.conn is not None:
|
|
133
|
+
try:
|
|
134
|
+
self.conn.quit()
|
|
135
|
+
except (smtplib.SMTPException, OSError):
|
|
136
|
+
pass
|
|
137
|
+
self.conn = None
|
|
138
|
+
|
|
139
|
+
def _ready(self) -> smtplib.SMTP:
|
|
140
|
+
if self.conn is not None:
|
|
141
|
+
try:
|
|
142
|
+
code, _ = self.conn.noop()
|
|
143
|
+
if code == 250:
|
|
144
|
+
return self.conn
|
|
145
|
+
except (smtplib.SMTPException, OSError):
|
|
146
|
+
pass
|
|
147
|
+
try:
|
|
148
|
+
self.conn.close()
|
|
149
|
+
except (smtplib.SMTPException, OSError):
|
|
150
|
+
pass
|
|
151
|
+
self.conn = connect(self.cfg)
|
|
152
|
+
return self.conn
|
|
153
|
+
|
|
154
|
+
def send(self, message: EmailMessage, envelope_to: list[str]) -> None:
|
|
155
|
+
"""Send or raise Refused / Uncertain. BCC addresses go in envelope_to only, never in a header.
|
|
156
|
+
|
|
157
|
+
Ctrl+C before the message started going raises StoppedBeforeSending (it did not go);
|
|
158
|
+
Ctrl+C after that is re-raised as a plain KeyboardInterrupt (it may have gone)."""
|
|
159
|
+
data_started = False
|
|
160
|
+
try:
|
|
161
|
+
try:
|
|
162
|
+
conn = self._ready()
|
|
163
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
164
|
+
raise Refused(f"could not connect to the mail server: {exc}", unreachable=True) from exc
|
|
165
|
+
code, resp = conn.mail(self.sender.from_email)
|
|
166
|
+
if code != 250:
|
|
167
|
+
raise Refused(f"server refused the sender ({code} {resp!r})")
|
|
168
|
+
accepted = 0
|
|
169
|
+
for addr in envelope_to:
|
|
170
|
+
code, resp = conn.rcpt(addr)
|
|
171
|
+
if code in (250, 251):
|
|
172
|
+
accepted += 1
|
|
173
|
+
elif addr == envelope_to[0]:
|
|
174
|
+
conn.rset()
|
|
175
|
+
raise Refused(f"server refused {addr} ({code} {_text(resp)})")
|
|
176
|
+
if not accepted:
|
|
177
|
+
raise Refused("server refused every recipient")
|
|
178
|
+
data_started = True
|
|
179
|
+
code, resp = conn.data(message.as_bytes(policy=SMTP_POLICY))
|
|
180
|
+
if code != 250:
|
|
181
|
+
raise Refused(f"server refused the message ({code} {_text(resp)})")
|
|
182
|
+
except Refused:
|
|
183
|
+
raise
|
|
184
|
+
except (KeyboardInterrupt, SystemExit):
|
|
185
|
+
self.conn = None # the conversation was cut off mid-way; never reuse it
|
|
186
|
+
if data_started:
|
|
187
|
+
raise
|
|
188
|
+
raise StoppedBeforeSending() from None
|
|
189
|
+
except smtplib.SMTPResponseException as exc:
|
|
190
|
+
# a numbered reply from the server: it answered, and the answer was no
|
|
191
|
+
raise Refused(f"server said {exc.smtp_code} {_text(exc.smtp_error)}") from exc
|
|
192
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
193
|
+
self.conn = None
|
|
194
|
+
if data_started:
|
|
195
|
+
raise Uncertain(f"the connection broke while the server was receiving the email ({exc})") from exc
|
|
196
|
+
raise Refused(f"the connection broke before the email was handed over ({exc})") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _text(resp) -> str:
|
|
200
|
+
return resp.decode("utf-8", "replace") if isinstance(resp, bytes) else str(resp)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def pause(seconds: float) -> None:
|
|
204
|
+
if seconds > 0:
|
|
205
|
+
time.sleep(seconds)
|