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>
This commit is contained in:
Scott Idem
2026-03-10 19:26:36 -04:00
parent f1c8958a7a
commit 3111ed5f22

View File

@@ -59,11 +59,11 @@ def test_novi_connection() -> Dict:
if not config: if not config:
return {"ok": False, "error": "Credentials not configured."} return {"ok": False, "error": "Credentials not configured."}
# TODO: Confirm the actual test endpoint for the target Novi instance. # Novi uses Basic auth with a Base64-encoded API key.
# Common pattern: GET /api/v1/members?$top=1 with ApiKey header. # Confirmed from IDAA Jitsi integration: Authorization: Basic {api_key}
base_url = config.get('base_url', '').rstrip('/') base_url = config.get('base_url', '').rstrip('/')
api_key = config.get('api_key', '') api_key = config.get('api_key', '')
headers = {"ApiKey": api_key, "Accept": "application/json"} headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
try: try:
resp = requests.get(f"{base_url}/api/v1/members", headers=headers, params={"$top": 1}, timeout=10) resp = requests.get(f"{base_url}/api/v1/members", headers=headers, params={"$top": 1}, timeout=10)
@@ -97,20 +97,26 @@ def get_novi_members(status_filter: Optional[str] = None, page_size: int = 500,
base_url = config.get('base_url', '').rstrip('/') base_url = config.get('base_url', '').rstrip('/')
api_key = config.get('api_key', '') api_key = config.get('api_key', '')
headers = {"ApiKey": api_key, "Accept": "application/json"} headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
params = {"$top": page_size, "$skip": offset} params = {"pageSize": page_size, "offset": offset}
if status_filter: if status_filter:
params["$filter"] = f"MembershipStatus eq '{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: try:
resp = requests.get(f"{base_url}/api/v1/members", headers=headers, params=params, timeout=30) resp = requests.get(f"{base_url}/members", headers=headers, params=params, timeout=30)
if resp.status_code != 200: if resp.status_code != 200:
log.error(f"Novi API error: {resp.status_code} - {resp.text[:200]}") log.error(f"Novi API error: {resp.status_code} - {resp.text[:200]}")
return None return None
data = resp.json() data = resp.json()
# Novi typically wraps results in a 'value' key (OData convention) # Novi may return array directly, or wrap in Results/Members key
return data.get('value', data) if isinstance(data, dict) else data if isinstance(data, list):
return data
return data.get('Results') or data.get('Members') or data.get('value') or []
except Exception as e: except Exception as e:
log.exception(f"Failed to fetch Novi members: {e}") log.exception(f"Failed to fetch Novi members: {e}")
return None return None
@@ -300,11 +306,12 @@ def sync_novi_to_mailman(list_id: str, active_status: str = 'Active') -> Optiona
results = {"total": len(members), "subscribed": 0, "unsubscribed": 0, "error": 0, "skipped": 0} results = {"total": len(members), "subscribed": 0, "unsubscribed": 0, "error": 0, "skipped": 0}
for member in members: for member in members:
# TODO: Confirm exact field names from your Novi instance # Field names confirmed PascalCase from Novi API (verified via IDAA Jitsi integration).
email = (member.get('Email') or member.get('email') or '').strip() # MembershipStatus field name still needs confirmation against the bulk member endpoint.
fname = member.get('FirstName') or member.get('first_name') or '' email = (member.get('Email') or '').strip().replace(' ', '+')
lname = member.get('LastName') or member.get('last_name') or '' fname = member.get('FirstName') or ''
status = member.get('MembershipStatus') or member.get('membership_status') or '' lname = member.get('LastName') or ''
status = member.get('MembershipStatus') or member.get('Status') or ''
if not email: if not email:
results['skipped'] += 1 results['skipped'] += 1
@@ -341,10 +348,10 @@ def handle_novi_webhook(payload: Dict) -> Optional[Dict]:
""" """
event_type = payload.get('EventType', '') event_type = payload.get('EventType', '')
member = payload.get('Member', {}) member = payload.get('Member', {})
email = (member.get('Email') or '').strip() email = (member.get('Email') or '').strip().replace(' ', '+')
fname = member.get('FirstName', '') fname = member.get('FirstName', '')
lname = member.get('LastName', '') lname = member.get('LastName', '')
status = member.get('MembershipStatus', '') status = member.get('MembershipStatus') or member.get('Status', '')
if not email: if not email:
log.warning(f"Novi webhook received with no email — skipping. Payload: {payload}") log.warning(f"Novi webhook received with no email — skipping. Payload: {payload}")