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/smtpcheck.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Find which port and security setting a mail server accepts, without sending anything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import smtplib
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
COMBOS = [
|
|
11
|
+
(465, "ssl"),
|
|
12
|
+
(587, "starttls"),
|
|
13
|
+
(25, "starttls"),
|
|
14
|
+
(2525, "starttls"),
|
|
15
|
+
(25, "none"),
|
|
16
|
+
(2525, "none"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Result:
|
|
22
|
+
port: int
|
|
23
|
+
security: str
|
|
24
|
+
connected: bool = False
|
|
25
|
+
logged_in: bool | None = None # None: login not tried
|
|
26
|
+
message: str = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def usable(self) -> bool:
|
|
30
|
+
return self.connected and self.logged_in is not False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def explain(exc: BaseException, port: int, security: str) -> str:
|
|
34
|
+
if isinstance(exc, (socket.timeout, TimeoutError)):
|
|
35
|
+
return ("timed out. Something between you and the server is blocking this port: usually a firewall, "
|
|
36
|
+
"antivirus 'mail shield', or a network (office, hotel, ISP) that blocks outgoing mail ports")
|
|
37
|
+
if isinstance(exc, ConnectionRefusedError):
|
|
38
|
+
return "connection refused: the server isn't accepting mail on this port"
|
|
39
|
+
if isinstance(exc, socket.gaierror):
|
|
40
|
+
return "the host name could not be found. Check [smtp] host for typos"
|
|
41
|
+
if isinstance(exc, ssl.SSLCertVerificationError):
|
|
42
|
+
return f"the server's certificate is not valid for this host name ({exc.verify_message})"
|
|
43
|
+
if isinstance(exc, ssl.SSLError):
|
|
44
|
+
if security == "ssl":
|
|
45
|
+
return "SSL handshake failed: this port probably expects STARTTLS or plain SMTP, not SSL"
|
|
46
|
+
return f"encryption failed to start ({exc.reason or exc})"
|
|
47
|
+
if isinstance(exc, smtplib.SMTPNotSupportedError):
|
|
48
|
+
return "the server doesn't offer STARTTLS on this port"
|
|
49
|
+
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
|
50
|
+
return "connected, but the username or password was refused. Some providers need an 'app password'"
|
|
51
|
+
if isinstance(exc, smtplib.SMTPServerDisconnected):
|
|
52
|
+
if security != "ssl" and port == 465:
|
|
53
|
+
return "the server hung up: port 465 normally needs security = ssl"
|
|
54
|
+
return "the server closed the connection straight away"
|
|
55
|
+
if isinstance(exc, ConnectionResetError):
|
|
56
|
+
return "the connection was reset, often by antivirus or a firewall inspecting mail traffic"
|
|
57
|
+
return f"{type(exc).__name__}: {exc}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def try_one(host: str, port: int, security: str, username: str, password: str, timeout: float) -> Result:
|
|
61
|
+
res = Result(port, security)
|
|
62
|
+
conn = None
|
|
63
|
+
try:
|
|
64
|
+
context = ssl.create_default_context()
|
|
65
|
+
if security == "ssl":
|
|
66
|
+
conn = smtplib.SMTP_SSL(host, port, timeout=timeout, context=context)
|
|
67
|
+
else:
|
|
68
|
+
conn = smtplib.SMTP(host, port, timeout=timeout)
|
|
69
|
+
conn.ehlo()
|
|
70
|
+
if security == "starttls":
|
|
71
|
+
conn.starttls(context=context)
|
|
72
|
+
conn.ehlo()
|
|
73
|
+
res.connected = True
|
|
74
|
+
if not username:
|
|
75
|
+
res.message = "connected (no username set, so login was not tested)"
|
|
76
|
+
elif security == "none":
|
|
77
|
+
res.message = "connected, but login was not tried: it would send your password unencrypted"
|
|
78
|
+
elif not conn.has_extn("auth"):
|
|
79
|
+
res.logged_in = False
|
|
80
|
+
res.message = "connected, but the server doesn't offer login here"
|
|
81
|
+
else:
|
|
82
|
+
conn.login(username, password)
|
|
83
|
+
res.logged_in = True
|
|
84
|
+
res.message = "connected and logged in"
|
|
85
|
+
except Exception as exc: # every failure becomes a readable line
|
|
86
|
+
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
|
87
|
+
res.logged_in = False
|
|
88
|
+
res.message = explain(exc, port, security)
|
|
89
|
+
finally:
|
|
90
|
+
if conn is not None:
|
|
91
|
+
try:
|
|
92
|
+
conn.quit()
|
|
93
|
+
except Exception:
|
|
94
|
+
try:
|
|
95
|
+
conn.close()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return res
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check(host: str, username: str, password: str, timeout: float = 10, combos=None, out=print) -> Result | None:
|
|
102
|
+
out(f"Checking {host}. Nothing will be sent.\n")
|
|
103
|
+
try:
|
|
104
|
+
socket.getaddrinfo(host, None)
|
|
105
|
+
except socket.gaierror:
|
|
106
|
+
out(f" {host}: the host name could not be found. Check [smtp] host for typos.")
|
|
107
|
+
return None
|
|
108
|
+
results = []
|
|
109
|
+
for port, security in combos or COMBOS:
|
|
110
|
+
out(f" port {port:<5} {security:<9} ... ", end="")
|
|
111
|
+
res = try_one(host, port, security, username, password, timeout)
|
|
112
|
+
results.append(res)
|
|
113
|
+
mark = "OK " if res.logged_in else ("ok " if res.usable else "no ")
|
|
114
|
+
out(f"{mark}{res.message}")
|
|
115
|
+
|
|
116
|
+
best = next((r for r in results if r.logged_in), None)
|
|
117
|
+
if best is None:
|
|
118
|
+
best = next((r for r in results if r.usable and r.security != "none"), None)
|
|
119
|
+
if best is None and not username:
|
|
120
|
+
# no password to protect: an unencrypted port is acceptable if it's all there is
|
|
121
|
+
best = next((r for r in results if r.usable), None)
|
|
122
|
+
out("")
|
|
123
|
+
if best:
|
|
124
|
+
out("Use these settings in the [smtp] section:")
|
|
125
|
+
out(f" port = {best.port}")
|
|
126
|
+
out(f" security = {best.security}")
|
|
127
|
+
if not best.logged_in:
|
|
128
|
+
out("(Login was not confirmed. Set username and password and run smtp-check again.)")
|
|
129
|
+
elif any(r.logged_in is False for r in results):
|
|
130
|
+
out("The server answered, but the login was refused. Check the username and password.")
|
|
131
|
+
else:
|
|
132
|
+
if all("timed out" in r.message for r in results):
|
|
133
|
+
out("Every port timed out. That almost always means a firewall or antivirus on this computer, "
|
|
134
|
+
"or the network you're on, is blocking outgoing mail. Try another network (a phone hotspot "
|
|
135
|
+
"is a quick test) or allow Python through the firewall/antivirus.")
|
|
136
|
+
else:
|
|
137
|
+
out("No working setting found. Check the host name with your email provider.")
|
|
138
|
+
return best
|
mailrows/table.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Reading rows from .xlsx or .csv, and writing the status column back.
|
|
2
|
+
|
|
3
|
+
Every write re-opens the file, finds the row by its ID (so sorting or inserting rows in
|
|
4
|
+
between is fine), and saves through a temporary file that then replaces the original, so
|
|
5
|
+
an interrupted save can't leave a half-written file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TableError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileLocked(TableError):
|
|
23
|
+
"""The file can't be written, usually because it is open in Excel."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Row:
|
|
28
|
+
line: int # the row number a person sees in Excel (header is 1)
|
|
29
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def as_text(value) -> str:
|
|
33
|
+
if value is None:
|
|
34
|
+
return ""
|
|
35
|
+
if isinstance(value, bool):
|
|
36
|
+
return "TRUE" if value else "FALSE"
|
|
37
|
+
if isinstance(value, float) and value.is_integer():
|
|
38
|
+
return str(int(value))
|
|
39
|
+
if isinstance(value, (dt.datetime, dt.date)):
|
|
40
|
+
if isinstance(value, dt.datetime) and value.time() != dt.time():
|
|
41
|
+
return value.isoformat(sep=" ")
|
|
42
|
+
return value.strftime("%Y-%m-%d")
|
|
43
|
+
return str(value).strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _replace_atomically(path: Path, write) -> None:
|
|
47
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
48
|
+
try:
|
|
49
|
+
write(tmp)
|
|
50
|
+
os.replace(tmp, path)
|
|
51
|
+
except PermissionError as exc:
|
|
52
|
+
raise FileLocked(f"{path.name} can't be saved. Is it open in Excel or another program?") from exc
|
|
53
|
+
finally:
|
|
54
|
+
if tmp.exists():
|
|
55
|
+
try:
|
|
56
|
+
tmp.unlink()
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Table:
|
|
62
|
+
"""One spreadsheet. Subclasses handle the file format."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: Path):
|
|
65
|
+
self.path = path
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def open(path: Path, sheet: str = "", delimiter: str = "") -> "Table":
|
|
69
|
+
if not path.exists():
|
|
70
|
+
raise TableError(f"Spreadsheet {path} not found.")
|
|
71
|
+
suffix = path.suffix.lower()
|
|
72
|
+
if suffix == ".csv":
|
|
73
|
+
return CsvTable(path, delimiter)
|
|
74
|
+
if suffix == ".xlsx":
|
|
75
|
+
return XlsxTable(path, sheet)
|
|
76
|
+
raise TableError(f"{path.name}: use an .xlsx or .csv file.")
|
|
77
|
+
|
|
78
|
+
def read(self) -> tuple[list[str], list[Row]]:
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
def set_values(self, id_column: str, column: str, updates: dict[str, str]) -> int:
|
|
82
|
+
"""Write column=value on the row whose id_column is each key. Returns rows written."""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
def check_writable(self) -> None:
|
|
86
|
+
try:
|
|
87
|
+
with open(self.path, "r+b"):
|
|
88
|
+
pass
|
|
89
|
+
except PermissionError:
|
|
90
|
+
raise FileLocked(f"{self.path.name} is open in another program (often Excel). Close it and try again.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _headers(raw: list) -> list[str]:
|
|
94
|
+
names = [as_text(v) for v in raw]
|
|
95
|
+
while names and not names[-1]:
|
|
96
|
+
names.pop()
|
|
97
|
+
seen = set()
|
|
98
|
+
for n in names:
|
|
99
|
+
if n and n in seen:
|
|
100
|
+
raise TableError(f"Two columns are both called '{n}'. Rename one.")
|
|
101
|
+
seen.add(n)
|
|
102
|
+
return names
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CsvTable(Table):
|
|
106
|
+
def __init__(self, path: Path, delimiter: str = ""):
|
|
107
|
+
super().__init__(path)
|
|
108
|
+
self.delimiter = delimiter
|
|
109
|
+
|
|
110
|
+
def _load(self):
|
|
111
|
+
raw = self.path.read_bytes()
|
|
112
|
+
bom = raw.startswith(b"\xef\xbb\xbf")
|
|
113
|
+
try:
|
|
114
|
+
text = raw.decode("utf-8-sig")
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
raise TableError(f"{self.path.name} is not UTF-8. Save it as 'CSV UTF-8' and try again.") from None
|
|
117
|
+
delim = self.delimiter
|
|
118
|
+
if not delim:
|
|
119
|
+
first = text.split("\n", 1)[0]
|
|
120
|
+
delim = max([",", ";", "\t"], key=first.count)
|
|
121
|
+
rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delim))
|
|
122
|
+
self.line_end = "\r\n" if b"\r\n" in raw else "\n"
|
|
123
|
+
return rows, delim, bom
|
|
124
|
+
|
|
125
|
+
def read(self):
|
|
126
|
+
rows, _, _ = self._load()
|
|
127
|
+
if not rows:
|
|
128
|
+
raise TableError(f"{self.path.name} is empty.")
|
|
129
|
+
headers = _headers(rows[0])
|
|
130
|
+
out = []
|
|
131
|
+
for i, raw in enumerate(rows[1:], start=2):
|
|
132
|
+
if not any(cell.strip() for cell in raw):
|
|
133
|
+
continue
|
|
134
|
+
values = {h: (raw[j].strip() if j < len(raw) else "") for j, h in enumerate(headers) if h}
|
|
135
|
+
out.append(Row(line=i, values=values))
|
|
136
|
+
return headers, out
|
|
137
|
+
|
|
138
|
+
def set_values(self, id_column, column, updates):
|
|
139
|
+
rows, delim, bom = self._load()
|
|
140
|
+
headers = _headers(rows[0])
|
|
141
|
+
if column not in headers:
|
|
142
|
+
raise TableError(f"{self.path.name} has no '{column}' column to write to.")
|
|
143
|
+
id_at, col_at = headers.index(id_column), headers.index(column)
|
|
144
|
+
written = 0
|
|
145
|
+
for raw in rows[1:]:
|
|
146
|
+
key = raw[id_at].strip() if id_at < len(raw) else ""
|
|
147
|
+
if key in updates:
|
|
148
|
+
while len(raw) <= col_at:
|
|
149
|
+
raw.append("")
|
|
150
|
+
raw[col_at] = updates[key]
|
|
151
|
+
written += 1
|
|
152
|
+
|
|
153
|
+
def write(tmp: Path):
|
|
154
|
+
with open(tmp, "w", encoding="utf-8-sig" if bom else "utf-8", newline="") as f:
|
|
155
|
+
csv.writer(f, delimiter=delim, lineterminator=self.line_end).writerows(rows)
|
|
156
|
+
|
|
157
|
+
_replace_atomically(self.path, write)
|
|
158
|
+
return written
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class XlsxTable(Table):
|
|
162
|
+
def __init__(self, path: Path, sheet: str = ""):
|
|
163
|
+
super().__init__(path)
|
|
164
|
+
self.sheet = sheet
|
|
165
|
+
|
|
166
|
+
def _book(self, data_only: bool):
|
|
167
|
+
import openpyxl
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
return openpyxl.load_workbook(self.path, data_only=data_only)
|
|
171
|
+
except PermissionError:
|
|
172
|
+
raise FileLocked(f"{self.path.name} can't be opened. Is it open in another program?") from None
|
|
173
|
+
except Exception as exc: # openpyxl raises several kinds for a damaged file
|
|
174
|
+
raise TableError(f"{self.path.name} can't be read as an Excel file ({exc}).") from None
|
|
175
|
+
|
|
176
|
+
def _ws(self, wb):
|
|
177
|
+
if self.sheet:
|
|
178
|
+
if self.sheet not in wb.sheetnames:
|
|
179
|
+
raise TableError(f"{self.path.name} has no tab called '{self.sheet}'. Tabs: {', '.join(wb.sheetnames)}")
|
|
180
|
+
return wb[self.sheet]
|
|
181
|
+
return wb.worksheets[0]
|
|
182
|
+
|
|
183
|
+
def read(self):
|
|
184
|
+
# data_only gives the last calculated value of formula cells, which is what a person sees
|
|
185
|
+
wb = self._book(data_only=True)
|
|
186
|
+
try:
|
|
187
|
+
ws = self._ws(wb)
|
|
188
|
+
it = ws.iter_rows(values_only=True)
|
|
189
|
+
first = next(it, None)
|
|
190
|
+
if first is None:
|
|
191
|
+
raise TableError(f"{self.path.name} is empty.")
|
|
192
|
+
headers = _headers(list(first))
|
|
193
|
+
out = []
|
|
194
|
+
for i, raw in enumerate(it, start=2):
|
|
195
|
+
cells = [as_text(v) for v in raw]
|
|
196
|
+
if not any(cells):
|
|
197
|
+
continue
|
|
198
|
+
values = {h: (cells[j] if j < len(cells) else "") for j, h in enumerate(headers) if h}
|
|
199
|
+
out.append(Row(line=i, values=values))
|
|
200
|
+
return headers, out
|
|
201
|
+
finally:
|
|
202
|
+
wb.close()
|
|
203
|
+
|
|
204
|
+
def set_values(self, id_column, column, updates):
|
|
205
|
+
# opened without data_only, so formulas are kept when the file is saved again
|
|
206
|
+
wb = self._book(data_only=False)
|
|
207
|
+
try:
|
|
208
|
+
ws = self._ws(wb)
|
|
209
|
+
headers = _headers([c.value for c in ws[1]])
|
|
210
|
+
if column not in headers:
|
|
211
|
+
raise TableError(f"{self.path.name} has no '{column}' column to write to.")
|
|
212
|
+
id_at, col = headers.index(id_column), headers.index(column) + 1
|
|
213
|
+
written = 0
|
|
214
|
+
for cells in ws.iter_rows(min_row=2):
|
|
215
|
+
if id_at >= len(cells):
|
|
216
|
+
continue
|
|
217
|
+
key = as_text(cells[id_at].value)
|
|
218
|
+
if key in updates:
|
|
219
|
+
cell = ws.cell(row=cells[id_at].row, column=col)
|
|
220
|
+
cell.value = updates[key]
|
|
221
|
+
cell.data_type = "s" # always plain text, never a formula
|
|
222
|
+
written += 1
|
|
223
|
+
_replace_atomically(self.path, wb.save)
|
|
224
|
+
return written
|
|
225
|
+
finally:
|
|
226
|
+
wb.close()
|
mailrows/template.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Email templates.
|
|
2
|
+
|
|
3
|
+
A template file starts with a line "Subject: ..." and the rest is the body. Placeholders
|
|
4
|
+
are {column name}, matched exactly against the spreadsheet headings. Write {{ or }} for a
|
|
5
|
+
literal brace. An optional HTML file has the same placeholders (and no subject line); its
|
|
6
|
+
values are HTML-escaped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SUBJECT_LINE = re.compile(r"\s*subject\s*:(.*)$", re.IGNORECASE)
|
|
18
|
+
TOKEN = re.compile(r"\{\{|\}\}|\{([^{}\n]*)\}|[{}]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TemplateError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Rendered:
|
|
27
|
+
to: str
|
|
28
|
+
subject: str
|
|
29
|
+
text: str
|
|
30
|
+
html: str | None
|
|
31
|
+
|
|
32
|
+
def fingerprint(self) -> str:
|
|
33
|
+
"""Hash of exactly what the recipient gets. Same inputs, same hash."""
|
|
34
|
+
h = hashlib.sha256()
|
|
35
|
+
for part in (self.to.strip().lower(), self.subject, self.text, self.html or ""):
|
|
36
|
+
h.update(part.encode("utf-8"))
|
|
37
|
+
h.update(b"\x00")
|
|
38
|
+
return h.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def placeholders(text: str, where: str) -> list[str]:
|
|
42
|
+
"""Names used in a template, in order. Raises TemplateError on a stray brace."""
|
|
43
|
+
names = []
|
|
44
|
+
for m in TOKEN.finditer(text):
|
|
45
|
+
tok = m.group(0)
|
|
46
|
+
if tok in ("{{", "}}"):
|
|
47
|
+
continue
|
|
48
|
+
if tok in ("{", "}"):
|
|
49
|
+
line = text.count("\n", 0, m.start()) + 1
|
|
50
|
+
raise TemplateError(f"{where}, line {line}: a lone '{tok}'. Write '{tok}{tok}' for a literal brace.")
|
|
51
|
+
name = m.group(1).strip()
|
|
52
|
+
if not name:
|
|
53
|
+
line = text.count("\n", 0, m.start()) + 1
|
|
54
|
+
raise TemplateError(f"{where}, line {line}: empty placeholder {{}}.")
|
|
55
|
+
names.append(name)
|
|
56
|
+
return names
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fill(text: str, values: dict[str, str], escape: bool = False) -> str:
|
|
60
|
+
def swap(m: re.Match) -> str:
|
|
61
|
+
tok = m.group(0)
|
|
62
|
+
if tok == "{{":
|
|
63
|
+
return "{"
|
|
64
|
+
if tok == "}}":
|
|
65
|
+
return "}"
|
|
66
|
+
value = values[m.group(1).strip()]
|
|
67
|
+
return html.escape(value) if escape else value
|
|
68
|
+
|
|
69
|
+
return TOKEN.sub(swap, text)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Template:
|
|
74
|
+
subject: str
|
|
75
|
+
body: str
|
|
76
|
+
html_body: str | None = None
|
|
77
|
+
source: str = "template"
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def load(cls, text_path: Path, html_path: Path | None = None) -> "Template":
|
|
81
|
+
if not text_path.exists():
|
|
82
|
+
raise TemplateError(f"Template file {text_path} not found.")
|
|
83
|
+
raw = text_path.read_text(encoding="utf-8-sig").replace("\r\n", "\n")
|
|
84
|
+
first, _, rest = raw.partition("\n")
|
|
85
|
+
head = SUBJECT_LINE.match(first)
|
|
86
|
+
if head is None:
|
|
87
|
+
raise TemplateError(f"{text_path.name}: the first line must start with 'Subject:'.")
|
|
88
|
+
subject = head.group(1).strip()
|
|
89
|
+
if not subject:
|
|
90
|
+
raise TemplateError(f"{text_path.name}: the subject line is empty.")
|
|
91
|
+
html_body = None
|
|
92
|
+
if html_path is not None:
|
|
93
|
+
if not html_path.exists():
|
|
94
|
+
raise TemplateError(f"HTML template {html_path} not found.")
|
|
95
|
+
html_body = html_path.read_text(encoding="utf-8-sig")
|
|
96
|
+
return cls(subject=subject, body=rest.lstrip("\n"), html_body=html_body, source=text_path.name)
|
|
97
|
+
|
|
98
|
+
def parts(self) -> list[tuple[str, str]]:
|
|
99
|
+
out = [("subject line", self.subject), (self.source, self.body)]
|
|
100
|
+
if self.html_body is not None:
|
|
101
|
+
out.append(("HTML template", self.html_body))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
def names(self) -> set[str]:
|
|
105
|
+
found: set[str] = set()
|
|
106
|
+
for where, text in self.parts():
|
|
107
|
+
found.update(placeholders(text, where))
|
|
108
|
+
return found
|
|
109
|
+
|
|
110
|
+
def check_columns(self, columns: list[str]) -> None:
|
|
111
|
+
"""Stop before anything is sent if the template uses a column the sheet doesn't have."""
|
|
112
|
+
known = set(columns)
|
|
113
|
+
unknown = sorted(self.names() - known)
|
|
114
|
+
if unknown:
|
|
115
|
+
listed = ", ".join("{" + n + "}" for n in unknown)
|
|
116
|
+
raise TemplateError(
|
|
117
|
+
f"The template uses {listed}, which {'is' if len(unknown) == 1 else 'are'} not a column "
|
|
118
|
+
f"in the spreadsheet. Columns are: {', '.join(columns)}. "
|
|
119
|
+
"Placeholders must match a heading exactly, including capitals and spaces."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def render(self, to: str, values: dict[str, str]) -> Rendered:
|
|
123
|
+
subject = " ".join(fill(self.subject, values).split()) # no line breaks in a header
|
|
124
|
+
text = fill(self.body, values)
|
|
125
|
+
html_out = fill(self.html_body, values, escape=True) if self.html_body is not None else None
|
|
126
|
+
return Rendered(to=to, subject=subject, text=text, html=html_out)
|