Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 +1955 to +1956

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}"
)
)
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>",
},
}
)

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