v4.0.0.1
This commit is contained in:
+13
-3
@@ -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.
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Mail API package — split out of the legacy single-file main.py."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.32
|
||||
resend>=2.0
|
||||
pydantic[email]>=2.10
|
||||
asyncpg>=0.30
|
||||
weasyprint>=63
|
||||
|
||||
Reference in New Issue
Block a user