This commit is contained in:
2026-05-19 23:36:58 +12:00
parent 5172588488
commit a7f8a619b1
68 changed files with 4486 additions and 1430 deletions
+13 -3
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.0
ARG APP_VERSION=4.0.1
FROM python:3.12-slim
ARG APP_VERSION
@@ -10,10 +10,20 @@ LABEL org.opencontainers.image.version="${APP_VERSION}"
COPY requirements.txt .
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& apt-get install -y --no-install-recommends \
tzdata \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfribidi0 \
libcairo2 \
shared-mime-info \
fonts-dejavu-core \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY main.py db.py ./
COPY mail_api ./mail_api
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -3,12 +3,12 @@
"fullName": "Matt Test",
"phone": "02124347477",
"dogName": "Geoffrey",
"welcomePackSentAt": "2026-05-18T20:37:14",
"welcomePackSentAt": "2026-05-19T22:59:53",
"welcomePackOffer": {
"serviceType": "test",
"priceDetails": "45",
"startDate": "2026-05-18",
"sentAt": "2026-05-18T20:37:14"
"sentAt": "2026-05-19T22:59:53"
}
},
"natalie@desseinparke.com": {
+177
View File
@@ -79,11 +79,188 @@ async def _ensure_schema() -> None:
value jsonb not null,
updated_at timestamptz not null default now()
);
create table if not exists events (
id bigserial primary key,
created_at timestamptz not null default now(),
request_id text,
event_type text not null,
actor_email text,
ip text,
status text,
detail jsonb
);
create index if not exists events_created_at_idx on events (created_at desc);
create index if not exists events_event_type_idx on events (event_type);
create index if not exists events_actor_email_idx on events (actor_email);
create table if not exists submissions (
id bigserial primary key,
created_at timestamptz not null default now(),
request_id text,
kind text not null,
email text not null,
full_name text,
phone text,
ip text,
payload jsonb not null
);
create index if not exists submissions_created_at_idx on submissions (created_at desc);
create index if not exists submissions_email_idx on submissions (email);
create index if not exists submissions_kind_idx on submissions (kind);
"""
)
_schema_ensured = True
async def record_event(
*,
event_type: str,
request_id: str | None = None,
actor_email: str | None = None,
ip: str | None = None,
status: str | None = None,
detail: dict | None = None,
) -> None:
"""Append a single business event to the events table. Best-effort:
failures are logged and swallowed so they never block request handling."""
try:
pool = await get_pool()
if pool is None:
return
await _ensure_schema()
payload = json.dumps(detail or {})
async with pool.acquire() as conn:
await conn.execute(
"""
insert into events (request_id, event_type, actor_email, ip, status, detail)
values ($1, $2, $3, $4, $5, $6::jsonb)
""",
request_id, event_type, actor_email, ip, status, payload,
)
except Exception as exc:
logger.warning("record_event(%s) failed: %s", event_type, exc)
async def record_submission(
*,
kind: str,
email: str,
full_name: str | None,
phone: str | None,
ip: str | None,
request_id: str | None,
payload: dict,
) -> None:
"""Persist a contact-form submission (booking / onboarding / contract)."""
try:
pool = await get_pool()
if pool is None:
return
await _ensure_schema()
async with pool.acquire() as conn:
await conn.execute(
"""
insert into submissions (request_id, kind, email, full_name, phone, ip, payload)
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
""",
request_id, kind, email, full_name, phone, ip, json.dumps(payload),
)
except Exception as exc:
logger.warning("record_submission(%s) failed: %s", kind, exc)
async def list_events(
*,
limit: int = 100,
before_id: int | None = None,
event_type: str | None = None,
actor_email: str | None = None,
) -> list[dict]:
pool = await get_pool()
if pool is None:
return []
await _ensure_schema()
clauses: list[str] = []
params: list[Any] = []
if before_id is not None:
params.append(before_id)
clauses.append(f"id < ${len(params)}")
if event_type:
params.append(event_type)
clauses.append(f"event_type = ${len(params)}")
if actor_email:
params.append(actor_email.strip().lower())
clauses.append(f"actor_email = ${len(params)}")
where = ("where " + " and ".join(clauses)) if clauses else ""
params.append(max(1, min(500, limit)))
sql = (
f"select id, created_at, request_id, event_type, actor_email, ip, status, detail "
f"from events {where} order by id desc limit ${len(params)}"
)
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [
{
"id": r["id"],
"createdAt": r["created_at"].isoformat() if r["created_at"] else None,
"requestId": r["request_id"],
"eventType": r["event_type"],
"actorEmail": r["actor_email"],
"ip": r["ip"],
"status": r["status"],
"detail": (json.loads(r["detail"]) if isinstance(r["detail"], (str, bytes, bytearray)) else r["detail"]) or {},
}
for r in rows
]
async def list_submissions(
*,
limit: int = 100,
before_id: int | None = None,
kind: str | None = None,
email: str | None = None,
) -> list[dict]:
pool = await get_pool()
if pool is None:
return []
await _ensure_schema()
clauses: list[str] = []
params: list[Any] = []
if before_id is not None:
params.append(before_id)
clauses.append(f"id < ${len(params)}")
if kind:
params.append(kind)
clauses.append(f"kind = ${len(params)}")
if email:
params.append(email.strip().lower())
clauses.append(f"email = ${len(params)}")
where = ("where " + " and ".join(clauses)) if clauses else ""
params.append(max(1, min(500, limit)))
sql = (
f"select id, created_at, request_id, kind, email, full_name, phone, ip, payload "
f"from submissions {where} order by id desc limit ${len(params)}"
)
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [
{
"id": r["id"],
"createdAt": r["created_at"].isoformat() if r["created_at"] else None,
"requestId": r["request_id"],
"kind": r["kind"],
"email": r["email"],
"fullName": r["full_name"],
"phone": r["phone"],
"ip": r["ip"],
"payload": (json.loads(r["payload"]) if isinstance(r["payload"], (str, bytes, bytearray)) else r["payload"]) or {},
}
for r in rows
]
async def get_kv(key: str) -> Any | None:
pool = await get_pool()
if pool is None:
+59
View File
@@ -1377,3 +1377,62 @@ resend.exceptions.ResendError: API key is invalid
18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <bookings@goodwalk.co.nz>' reply_to='aless@goodwalk.co.nz' owner='info@goodwalk.co.nz' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s
18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
18/05/2026 22:22:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 22:50:41 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:50:51 New Zealand Standard Time INFO mail-api: [26b6b10d] POST /auth/request-code → 400 (0ms)
19/05/2026 22:50:52 New Zealand Standard Time INFO mail-api: [e939d522] POST /auth/request-code → 400 (0ms)
19/05/2026 22:50:56 New Zealand Standard Time INFO mail-api: [6fe39f49] POST /auth/request-code → 400 (0ms)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 22:57:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] auth: code issued for email=info@goodwalk.co.nz
19/05/2026 22:57:42 New Zealand Standard Time WARNING mail-api: [DEV] auth code for info@goodwalk.co.nz: 237030
19/05/2026 22:57:42 New Zealand Standard Time INFO mail-api: [0677fc9e] POST /auth/request-code → 200 (3ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] auth: session created for email=info@goodwalk.co.nz
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [19adbd31] POST /auth/verify-code → 200 (2ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [40f46274] GET /auth/verify → 200 (1ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [83064e1b] GET /owner/pending-onboarding → 200 (1ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [b24a0321] GET /owner/all-clients → 200 (5ms)
19/05/2026 22:57:51 New Zealand Standard Time INFO mail-api: [ad234ec3] GET /owner/birthdays → 200 (5ms)
19/05/2026 22:57:56 New Zealand Standard Time INFO mail-api: [8bbcb582] GET /owner/activity → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [11032d19] GET /auth/verify → 200 (0ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [6e3af292] GET /owner/pending-onboarding → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [ffa04e1b] GET /owner/birthdays → 200 (1ms)
19/05/2026 22:57:57 New Zealand Standard Time INFO mail-api: [fc51922d] GET /owner/all-clients → 200 (1ms)
19/05/2026 22:57:58 New Zealand Standard Time INFO mail-api: [1219dba0] GET /owner/activity → 200 (0ms)
19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [755e00f2] GET /owner/activity → 200 (0ms)
19/05/2026 22:57:59 New Zealand Standard Time INFO mail-api: [230cd479] GET /owner/activity → 200 (0ms)
19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [b0c81ebf] GET /owner/message-templates → 200 (1ms)
19/05/2026 22:58:01 New Zealand Standard Time INFO mail-api: [da344121] POST /owner/render-message → 200 (1ms)
19/05/2026 22:58:04 New Zealand Standard Time INFO mail-api: [4ecf56ff] GET /owner/activity → 200 (0ms)
19/05/2026 22:58:30 New Zealand Standard Time INFO mail-api: [ed0b1740] GET /owner/client-enquiry → 200 (1ms)
19/05/2026 22:58:33 New Zealand Standard Time INFO mail-api: [d06c7a3b] GET /owner/client-enquiry → 200 (0ms)
19/05/2026 22:58:54 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=bulk_message to=['info@goodwalk.co.nz'] subject='A note from Goodwalk'
19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] bulk message sent: template=general recipients=1
19/05/2026 22:58:54 New Zealand Standard Time INFO mail-api: [272f52a5] POST /owner/send-message → 200 (18ms)
19/05/2026 22:59:33 New Zealand Standard Time INFO mail-api: [f52b4acc] GET /owner/activity → 200 (0ms)
19/05/2026 22:59:53 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=welcome_pack_email to=['mattcohen0@gmail.com'] subject='Welcome to the pack | Goodwalk'
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] welcome pack sent: email=mattcohen0@gmail.com service=test start=2026-05-18
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [a82128ad] POST /owner/send-welcome-pack → 200 (13ms)
19/05/2026 22:59:53 New Zealand Standard Time INFO mail-api: [9e00fe59] GET /owner/all-clients → 200 (0ms)
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:02 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:02 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:02 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:33 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:33 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:33 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk <info@goodwalk.co.nz>' reply_to='info@goodwalk.co.nz' owner='info@goodwalk.co.nz' cp_admins=['info@goodwalk.co.nz'] owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s send_timeout=20s
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Auth: loaded 3 allowed email(s)
19/05/2026 23:32:40 New Zealand Standard Time ERROR mail-api: Startup smoke: WeasyPrint UNAVAILABLE — PDF attachments will be skipped (No module named 'weasyprint')
19/05/2026 23:32:40 New Zealand Standard Time WARNING mail-api: Startup smoke: postgres disabled — activity/submissions will NOT be recorded
19/05/2026 23:32:40 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address
+1
View File
@@ -0,0 +1 @@
"""Mail API package — split out of the legacy single-file main.py."""
Binary file not shown.
Binary file not shown.
+257
View File
@@ -0,0 +1,257 @@
"""Runtime configuration for the mail API.
Loads environment variables once at import time, validates required values,
and exposes a frozen :class:`Settings` dataclass plus module-level constants
for compatibility with code that still imports the legacy names.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
import resend
# ── Logging ──────────────────────────────────────────────────────────────────
def setup_logging() -> logging.Logger:
log_dir = Path(os.environ.get("LOG_DIR", "logs"))
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "mail-api.log"
fmt = logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%d/%m/%Y %H:%M:%S %Z",
)
root = logging.getLogger()
root.setLevel(logging.DEBUG)
for handler in list(root.handlers):
root.removeHandler(handler)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
root.addHandler(console)
rotating = logging.handlers.RotatingFileHandler(
log_file, maxBytes=2_000_000, backupCount=5, encoding="utf-8"
)
rotating.setLevel(logging.DEBUG)
rotating.setFormatter(fmt)
root.addHandler(rotating)
log = logging.getLogger("mail-api")
log.info("Logging initialised → console=INFO, file=%s (DEBUG, rotating)", log_file)
return log
logger = setup_logging()
# ── Settings ─────────────────────────────────────────────────────────────────
DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"}
REQUIRED_ENV = {
"RESEND_API_KEY": "API key from https://resend.com/api-keys",
"OWNER_EMAIL": "Email address that receives new lead notifications",
}
def _parse_email_env_list(*values: str | None) -> list[str]:
emails: list[str] = []
seen: set[str] = set()
for raw in values:
if not raw:
continue
for part in re.split(r"[;,\s]+", raw.strip()):
normalized = part.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
emails.append(normalized)
return emails
@dataclass(frozen=True)
class Settings:
resend_api_key: str
owner_email: str
cp_admin_emails: tuple[str, ...]
from_email: str
reply_to: str
owner_bcc: str
client_bcc: str
enable_general_enquiries: bool
max_attempts: int
form_min_seconds: int
form_max_seconds: int
rate_limit_window_seconds: int
rate_limit_max_per_ip: int
rate_limit_max_per_email: int
rate_limit_min_interval_seconds: int
email_send_timeout_seconds: int
def _load_settings() -> Settings:
if DEV_MODE:
owner_email = os.environ.get("OWNER_EMAIL", "dev@localhost").strip().lower()
return Settings(
resend_api_key=os.environ.get("RESEND_API_KEY", "dev"),
owner_email=owner_email,
cp_admin_emails=tuple(_parse_email_env_list(
owner_email,
os.environ.get("SECONDARY_CP_EMAIL"),
os.environ.get("SECONDARY_CP_EMAILS"),
)),
from_email=os.environ.get("FROM_EMAIL", "GoodWalk <info@goodwalk.co.nz>"),
reply_to=os.environ.get("REPLY_TO", "info@goodwalk.co.nz"),
owner_bcc="",
client_bcc="",
enable_general_enquiries=False,
max_attempts=3,
form_min_seconds=1,
form_max_seconds=7200,
rate_limit_window_seconds=900,
rate_limit_max_per_ip=50,
rate_limit_max_per_email=50,
rate_limit_min_interval_seconds=1,
email_send_timeout_seconds=20,
)
missing = [(name, hint) for name, hint in REQUIRED_ENV.items() if not os.environ.get(name)]
if missing:
lines = [
"",
"Mail API cannot start — required environment variables are not set:",
"",
]
for name, hint in missing:
lines.append(f"{name} ({hint})")
lines += [
"",
"Set them in your shell and try again. For example, in PowerShell:",
"",
]
for name, _ in missing:
lines.append(f' $env:{name} = "..."')
lines.append("")
message = "\n".join(lines)
logger.critical("Startup aborted: missing env vars: %s", [n for n, _ in missing])
print(message, file=sys.stderr)
sys.exit(1)
owner_email = os.environ["OWNER_EMAIL"].strip().lower()
return Settings(
resend_api_key=os.environ["RESEND_API_KEY"],
owner_email=owner_email,
cp_admin_emails=tuple(_parse_email_env_list(
owner_email,
os.environ.get("SECONDARY_CP_EMAIL"),
os.environ.get("SECONDARY_CP_EMAILS"),
)),
from_email=os.environ.get("FROM_EMAIL", "GoodWalk <info@goodwalk.co.nz>"),
reply_to=os.environ.get("REPLY_TO", "info@goodwalk.co.nz"),
owner_bcc=os.environ.get("OWNER_BCC", "example@example.com").strip(),
client_bcc=os.environ.get("CLIENT_BCC", "").strip(),
enable_general_enquiries=os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower()
in {"1", "true", "yes", "on", "enabled"},
max_attempts=max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
form_min_seconds=max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))),
form_max_seconds=max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))),
rate_limit_window_seconds=max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))),
rate_limit_max_per_ip=max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))),
rate_limit_max_per_email=max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))),
rate_limit_min_interval_seconds=max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))),
email_send_timeout_seconds=max(5, int(os.environ.get("EMAIL_SEND_TIMEOUT_SECONDS", "20"))),
)
settings = _load_settings()
resend.api_key = settings.resend_api_key
APP_VERSION = os.environ.get("APP_VERSION", "unknown")
AUTH_CODE_TTL_SECONDS = max(60, int(os.environ.get("AUTH_CODE_TTL_SECONDS", "600")))
AUTH_SESSION_TTL_SECONDS = max(3600, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(7 * 24 * 3600))))
AUTH_CODE_MAX_ATTEMPTS = 5
AUTH_CODE_REQUESTS_PER_HOUR = 5
AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10")))
AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600")))
AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600")))
BIRTHDAY_CHECK_INTERVAL_SECONDS = max(3600, int(os.environ.get("BIRTHDAY_CHECK_INTERVAL_SECONDS", str(12 * 3600))))
def _split_csv_env(name: str, default: str) -> tuple[str, ...]:
# Treat empty-string env (e.g. compose `${VAR:-}`) the same as "unset" so
# an unset compose key falls back to the default, not to an empty allowlist.
raw = (os.environ.get(name) or "").strip() or default
parts = [p.strip() for p in raw.split(",")]
return tuple(p for p in parts if p)
CORS_ALLOWED_ORIGINS = _split_csv_env(
"CORS_ALLOWED_ORIGINS",
"https://goodwalk.co.nz,https://www.goodwalk.co.nz,https://clients.goodwalk.co.nz,https://cp.goodwalk.co.nz",
)
TRUSTED_HOSTS = _split_csv_env(
"TRUSTED_HOSTS",
"goodwalk.co.nz,www.goodwalk.co.nz,clients.goodwalk.co.nz,cp.goodwalk.co.nz,localhost,127.0.0.1",
)
# Hard cap on request body size. Signed contracts include a base64 PNG of the
# signature (~30 KB) plus form fields, so 2 MB is a generous ceiling.
MAX_REQUEST_BODY_BYTES = max(64 * 1024, int((os.environ.get("MAX_REQUEST_BODY_BYTES") or str(2 * 1024 * 1024)).strip() or str(2 * 1024 * 1024)))
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
ALLOWED_EMAILS_FILE = _DATA_DIR / "allowed_emails.json"
CLIENT_PROFILES_FILE = _DATA_DIR / "client_profiles.json"
DRAFTS_FILE = _DATA_DIR / "drafts.json"
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
# ── Legacy module constants (kept for compatibility with the existing main.py) ──
OWNER_EMAIL = settings.owner_email
CP_ADMIN_EMAILS = set(settings.cp_admin_emails)
OWNER_BCC = settings.owner_bcc
CLIENT_BCC = settings.client_bcc
FROM_EMAIL = settings.from_email
REPLY_TO = settings.reply_to
ENABLE_GENERAL_ENQUIRIES = settings.enable_general_enquiries
MAX_SEND_ATTEMPTS = settings.max_attempts
FORM_MIN_SECONDS = settings.form_min_seconds
FORM_MAX_SECONDS = settings.form_max_seconds
RATE_LIMIT_WINDOW_SECONDS = settings.rate_limit_window_seconds
RATE_LIMIT_MAX_PER_IP = settings.rate_limit_max_per_ip
RATE_LIMIT_MAX_PER_EMAIL = settings.rate_limit_max_per_email
RATE_LIMIT_MIN_INTERVAL_SECONDS = settings.rate_limit_min_interval_seconds
EMAIL_SEND_TIMEOUT_SECONDS = settings.email_send_timeout_seconds
# Owner-BCC placeholder used by deploy.env.template; treat it as "unset" for the smoke email.
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
logger.info(
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r cp_admins=%r owner_bcc=%r client_bcc=%r general_enquiries=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss send_timeout=%ss",
APP_VERSION,
os.environ.get("TZ", "system-default"),
FROM_EMAIL,
REPLY_TO,
OWNER_EMAIL,
sorted(CP_ADMIN_EMAILS),
OWNER_BCC,
CLIENT_BCC,
ENABLE_GENERAL_ENQUIRIES,
MAX_SEND_ATTEMPTS,
FORM_MIN_SECONDS,
FORM_MAX_SECONDS,
RATE_LIMIT_WINDOW_SECONDS,
RATE_LIMIT_MAX_PER_IP,
RATE_LIMIT_MAX_PER_EMAIL,
RATE_LIMIT_MIN_INTERVAL_SECONDS,
EMAIL_SEND_TIMEOUT_SECONDS,
)
+117
View File
@@ -0,0 +1,117 @@
"""Pydantic request/response models for the mail API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr
class BaseSubmission(BaseModel):
fullName: str
email: EmailStr
phone: str
website: str = ""
formStartedAt: int | None = None
visitStartedAt: int | None = None
pageEnteredAt: int | None = None
firstInteractionAt: int | None = None
sendClickedAt: int | None = None
referrer: str = ""
page: str = ""
class BookingSubmission(BaseSubmission):
enquiryType: str = "booking"
petName: str = ""
location: str = ""
message: str = ""
services: list[str] = []
stepChanges: int = 0
journey: list[str] = []
class OnboardingSubmission(BaseSubmission):
address: str
dogName: str
dogBreed: str
dogAge: str = ""
servicesNeeded: list[str] = []
temperament: str = ""
medicalNotes: str = ""
accessInstructions: str = ""
vetName: str
vetPhone: str
emergencyContactName: str
emergencyContactPhone: str
councilRegistrationConfirmed: bool = False
vaccinationsConfirmed: bool = False
emergencyVetConsent: bool = False
termsAccepted: bool = False
signatureDataUrl: str
submissionSnapshot: dict[str, Any] = {}
class WelcomePackEmailRequest(BaseModel):
email: EmailStr
serviceType: str
priceDetails: str
startDate: str
preview: bool = False
class BirthdayEmailRequest(BaseModel):
email: EmailStr
preview: bool = False
class BirthdayAutoSendRequest(BaseModel):
email: EmailStr
enabled: bool
class ContractSubmission(BaseSubmission):
address: str
dogName: str
dogBreed: str
dogAge: str = ""
serviceType: str
startDate: str
walkFrequency: str = ""
additionalNotes: str = ""
agreeServiceTerms: bool = False
agreeCancellation: bool = False
agreePayment: bool = False
agreeEmergency: bool = False
agreeLiability: bool = False
agreeAccuracy: bool = False
signatureDataUrl: str
class RenderMessageRequest(BaseModel):
templateId: str
heading: str = ""
body: str = ""
ctaLabel: str = ""
ctaUrl: str = ""
subHeading: str = ""
highlightText: str = ""
signOff: str = ""
footerNote: str = ""
fontId: str = "system"
class SendMessageRequest(BaseModel):
templateId: str
subject: str
heading: str = ""
body: str = ""
ctaLabel: str = ""
ctaUrl: str = ""
subHeading: str = ""
highlightText: str = ""
signOff: str = ""
footerNote: str = ""
fontId: str = "system"
recipients: list[EmailStr] = []
preview: bool = False
+550 -309
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.32
resend>=2.0
pydantic[email]>=2.10
asyncpg>=0.30
weasyprint>=63