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/__init__.py
ADDED
mailrows/__main__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
; mailrows settings. Copy this file to config.ini and fill it in.
|
|
2
|
+
; config.ini is listed in .gitignore: never commit it.
|
|
3
|
+
;
|
|
4
|
+
; Any value can come from an environment variable instead, named MAILROWS_<SECTION>_<KEY>,
|
|
5
|
+
; for example MAILROWS_SMTP_PASSWORD or MAILROWS_AIRTABLE_TOKEN. The variable wins.
|
|
6
|
+
; Paths are relative to this file's folder.
|
|
7
|
+
|
|
8
|
+
[input]
|
|
9
|
+
file = events.csv
|
|
10
|
+
; for an .xlsx file with several tabs, the tab to read (default: the first)
|
|
11
|
+
sheet =
|
|
12
|
+
; which columns mean what
|
|
13
|
+
id_column = id
|
|
14
|
+
email_column = email
|
|
15
|
+
; mailrows writes progress here after each email. Leave blank to not touch the file.
|
|
16
|
+
status_column = status
|
|
17
|
+
; rows with any of these empty are skipped
|
|
18
|
+
required_columns = first_name, email
|
|
19
|
+
; for a large .xlsx: save the status column every N emails instead of after each one.
|
|
20
|
+
; The send log is updated after every email either way.
|
|
21
|
+
status_save_every = 1
|
|
22
|
+
|
|
23
|
+
[template]
|
|
24
|
+
; first line "Subject: ...", then the body. Placeholders look like {first_name}.
|
|
25
|
+
text = template.txt
|
|
26
|
+
; optional HTML version with the same placeholders (leave blank for plain text only)
|
|
27
|
+
html =
|
|
28
|
+
|
|
29
|
+
[smtp]
|
|
30
|
+
host = smtp.example.com
|
|
31
|
+
port = 587
|
|
32
|
+
; ssl (usually port 465), starttls (usually 587) or none. 'mailrows smtp-check' finds the right one.
|
|
33
|
+
security = starttls
|
|
34
|
+
username = your.name@example.com
|
|
35
|
+
password = CHANGE_ME
|
|
36
|
+
timeout = 30
|
|
37
|
+
|
|
38
|
+
[sender]
|
|
39
|
+
from_name = Example Community Events
|
|
40
|
+
from_email = events@example.com
|
|
41
|
+
reply_to = hello@example.com
|
|
42
|
+
; optional: a copy of every real (not test) email goes here, invisibly
|
|
43
|
+
bcc =
|
|
44
|
+
; seconds to wait between emails
|
|
45
|
+
delay_seconds = 2
|
|
46
|
+
|
|
47
|
+
[log]
|
|
48
|
+
; the send log that stops anything going out twice. Keep it with the spreadsheet.
|
|
49
|
+
path = mailrows-log.sqlite3
|
|
50
|
+
|
|
51
|
+
[status]
|
|
52
|
+
; words written to the status column
|
|
53
|
+
sent = sent
|
|
54
|
+
pending = sent, update pending
|
|
55
|
+
unknown = check: may have been sent
|
|
56
|
+
|
|
57
|
+
[hooks]
|
|
58
|
+
; things to do after each email, comma separated: airtable, webhook, csv_append. Off by default.
|
|
59
|
+
enabled =
|
|
60
|
+
|
|
61
|
+
[airtable]
|
|
62
|
+
base_id = appXXXXXXXXXXXXXX
|
|
63
|
+
; table name or ID
|
|
64
|
+
table = Attendees
|
|
65
|
+
; the Airtable field to find the record by, and the spreadsheet column with the same value
|
|
66
|
+
match_field = Ticket ID
|
|
67
|
+
match_column = id
|
|
68
|
+
; personal access token with data.records:read and data.records:write on this base only
|
|
69
|
+
token = CHANGE_ME
|
|
70
|
+
|
|
71
|
+
[airtable.fields]
|
|
72
|
+
; Airtable field = value. {column} takes the value from the row. Built-ins: {_sent_at}, {_sent_date},
|
|
73
|
+
; {_row_id}, {_recipient}, {_subject}.
|
|
74
|
+
Invite status = Invited
|
|
75
|
+
Invite sent on = {_sent_date}
|
|
76
|
+
|
|
77
|
+
[webhook]
|
|
78
|
+
url = https://hooks.example.com/mailrows
|
|
79
|
+
; optional header, for example a shared secret
|
|
80
|
+
header_name =
|
|
81
|
+
header_value = CHANGE_ME
|
|
82
|
+
; columns to include in the JSON (default: all)
|
|
83
|
+
columns =
|
|
84
|
+
|
|
85
|
+
[csv_append]
|
|
86
|
+
path = sent-copy.csv
|
|
87
|
+
columns = id, email, _sent_at
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
id,first_name,last_name,email,session,seats,status
|
|
2
|
+
E-1001,Ada,Testperson,ada.testperson@example.com,Morning workshop,1,
|
|
3
|
+
E-1002,Bram,Sampleton,bram.sampleton@example.com,Afternoon talks,2,
|
|
4
|
+
E-1003,Cleo,Placeholder,cleo.placeholder@example.com,Morning workshop,1,
|
|
5
|
+
E-1004,Dev,Fakename,dev.fakename@example,Afternoon talks,1,
|
|
6
|
+
E-1005,Esme,Notreal,esme.notreal@example.com,Evening social,3,
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
|
|
4
|
+
<p>Hi {first_name},</p>
|
|
5
|
+
<p>Thanks for signing up for the <strong>Riverside Maker Day</strong> on Saturday 14 March.<br>
|
|
6
|
+
You're booked into: <strong>{session}</strong> ({seats} seat(s)).</p>
|
|
7
|
+
<p>Doors open at 9:30. Bring a laptop if you have one; we'll have power strips,
|
|
8
|
+
coffee and plenty of spare cables.</p>
|
|
9
|
+
<p>Your booking reference is <code>{id}</code>. If you can't make it, just reply to this email
|
|
10
|
+
and we'll offer your place to someone on the waiting list.</p>
|
|
11
|
+
<p>See you there,<br>The Maker Day volunteers</p>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Subject: Your place at the Riverside Maker Day, {first_name}
|
|
2
|
+
Hi {first_name},
|
|
3
|
+
|
|
4
|
+
Thanks for signing up for the Riverside Maker Day on Saturday 14 March.
|
|
5
|
+
You're booked into: {session} ({seats} seat(s)).
|
|
6
|
+
|
|
7
|
+
Doors open at 9:30. Bring a laptop if you have one; we'll have power strips,
|
|
8
|
+
coffee and plenty of spare cables.
|
|
9
|
+
|
|
10
|
+
Your booking reference is {id}. If you can't make it, just reply to this email
|
|
11
|
+
and we'll offer your place to someone on the waiting list.
|
|
12
|
+
|
|
13
|
+
See you there,
|
|
14
|
+
The Maker Day volunteers
|
mailrows/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Command line: mailrows send | smtp-check | resolve | init."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import smtplib
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from mailrows import __version__, run, smtpcheck
|
|
12
|
+
from mailrows.config import ConfigError, load, looks_unset
|
|
13
|
+
from mailrows.table import TableError
|
|
14
|
+
from mailrows.template import TemplateError
|
|
15
|
+
|
|
16
|
+
STARTER_FILES = {
|
|
17
|
+
"config.example.ini": "config.ini",
|
|
18
|
+
"examples/events.csv": "events.csv",
|
|
19
|
+
"examples/template.txt": "template.txt",
|
|
20
|
+
"examples/template.html": "template.html",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def starter_source() -> Path:
|
|
25
|
+
"""Example files: inside the installed package, or the repository root when run from source."""
|
|
26
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
27
|
+
packaged = pkg_dir / "_starter"
|
|
28
|
+
return packaged if packaged.is_dir() else pkg_dir.parents[1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_init(args) -> int:
|
|
32
|
+
dest = Path(args.folder)
|
|
33
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
src = starter_source()
|
|
35
|
+
made = []
|
|
36
|
+
for rel, name in STARTER_FILES.items():
|
|
37
|
+
target = dest / name
|
|
38
|
+
if target.exists():
|
|
39
|
+
print(f" kept {target} (already exists)")
|
|
40
|
+
continue
|
|
41
|
+
shutil.copyfile(src / rel, target)
|
|
42
|
+
made.append(target)
|
|
43
|
+
print(f" created {target}")
|
|
44
|
+
gitignore = dest / ".gitignore"
|
|
45
|
+
if not gitignore.exists():
|
|
46
|
+
gitignore.write_text("config.ini\n*.sqlite3\n*.sqlite3-journal\n", encoding="utf-8")
|
|
47
|
+
print(f" created {gitignore} (keeps config.ini and the send log out of git)")
|
|
48
|
+
print("\nNext: open config.ini, fill in the [smtp] and [sender] sections, then run")
|
|
49
|
+
print(f" mailrows smtp-check -c {dest / 'config.ini'}")
|
|
50
|
+
print(f" mailrows send --dry-run -c {dest / 'config.ini'}")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_send(args) -> int:
|
|
55
|
+
settings = load(args.config)
|
|
56
|
+
if args.dry_run:
|
|
57
|
+
mode = run.DRY
|
|
58
|
+
elif args.test_to:
|
|
59
|
+
mode = run.TEST
|
|
60
|
+
else:
|
|
61
|
+
mode = run.REAL
|
|
62
|
+
return run.send_command(settings, mode, test_to=args.test_to or "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_smtp_check(args) -> int:
|
|
66
|
+
host, user, password = args.host, args.username, None
|
|
67
|
+
if args.config and Path(args.config).exists():
|
|
68
|
+
s = load(args.config)
|
|
69
|
+
host = host or s.get("smtp", "host", "")
|
|
70
|
+
user = user if user is not None else s.get("smtp", "username", "")
|
|
71
|
+
password = s.get("smtp", "password", "")
|
|
72
|
+
if not host:
|
|
73
|
+
raise ConfigError("Give a host: --host smtp.example.com, or set [smtp] host in the config file.")
|
|
74
|
+
user = user or ""
|
|
75
|
+
if user and (password is None or looks_unset(password)):
|
|
76
|
+
import getpass
|
|
77
|
+
|
|
78
|
+
password = getpass.getpass(f"Password for {user} (not shown, not saved): ")
|
|
79
|
+
best = smtpcheck.check(host, user, password or "", timeout=args.timeout)
|
|
80
|
+
return 0 if best else 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_resolve(args) -> int:
|
|
84
|
+
return run.resolve_command(load(args.config), args.row_id, was_sent=args.sent)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
p = argparse.ArgumentParser(prog="mailrows",
|
|
89
|
+
description="Mail merge from Excel or CSV: one templated email per row, safely.")
|
|
90
|
+
p.add_argument("--version", action="version", version=f"mailrows {__version__}")
|
|
91
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="command")
|
|
92
|
+
|
|
93
|
+
def with_config(sp):
|
|
94
|
+
sp.add_argument("-c", "--config", default="config.ini", help="settings file (default: config.ini)")
|
|
95
|
+
|
|
96
|
+
s = sub.add_parser("send", help="check every row, show a preview, then send after you type yes")
|
|
97
|
+
with_config(s)
|
|
98
|
+
mode = s.add_mutually_exclusive_group()
|
|
99
|
+
mode.add_argument("--dry-run", action="store_true", help="render and check everything, send and change nothing")
|
|
100
|
+
mode.add_argument("--test-to", metavar="ADDRESS",
|
|
101
|
+
help="send every email to ADDRESS with [TEST] in the subject; no files or logs change")
|
|
102
|
+
s.set_defaults(func=cmd_send)
|
|
103
|
+
|
|
104
|
+
c = sub.add_parser("smtp-check", help="find the port and security setting that work, without sending")
|
|
105
|
+
with_config(c)
|
|
106
|
+
c.add_argument("--host", help="mail server (default: [smtp] host from the config)")
|
|
107
|
+
c.add_argument("--username", help="login to test (default: [smtp] username)")
|
|
108
|
+
c.add_argument("--timeout", type=float, default=10, help="seconds to wait per attempt (default 10)")
|
|
109
|
+
c.set_defaults(func=cmd_smtp_check)
|
|
110
|
+
|
|
111
|
+
r = sub.add_parser("resolve", help="after checking your Sent folder, record whether an uncertain email went")
|
|
112
|
+
with_config(r)
|
|
113
|
+
r.add_argument("row_id")
|
|
114
|
+
which = r.add_mutually_exclusive_group(required=True)
|
|
115
|
+
which.add_argument("--sent", action="store_true", help="it arrived: never send it again")
|
|
116
|
+
which.add_argument("--not-sent", action="store_true", help="it did not go: send it on the next run")
|
|
117
|
+
r.set_defaults(func=cmd_resolve)
|
|
118
|
+
|
|
119
|
+
i = sub.add_parser("init", help="create a starter folder with a config, a sample sheet and templates")
|
|
120
|
+
i.add_argument("folder", nargs="?", default=".")
|
|
121
|
+
i.set_defaults(func=cmd_init)
|
|
122
|
+
return p
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None) -> int:
|
|
126
|
+
run.setup_stdout()
|
|
127
|
+
args = build_parser().parse_args(argv)
|
|
128
|
+
try:
|
|
129
|
+
return args.func(args)
|
|
130
|
+
except (ConfigError, run.SetupError, TableError, TemplateError, sqlite3.Error,
|
|
131
|
+
smtplib.SMTPException, OSError, KeyboardInterrupt) as exc:
|
|
132
|
+
message, code = explain_stop(exc)
|
|
133
|
+
print("\n" + message)
|
|
134
|
+
return code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def explain_stop(exc: BaseException) -> tuple[str, int]:
|
|
138
|
+
"""A readable line, and the exit code, for whatever ended the run early."""
|
|
139
|
+
if isinstance(exc, (ConfigError, run.SetupError)):
|
|
140
|
+
return f"Stopped before sending anything: {exc}", run.EXIT_SETUP
|
|
141
|
+
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
|
142
|
+
return ("The mail server refused the username or password. Nothing was sent. "
|
|
143
|
+
"Run 'mailrows smtp-check' to test the login."), run.EXIT_SETUP
|
|
144
|
+
if isinstance(exc, sqlite3.Error):
|
|
145
|
+
return (f"Stopped: the send log couldn't be used ({exc}). If another mailrows run is using the same "
|
|
146
|
+
"log, wait for it to finish. Nothing already sent will be sent again."), run.EXIT_SETUP
|
|
147
|
+
if isinstance(exc, (TableError, TemplateError)):
|
|
148
|
+
return (f"Stopped: {exc}. Every email already sent is in the send log and won't be sent again.",
|
|
149
|
+
run.EXIT_SETUP)
|
|
150
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
151
|
+
return "Stopped. Anything already sent is in the send log and won't be sent again.", 130
|
|
152
|
+
return (f"Stopped: {exc}. Every email already sent is in the send log and won't be sent again. "
|
|
153
|
+
"If this is a mail server problem, 'mailrows smtp-check' can help."), run.EXIT_SETUP
|
mailrows/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Settings: an INI file, with any value overridable by an environment variable.
|
|
2
|
+
|
|
3
|
+
Environment variables win over the file. The name is MAILROWS_<SECTION>_<KEY> in capitals,
|
|
4
|
+
with dots and dashes in the section name turned into underscores, for example
|
|
5
|
+
MAILROWS_SMTP_PASSWORD or MAILROWS_AIRTABLE_TOKEN.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import configparser
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""A setting is missing or wrong. The message says which and how to fix it."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PLACEHOLDERS = {"change_me", "changeme", "your_password", "your_token", "paste_here"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def env_name(section: str, key: str) -> str:
|
|
24
|
+
clean = section.replace(".", "_").replace("-", "_")
|
|
25
|
+
return f"MAILROWS_{clean}_{key}".upper()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def looks_unset(value: str) -> bool:
|
|
29
|
+
"""True for an empty value or an obvious placeholder. Only whole-value matches count, so a
|
|
30
|
+
real password that happens to contain '<' or 'your_' is accepted."""
|
|
31
|
+
v = value.strip()
|
|
32
|
+
if not v:
|
|
33
|
+
return True
|
|
34
|
+
if v.lower() in PLACEHOLDERS:
|
|
35
|
+
return True
|
|
36
|
+
return v.startswith("<") and v.endswith(">") # e.g. <paste your token here>
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Settings:
|
|
41
|
+
path: Path
|
|
42
|
+
parser: configparser.ConfigParser = field(repr=False)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def folder(self) -> Path:
|
|
46
|
+
return self.path.parent
|
|
47
|
+
|
|
48
|
+
def get(self, section: str, key: str, default: str | None = None) -> str:
|
|
49
|
+
from_env = os.environ.get(env_name(section, key))
|
|
50
|
+
if from_env is not None:
|
|
51
|
+
return from_env.strip()
|
|
52
|
+
if self.parser.has_option(section, key):
|
|
53
|
+
return self.parser.get(section, key).strip()
|
|
54
|
+
if default is None:
|
|
55
|
+
raise ConfigError(
|
|
56
|
+
f"Missing setting [{section}] {key} in {self.path.name} "
|
|
57
|
+
f"(or set the environment variable {env_name(section, key)})."
|
|
58
|
+
)
|
|
59
|
+
return default
|
|
60
|
+
|
|
61
|
+
def secret(self, section: str, key: str) -> str:
|
|
62
|
+
"""A password or token. Must be set, and not left as the example placeholder."""
|
|
63
|
+
value = self.get(section, key, "")
|
|
64
|
+
if looks_unset(value):
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
f"[{section}] {key} is not set. Put it in {self.path.name} (which must stay out of git) "
|
|
67
|
+
f"or set the environment variable {env_name(section, key)}."
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
def get_int(self, section: str, key: str, default: int) -> int:
|
|
72
|
+
raw = self.get(section, key, str(default))
|
|
73
|
+
try:
|
|
74
|
+
return int(raw)
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise ConfigError(f"[{section}] {key} must be a whole number, not '{raw}'.") from None
|
|
77
|
+
|
|
78
|
+
def get_float(self, section: str, key: str, default: float) -> float:
|
|
79
|
+
raw = self.get(section, key, str(default))
|
|
80
|
+
try:
|
|
81
|
+
value = float(raw)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise ConfigError(f"[{section}] {key} must be a number, not '{raw}'.") from None
|
|
84
|
+
if value < 0:
|
|
85
|
+
raise ConfigError(f"[{section}] {key} can't be negative.")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
def get_list(self, section: str, key: str) -> list[str]:
|
|
89
|
+
raw = self.get(section, key, "")
|
|
90
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
91
|
+
|
|
92
|
+
def items(self, section: str) -> dict[str, str]:
|
|
93
|
+
"""Every key in a section, in file order, keeping the key's original capitals."""
|
|
94
|
+
if not self.parser.has_section(section):
|
|
95
|
+
return {}
|
|
96
|
+
return {k: self.get(section, k) for k in self.parser.options(section)}
|
|
97
|
+
|
|
98
|
+
def resolve(self, value: str) -> Path:
|
|
99
|
+
"""A path from the config, relative to the config file's folder."""
|
|
100
|
+
p = Path(value).expanduser()
|
|
101
|
+
return p if p.is_absolute() else (self.folder / p)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load(path: str | Path) -> Settings:
|
|
105
|
+
path = Path(path)
|
|
106
|
+
if not path.exists():
|
|
107
|
+
raise ConfigError(
|
|
108
|
+
f"Config file {path} not found. Copy config.example.ini to config.ini and fill it in, "
|
|
109
|
+
"or run 'mailrows init' to create a starter folder."
|
|
110
|
+
)
|
|
111
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
112
|
+
parser.optionxform = str # keep field names like "Last emailed" exactly as written
|
|
113
|
+
try:
|
|
114
|
+
parser.read(path, encoding="utf-8-sig")
|
|
115
|
+
except (configparser.Error, UnicodeDecodeError) as exc:
|
|
116
|
+
first_line = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
|
|
117
|
+
raise ConfigError(f"{path.name} can't be read: {first_line}") from None
|
|
118
|
+
return Settings(path=path.resolve(), parser=parser)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Post-send hooks: something to do for each row after its email has gone.
|
|
2
|
+
|
|
3
|
+
A hook has two steps:
|
|
4
|
+
|
|
5
|
+
* prepare(rows) runs before ANY email is sent. It checks that the hook will be able to do
|
|
6
|
+
its job for each row (for Airtable: that the matching record exists) and returns the
|
|
7
|
+
rows it can't handle, which are then skipped. Raising HookError stops the whole run.
|
|
8
|
+
* run(row) runs after that row's email was sent. If it raises, the row is marked
|
|
9
|
+
"sent, update pending" and a later run retries only the hook, never the email.
|
|
10
|
+
|
|
11
|
+
To add a target, subclass Hook, give it a unique name, and register it in HOOK_TYPES.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from mailrows.config import ConfigError, Settings
|
|
19
|
+
from mailrows.template import fill, placeholders
|
|
20
|
+
|
|
21
|
+
EXTRA_VALUES = ("_row_id", "_recipient", "_subject", "_sent_at", "_sent_date")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HookError(Exception):
|
|
25
|
+
"""The hook can't run at all (bad token, unknown table...). Nothing will be sent."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class HookRow:
|
|
30
|
+
row_id: str
|
|
31
|
+
values: dict[str, str]
|
|
32
|
+
recipient: str
|
|
33
|
+
subject: str
|
|
34
|
+
sent_at: str = ""
|
|
35
|
+
scratch: dict = field(default_factory=dict) # whatever prepare() wants to hand to run()
|
|
36
|
+
|
|
37
|
+
def merged(self) -> dict[str, str]:
|
|
38
|
+
extra = {
|
|
39
|
+
"_row_id": self.row_id,
|
|
40
|
+
"_recipient": self.recipient,
|
|
41
|
+
"_subject": self.subject,
|
|
42
|
+
"_sent_at": self.sent_at,
|
|
43
|
+
"_sent_date": self.sent_at[:10],
|
|
44
|
+
}
|
|
45
|
+
return {**extra, **self.values}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Hook:
|
|
49
|
+
name = "hook"
|
|
50
|
+
|
|
51
|
+
def __init__(self, settings: Settings):
|
|
52
|
+
self.settings = settings
|
|
53
|
+
|
|
54
|
+
def check_columns(self, columns: list[str]) -> None:
|
|
55
|
+
"""Raise ConfigError if a configured value uses an unknown {column}."""
|
|
56
|
+
|
|
57
|
+
def prepare(self, rows: list[HookRow]) -> dict[str, str]:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
def run(self, row: HookRow) -> None:
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def describe(self) -> str:
|
|
64
|
+
return self.name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_value_placeholders(where: str, values: dict[str, str], columns: list[str]) -> None:
|
|
68
|
+
known = set(columns) | set(EXTRA_VALUES)
|
|
69
|
+
for key, value in values.items():
|
|
70
|
+
for name in placeholders(value, f"{where} {key}"):
|
|
71
|
+
if name not in known:
|
|
72
|
+
raise ConfigError(
|
|
73
|
+
f"{where} '{key}' uses {{{name}}}, which is not a spreadsheet column. "
|
|
74
|
+
f"Built-in values are {', '.join('{' + v + '}' for v in EXTRA_VALUES)}."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fill_values(values: dict[str, str], row: HookRow) -> dict[str, str]:
|
|
79
|
+
merged = row.merged()
|
|
80
|
+
return {k: fill(v, merged) for k, v in values.items()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_hooks(settings: Settings) -> list[Hook]:
|
|
84
|
+
from mailrows.hooks.airtable import UpdateAirtable
|
|
85
|
+
from mailrows.hooks.csv_append import CsvAppender
|
|
86
|
+
from mailrows.hooks.webhook import PostWebhook
|
|
87
|
+
|
|
88
|
+
types = {h.name: h for h in (UpdateAirtable, PostWebhook, CsvAppender)}
|
|
89
|
+
out = []
|
|
90
|
+
for name in settings.get_list("hooks", "enabled"):
|
|
91
|
+
if name not in types:
|
|
92
|
+
raise ConfigError(f"[hooks] enabled lists '{name}'. Known hooks: {', '.join(types)}.")
|
|
93
|
+
out.append(types[name](settings))
|
|
94
|
+
return out
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Update one Airtable record per sent email.
|
|
2
|
+
|
|
3
|
+
Config:
|
|
4
|
+
|
|
5
|
+
[airtable]
|
|
6
|
+
base_id = appXXXXXXXXXXXXXX ; from the base's API page
|
|
7
|
+
table = Attendees ; table name or ID
|
|
8
|
+
match_field = Ticket ID ; Airtable field to look the record up by
|
|
9
|
+
match_column = id ; spreadsheet column holding that value
|
|
10
|
+
token = ... ; or MAILROWS_AIRTABLE_TOKEN
|
|
11
|
+
|
|
12
|
+
[airtable.fields]
|
|
13
|
+
; Airtable field = value. {column} takes the value from the spreadsheet row.
|
|
14
|
+
Status = Invited
|
|
15
|
+
Invite sent = {_sent_date}
|
|
16
|
+
|
|
17
|
+
Every matching record is found before any email is sent. Calls are kept under 5 per second
|
|
18
|
+
and retried with backoff when Airtable is busy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
from mailrows.config import ConfigError
|
|
26
|
+
from mailrows.hooks import Hook, HookError, HookRow, check_value_placeholders, fill_values
|
|
27
|
+
from mailrows.hooks.http import HttpError, JsonClient
|
|
28
|
+
from mailrows.table import as_text
|
|
29
|
+
|
|
30
|
+
LOOKUP_BATCH = 25 # values per lookup request: keeps the URL short
|
|
31
|
+
|
|
32
|
+
HINTS = {
|
|
33
|
+
401: "the token was not accepted. Create a new one at airtable.com/create/tokens",
|
|
34
|
+
403: "the token can't use this base or table. Give it the data.records:read and "
|
|
35
|
+
"data.records:write scopes and access to this base",
|
|
36
|
+
404: "the base or table was not found. Check base_id and table under [airtable]",
|
|
37
|
+
422: "Airtable turned the request down. Check match_field and the names under [airtable.fields]",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def quote_formula_string(value: str) -> str:
|
|
42
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UpdateAirtable(Hook):
|
|
46
|
+
name = "airtable"
|
|
47
|
+
|
|
48
|
+
def __init__(self, settings, sleep=None):
|
|
49
|
+
super().__init__(settings)
|
|
50
|
+
s = settings
|
|
51
|
+
self.base = s.get("airtable", "base_id")
|
|
52
|
+
self.table = s.get("airtable", "table")
|
|
53
|
+
self.match_field = s.get("airtable", "match_field")
|
|
54
|
+
self.match_column = s.get("airtable", "match_column")
|
|
55
|
+
if any(ch in self.match_field for ch in "{}"):
|
|
56
|
+
raise ConfigError("[airtable] match_field can't contain { or }.")
|
|
57
|
+
self.fields = s.items("airtable.fields")
|
|
58
|
+
if not self.fields:
|
|
59
|
+
raise ConfigError("[airtable.fields] is empty: list at least one 'Field name = value'.")
|
|
60
|
+
self.api = s.get("airtable", "api_url", "https://api.airtable.com/v0").rstrip("/")
|
|
61
|
+
token = s.secret("airtable", "token")
|
|
62
|
+
extra = {"sleep": sleep} if sleep else {}
|
|
63
|
+
self.client = JsonClient({"Authorization": f"Bearer {token}"}, per_second=4.5,
|
|
64
|
+
retries=s.get_int("airtable", "retries", 5),
|
|
65
|
+
backoff=s.get_float("airtable", "backoff_seconds", 1.0), **extra)
|
|
66
|
+
|
|
67
|
+
def describe(self):
|
|
68
|
+
names = ", ".join(self.fields)
|
|
69
|
+
return f"airtable: set {names} on the record whose '{self.match_field}' matches '{self.match_column}'"
|
|
70
|
+
|
|
71
|
+
def _url(self, record_id: str = "") -> str:
|
|
72
|
+
path = f"{self.api}/{urllib.parse.quote(self.base, safe='')}/{urllib.parse.quote(self.table, safe='')}"
|
|
73
|
+
return f"{path}/{urllib.parse.quote(record_id, safe='')}" if record_id else path
|
|
74
|
+
|
|
75
|
+
def _request(self, method: str, url: str, payload=None):
|
|
76
|
+
try:
|
|
77
|
+
return self.client.call(method, url, payload)
|
|
78
|
+
except HttpError as exc:
|
|
79
|
+
hint = HINTS.get(exc.status or 0)
|
|
80
|
+
detail = hint or (exc.body[:200] if exc.body else str(exc))
|
|
81
|
+
raise HookError(f"Airtable: {exc} - {detail}") from None
|
|
82
|
+
|
|
83
|
+
def check_columns(self, columns):
|
|
84
|
+
if self.match_column not in columns:
|
|
85
|
+
raise ConfigError(f"[airtable] match_column '{self.match_column}' is not a spreadsheet column.")
|
|
86
|
+
check_value_placeholders("[airtable.fields]", self.fields, columns)
|
|
87
|
+
|
|
88
|
+
def prepare(self, rows: list[HookRow]) -> dict[str, str]:
|
|
89
|
+
wanted: dict[str, list[HookRow]] = {}
|
|
90
|
+
problems: dict[str, str] = {}
|
|
91
|
+
for row in rows:
|
|
92
|
+
value = row.values.get(self.match_column, "").strip()
|
|
93
|
+
if not value:
|
|
94
|
+
problems[row.row_id] = f"'{self.match_column}' is empty, so no Airtable record can be matched"
|
|
95
|
+
else:
|
|
96
|
+
wanted.setdefault(value, []).append(row)
|
|
97
|
+
found: dict[str, list[str]] = {v: [] for v in wanted}
|
|
98
|
+
values = list(wanted)
|
|
99
|
+
for i in range(0, len(values), LOOKUP_BATCH):
|
|
100
|
+
chunk = values[i:i + LOOKUP_BATCH]
|
|
101
|
+
# &'' turns numbers into text, so 101 and "101" both match
|
|
102
|
+
terms = [f"{{{self.match_field}}}&''={quote_formula_string(v)}" for v in chunk]
|
|
103
|
+
formula = terms[0] if len(terms) == 1 else "OR(" + ",".join(terms) + ")"
|
|
104
|
+
offset = None
|
|
105
|
+
while True:
|
|
106
|
+
params = [("filterByFormula", formula), ("fields[]", self.match_field), ("pageSize", "100")]
|
|
107
|
+
if offset:
|
|
108
|
+
params.append(("offset", offset))
|
|
109
|
+
page = self._request("GET", self._url() + "?" + urllib.parse.urlencode(params))
|
|
110
|
+
for record in page.get("records") or []:
|
|
111
|
+
got = (record.get("fields") or {}).get(self.match_field)
|
|
112
|
+
if isinstance(got, list):
|
|
113
|
+
got = got[0] if got else None
|
|
114
|
+
key = as_text(got)
|
|
115
|
+
if key in found:
|
|
116
|
+
found[key].append(record["id"])
|
|
117
|
+
offset = page.get("offset") or None
|
|
118
|
+
if not offset:
|
|
119
|
+
break
|
|
120
|
+
for value, matched in found.items():
|
|
121
|
+
for row in wanted[value]:
|
|
122
|
+
if not matched:
|
|
123
|
+
problems[row.row_id] = f"no Airtable record has {self.match_field} = {value}"
|
|
124
|
+
elif len(matched) > 1:
|
|
125
|
+
problems[row.row_id] = f"{len(matched)} Airtable records have {self.match_field} = {value}"
|
|
126
|
+
else:
|
|
127
|
+
row.scratch["airtable_id"] = matched[0]
|
|
128
|
+
return problems
|
|
129
|
+
|
|
130
|
+
def run(self, row: HookRow) -> None:
|
|
131
|
+
record_id = row.scratch.get("airtable_id")
|
|
132
|
+
if not record_id:
|
|
133
|
+
raise HookError("the Airtable record was not looked up first")
|
|
134
|
+
self._request("PATCH", self._url(record_id), {"fields": fill_values(self.fields, row), "typecast": True})
|