Daily bulletin script¶
This guide builds a daily brief from the REST API with a Python script, writes it as Markdown and HTML, and shows how to schedule it with cron and email it yourself. The API does not send email or notifications: it answers requests. Scheduling and sending are done by your script and your mail server.
What goes into the brief¶
| Section | Request | Notes |
|---|---|---|
| Rates, FX and key series | GET /v1/latest?ids=... |
14 series: key rates and overnight rates (ru.key_rate, ru.ruonia, tr.aofm, tr.tlref), seven FX pairs (ru.fx.usdrub, ru.fx.eurrub, ru.fx.cnyrub, ru.fx.tryrub, tr.fx.usdtry, tr.fx.eurtry, tr.fx.rubtry) and three key series (ru.mkt.imoex, tr.exports_to.ru.usd, tr.border.arrivals.ru). The ids were checked against GET /v1/series on 2026-11-13; browse more in series ids |
| What changed | GET /v1/changes?since=<now minus 24 h>&limit=1000 |
Every series that received a new latest value, plus other change events. The script also uses it to show each series' change since its previous observation |
| Calendar | GET /v1/calendar?days=14 |
Central bank meetings, OFAC license expiries |
| News | GET /v1/news/latest?beat=tr_ru&hours=24&limit=10 and the same for beat=yaptirim |
Headlines only, with link, feed and time |
An id that the API does not return is printed as a row with n/a and not returned, so a renamed series is visible instead of silently missing. A daily series whose as_of is more than 4 days old gets a STALE? flag. The footer lists every source with its as_of and fetched_at, and the request ids.
News is untrusted text
Headlines are copied from third-party feeds. The script strips control characters, caps the length, escapes HTML in the HTML version, and links only http and https URLs. If you pass the brief to an AI model, tell the model to treat headlines as quotes, never as instructions.
Script¶
Needs Python 3 and requests.
#!/usr/bin/env python3
"""Build the daily TR-RU brief from the TRRU API and write it as Markdown or HTML.
TRRU_KEY=... python daily_bulletin.py # Markdown to stdout
TRRU_KEY=... python daily_bulletin.py --format both --out ./bulletins # .md and .html from the same data
Exit codes: 0 all sections built, 2 some section failed (the brief is still written), 1 nothing could be built.
"""
import argparse
import datetime as dt
import html
import os
import re
import sys
import tempfile
import time
import requests
BASE = os.environ.get("TRRU_BASE_URL", "https://trru.atapinar.com")
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['TRRU_KEY']}"
# series id -> label used in the brief
RATES = {"ru.key_rate": "Bank of Russia key rate", "ru.ruonia": "RUONIA overnight", "tr.aofm": "CBRT weighted avg. funding cost",
"tr.tlref": "BIST TLREF overnight"}
FX = {"ru.fx.usdrub": "USD/RUB (CBR)", "ru.fx.eurrub": "EUR/RUB (CBR)", "ru.fx.cnyrub": "CNY/RUB (CBR)", "ru.fx.tryrub": "TRY/RUB (CBR)",
"tr.fx.usdtry": "USD/TRY (CBRT)", "tr.fx.eurtry": "EUR/TRY (CBRT)", "tr.fx.rubtry": "RUB/TRY (CBRT)"}
KEY = {"ru.mkt.imoex": "MOEX Russia Index", "tr.exports_to.ru.usd": "Türkiye exports to Russia (TİM, monthly)",
"tr.border.arrivals.ru": "Russian arrivals to Türkiye (monthly)"}
NEWS_BEATS = ("tr_ru", "yaptirim")
class ApiError(RuntimeError):
pass
def get(path, **params):
"""GET with retry: 429/502/503/504 (honours Retry-After) and network errors, up to 4 attempts. Returns (data, meta)."""
last = None
for attempt in range(4):
try:
r = SESSION.get(BASE + path, params=params, timeout=45)
except requests.RequestException as e:
last = f"network error: {type(e).__name__}"
time.sleep(2 ** attempt)
continue
if r.status_code in (429, 502, 503, 504):
last = f"HTTP {r.status_code}"
time.sleep(min(int(r.headers.get("Retry-After", 2 ** attempt)), 30))
continue
body = r.json()
if r.status_code != 200:
e = body.get("error", {})
raise ApiError(f"{path}: HTTP {r.status_code} {e.get('code')}: {e.get('message')} (request id {r.headers.get('X-Request-Id')})")
return body["data"], body["meta"]
raise ApiError(f"{path}: gave up after 4 attempts ({last})")
# ---- small document model: blocks are ("h2", text) | ("p", text) | ("ul", [text]) | ("table", header, rows) | ("link-list", [(text, url, note)])
def clean(text, limit=200):
"""External text: drop control characters, collapse whitespace, cap length."""
t = re.sub(r"[\x00-\x1f\x7f]+", " ", str(text or ""))
t = re.sub(r"\s+", " ", t).strip()
return t if len(t) <= limit else t[: limit - 1] + "…"
def safe_url(u):
return u if isinstance(u, str) and re.match(r"https?://", u) else ""
def fmt(v):
if v is None:
return "n/a"
return f"{v:,.4f}".rstrip("0").rstrip(".") if abs(v) < 1000 else f"{v:,.0f}"
def section_markets(sources, deltas):
ids = list(RATES) + list(FX) + list(KEY)
data, meta = get("/v1/latest", ids=",".join(ids))
sources.append(meta)
by_id = {r["series_id"]: r for r in data["rows"]}
today = dt.datetime.now(dt.timezone.utc).date()
blocks = []
for title, group in (("Policy and overnight rates", RATES), ("Exchange rates", FX), ("Key series", KEY)):
rows = []
for sid, label in group.items():
r = by_id.get(sid)
if r is None: # unknown or no data: say so instead of dropping the row
rows.append([label, sid, "n/a", "", "not returned"])
continue
d = deltas.get(sid)
change = f"{fmt(d['new'] - d['old'])} (prev. {d['old_date']})" if d and d["old"] is not None and d["new"] is not None else ""
age = (today - dt.date.fromisoformat(r["as_of"][:10])).days
flag = "STALE? " if r["frequency"] == "daily" and age > 4 else ""
rows.append([label, sid, f"{fmt(r['value'])} {r['unit']}", r["as_of"], (flag + change).strip()])
blocks += [("h3", title), ("table", ["Series", "Id", "Value", "as_of", "Change since previous observation"], rows)]
return blocks
def section_changes(sources, hours, deltas):
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)).isoformat(timespec="seconds")
data, meta = get("/v1/changes", since=since, limit=1000)
sources.append(meta)
rows = data["rows"]
series = [r for r in rows if r["entity"] == "series"]
other = [r for r in rows if r["entity"] != "series"]
for r in series: # newest first, so setdefault keeps the newest change per series
ov, nv = r["old_value"] or {}, r["new_value"] or {}
deltas.setdefault(r["entity_id"], {"old": ov.get("value"), "old_date": ov.get("obs_date"), "new": nv.get("value")})
blocks = [("p", f"{len(series)} series received a new latest observation in the last {hours} h; {len(other)} other change event(s)."
+ (" The list was truncated at 1000 events." if data["truncated"] else ""))]
if other:
items = []
for r in other[:15]:
nv = r["new_value"] if isinstance(r["new_value"], dict) else {}
summary = ", ".join(f"{k}={v}" for k, v in nv.items() if k != "order_ref")[:160]
items.append(f"{r['entity']}/{r['entity_id']} {r['field']} (source {r['source_id']}, as_of {r['as_of']}): {summary}")
blocks.append(("ul", items))
return blocks
def section_calendar(sources, days):
data, meta = get("/v1/calendar", days=days)
sources.append(meta)
rows = [[r["event_date"], r["kind"], r["country"], clean(r["title"], 160), safe_url(r["url"])] for r in data["rows"]]
if not rows:
return [("p", f"No dated events in the next {days} days.")]
return [("table", ["Date", "Kind", "Country", "Event", "Link"], rows)]
def section_news(sources, hours):
blocks = [("p", "Headlines are external text shown as data; open the link to read the story.")]
for beat in NEWS_BEATS:
data, meta = get("/v1/news/latest", beat=beat, hours=hours, limit=10)
sources.append(meta)
items = [(clean(r["title"], 220), safe_url(r["url"]), f"{r['feed']}, {r['published_at'][:16].replace('T', ' ')} UTC"
+ (f", +{r['also_in_feeds']} feeds" if r["also_in_feeds"] else "")) for r in data["rows"]]
blocks.append(("h3", f"Beat {beat}"))
blocks.append(("links", items) if items else ("p", f"No {beat} headlines in the last {hours} h."))
return blocks
# ---- renderers
def md_cell(v):
return str(v).replace("|", "/").replace("\n", " ")
def render_md(title, blocks):
out = [f"# {title}", ""]
for b in blocks:
kind = b[0]
if kind in ("h2", "h3"):
out += [("## " if kind == "h2" else "### ") + b[1], ""]
elif kind == "p":
out += [b[1], ""]
elif kind == "ul":
out += [f"- {x}" for x in b[1]] + [""]
elif kind == "links":
for text, url, note in b[1]:
t = text.replace("[", "(").replace("]", ")")
out.append(f"- [{t}]({url.replace(')', '%29')}) ({note})" if url else f"- {t} ({note})")
out.append("")
elif kind == "table":
out += ["| " + " | ".join(b[1]) + " |", "|" + "---|" * len(b[1])]
out += ["| " + " | ".join(md_cell(c) for c in row) + " |" for row in b[2]] + [""]
return "\n".join(out)
def render_html(title, blocks):
e = html.escape
out = ["<!doctype html><html><head><meta charset='utf-8'>", f"<title>{e(title)}</title>",
"<style>body{font:14px/1.45 Arial,sans-serif;max-width:860px;margin:auto;padding:12px}"
"table{border-collapse:collapse;width:100%;margin:6px 0 14px}th,td{border:1px solid #ccc;padding:4px 8px;text-align:left}"
"th{background:#f0f0f5}h1{font-size:20px}h2{font-size:17px;margin-top:22px}h3{font-size:15px}</style></head><body>",
f"<h1>{e(title)}</h1>"]
for b in blocks:
kind = b[0]
if kind in ("h2", "h3"):
out.append(f"<{kind}>{e(b[1])}</{kind}>")
elif kind == "p":
out.append(f"<p>{e(b[1])}</p>")
elif kind == "ul":
out.append("<ul>" + "".join(f"<li>{e(x)}</li>" for x in b[1]) + "</ul>")
elif kind == "links":
out.append("<ul>" + "".join(
(f"<li><a href='{e(url, quote=True)}'>{e(text)}</a> ({e(note)})</li>" if url else f"<li>{e(text)} ({e(note)})</li>")
for text, url, note in b[1]) + "</ul>")
elif kind == "table":
def cell(c):
c = str(c)
return f"<a href='{e(c, quote=True)}'>link</a>" if c.startswith(("http://", "https://")) else e(c)
out.append("<table><tr>" + "".join(f"<th>{e(h)}</th>" for h in b[1]) + "</tr>"
+ "".join("<tr>" + "".join(f"<td>{cell(c)}</td>" for c in row) + "</tr>" for row in b[2]) + "</table>")
out.append("</body></html>")
return "\n".join(out)
def write_atomic(path, text):
"""Write to a temp file in the same directory, then rename: a half-written brief is never visible."""
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--format", choices=["md", "html", "both"], default="md")
ap.add_argument("--out", default="", help="directory; writes bulletin-YYYY-MM-DD.<ext> (default: stdout)")
ap.add_argument("--hours", type=int, default=24, help="look-back window for changes and news")
ap.add_argument("--calendar-days", type=int, default=14)
a = ap.parse_args()
if a.format == "both" and not a.out:
ap.error("--format both needs --out")
now = dt.datetime.now(dt.timezone.utc)
sources, deltas, failures = [], {}, []
blocks = []
def run(title, fn):
try:
blocks.extend([("h2", title)] + fn())
except ApiError as e:
failures.append(title)
blocks.extend([("h2", title), ("p", f"Section unavailable: {e}")])
# changes first: its per-series deltas feed the markets table
changes_blocks = []
change_meta = [] # kept apart: the change feed names every source that had an update, which would swamp the Sources table
try:
changes_blocks = section_changes(change_meta, a.hours, deltas)
except ApiError as e:
failures.append("What changed")
changes_blocks = [("p", f"Section unavailable: {e}")]
run("Rates, FX and key series", lambda: section_markets(sources, deltas))
blocks += [("h2", f"What changed in the data (last {a.hours} h)")] + changes_blocks
run(f"Calendar: next {a.calendar_days} days", lambda: section_calendar(sources, a.calendar_days))
run(f"News: last {a.hours} h", lambda: section_news(sources, a.hours))
if len(failures) == 4:
sys.exit("error: every section failed; nothing written")
merged = {}
for meta in sources:
for s in meta["sources"]:
cur = merged.get(s["source_id"])
if cur is None or (s["as_of"] or "") > (cur["as_of"] or ""):
merged[s["source_id"]] = s
blocks += [("h2", "Sources"), ("table", ["source_id", "as_of", "fetched_at"],
[[s["source_id"], s["as_of"] or "n/a", s["fetched_at"] or "n/a"] for s in sorted(merged.values(), key=lambda s: s["source_id"])]),
("p", "Request ids: " + ", ".join(m["request_id"] for m in change_meta + sources))]
if failures:
blocks.insert(0, ("p", "Incomplete: " + ", ".join(failures) + " could not be built."))
title = f"TR-RU daily brief {now:%Y-%m-%d}"
renderers = {"md": render_md, "html": render_html}
formats = ["md", "html"] if a.format == "both" else [a.format]
if a.out:
os.makedirs(a.out, exist_ok=True)
for fmt_name in formats:
path = os.path.join(a.out, f"bulletin-{now:%Y-%m-%d}.{fmt_name}")
write_atomic(path, renderers[fmt_name](title, blocks))
print(path)
else:
print(renderers[formats[0]](title, blocks))
return 2 if failures else 0
if __name__ == "__main__":
sys.exit(main())
export TRRU_KEY=trru_YOUR_KEY
python daily_bulletin.py # Markdown to stdout
python daily_bulletin.py --format both --out ./bulletins # bulletin-YYYY-MM-DD.md and .html
python daily_bulletin.py --hours 48 --calendar-days 30
Exit codes: 0 everything built, 2 the brief was written but at least one section failed (the section says Section unavailable and why), 1 every section failed and nothing was written.
Output¶
Markdown output of a real run on 2026-11-13, shortened to the first rows of each table and three headlines (the real brief has 10 per beat, all the series, and a longer Sources table):
# TR-RU daily brief 2026-11-13
## Rates, FX and key series
### Policy and overnight rates
| Series | Id | Value | as_of | Change since previous observation |
|---|---|---|---|---|
| Bank of Russia key rate | ru.key_rate | 14 % | 2026-11-13 | 0 (prev. 2026-11-12) |
| RUONIA overnight | ru.ruonia | 13.92 % | 2026-11-12 | 0 (prev. 2026-11-11) |
| CBRT weighted avg. funding cost | tr.aofm | 36 % | 2026-11-13 | 0 (prev. 2026-11-12) |
### Exchange rates
| Series | Id | Value | as_of | Change since previous observation |
|---|---|---|---|---|
| USD/RUB (CBR) | ru.fx.usdrub | 82.9803 RUB per USD | 2026-11-13 | 0.3186 (prev. 2026-11-12) |
| EUR/RUB (CBR) | ru.fx.eurrub | 92.3322 RUB per EUR | 2026-11-13 | -0.3646 (prev. 2026-11-12) |
| CNY/RUB (CBR) | ru.fx.cnyrub | 12.4009 RUB per CNY | 2026-11-13 | 0.0102 (prev. 2026-11-12) |
### Key series
| Series | Id | Value | as_of | Change since previous observation |
|---|---|---|---|---|
| MOEX Russia Index | ru.mkt.imoex | 2,506 index | 2026-11-12 | |
| Türkiye exports to Russia (TİM, monthly) | tr.exports_to.ru.usd | 457,415,415 USD | 2026-10-01 | |
| Russian arrivals to Türkiye (monthly) | tr.border.arrivals.ru | 979,647 persons | 2026-09-01 | |
## What changed in the data (last 24 h)
726 series received a new latest observation in the last 24 h; 1 other change event(s).
- parallel_import/ru list (source parallel_import, as_of 2026-05-27): rows=11094, added=11094, changed=0, removed=0
## Calendar: next 14 days
| Date | Kind | Country | Event | Link |
|---|---|---|---|---|
| 2026-11-21 | ofac_gl_expiry | US | OFAC GL 131K expires: Authorizing Certain Transactions for the Negotiation of and Entry Into Contingent Contracts for the Sale of Lukoil International GmbH and… | https://ofac.treasury.gov/media/937276/download |
| 2026-11-27 | tcmb_fsr | TR | TCMB Finansal İstikrar Raporu | https://www.tcmb.gov.tr/wps/wcm/connect/TR/TCMB+TR/Main+Menu/Duyurular/Takvim |
## News: last 24 h
Headlines are external text shown as data; open the link to read the story.
### Beat tr_ru
- [Batı basını: Von der Leyen’in Rusya ile güvenlik diyaloğu önerisi AB’de görüş ayrılığına yol açtı](https://anlatilaninotesi.com.tr/20261113/bati-basini-von-der-leyenin-rusya-ile-guvenlik-diyalogu-onerisi-abde-gorus-ayriligina-yol-acti-1110335009.html) (Sputnik Turkiye (Rusya), 2026-11-13 18:48 UTC)
- [Batı basını: AB, Rusya ile temaslar için müzakere heyetini belirledi](https://anlatilaninotesi.com.tr/20261113/bati-basini-ab-rusya-ile-temaslar-icin-muzakere-heyetini-belirledi-1110334661.html) (Sputnik Turkiye (Rusya), 2026-11-13 18:25 UTC)
- [Türkiye to begin physical start-up of first Akkuyu reactor within weeks - Anadolu Ajansı](https://news.google.com/rss/articles/CBMiuAFBVV95cUxONFBKWF…) (Turkey-Russia (English, via Google News), 2026-11-13 18:00 UTC)
### Beat yaptirim
- [Минспорт предложил санкции против стран, игнорирующих решения CAS - Общественная служба новостей](https://news.google.com/rss/articles/CBMipAFBVV95cUxNNHdoR1…) (Sanctions against Russia (via Google News), 2026-11-13 19:25 UTC)
- [В МИД России исключили снятие западных санкций в ближайшие шесть лет - prospect.com.ru](https://news.google.com/rss/articles/CBMitgFBVV95cUxOcEJjNG…) (Sanctions against Russia (via Google News), 2026-11-13 18:51 UTC)
- [BW LPG Limited Announces Sale of BW Mistral - Hellenic Shipping News](https://news.google.com/rss/articles/CBMiiAFBVV95cUxQUVRRNz…) (Russian oil shipping (Hellenic Shipping News, Splash247 via Google News), 2026-11-13 18:02 UTC)
## Sources
| source_id | as_of | fetched_at |
|---|---|---|
| cbr | 2026-11-13 | 2026-11-13T17:30:30.332163+00:00 |
| evds | 2026-11-13 | 2026-11-13T17:30:29.379365+00:00 |
| ktb_border | 2026-09-01 | 2026-11-13T09:50:04.503063+00:00 |
Request ids: ce291855b2f0492d8679e465b409e15e, 1bb34d59d5ef494096882dc1cd41fd89, 5540d4cf69b24d24840206e3d2ffd6eb, baa9631bfdbc4116adeea4d4ad73e014, 06dbff9acbfc4a0f8fcf694a97ac930f
Schedule it with cron¶
Create a directory with the two scripts, a private environment file and a small wrapper:
export TRRU_KEY=trru_YOUR_KEY
export SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_SECURITY=starttls
export SMTP_USER=[email protected] SMTP_PASSWORD=your-smtp-password
export MAIL_FROM=[email protected] MAIL_TO=[email protected],[email protected]
#!/bin/sh
# Daily bulletin: build, then send once. Called by cron.
set -eu
cd "$(dirname "$0")"
. ./env # exports TRRU_KEY and the SMTP_* / MAIL_* variables
python3 daily_bulletin.py --format both --out bulletins || [ $? -eq 2 ] # exit 2 = brief written but a section failed
python3 send_bulletin.py bulletins
# minute hour day month weekday (the time zone of the cron daemon, often UTC on servers)
30 6 * * 1-5 /home/you/trru-bulletin/run.sh >> /home/you/trru-bulletin/run.log 2>&1
Cron starts with a minimal environment, which is why run.sh loads env itself and changes into its own directory. Choose a time after your sources have refreshed: GET /v1/sources/freshness shows last_ok_at for each source, and the brief itself shows as_of and STALE? flags if you run too early. The wrapper was run end to end with a minimal environment (env -i) against the live API and a local SMTP server.
Send it by email¶
send_bulletin.py uses only the standard library (smtplib and email) and works with any SMTP server. It sends a multipart/alternative message: the Markdown as the plain-text part and the HTML as the HTML part.
#!/usr/bin/env python3
"""Email today's bulletin files (bulletin-YYYY-MM-DD.md and .html) through any SMTP server.
Settings come from environment variables (never from the command line):
SMTP_HOST (required) SMTP_PORT (default 587) SMTP_SECURITY = starttls | ssl | none (default starttls)
SMTP_USER, SMTP_PASSWORD (optional pair) MAIL_FROM, MAIL_TO (comma-separated)
python send_bulletin.py ./bulletins # sends today's (UTC) brief once
Exit codes: 0 sent or already sent, 1 files missing, 2 SMTP failure after retries.
"""
import datetime as dt
import os
import smtplib
import ssl
import sys
import time
from email.message import EmailMessage
def send(msg):
host, port = os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587"))
security = os.environ.get("SMTP_SECURITY", "starttls").lower()
ctx = ssl.create_default_context()
smtp = smtplib.SMTP_SSL(host, port, timeout=30, context=ctx) if security == "ssl" else smtplib.SMTP(host, port, timeout=30)
with smtp:
if security == "starttls":
smtp.starttls(context=ctx)
if os.environ.get("SMTP_USER"):
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
smtp.send_message(msg)
def main():
directory = sys.argv[1] if len(sys.argv) > 1 else "."
day = sys.argv[2] if len(sys.argv) > 2 else dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
stem = os.path.join(directory, f"bulletin-{day}")
marker = stem + ".sent"
if os.path.exists(marker): # idempotency: a second cron run or a manual re-run never sends twice
print(f"already sent ({marker})")
return 0
try:
text = open(stem + ".md", encoding="utf-8").read()
html_body = open(stem + ".html", encoding="utf-8").read()
except FileNotFoundError as e:
print(f"missing file: {e.filename}", file=sys.stderr)
return 1
msg = EmailMessage()
msg["Subject"] = f"TR-RU daily brief {day}"
msg["From"] = os.environ["MAIL_FROM"]
msg["To"] = os.environ["MAIL_TO"]
msg.set_content(text) # plain-text part (the Markdown)
msg.add_alternative(html_body, subtype="html") # HTML part
for attempt in range(1, 4):
try:
send(msg)
break
except (smtplib.SMTPException, OSError) as e:
print(f"attempt {attempt} failed: {type(e).__name__}: {e}", file=sys.stderr)
if attempt == 3:
return 2
time.sleep(10 * attempt)
with open(marker, "w") as f: # written only after the server accepted the message
f.write(dt.datetime.now(dt.timezone.utc).isoformat() + "\n")
print(f"sent to {msg['To']}")
return 0
if __name__ == "__main__":
sys.exit(main())
| Variable | Meaning |
|---|---|
SMTP_HOST |
SMTP server (required) |
SMTP_PORT |
Default 587 (use 465 with ssl) |
SMTP_SECURITY |
starttls (default), ssl or none (only for a local test server) |
SMTP_USER, SMTP_PASSWORD |
Optional login pair |
MAIL_FROM, MAIL_TO |
Sender and a comma-separated list of recipients |
Test the whole path without sending real mail by starting a local debugging server in one terminal (python3 -m smtpd -n -c DebuggingServer 127.0.0.1:1025, available up to Python 3.11) and setting SMTP_HOST=127.0.0.1 SMTP_PORT=1025 SMTP_SECURITY=none. The STARTTLS and login branches use only the standard smtplib calls and were not exercised against a real provider here.
Idempotency and errors¶
| Concern | What the scripts do |
|---|---|
| Running twice on the same day | The brief is rebuilt into the same file name (bulletin-YYYY-MM-DD.*, written to a temporary file and renamed, so a reader never sees half a file). The email is sent once: send_bulletin.py writes bulletin-YYYY-MM-DD.sent only after the SMTP server accepted the message and exits early when it exists. Delete the marker to resend |
429 rate_limited |
The client waits for the Retry-After seconds (capped at 30), then retries, up to 4 attempts |
504 timeout, 502, 503 |
Retried with a growing pause (1, 2, 4 s) |
| Network errors | Same retry. After the last attempt the section is reported as unavailable |
400, 401, 403, 404 |
Not retried. The message includes the API's error text and request id |
| One section fails | The others are still built; the brief says which one failed, and the script exits with 2 so cron mail or your monitor notices |
| SMTP failure | Three attempts with a growing pause, then exit code 2 and no marker, so the next run tries again |
Because failures exit non-zero, run.sh treats exit code 2 of the builder as a partial brief and still sends it. Remove the || [ $? -eq 2 ] part if you prefer to send nothing when a section failed.
Keep env out of version control and readable only by you. Keys are shown once and cannot be recovered, only replaced.