Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3c1183a
Add Brevo newsletter sync for mailbox provisioning
michaelmwu Jun 9, 2026
6780a01
Resolve Brevo members list by name
michaelmwu Jun 9, 2026
e6dce19
Add recurring member newsletter sync
michaelmwu Jun 10, 2026
224a385
Address newsletter sync review feedback
michaelmwu Jun 10, 2026
8e1ce53
Skip Brevo list-level unsubscribes
michaelmwu Jun 10, 2026
8c434d4
Harden newsletter contact lookups
michaelmwu Jun 10, 2026
cfacfe4
Report suppressed newsletter skips
michaelmwu Jun 10, 2026
7345a8f
Address newsletter sync review hardening
michaelmwu Jun 10, 2026
32c9e5f
Merge main and dashboard-configure newsletter sync
michaelmwu Jun 10, 2026
54304cf
Address newsletter sync review feedback
michaelmwu Jun 11, 2026
75fac4d
Use CRM gating for newsletter resync exclusions
michaelmwu Jun 11, 2026
32155a7
Harden newsletter sync review surfaces
michaelmwu Jun 11, 2026
482d537
Merge main into newsletter sync branch
michaelmwu Jun 11, 2026
0f33bbf
Redact emails in provider error excerpts
michaelmwu Jun 11, 2026
a4252e5
Merge main into newsletter sync branch
michaelmwu Jun 13, 2026
aeb4ac0
Harden newsletter sync PII masking
michaelmwu Jun 14, 2026
19a831c
Merge main into newsletter sync branch
michaelmwu Jun 15, 2026
9786246
Tighten newsletter provider contact handling
michaelmwu Jun 15, 2026
209846b
Add newsletter sync dry run preview
michaelmwu Jun 25, 2026
416f4f4
Speed up newsletter dry run preview
michaelmwu Jun 25, 2026
24fe493
Sanitize newsletter dry run responses
michaelmwu Jun 25, 2026
fcc62ad
Redact newsletter exception warnings
michaelmwu Jun 25, 2026
3c35dd6
Add newsletter suppression registry
michaelmwu Jun 26, 2026
aa583fa
Add newsletter dashboard status tab
michaelmwu Jun 26, 2026
c12f5f0
Harden newsletter suppression sync
michaelmwu Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ EMAIL_REQUIRE_SENDER_AUTH_HEADERS=true
MIGADU_API_USER=your_migadu_api_user
MIGADU_API_KEY=your_migadu_api_key
MIGADU_MAILBOX_DOMAIN=508.dev
# Newsletter sync settings are dashboard-managed in normal deployments.
# Set non-empty env values only when you intentionally want env to lock dashboard edits.
# BREVO_API_KEY=
# BREVO_API_BASE_URL=https://api.brevo.com/v3
# BREVO_API_TIMEOUT_SECONDS=20.0
# BREVO_508_MEMBERS_NEWSLETTER_LIST_ID=4
# BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME="508 members"
# KEILA_API_KEY=
# KEILA_API_BASE_URL=https://app.keila.io
# KEILA_API_TIMEOUT_SECONDS=20.0
# NEWSLETTER_SYNC_ENABLED=true
# NEWSLETTER_SYNC_INTERVAL_SECONDS=604800
# NEWSLETTER_SYNC_EXCLUDED_MAILBOXES=system,service-account

# EspoCRM (required for worker integration)
ESPO_API_KEY=your_key_here
Expand Down
14 changes: 14 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ current precedence rules.

