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.
@@ -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)