Files
OSIT-AE-API-FastAPI/app/methods/e_novi_mailman_methods.py
Scott Idem 3111ed5f22 fix: Correct Novi API auth header and field names in Mailman bridge
- Auth: ApiKey header → Authorization: Basic (confirmed from IDAA Jitsi code)
- Member fields: confirmed PascalCase (FirstName, LastName, Email) from Novi API
- email.replace(' ', '+') to match Jitsi's sanitization pattern
- Bulk member list endpoint marked TODO pending confirmation
- Response unwrapping handles Results/Members/value/array shapes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 19:26:36 -04:00

385 lines
15 KiB
Python

import json, requests
from typing import Dict, List, Optional
from app.db_sql import sql_select, sql_update, sql_insert
from app.lib_general import log, logging, logger_reset
# ---------------------------------------------------------------------------
# Novi-Mailman Bridge
# Synchronizes Novi AMS membership data with Mailman 3 mailing lists.
#
# Credential Storage (data_store table):
# code='novi_api_config' → JSON: { "api_key": "...", "base_url": "https://..." }
# code='mailman_api_config' → JSON: { "base_url": "http://...:8001", "username": "...", "password": "..." }
#
# Sync Logic:
# - Active Novi members → subscribed in target Mailman list(s)
# - Lapsed/expired members → unsubscribed (or held, depending on policy)
# - Driven by webhooks (Novi membership events) + optional full-sync
# ---------------------------------------------------------------------------
# ── Credential Helpers ────────────────────────────────────────────────────
@logger_reset
def _load_novi_config() -> Optional[Dict]:
"""Load Novi AMS API credentials from data_store (code='novi_api_config')."""
rec = sql_select(table_name='data_store', field_name='code', field_value='novi_api_config')
if not rec:
log.error("Novi API config not found in data_store (code='novi_api_config').")
return None
try:
return json.loads(rec['text'])
except Exception as e:
log.error(f"Failed to parse Novi config: {e}")
return None
@logger_reset
def _load_mailman_config() -> Optional[Dict]:
"""Load Mailman 3 REST API credentials from data_store (code='mailman_api_config')."""
rec = sql_select(table_name='data_store', field_name='code', field_value='mailman_api_config')
if not rec:
log.error("Mailman API config not found in data_store (code='mailman_api_config').")
return None
try:
return json.loads(rec['text'])
except Exception as e:
log.error(f"Failed to parse Mailman config: {e}")
return None
# ── Novi AMS Methods ──────────────────────────────────────────────────────
@logger_reset
def test_novi_connection() -> Dict:
"""
Verify Novi AMS API credentials are valid.
Returns a dict with 'ok' bool and optional error message.
"""
config = _load_novi_config()
if not config:
return {"ok": False, "error": "Credentials not configured."}
# Novi uses Basic auth with a Base64-encoded API key.
# Confirmed from IDAA Jitsi integration: Authorization: Basic {api_key}
base_url = config.get('base_url', '').rstrip('/')
api_key = config.get('api_key', '')
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
try:
resp = requests.get(f"{base_url}/api/v1/members", headers=headers, params={"$top": 1}, timeout=10)
if resp.status_code == 200:
return {"ok": True}
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
log.exception(f"Novi connection test failed: {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.
Args:
status_filter: Optional Novi membership status (e.g. 'Active', 'Lapsed').
None returns all members.
page_size: Records per page (Novi uses OData $top/$skip).
offset: Pagination offset ($skip).
Returns:
List of member dicts, or None on failure.
TODO: Confirm OData filter field names against your Novi instance schema.
"""
config = _load_novi_config()
if not config:
return None
base_url = config.get('base_url', '').rstrip('/')
api_key = config.get('api_key', '')
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
params = {"pageSize": page_size, "offset": offset}
if status_filter:
params["membershipStatus"] = status_filter
# TODO: Confirm the bulk member list endpoint for this Novi instance.
# The IDAA Jitsi code uses /customers/{uuid} for individual lookups and
# /groups/{guid}/members for group membership. A bulk member list may be
# /members, /customers, or require a group-based approach.
try:
resp = requests.get(f"{base_url}/members", headers=headers, params=params, timeout=30)
if resp.status_code != 200:
log.error(f"Novi API error: {resp.status_code} - {resp.text[:200]}")
return None
data = resp.json()
# Novi may return array directly, or wrap in Results/Members key
if isinstance(data, list):
return data
return data.get('Results') or data.get('Members') or data.get('value') or []
except Exception as e:
log.exception(f"Failed to fetch Novi members: {e}")
return None
# ── Mailman 3 Methods ─────────────────────────────────────────────────────
@logger_reset
def test_mailman_connection() -> Dict:
"""
Verify Mailman 3 REST API credentials are valid.
Returns a dict with 'ok' bool and optional error message.
"""
config = _load_mailman_config()
if not config:
return {"ok": False, "error": "Credentials not configured."}
base_url = config.get('base_url', '').rstrip('/')
auth = (config.get('username', 'restadmin'), config.get('password', ''))
try:
resp = requests.get(f"{base_url}/3.1/system/versions", auth=auth, 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(f"Mailman connection test failed: {e}")
return {"ok": False, "error": str(e)}
@logger_reset
def get_mailman_lists() -> Optional[List[Dict]]:
"""
Return all mailing lists from this Mailman 3 instance.
"""
config = _load_mailman_config()
if not config:
return None
base_url = config.get('base_url', '').rstrip('/')
auth = (config.get('username', 'restadmin'), config.get('password', ''))
try:
resp = requests.get(f"{base_url}/3.1/lists", auth=auth, timeout=10)
if resp.status_code != 200:
log.error(f"Mailman list fetch failed: {resp.status_code}")
return None
return resp.json().get('entries', [])
except Exception as e:
log.exception(f"Failed to fetch Mailman lists: {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.
Uses pre-confirmed subscription (no confirmation email sent).
Args:
list_id: Mailman list ID, e.g. 'members@yourdomain.org'
email: Member email address
display_name: Optional display name
Returns:
True on success or already subscribed, False on error.
"""
config = _load_mailman_config()
if not config:
return False
base_url = config.get('base_url', '').rstrip('/')
auth = (config.get('username', 'restadmin'), config.get('password', ''))
payload = {
"list_id": list_id.replace('@', '.'), # Mailman uses dot notation for list IDs
"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(f"Subscribed {email} to {list_id}")
return True
if resp.status_code == 409:
log.debug(f"{email} already subscribed to {list_id} — skipping.")
return True # Already a member, treat as success
log.error(f"Subscribe failed for {email}: {resp.status_code} - {resp.text[:200]}")
return False
except Exception as e:
log.exception(f"Error subscribing {email} to {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 (already unsubscribed), False on error.
"""
config = _load_mailman_config()
if not config:
return False
base_url = config.get('base_url', '').rstrip('/')
auth = (config.get('username', 'restadmin'), config.get('password', ''))
list_id_dot = list_id.replace('@', '.')
try:
# Mailman member ID is {email}_{list_id} (base64 encoded in REST path)
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(f"Unsubscribed {email} from {list_id}")
return True
if resp.status_code == 404:
log.debug(f"{email} not found in {list_id} — skipping.")
return True # Not a member, treat as success
log.error(f"Unsubscribe failed for {email}: {resp.status_code} - {resp.text[:200]}")
return False
except Exception as e:
log.exception(f"Error unsubscribing {email} from {list_id}: {e}")
return False
# ── Sync Engine ───────────────────────────────────────────────────────────
@logger_reset
def sync_single_member(email: str, display_name: str, list_id: str, is_active: bool) -> str:
"""
Sync one member's subscription state for a given Mailman list.
Args:
email: Member email
display_name: Member display name
list_id: Target Mailman list ID
is_active: True = subscribe, False = unsubscribe
Returns:
'subscribed', 'unsubscribed', or 'error'
"""
if is_active:
ok = subscribe_member_to_list(list_id, email, display_name)
return 'subscribed' if ok else 'error'
else:
ok = unsubscribe_member_from_list(list_id, email)
return 'unsubscribed' if ok else 'error'
@logger_reset
def sync_novi_to_mailman(list_id: str, active_status: str = 'Active') -> Optional[Dict]:
"""
Full sync: pull all Novi members and reconcile their Mailman subscription state.
- Novi members with `active_status` → subscribed to `list_id`
- All other statuses → unsubscribed from `list_id`
Args:
list_id: Target Mailman list ID (e.g. 'members@yourdomain.org')
active_status: Novi membership status string that counts as active.
Returns:
Result dict with counts, or None on fatal error.
TODO: Adjust the Novi member field names (Email, FirstName, LastName,
MembershipStatus) to match your actual Novi instance schema.
"""
log.info(f"Starting full Novi → Mailman sync for list: {list_id}")
members = get_novi_members()
if members is None:
log.error("Novi member fetch failed — aborting sync.")
return None
log.info(f"Fetched {len(members)} members from Novi.")
results = {"total": len(members), "subscribed": 0, "unsubscribed": 0, "error": 0, "skipped": 0}
for member in members:
# Field names confirmed PascalCase from Novi API (verified via IDAA Jitsi integration).
# MembershipStatus field name still needs confirmation against the bulk member endpoint.
email = (member.get('Email') or '').strip().replace(' ', '+')
fname = member.get('FirstName') or ''
lname = member.get('LastName') or ''
status = member.get('MembershipStatus') or member.get('Status') or ''
if not email:
results['skipped'] += 1
continue
display_name = f"{fname} {lname}".strip()
is_active = (status == active_status)
outcome = sync_single_member(email, display_name, list_id, is_active)
results[outcome] = results.get(outcome, 0) + 1
log.info(f"Novi → Mailman sync complete: {results}")
return results
@logger_reset
def handle_novi_webhook(payload: Dict) -> Optional[Dict]:
"""
Process a Novi membership webhook event and update the appropriate Mailman list.
Expected payload shape (Novi webhook format — confirm against Novi docs):
{
"EventType": "MembershipActivated" | "MembershipLapsed" | "MembershipExpired" | ...,
"Member": {
"Email": "...",
"FirstName": "...",
"LastName": "...",
"MembershipStatus": "Active" | "Lapsed" | ...
}
}
TODO: Confirm Novi webhook payload format and EventType values.
TODO: Pull target list_id from data_store config or per-account settings.
"""
event_type = payload.get('EventType', '')
member = payload.get('Member', {})
email = (member.get('Email') or '').strip().replace(' ', '+')
fname = member.get('FirstName', '')
lname = member.get('LastName', '')
status = member.get('MembershipStatus') or member.get('Status', '')
if not email:
log.warning(f"Novi webhook received with no email — skipping. Payload: {payload}")
return None
# Load target list_id from config
# TODO: Support per-account list routing (e.g. multiple orgs, each with their own list)
novi_config = _load_novi_config()
if not novi_config:
return None
list_id = novi_config.get('mailman_list_id', '')
if not list_id:
log.error("'mailman_list_id' not set in novi_api_config — cannot route webhook.")
return None
ACTIVE_EVENTS = {'MembershipActivated', 'MembershipRenewed', 'MembershipCreated'}
INACTIVE_EVENTS = {'MembershipLapsed', 'MembershipExpired', 'MembershipTerminated', 'MembershipCancelled'}
if event_type in ACTIVE_EVENTS:
is_active = True
elif event_type in INACTIVE_EVENTS:
is_active = False
else:
log.info(f"Novi webhook: unhandled EventType '{event_type}' for {email} — ignoring.")
return {"action": "ignored", "reason": f"Unhandled EventType: {event_type}"}
display_name = f"{fname} {lname}".strip()
outcome = sync_single_member(email, display_name, list_id, is_active)
log.info(f"Novi webhook processed: {email}{outcome} (EventType: {event_type})")
return {"action": outcome, "email": email, "event_type": event_type}