- `Required for /create-mailbox and /create-user-accounts`: `MIGADU_API_USER`, `MIGADU_API_KEY`
- `Optional`: `MIGADU_MAILBOX_DOMAIN` (default: `508.dev`)
- Newsletter sync settings are normally set from the admin dashboard configuration page. A non-empty env or `.env` value locks the matching dashboard field.
- `Optional for Brevo newsletter sync`: `BREVO_API_KEY`
- `Optional`: `BREVO_API_BASE_URL` (default: `https://api.brevo.com/v3`)
- `Optional`: `BREVO_API_TIMEOUT_SECONDS` (default: `20.0`)
- `Optional for Brevo newsletter sync`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID` (explicit Brevo list ID override; use `4` for the 508 members list when setting it directly)
- `Optional`: `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default: `508 members`; used to look up the list ID when the explicit ID is unset)
- `Optional for Keila contact sync`: `KEILA_API_KEY`
- `Optional`: `KEILA_API_BASE_URL` (default: `https://app.keila.io`)
- `Optional`: `KEILA_API_TIMEOUT_SECONDS` (default: `20.0`)
Comment thread
Copilot marked this conversation as resolved.
- `Optional`: `NEWSLETTER_SYNC_ENABLED` (default: `true`)
- `Optional`: `NEWSLETTER_SYNC_INTERVAL_SECONDS` (default: `604800`, one week)
- `Optional`: `NEWSLETTER_SYNC_EXCLUDED_MAILBOXES` (comma-separated mailbox local-parts or full addresses to skip during Migadu resync)
- Note: mailbox and backup email subscription to configured newsletter tools is best effort. Failures are reported as warnings and do not block mailbox or account creation.
- Note: the periodic sync uses Migadu mailboxes and password recovery emails as the source of truth for `@508.dev`. When CRM is configured, it only syncs mailboxes that match a CRM contact; it also skips configured excluded mailboxes and does not re-add provider-suppressed contacts.

## Authentik SSO Provisioning

Expand Down
48 changes: 48 additions & 0 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ const configurationGroups: ConfigurationGroupMetadata[] = [
description:
"Editable onboarding integrations such as DocuSeal, Outline, and onboarding email SMTP.",
},
{
category: "Newsletter",
label: "Newsletter",
description: "Brevo, Keila, and recurring 508 members audience sync settings.",
},
{
category: "AI",
label: "AI Providers",
Expand Down Expand Up @@ -1939,6 +1944,32 @@ function App() {
}
}

async function syncNewsletters() {
setBusy("syncNewsletters", true)
showToast("Queueing newsletter sync")
try {
const payload = await requestJson<{
job_id?: string
dry_run?: boolean
would_enqueue?: { job_type?: string }
}>("/dashboard/api/sync/newsletters", {
method: "POST",
Comment on lines +2090 to +2091

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild dashboard bundle for new sync action

In deployments that serve the committed dashboard bundle, this new source-only handler is not reachable: the API mounts apps/api/src/five08/backend/static/dashboard as the dashboard assets, and rg "/dashboard/api/sync/newsletters" apps/api/src/five08/backend/static/dashboard has no hits. Until the built index-*.js/manifest are regenerated alongside this main.tsx change, users of the packaged dashboard will not see or call the new “Sync newsletters” button even though the backend route exists.

Useful? React with 👍 / 👎.

})
if (payload.dry_run) {
showToast(
`Dry run only: would queue ${payload.would_enqueue?.job_type || "newsletter sync"}`,
"warning",
)
} else {
showToast(`Queued newsletter sync ${payload.job_id}`, "ok")
}
} catch (error) {
showError(error, "Unable to queue newsletter sync")
} finally {
setBusy("syncNewsletters", false)
}
}

