Skip to content

feat: integrate mailing list service (CM-1318)#4346

Open
themarolt wants to merge 77 commits into
mainfrom
feat/mailing-list-integration-CM-1318
Open

feat: integrate mailing list service (CM-1318)#4346
themarolt wants to merge 77 commits into
mainfrom
feat/mailing-list-integration-CM-1318

Conversation

@themarolt

Copy link
Copy Markdown
Contributor

Summary

Integrate mailing list (public-inbox) support into CDP as a containerized worker. This implements email ingestion from public-inbox repositories (like LKML), mirroring shards, parsing emails, and emitting activities to Kafka for the data sink.

  • New mailing_list_integration service (Python, FastAPI)
  • Database schema for list management and processing state
  • Public-inbox integration via subprocess (mirrors, incremental fetch)
  • Email parser ported from noteren
  • Kafka producer for activity ingestion
  • Docker image for containerized deployment

How it works

  1. Worker polls mailinglist.listProcessing with FOR UPDATE SKIP LOCKED
  2. For each list, calls public-inbox-clone (initial) or public-inbox-fetch (incremental)
  3. Discovers shards (0.git, 1.git, etc.) and walks new commits since last processed
  4. Parses each email blob, emits groupsio-platform activity JSON
  5. Inserts results into integration.results (state=pending)
  6. Sends Kafka message to data-sink-worker for activity processing
  7. Updates lastProcessedHeads (per-shard tracking) and marks list as COMPLETED

Test coverage

  • 54 pytest tests (email parser) remain green
  • Ruff lint clean on all new Python files
  • Database schema migrates cleanly
  • Server imports and FastAPI lifespan tested

Out of scope

  • Onboarding UI to create integrations + mailinglist.lists rows
  • Member join/leave derivation (message ingestion only initially)

This PR targets hardcoded integrations for initial deployment; UI follows in a separate ticket.

themarolt added 11 commits July 14, 2026 18:50
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI review requested due to automatic review settings July 15, 2026 10:56
@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New integration surface (external URL fetch in worker, tenant connect API) with SSRF mitigations on sourceUrl, but large cross-cutting changes to ingestion and list ownership semantics warrant careful review and staged rollout.

Overview
Adds end-to-end mailing list ingestion (lore/public-inbox archives) as a new mailinglist platform: a Python crowdmail worker mirrors lists, parses messages, writes integration.results, and emits Kafka for the data-sink pipeline, plus backend onboarding and ops tooling until the UI ships.

Backend & data model: New PUT /mailing-list-connect (tenant edit) validates list name + sourceUrl (HTTPS-only, localhost blocked, URL canonicalization for dedup/ownership), then IntegrationService.mailingListConnectOrUpdate upserts mailinglist.lists / listProcessing with row locks and cross-integration sourceUrl conflict checks. Integration delete soft-removes mailing lists. Migrations add the mailinglist schema and a message activity type.

Worker (services/apps/mailing_list_integration): FastAPI lifespan runs a poll worker (SKIP LOCKED acquire, onboarding concurrency/backlog guards), public-inbox-clone/fetch, shard head tracking, batched idempotent inserts, and Kafka process_integration_result messages. Parser rejects implausible email dates (ADR-0011). CI adds ruff + pytest for this app; Docker/compose and local dev scripts (onboard-default-tenant, create-mailing-list-integration) support bootstrap without Auth0/UI.

Reviewed by Cursor Bugbot for commit a2ac5b9. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a containerized Python worker for ingesting public-inbox mailing lists into CDP through Kafka and integration.results.

Changes:

  • Adds mirroring, email parsing, processing, and Kafka publishing.
  • Adds mailing-list database schema and development seed.
  • Adds container, Compose, CI, and local tooling.

Review note: This exceeds the recommended 1,000-line PR target.

Reviewed changes

