Files
OSIT-AE-API-FastAPI/app/methods/e_novi_mailman_methods.py
Scott Idem 6b25cf9c6d feat: add Novi AMS → Mailman 3 cron-based mirror sync bridge (IDAA)
Implements a full proof-of-concept for syncing IDAA's Novi AMS membership
groups to Mailman 3 mailing lists via a cron-triggered reconciliation approach.

Key changes:
- methods: rewrote sync engine around confirmed Novi API shape — group-based
  member fetch (/groups/{guid}/members + /customers/{uuid}), respects
  Active=false and UnsubscribeFromEmails=true flags
- methods: mirror_novi_group_to_mailman_list() diffs Novi group against
  Mailman roster and subscribes/unsubscribes accordingly (full mirror)
- methods: mirror_all_configured_mappings() iterates novi_mailman_sync
  config array in IDAA site cfg_json — this is the cron target
- router: replaced old /sync endpoint with POST /sync (all mappings) and
  POST /sync/group/{guid} (single mapping); removed webhook endpoint
  (sync is cron-based, not event-driven)
- router: added GET/POST/DELETE endpoints for list member inspection
  and manual subscribe/unsubscribe
- tests: two new e2e scripts covering connection checks and full member
  lifecycle; old webhook integration test archived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:36:32 -04:00

449 lines
18 KiB
Python