async function assignOnboarder(contactId: string | undefined, onboarder: string) {
const normalizedContactId = String(contactId || "").trim()
const normalizedOnboarder = onboarder.trim()
Expand Down Expand Up @@ -2454,6 +2485,7 @@ function App() {
people={sortedPeople}
sort={sort.people}
canSync={canUse("people:sync")}
canSyncNewsletters={canUse("people:sync")}
loading={loading}
peopleQuery={peopleQuery}
peopleMember={peopleMember}
Expand All @@ -2463,6 +2495,7 @@ function App() {
peopleFilterKeys={peopleFilterKeys}
onSearch={loadPeople}
onSync={syncPeople}
onSyncNewsletters={syncNewsletters}
onSort={(key) => handleSort("people", key)}
setPeopleQuery={setPeopleQuery}
setPeopleMember={setPeopleMember}
Expand Down Expand Up @@ -5492,6 +5525,7 @@ function PeopleView(props: {
people: Person[]
sort: { key: string; direction: SortDirection }
canSync: boolean
canSyncNewsletters: boolean
loading: Record<string, boolean>
peopleQuery: string
peopleMember: string
Expand All @@ -5501,6 +5535,7 @@ function PeopleView(props: {
peopleFilterKeys: PeopleFilterKey[]
onSearch: () => void
onSync: () => void
onSyncNewsletters: () => void
onSort: (key: string) => void
setPeopleQuery: (value: string) => void
setPeopleMember: (value: string) => void
Expand Down Expand Up @@ -5529,6 +5564,19 @@ function PeopleView(props: {
Sync people
</Button>
) : null}
{props.canSyncNewsletters ? (
<Button
id="syncNewsletters"
data-permission="people:sync"
type="button"
variant="secondary"
onClick={props.onSyncNewsletters}
disabled={props.loading.syncNewsletters}
>
<RefreshCw />
Sync newsletters
</Button>
) : null}
{props.crmBaseUrl ? (
<a
id="crmHomeLink"
Expand Down
128 changes: 127 additions & 1 deletion apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,9 @@ def _get_agent_orchestrator() -> AgentOrchestrator:
_AGENT_ORCHESTRATOR = AgentOrchestrator(
registry=ToolRegistry(
_AGENT_TASK_STORE,
runtime_config=ToolRuntimeConfig.from_settings(settings),
runtime_config_factory=lambda: ToolRuntimeConfig.from_settings(
settings
),
),
model_config=AgentModelConfig.from_settings(settings),
intent_normalizer=OpenAICompatibleIntentNormalizer.from_settings(
Expand Down Expand Up @@ -580,6 +582,12 @@ def _crm_sync_idempotency_key(*, now: datetime) -> str:
return f"crm-sync:{bucket}"


def _newsletter_sync_idempotency_key(*, now: datetime) -> str:
interval_seconds = max(1, settings.newsletter_sync_interval_seconds)
bucket = int(now.timestamp()) // interval_seconds
return f"newsletter-sync:508-members:{bucket}"


def _normalize_google_forms_input(value: str | None) -> str | None:
if not isinstance(value, str):
return None
Expand Down Expand Up @@ -663,6 +671,37 @@ async def _enqueue_erpnext_project_sync_job(
return job


async def _enqueue_newsletter_sync_job(
queue: QueueClient,
*,
reason: str,
) -> EnqueuedJob:
now = datetime.now(tz=timezone.utc)
idempotency_key = (
_newsletter_sync_idempotency_key(now=now)
if reason == "scheduler"
else (
f"newsletter-sync:508-members:{reason}:"
f"{now.strftime('%Y%m%d%H%M%S%f')}:{uuid4().hex}"
)
)
Comment on lines +683 to +690
job: EnqueuedJob = await asyncio.to_thread(
enqueue_job,
queue=queue,
fn=JOB_FUNCTIONS["sync_508_members_newsletters_job"],
args=(),
settings=settings,
idempotency_key=idempotency_key,
)
logger.info(
"Enqueued 508 members newsletter sync job id=%s created=%s reason=%s",
job.id,
job.created,
reason,
)
return job


async def _crm_sync_scheduler(app: FastAPI) -> None:
queue = app.state.queue
interval_seconds = max(1, settings.crm_sync_interval_seconds)
Expand All @@ -674,6 +713,17 @@ async def _crm_sync_scheduler(app: FastAPI) -> None:
await asyncio.sleep(interval_seconds)


async def _newsletter_sync_scheduler(app: FastAPI) -> None:
queue = app.state.queue
interval_seconds = max(1, settings.newsletter_sync_interval_seconds)
while True:
try:
await _enqueue_newsletter_sync_job(queue, reason="scheduler")
except Exception:
logger.exception("Failed scheduling 508 members newsletter sync job")
await asyncio.sleep(interval_seconds)


async def _email_resume_scheduler() -> None:
"""Run periodic mailbox polling for resume ingestion."""
poller = ResumeMailboxProcessor(settings)
Expand Down Expand Up @@ -6611,6 +6661,64 @@ async def dashboard_sync_people_handler(request: Request) -> JSONResponse:
)


async def dashboard_sync_newsletters_handler(request: Request) -> JSONResponse:
"""Queue a 508 members newsletter sync from the authenticated dashboard."""
session, error_response, dry_run = await _dashboard_write_session_or_dry_run(
request,
required_permission=DASHBOARD_PERMISSION_PEOPLE_SYNC,
dry_run_permission=DASHBOARD_PERMISSION_PEOPLE_SYNC_DRY_RUN,
)
if error_response is not None:
return error_response
assert session is not None

csrf_error = _dashboard_same_origin_post_or_error(request)
if csrf_error is not None:
return csrf_error

if dry_run:
return JSONResponse(
{
"status": "dry_run",
"dry_run": True,
"source": "dashboard",
"would_enqueue": {
"queue": settings.redis_queue_name,
"job_type": "sync_508_members_newsletters_job",
"reason": "dashboard",
"idempotency_key_pattern": "newsletter-sync:508-members:dashboard:<timestamp>:<uuid>",
},
Comment on lines +6797 to +6800
}
)

job = await _enqueue_newsletter_sync_job(
request.app.state.queue, reason="dashboard"
)
actor_provider, actor_subject = _session_audit_actor(session)
await _write_auth_audit_event(
action="newsletter.508_members_sync",
result=AuditResult.SUCCESS,
actor_subject=actor_subject,
actor_display_name=session.display_name,
actor_provider=actor_provider,
resource_type="newsletter_sync",
resource_id=job.id,
metadata={
"source": "dashboard",
"queue": settings.redis_queue_name,
},
)
return JSONResponse(
{
"status": "queued",
"source": "dashboard",
"job_id": job.id,
"created": job.created,
},
status_code=202,
)


async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
"""Queue per-contact people cache sync jobs from CRM webhook events."""
if not _is_webhook_authorized(request):
Expand Down Expand Up @@ -8085,6 +8193,13 @@ async def _lifespan(app: FastAPI) -> Any:
else:
logger.info("CRM sync scheduler disabled by config")

if settings.newsletter_sync_enabled:
app.state.newsletter_sync_task = asyncio.create_task(
_newsletter_sync_scheduler(app)
)
else:
logger.info("508 members newsletter sync scheduler disabled by config")

if settings.email_resume_intake_enabled:
app.state.email_resume_task = asyncio.create_task(_email_resume_scheduler())
else:
Expand All @@ -8105,6 +8220,12 @@ async def _lifespan(app: FastAPI) -> Any:
with contextlib.suppress(asyncio.CancelledError):
await task

if hasattr(app.state, "newsletter_sync_task"):
task = app.state.newsletter_sync_task
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

if hasattr(app.state, "http_client"):
await app.state.http_client.aclose()

Expand Down Expand Up @@ -8330,6 +8451,11 @@ def create_app(*, run_lifespan: bool = True) -> FastAPI:
dashboard_sync_people_handler,
methods=["POST"],
)
app.add_api_route(
"/dashboard/api/sync/newsletters",
dashboard_sync_newsletters_handler,
methods=["POST"],
)
app.add_api_route(
"/dashboard/gigs/{item_id}",
dashboard_handler,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"index.html": {
"file": "assets/index-DUbmN0NW.js",
"file": "assets/index-DLefnMNV.js",
"name": "index",
"src": "index.html",
"isEntry": true,
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/api/src/five08/backend/static/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>508 Operations Dashboard</title>
<script type="module" crossorigin src="/dashboard/assets/index-DUbmN0NW.js"></script>
<script type="module" crossorigin src="/dashboard/assets/index-DLefnMNV.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-BoK8s4aw.css">
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions apps/discord_bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Relevant configuration:
- `/create-mailbox`
- Description: Create a Migadu mailbox for a 508 user, optionally link it to a CRM contact, and sync `c508Email`.
- Prerequisites: `MIGADU_API_USER` and `MIGADU_API_KEY` must be configured (configured in env; command will fail if missing).
- Newsletter sync: if Brevo and/or Keila are configured, the new 508 mailbox and backup email are added to the configured 508 members audience. Brevo uses `BREVO_508_MEMBERS_NEWSLETTER_LIST_ID`, or the list named by `BREVO_508_MEMBERS_NEWSLETTER_LIST_NAME` (default `508 members`) when the explicit ID is unset. Newsletter failures are shown as warnings and do not block mailbox creation.
- Required role: Admin
- Args:
- `mailbox_username` (required): 508 mailbox username or address. If the domain is omitted, `@508.dev` is added automatically.
Expand Down
Loading