Copilot reviewed 26 out of 35 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
services/apps/mailing_list_integration/src/test/test_noteren.py Adds parser and CLI tests.
services/apps/mailing_list_integration/src/runner.sh Starts Uvicorn.
services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Implements ingestion loop.
services/apps/mailing_list_integration/src/crowdmail/worker/__init__.py Initializes worker package.
services/apps/mailing_list_integration/src/crowdmail/settings.py Defines environment configuration.
services/apps/mailing_list_integration/src/crowdmail/services/queue/queue_service.py Adds Kafka producer.
services/apps/mailing_list_integration/src/crowdmail/services/queue/__init__.py Initializes queue package.
services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py Parses public-inbox emails.
services/apps/mailing_list_integration/src/crowdmail/services/parse/__init__.py Initializes parser package.
services/apps/mailing_list_integration/src/crowdmail/services/mirror/mirror_service.py Manages public-inbox mirrors.
services/apps/mailing_list_integration/src/crowdmail/services/mirror/__init__.py Initializes mirror package.
services/apps/mailing_list_integration/src/crowdmail/services/__init__.py Initializes services package.
services/apps/mailing_list_integration/src/crowdmail/server.py Adds FastAPI lifecycle and health route.
services/apps/mailing_list_integration/src/crowdmail/models/list.py Defines mailing-list model.
services/apps/mailing_list_integration/src/crowdmail/models/__init__.py Initializes models package.
services/apps/mailing_list_integration/src/crowdmail/logger.py Configures Loguru.
services/apps/mailing_list_integration/src/crowdmail/errors.py Defines service errors.
services/apps/mailing_list_integration/src/crowdmail/enums.py Defines service enums.
services/apps/mailing_list_integration/src/crowdmail/database/registry.py Adds database helpers.
services/apps/mailing_list_integration/src/crowdmail/database/crud.py Adds processing-state queries.
services/apps/mailing_list_integration/src/crowdmail/database/connection.py Manages asyncpg pooling.
services/apps/mailing_list_integration/src/crowdmail/database/__init__.py Initializes database package.
services/apps/mailing_list_integration/src/crowdmail/__init__.py Initializes application package.
services/apps/mailing_list_integration/README.md Documents operation and setup.
services/apps/mailing_list_integration/pyproject.toml Defines package and tooling.
services/apps/mailing_list_integration/Makefile Adds development commands.
services/apps/mailing_list_integration/dev/seed.sql Seeds an example integration.
scripts/services/mailing-list-integration.yaml Adds Compose services.
scripts/services/docker/Dockerfile.mailing_list_integration Builds the worker image.
scripts/cli Registers the service in clean-start handling.
scripts/builders/mailing-list-integration.env Configures image publishing.
backend/src/database/migrations/V1784048135__mailinglist-schema.sql Creates mailing-list tables.
.github/workflows/backend-lint.yaml Adds Python linting CI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/services/docker/Dockerfile.mailing_list_integration
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread services/apps/mailing_list_integration/pyproject.toml
Comment thread services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py Outdated
Comment thread .github/workflows/backend-lint.yaml
Signed-off-by: Uroš Marolt <uros@marolt.me>
…ion (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
…318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI review requested due to automatic review settings July 20, 2026 09:17
Comment thread services/apps/mailing_list_integration/src/crowdmail/server.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 40 out of 51 changed files in this pull request and generated 16 comments.

Comments suppressed due to low confidence (3)

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:90

  • Initial onboarding materializes every commit ID and retains every parsed DB/Kafka record until the entire archive finishes. For LKML-scale archives this is unbounded memory usage and no progress is checkpointed before a likely OOM/restart. Process bounded commit batches and persist each batch/head checkpoint before continuing, similar to the 250-commit chunking in git_integration/src/crowdgit/services/commit/commit_service.py:709-743.
    .github/workflows/backend-lint.yaml:147
  • This CI job installs pytest and the PR adds a substantial test suite, but the job runs only Ruff. As a result, parser regressions can merge even though the PR reports the tests as coverage. Run pytest in this job after lint/format checks.
      - name: Check Python linting and formatting
        if: steps.changes.outputs.python_changed == 'true'
        run: |
          uv run ruff check src/ --output-format=github
          uv run ruff format --check src/

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:31

  • This introduces a new class-based worker, contrary to the repository's functions-over-classes direction for new service code (CLAUDE.md:42-43 and services-checklist.md:41-43). Keep shutdown state in a small functional runner/context and expose plain processing functions so the polling and per-list logic remain independently testable.

Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread backend/src/api/integration/helpers/mailingListAuthenticate.ts Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
Comment thread services/apps/mailing_list_integration/README.md Outdated
Comment thread services/apps/mailing_list_integration/README.md Outdated
Comment thread services/apps/mailing_list_integration/README.md Outdated
Comment thread services/apps/mailing_list_integration/README.md Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py Outdated
Copilot AI review requested due to automatic review settings July 20, 2026 16:49
Comment thread services/apps/mailing_list_integration/uv.lock
Comment thread .github/workflows/backend-lint.yaml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 40 out of 51 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (10)

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:84

  • This ignores the stored source_url; ensure_mirror always clones https://lore.kernel.org/{name}/. A valid public-inbox URL with a different host or path will silently ingest the wrong archive. Pass the source URL to the mirror layer and keep the local directory key separate.
    services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:91
  • On initial onboarding, new_commits(..., None) materializes the shard’s entire history, and this method then retains every DB and Kafka record until all shards finish. Public-inbox shards can contain hundreds of thousands of messages, so the worker can exhaust memory before reaching the existing insert chunks. Iterate in bounded batches and checkpoint each batch only after persistence and emission succeed.
    services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:93
  • read_email launches synchronous Git subprocesses on the FastAPI event loop—twice per message. A large import will block health requests, shutdown handling, and other async work for long periods. Offload this blocking call to a thread.
    services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:94
  • Any parser exception escapes this per-commit loop, marks the whole list failed, and leaves the shard head unchanged. The next retry starts from the same malformed message, so one poison email can permanently block every later message in the shard. Catch failures per commit and skip/quarantine them with observable logging or metrics, as the Git integration does in commit_service.py:652-655.
    services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py:72
  • This helper is called by the long-running worker, so sys.exit() raises SystemExit, bypasses its except Exception handlers, and can terminate the service because of one malformed commit. Raise a domain exception and let the worker apply its per-message failure policy instead.
    services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py:87
  • The blob-read failure also calls sys.exit(). In worker mode this can terminate the entire service instead of failing one message/list. Raise a normal domain exception here, consistent with the commit-not-found branch.
    services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py:354
  • Messages without Message-ID are emitted with an empty sourceId. Activity deduplication keys include sourceId, so multiple such messages in the same segment/platform/type/channel can collapse into one activity. Use a stable fallback such as the Git commit/blob ID, or explicitly skip these messages.
    services/apps/mailing_list_integration/src/crowdmail/services/mirror/mirror_service.py:58
  • The five-minute default applies to initial public-inbox-clone and fetch operations. LKML-scale archives can legitimately take much longer, so onboarding will repeatedly kill the clone and never complete. Use a separately configurable mirror timeout sized for large archives while retaining shorter limits for lightweight commands.
    services/apps/mailing_list_integration/src/crowdmail/database/crud.py:117
  • A process crash after acquisition leaves state='processing' and lockedAt set. This exclusion, combined with both selectors requiring lockedAt IS NULL, means the list is never acquired again; stale onboarding rows also consume the concurrency count forever. Treat locks as leases and reclaim/reset processing rows older than a configured threshold.
    states_to_exclude = (ListState.PENDING, ListState.PROCESSING, ListState.PENDING_REONBOARD)

services/apps/mailing_list_integration/README.md:16

  • This documentation still says the new worker emits platform=groupsio, while the implementation, seed, activity type, and identities all use platform=mailinglist. The same stale Groups.io references recur at lines 46, 62, and 66; update them so operators verify the actual platform.
  parses new messages, writes activities to `integration.results`, and emits
  Kafka messages to the `data-sink-worker` topic — same plumbing as
  git_integration, with `platform=groupsio`.

Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py Outdated
Comment thread backend/src/database/migrations/V1784291394__add_mailinglist_activity_type.sql Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 41 out of 52 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (5)

backend/src/api/integration/helpers/mailingListAuthenticate.ts:32

  • This validation still permits SSRF targets such as https://10.0.0.1/..., link-local/private IPv6 addresses, and hostnames that resolve to private addresses; the worker then passes the URL to public-inbox-clone. HTTPS only excludes the HTTP metadata endpoint, not internal HTTPS services, and redirect/DNS resolution must also be checked. Validate the resolved destination (and redirects) against private, loopback, link-local, and reserved ranges, or restrict sources to an explicit allowlist.
const isSafeSourceUrl = (sourceUrl: string): boolean => {
  try {
    const url = new URL(sourceUrl)
    return url.protocol === 'https:' && !isBlockedHost(url.hostname.toLowerCase())

backend/src/api/integration/helpers/mailingListAuthenticate.ts:45

  • This only strips trailing slashes, so equivalent archive URLs such as https://LORE.KERNEL.ORG/list, https://lore.kernel.org:443/list, and fragment variants remain different lock/unique keys. Two integrations can therefore pass the ownership check and mirror the same archive. Canonicalize through URL so host casing, the default HTTPS port, fragments, and trailing slashes normalize consistently before locking or storing.
export const canonicalizeSourceUrl = (sourceUrl: string): string => sourceUrl.replace(/\/+$/, '')

services/libs/data-access-layer/src/mailinglist/index.ts:128

  • A source owned by a soft-deleted integration is allowed through findMailingListsOwnedByOtherIntegration, but its mailinglist.lists row itself remains active. On takeover, this condition does not reset lastProcessedHeads, so the new integration resumes the old owner's checkpoint and emits no historical messages. Reset processing state whenever ownership changes (or when the previous integration is deleted), not only when the list row was soft-deleted.
    services/apps/mailing_list_integration/src/crowdmail/database/crud.py:82
  • Stale reclaim can run concurrently with the original worker, but the acquired row has no lease token and all later checkpoint/state/release writes match only listId. If the original worker resumes, it can overwrite a newer shard head or clear the replacement worker's lock. Carry an acquisition token/version and make every mutation conditional on it so stale workers can no longer commit.
                lp.state = $1
                AND lp."lastProcessedAt" IS NULL
                AND lp."lockedAt" < NOW() - INTERVAL '1 hour' * $4::numeric
            )

services/apps/mailing_list_integration/src/crowdmail/server.py:19

  • The PR description says FastAPI lifespan/server imports are tested, but the only test module is src/test/test_noteren.py and it never imports crowdmail.server. Worker startup, shutdown, timeout cancellation, and queue cleanup in this lifespan are therefore untested. Add lifespan tests or correct the stated test coverage.

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 41 out of 52 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

backend/src/api/integration/helpers/mailingListAuthenticate.ts:32

  • This check does not prevent SSRF from the worker. An allowed hostname can resolve to an RFC1918/link-local address (or redirect there), and IPv6 private/mapped-loopback forms are also accepted; public-inbox-clone then performs the request from the service network. Validate resolved addresses and every redirect at fetch time, or restrict sources to an explicit public-inbox allowlist.
    return url.protocol === 'https:' && !isBlockedHost(url.hostname.toLowerCase())

services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py:336

  • The RFC From value—and even the first body From: line above—is unauthenticated and sender-controlled, but this marks it as a globally verified identity. The data sink uses verified identities for canonical member ownership, so a forged message can be attached to an existing member and misattribute activity. Keep the address unverified unless an authentication signal is validated, and use a non-spoofable mailing-list identifier for the required platform identity.
    services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:116
  • A git-read error is described as transient, but break only exits this shard; execution reaches state = COMPLETED, so the failed commit is not retried until the normal 24-hour recurrent interval instead of the 6-hour failed interval. Propagate the error so the list is marked FAILED while leaving this commit uncheckpointed.
    services/apps/mailing_list_integration/src/crowdmail/database/crud.py:86
  • c.count < $3 is not an atomic concurrency limit. Concurrent workers can all observe the same count below the cap, lock different list rows via SKIP LOCKED, and each transition one to PROCESSING, exceeding MAX_CONCURRENT_ONBOARDINGS. Serialize the count-and-acquire operation (for example with a transaction advisory lock) or use a dedicated semaphore/slot table.
            AND c.count < $3

services/apps/mailing_list_integration/pyproject.toml:10

  • The package metadata points to the repository Apache-2.0 LICENSE, while crowdmail/services/parse/noteren.py declares GPL-2.0-or-later and contains code taken from b4. Shipping the wheel/container as Apache-only is inconsistent with the included source and omits the applicable GPL license terms. Confirm the port's licensing and update package metadata/distributed notices before release.
license = { file = "LICENSE" }

services/apps/mailing_list_integration/src/crowdmail/server.py:13

  • The PR description says the FastAPI lifespan is tested, but the new pytest suite contains only test_noteren.py; no test starts this lifespan or verifies worker startup/shutdown and timeout cancellation. Add the stated lifespan coverage (or correct the PR claim) so this deployment-critical behavior is actually validated.

…M-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
…318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
…M-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 53 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

services/libs/data-access-layer/src/mailinglist/index.ts:128

  • This reset misses the integration-deletion path. IntegrationService.destroyAll() soft-deletes the integration but does not soft-delete its mailinglist.lists rows; the ownership check then permits a new integration to claim that URL. Because e."deletedAt" is still null, the upsert changes the owner while preserving the old owner's shard heads, so the new project permanently skips all previously processed history. Include the previous integrationId (or deleted owner state) in existing and reset processing whenever ownership changes.
    services/apps/mailing_list_integration/src/crowdmail/database/crud.py:180
  • This counts every historical integration result, not the pending backlog described by the method. Once the table contains 5,000,000 processed/error rows, onboarding is disabled forever even with zero queued work. Restrict the bounded scan to pending rows (the table has an index on state).
        integration_results_count = await fetchval(
            "SELECT COUNT(*) FROM (SELECT 1 FROM integration.results LIMIT $1) t",
            (MAX_INTEGRATION_RESULTS,),
        )

services/apps/mailing_list_integration/src/crowdmail/database/crud.py:89

  • The onboarding cap is not atomic across worker replicas. For example, with a count of 2 and a cap of 3, two transactions can both observe c.count = 2, lock different rows via SKIP LOCKED, and both transition them to processing, producing 4 concurrent onboardings. Serialize the count-and-claim operation with a database semaphore/advisory lock acquired before the statement, or model a fixed number of claimable slots.
            AND l."deletedAt" IS NULL
            AND i."deletedAt" IS NULL
            AND c.count < $3
        ORDER BY lp.priority ASC, lp."createdAt" ASC
        LIMIT 1
        FOR UPDATE OF lp SKIP LOCKED

backend/src/api/integration/helpers/mailingListAuthenticate.ts:32

  • Requiring HTTPS and blocking only literal loopback names is not an SSRF boundary. Inputs such as https://10.0.0.1/..., private/link-local IPv6, localhost., or a public hostname that resolves or redirects to an internal address all pass and are later fetched by public-inbox-clone from the worker network. Use an explicit host allowlist or resolve and reject all non-public addresses at validation time and again for every worker-side redirect; also reject URL credentials.
const isSafeSourceUrl = (sourceUrl: string): boolean => {
  try {
    const url = new URL(sourceUrl)
    return url.protocol === 'https:' && !isBlockedHost(url.hostname.toLowerCase())

backend/src/api/integration/helpers/mailingListAuthenticate.ts:45

  • This only removes trailing slashes, so equivalent URLs such as https://LORE.kernel.org/list and https://lore.kernel.org:443/list remain distinct. That bypasses duplicate detection, the ownership check, advisory locking, and the database uniqueness constraint, allowing the same archive to be connected to multiple projects and ingested twice. Canonicalize with URL semantics (host casing, default port, normalized path) and explicitly reject credentials, query strings, and fragments before deduplication and storage.
// "https://host/list" and "https://host/list/" must resolve to the same DB
// row — the worker's ensure_mirror() already normalizes to this form before
// cloning (mirror_service.py), so storage must match or the same archive
// ends up mirrored twice under two rows. Kept as a plain function (not a
// zod .transform()/.preprocess()) since either makes the field optional in
// z.infer with the installed zod v4 — reproduced in isolation, unrelated to
// this schema's nesting.
export const canonicalizeSourceUrl = (sourceUrl: string): string => sourceUrl.replace(/\/+$/, '')

Comment thread services/apps/mailing_list_integration/src/crowdmail/errors.py
…st segment (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
Comment thread services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py Outdated
…ls (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 53 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

services/libs/data-access-layer/src/mailinglist/index.ts:112

  • Reassigning the existing row to a new integration is unsafe while a worker may still hold this list ID. Worker checkpoint/state writes are keyed only by listId, so an old owner's in-flight run can overwrite the reset after this transaction commits, advance the new owner's heads, and cause its historical messages to be skipped. Use a new row or an ownership/lease generation that every worker write must match.
    services/apps/mailing_list_integration/src/crowdmail/database/crud.py:82
  • A legitimate onboarding run that lasts beyond this timeout is reclaimed even though the original worker is still alive, because lockedAt is set only on acquisition and never refreshed. The two workers can then write the same shard checkpoint, and JSONB merge does not prevent the older worker from regressing that shard key. Add a renewable lease/owner token and make checkpoint/final-state writes conditional on it.
                lp.state = $1
                AND lp."lastProcessedAt" IS NULL
                AND lp."lockedAt" < NOW() - INTERVAL '1 hour' * $4::numeric
            )

services/apps/mailing_list_integration/src/crowdmail/database/crud.py:135

  • The recurrent stale-reclaim path can also acquire a still-running worker: no code refreshes lockedAt during processing. Runs exceeding four hours may overlap, duplicate Kafka work, and regress a same-shard checkpoint when the slower worker writes later. Use a renewable lease/owner token and reject stale-owner updates.
                lp.state = $1
                AND lp."lastProcessedAt" IS NOT NULL
                AND lp."lockedAt" < NOW() - INTERVAL '1 hour' * $6::numeric
            )

services/apps/mailing_list_integration/src/crowdmail/services/parse/noteren.py:237

  • An empty or whitespace-only From: header is not None, so parse_author returns an empty verified username. The data sink filters both empty identities and rejects the activity, permanently dropping the message after this worker advances its head. Apply the same per-commit synthetic author fallback when parsing yields an empty address.
    backend/src/api/integration/helpers/mailingListAuthenticate.ts:32
  • This validation still permits SSRF targets such as RFC1918/ULA/link-local addresses, internal DNS names, and redirects to private services; https is not a network-boundary check. Because the worker passes this value to public-inbox-clone, validate the resolved destination (and every redirect) at fetch time or restrict sources to an explicit public-inbox allowlist.
const isSafeSourceUrl = (sourceUrl: string): boolean => {
  try {
    const url = new URL(sourceUrl)
    return (

Comment thread services/apps/data_sink_worker/src/service/member.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 53 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (5)

backend/src/api/integration/helpers/mailingListAuthenticate.ts:37

  • Requiring HTTPS and blocking only loopback literals still permits targets such as https://10.0.0.1, https://169.254.169.254, IPv6 private/link-local addresses, public DNS names resolving privately, and redirects to those hosts. Because the worker passes this tenant-controlled URL to public-inbox-clone, the endpoint can issue requests into the internal network. Enforce an approved-host policy or resolve and reject every non-public address on every redirect, backed by network egress restrictions.
      url.protocol === 'https:' &&
      !isBlockedHost(url.hostname.toLowerCase()) &&
      // Credentials/query/fragment have no meaning for a public-inbox archive
      // URL; rejecting them keeps canonicalizeSourceUrl a lossless
      // normalization instead of one that silently drops caller-supplied data.

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:116

  • This branch logs that the commit will be retried but then falls through to state = COMPLETED; mark_list_processed sets lastProcessedAt, so the failed commit is not retried until the normal 24-hour recurrent interval. Re-raise the operational read failure so the list is marked FAILED and uses the shorter failure retry interval.
    services/apps/data_sink_worker/src/service/member.service.ts:162
  • When this conflict is caused by an identity the member already owns with verified=false, the case-insensitive missing filter drops an incoming verified=true version and never promotes the stored row. The call then succeeds while the verified assertion is silently lost. As in syncIdentitiesAfterRedirect, update matching unverified identities before retrying the genuinely missing ones.
        const missing = deduped.filter(
          (incoming) =>
            !current.some(
              (existing) =>
                existing.platform === incoming.platform &&

services/apps/data_sink_worker/src/service/activity.service.ts:1845

  • This redirect path also treats an existing (platform, type, value) as complete regardless of verified. If the owner has that identity unverified and the incoming activity asserts it verified, it is excluded from missingIdentities, so the verification upgrade is silently discarded. Promote matching unverified rows before inserting the missing identities, as MemberService.syncIdentitiesAfterRedirect already does.
            !ownerIdentities.some(
              (existing) =>
                existing.platform === incoming.platform &&
                existing.type === incoming.type &&
                existing.value.trim().toLowerCase() === incoming.value.trim().toLowerCase(),

services/apps/mailing_list_integration/src/crowdmail/database/crud.py:89

  • The concurrency cap is not atomic across worker processes: concurrent transactions can both read the same count below the limit, lock different listProcessing rows via SKIP LOCKED, and each transition one to PROCESSING. This can exceed MAX_CONCURRENT_ONBOARDINGS precisely during expensive initial clones. Serialize the count-and-acquire decision (for example with a transaction advisory lock) or use a database-backed semaphore.
            AND c.count < $3
        ORDER BY lp.priority ASC, lp."createdAt" ASC
        LIMIT 1
        FOR UPDATE OF lp SKIP LOCKED

Comment thread services/libs/data-access-layer/src/mailinglist/index.ts Outdated
Comment thread services/libs/data-access-layer/src/mailinglist/index.ts
Signed-off-by: Uroš Marolt <uros@marolt.me>
…ame (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
…ple owners (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 53 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

backend/src/api/integration/helpers/mailingListAuthenticate.ts:27

  • This host check is bypassable: Node's URL parser reports IPv6 literals with brackets, so https://[::1]/... does not equal ::1; private/link-local addresses and hostnames resolving to them are also accepted. Because the worker passes this tenant-controlled URL to public-inbox-clone, this leaves an SSRF path into the worker's network. Resolve and reject all non-public addresses (including redirects), or restrict sources to an explicit public-inbox allowlist, and enforce the check again at fetch time.

const isSafeSourceUrl = (sourceUrl: string): boolean => {

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:116

  • This break turns a failed read into a successful run: control later sets the list state to COMPLETED and advances lastProcessedAt, even though this shard still has unread commits. Re-raise the operational error so the list is marked FAILED and uses the failed retry cadence; any unflushed commits remain safely uncheckpointed for retry.
    services/apps/data_sink_worker/src/service/activity.service.ts:1862
  • insertMemberIdentities defaults to failOnConflict=false, so this recovery path silently DO NOTHINGs any missing identity that is already owned by another member. The activity is then attached to ownerId without merging or surfacing the second identity conflict, leaving identities split across members. Route these identities through the same conflict-aware redirect/merge logic used by MemberService.syncIdentitiesAfterRedirect instead of using a conflict-ignoring insert.
          await insertMemberIdentities(
            this.pgQx,
            missingIdentities.map((identity) => ({
              memberId: ownerId,
              platform: identity.platform,
              value: identity.value,
              type: identity.type,
              verified: identity.verified,
              source: identity.source,
              sourceId: identity.sourceId,
              integrationId: identity.integrationId,
              verifiedBy: identity.verifiedBy,
            })),
          )

services/apps/mailing_list_integration/src/crowdmail/worker/list_worker.py:87

  • The core ingestion/checkpoint path has no tests; the added suite only exercises noteren.py. Please cover DB-insert → Kafka-send → head-update ordering, partial flushes, read failures, parse/date skips, and state/lock finalization. This is especially important because the analogous git ingestion path has integration coverage in services/apps/git_integration/src/test/test_activity_extraction.py.

Signed-off-by: Uroš Marolt <uros@marolt.me>
…CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 42 out of 53 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

backend/src/api/integration/helpers/mailingListAuthenticate.ts:25

  • This host check still permits SSRF. An authenticated caller can supply RFC1918, link-local, or private IPv6 destinations (and URL.hostname represents IPv6 loopback as [::1], which this comparison misses), while a public hostname can resolve or redirect to a private address. Because the worker passes this URL to public-inbox-clone, it can be made to access internal HTTPS services. Restrict sources to an allowlist or validate resolved addresses and every redirect again in the worker before connecting.
const isBlockedHost = (h: string): boolean =>
  h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.startsWith('127.')

services/apps/mailing_list_integration/src/crowdmail/database/crud.py:234

  • Merging JSONB does not prevent a stale worker from moving a shard checkpoint backward: lockedAt is never refreshed during processing, so stale-reclaim can start a second worker, and both workers overwrite the same shard key here. If the slower worker flushes last, subsequent polls re-read and requeue the already processed range. Add lease heartbeats plus a fencing token/conditional checkpoint update, or otherwise ensure only the current lease holder can advance heads.
    sql_query = """
    UPDATE mailinglist."listProcessing"
        SET "lastProcessedHeads" = "lastProcessedHeads" || $1::jsonb,
        "updatedAt" = NOW()
    WHERE "listId" = $2

services/apps/data_sink_worker/src/service/member.service.ts:165

  • This reconciliation treats an existing identity as complete without considering verification. If a concurrent write created the identity as unverified while this payload carries verified: true, the retry silently drops the promotion. The redirect path below already promotes this exact transition; do the same here before retrying missing identities.
        const currentMap = await findIdentitiesForMembers(this.pgQx, [memberId])
        const current = currentMap.get(memberId) ?? []
        const missing = deduped.filter(
          (incoming) =>
            !current.some(
              (existing) =>
                existing.platform === incoming.platform &&
                existing.type === incoming.type &&
                existing.value.trim().toLowerCase() === incoming.value.trim().toLowerCase(),
            ),

services/apps/data_sink_worker/src/service/activity.service.ts:1856

  • This second identity-sync implementation also ignores verification state. When the owner already has the same identity as unverified and the incoming payload verifies it, missingIdentities excludes it and the owner remains unverified. Reuse the redirect synchronization logic that both inserts missing identities and promotes verified=false to true, rather than duplicating only half of it here.
        const ownerIdentities =
          (await findIdentitiesForMembers(this.pgQx, [ownerId])).get(ownerId) ?? []
        const missingIdentities = incomingIdentities.filter(
          (incoming) =>
            !ownerIdentities.some(
              (existing) =>
                existing.platform === incoming.platform &&
                existing.type === incoming.type &&
                existing.value.trim().toLowerCase() === incoming.value.trim().toLowerCase(),
            ),

services/apps/mailing_list_integration/src/crowdmail/server.py:22

  • The PR description says FastAPI imports and lifespan behavior are tested, but the only added pytest module is test_noteren.py and it never imports this server or invokes lifespan. Add a lifespan test that verifies worker startup and graceful shutdown (including timeout cancellation), or correct the stated test coverage.

Comment thread services/apps/mailing_list_integration/src/crowdmail/server.py
…(CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>
…ach (CM-1318)

Signed-off-by: Uroš Marolt <uros@marolt.me>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 8 total unresolved issues (including 7 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 19018a8. Configure here.

Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Signed-off-by: Uroš Marolt <uros@marolt.me>
…egration-CM-1318

Signed-off-by: Uroš Marolt <uros@marolt.me>

# Conflicts:
#	docs/adr/README.md
#	scripts/cli
Signed-off-by: Uroš Marolt <uros@marolt.me>
Signed-off-by: Uroš Marolt <uros@marolt.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants