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}