import json, requests
from typing import Dict, List, Optional
from app.db_sql import sql_select
from app.lib_general import log, logging, logger_reset
# ---------------------------------------------------------------------------
# Novi-Mailman Bridge — IDAA
#
# Credentials live in site.cfg_json for the IDAA site (id_random='58_gJESdlUh').
# Novi keys already present:
# novi_api_root_url — e.g. "https://www.idaa.org/api"
# novi_idaa_api_key — Base64 API key (Basic auth)
#
# Keys that must be added to cfg_json before Mailman or webhooks can work:
# mailman_base_url — e.g. "http://lists.idaa.org:8001"
# mailman_username — Mailman REST admin user (usually "restadmin")
# mailman_password — Mailman REST admin password
# mailman_list_id — Target list, e.g. "members@idaa.org"
# novi_webhook_secret — Shared secret for HMAC-SHA256 webhook validation
# ---------------------------------------------------------------------------
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
# ── Config Helper ─────────────────────────────────────────────────────────
@logger_reset
def _load_idaa_cfg() -> Optional[Dict]:
"""
Load IDAA site cfg_json. Returns the parsed dict, or None on failure.
"""
from app.methods.site_methods import load_site_obj
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
if not site:
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
return None
cfg = site.get('cfg_json')
if isinstance(cfg, str):
try:
cfg = json.loads(cfg)
except Exception as e:
log.error("Failed to parse IDAA cfg_json: %s", e)
return None
if not isinstance(cfg, dict):
log.error("IDAA cfg_json is not a dict after parsing.")
return None
return cfg
# ── Novi AMS Methods ──────────────────────────────────────────────────────
@logger_reset
def test_novi_connection() -> Dict:
"""
Verify Novi AMS API credentials from IDAA site cfg_json.
Uses the first group GUID in novi_idaa_group_guid_li as a lightweight auth probe.
Returns {'ok': True, 'member_count': N} on success.
"""
cfg = _load_idaa_cfg()
if not cfg:
return {"ok": False, "error": "Could not load IDAA site config."}
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
api_key = cfg.get('novi_idaa_api_key', '')
if not base_url or not api_key:
return {"ok": False, "error": "novi_api_root_url or novi_idaa_api_key missing from cfg_json."}
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
if not group_guid_li:
return {"ok": False, "error": "novi_idaa_group_guid_li missing from cfg_json."}
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
try:
# Use first group as a lightweight auth probe (pageSize=1)
guid = group_guid_li[0]
resp = requests.get(f"{base_url}/groups/{guid}/members",
headers=headers, params={"pageSize": 1}, timeout=10)
if resp.status_code == 200:
return {"ok": True, "probe_group": guid}
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
log.exception("Novi connection test failed: %s", e)
return {"ok": False, "error": str(e)}
@logger_reset
def get_novi_members(status_filter: Optional[str] = None, page_size: int = 500, offset: int = 0) -> Optional[List[Dict]]:
"""
Fetch member records from Novi AMS.
Novi has no flat member-list endpoint. Members are fetched per group from
novi_idaa_group_guid_li, deduped by UniqueID, then each member's full record
(including Email) is fetched via GET /customers/{uuid}.
status_filter and pagination (page_size/offset) are not supported at the
Novi API level for this approach — all group members are returned.
"""
cfg = _load_idaa_cfg()
if not cfg:
return None
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
api_key = cfg.get('novi_idaa_api_key', '')
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
if not group_guid_li:
log.error("novi_idaa_group_guid_li missing from cfg_json.")
return None
# Step 1: collect unique member UUIDs across all configured groups
seen_uuids: set = set()
uuid_list: List[str] = []
for guid in group_guid_li:
try:
resp = requests.get(f"{base_url}/groups/{guid}/members",
headers=headers, params={"pageSize": page_size}, timeout=30)
if resp.status_code != 200:
log.error("Novi group %s fetch error: %s", guid, resp.status_code)
continue
for entry in resp.json():
uid = entry.get('UniqueID')
if uid and uid not in seen_uuids:
seen_uuids.add(uid)
uuid_list.append(uid)
except Exception as e:
log.exception("Failed to fetch Novi group %s: %s", guid, e)
log.info("Novi: %d unique members across %d group(s).", len(uuid_list), len(group_guid_li))
# Step 2: fetch full customer record (including Email) for each UUID
members: List[Dict] = []
for uid in uuid_list:
try:
resp = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
if resp.status_code == 200:
members.append(resp.json())
else:
log.warning("Novi customer %s fetch error: %s", uid, resp.status_code)
except Exception as e:
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
return members
# ── Mailman 3 Methods ─────────────────────────────────────────────────────
@logger_reset
def test_mailman_connection() -> Dict:
"""
Verify Mailman 3 REST API credentials from IDAA site cfg_json.
Returns {'ok': True, 'version': '...'} on success.
"""
cfg = _load_idaa_cfg()
if not cfg:
return {"ok": False, "error": "Could not load IDAA site config."}
base_url = cfg.get('mailman_base_url', '').rstrip('/')
username = cfg.get('mailman_username', 'restadmin')
password = cfg.get('mailman_password', '')
if not base_url or not password:
return {"ok": False, "error": "mailman_base_url or mailman_password missing from cfg_json."}
try:
resp = requests.get(f"{base_url}/3.1/system/versions", auth=(username, password), timeout=10)
if resp.status_code == 200:
return {"ok": True, "version": resp.json().get('mailman_version')}
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
log.exception("Mailman connection test failed: %s", e)
return {"ok": False, "error": str(e)}
@logger_reset
def get_mailman_list_members(list_id: str, count: int = 100, page: int = 1) -> Optional[List[Dict]]:
"""
Return members of a specific Mailman 3 list.
Args:
list_id: fqdn_listname e.g. 'mm3@idaa.org' or dot-notation 'mm3.idaa.org'
count: page size (Mailman default 20, max typically 100)
page: 1-based page number
"""
cfg = _load_idaa_cfg()
if not cfg:
return None
base_url = cfg.get('mailman_base_url', '').rstrip('/')
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
list_id_dot = list_id.replace('@', '.')
try:
resp = requests.get(
f"{base_url}/3.1/lists/{list_id_dot}/roster/member",
auth=auth,
params={"count": count, "page": page},
timeout=10,
)
if resp.status_code != 200:
log.error("Mailman member list fetch failed for %s: %s", list_id, resp.status_code)
return None
data = resp.json()
return data.get('entries', [])
except Exception as e:
log.exception("Failed to fetch members for list %s: %s", list_id, e)
return None
@logger_reset
def get_mailman_lists() -> Optional[List[Dict]]:
"""Return all mailing lists from this Mailman 3 instance."""
cfg = _load_idaa_cfg()
if not cfg:
return None
base_url = cfg.get('mailman_base_url', '').rstrip('/')
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
try:
resp = requests.get(f"{base_url}/3.1/lists", auth=auth, timeout=10)
if resp.status_code != 200:
log.error("Mailman list fetch failed: %s", resp.status_code)
return None
return resp.json().get('entries', [])
except Exception as e:
log.exception("Failed to fetch Mailman lists: %s", e)
return None
@logger_reset
def subscribe_member_to_list(list_id: str, email: str, display_name: str = '') -> bool:
"""
Subscribe an email address to a Mailman 3 list (pre-confirmed, no welcome email).
Returns True on success or already-subscribed, False on error.
"""
cfg = _load_idaa_cfg()
if not cfg:
return False
base_url = cfg.get('mailman_base_url', '').rstrip('/')
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
payload = {
"list_id": list_id.replace('@', '.'),
"subscriber": email,
"display_name": display_name,
"pre_verified": True,
"pre_confirmed": True,
"pre_approved": True,
"send_welcome_message": False,
}
try:
resp = requests.post(f"{base_url}/3.1/members", auth=auth, json=payload, timeout=10)
if resp.status_code in (200, 201):
log.info("Subscribed %s to %s", email, list_id)
return True
if resp.status_code == 409:
log.debug("%s already subscribed to %s — skipping.", email, list_id)
return True
log.error("Subscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
return False
except Exception as e:
log.exception("Error subscribing %s to %s: %s", email, list_id, e)
return False
@logger_reset
def unsubscribe_member_from_list(list_id: str, email: str) -> bool:
"""
Unsubscribe an email address from a Mailman 3 list.
Returns True on success or not-found, False on error.
"""
cfg = _load_idaa_cfg()
if not cfg:
return False
base_url = cfg.get('mailman_base_url', '').rstrip('/')
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
list_id_dot = list_id.replace('@', '.')
try:
resp = requests.delete(
f"{base_url}/3.1/lists/{list_id_dot}/member/{email}",
auth=auth,
timeout=10,
)
if resp.status_code in (200, 204):
log.info("Unsubscribed %s from %s", email, list_id)
return True
if resp.status_code == 404:
log.debug("%s not found in %s — skipping.", email, list_id)
return True
log.error("Unsubscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
return False
except Exception as e:
log.exception("Error unsubscribing %s from %s: %s", email, list_id, e)
return False
# ── Mirror Sync Engine ────────────────────────────────────────────────────
@logger_reset
def mirror_novi_group_to_mailman_list(novi_group_guid: str, mailman_list_id: str) -> Optional[Dict]:
"""
Mirror a single Novi group to a Mailman 3 list.
- Fetches all members of `novi_group_guid` from Novi.
- For each member, fetches their customer record to get Email, name, and
membership flags. Members with `Active=False` or `UnsubscribeFromEmails=True`
are excluded from the target set.
- Fetches current members of `mailman_list_id`.
- Subscribes addresses in Novi but not in Mailman.
- Unsubscribes addresses in Mailman but not in Novi (mirror / full reconcile).
- Returns a result dict with counts.
"""
cfg = _load_idaa_cfg()
if not cfg:
return None
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
api_key = cfg.get('novi_idaa_api_key', '')
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
log.info("Mirror sync: Novi group %s → Mailman list %s", novi_group_guid, mailman_list_id)
# ── Step 1: Novi group → UUIDs ────────────────────────────────────────
try:
resp = requests.get(f"{base_url}/groups/{novi_group_guid}/members",
headers=headers, params={"pageSize": 500}, timeout=30)
if resp.status_code != 200:
log.error("Novi group fetch failed (%s): %s", novi_group_guid, resp.status_code)
return None
uuid_list = [m['UniqueID'] for m in resp.json() if m.get('UniqueID')]
except Exception as e:
log.exception("Failed to fetch Novi group %s: %s", novi_group_guid, e)
return None
log.info("Novi group %s has %d member(s).", novi_group_guid, len(uuid_list))
# ── Step 2: UUID → customer record → email ────────────────────────────
# email.lower() → display_name
novi_members: Dict[str, str] = {}
skipped_inactive = 0
skipped_unsub = 0
skipped_no_email = 0
for uid in uuid_list:
try:
r = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
if r.status_code != 200:
log.warning("Novi customer %s fetch failed: %s", uid, r.status_code)
continue
c = r.json()
if not c.get('Active', False):
skipped_inactive += 1
continue
if c.get('UnsubscribeFromEmails', False):
skipped_unsub += 1
continue
email = (c.get('Email') or '').strip()
if not email:
skipped_no_email += 1
continue
display = f"{c.get('FirstName', '')} {c.get('LastName', '')}".strip()
novi_members[email.lower()] = display
except Exception as e:
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
log.info("Novi active/subscribed members with email: %d (skipped: inactive=%d unsub=%d no_email=%d)",
len(novi_members), skipped_inactive, skipped_unsub, skipped_no_email)
# ── Step 3: Current Mailman members ───────────────────────────────────
mailman_entries = get_mailman_list_members(mailman_list_id)
if mailman_entries is None:
log.error("Could not fetch current Mailman members for %s — aborting.", mailman_list_id)
return None
mailman_emails = {m['email'].lower() for m in mailman_entries}
# ── Step 4: Diff ──────────────────────────────────────────────────────
novi_email_set = set(novi_members.keys())
to_subscribe = novi_email_set - mailman_emails
to_unsubscribe = mailman_emails - novi_email_set
log.info("Diff — to subscribe: %d, to unsubscribe: %d", len(to_subscribe), len(to_unsubscribe))
results = {
"novi_group_guid": novi_group_guid,
"mailman_list_id": mailman_list_id,
"novi_count": len(novi_email_set),
"mailman_count_before": len(mailman_emails),
"subscribed": 0,
"unsubscribed": 0,
"errors": 0,
"skipped_inactive": skipped_inactive,
"skipped_unsub": skipped_unsub,
"skipped_no_email": skipped_no_email,
}
# ── Step 5: Apply ─────────────────────────────────────────────────────
for email in to_subscribe:
ok = subscribe_member_to_list(mailman_list_id, email, novi_members[email])
if ok: results['subscribed'] += 1
else: results['errors'] += 1
for email in to_unsubscribe:
ok = unsubscribe_member_from_list(mailman_list_id, email)
if ok: results['unsubscribed'] += 1
else: results['errors'] += 1
log.info("Mirror sync complete: %s", results)
return results
@logger_reset
def mirror_all_configured_mappings() -> Optional[List[Dict]]:
"""
Run mirror_novi_group_to_mailman_list for every entry in
cfg_json['novi_mailman_sync'].
Expected cfg_json shape:
"novi_mailman_sync": [
{"novi_group_guid": "...", "mailman_list_id": "members@idaa.org"},
...
]
"""
cfg = _load_idaa_cfg()
if not cfg:
return None
sync_map = cfg.get('novi_mailman_sync') or []
if not sync_map:
log.warning("novi_mailman_sync not configured in IDAA cfg_json.")
return []
results = []
for mapping in sync_map:
guid = mapping.get('novi_group_guid', '').strip()
list_id = mapping.get('mailman_list_id', '').strip()
if not guid or not list_id:
log.warning("Skipping incomplete novi_mailman_sync entry: %s", mapping)
continue
result = mirror_novi_group_to_mailman_list(guid, list_id)
results.append(result or {"novi_group_guid": guid, "mailman_list_id": list_id, "error": "sync failed"})
return results