Skip to content

Commit c78b133

Browse files
authored
feat: integrate mailing list service (CM-1318) (#4346)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 4cf0734 commit c78b133

51 files changed

Lines changed: 5210 additions & 16 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/backend-lint.yaml

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jobs:
7777
- name: Check for Python file changes
7878
id: changes
7979
run: |
80-
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -q "^services/apps/git_integration/.*\.py$"; then
80+
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -qE "^services/apps/git_integration/.*(\.py|pyproject\.toml|uv\.lock)$"; then
8181
echo "python_changed=true" >> $GITHUB_OUTPUT
8282
else
8383
echo "python_changed=false" >> $GITHUB_OUTPUT
@@ -104,6 +104,53 @@ jobs:
104104
uv run ruff check src/ --output-format=github
105105
uv run ruff format --check src/
106106
107+
lint-python-mailing-list-integration:
108+
runs-on: ubuntu-latest
109+
defaults:
110+
run:
111+
shell: bash
112+
working-directory: ./services/apps/mailing_list_integration
113+
114+
steps:
115+
- name: Check out repository code
116+
uses: actions/checkout@v4
117+
with:
118+
fetch-depth: 0
119+
120+
- name: Check for Python file changes
121+
id: changes
122+
run: |
123+
if git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep -qE "^services/apps/mailing_list_integration/.*(\.py|pyproject\.toml|uv\.lock)$"; then
124+
echo "python_changed=true" >> $GITHUB_OUTPUT
125+
else
126+
echo "python_changed=false" >> $GITHUB_OUTPUT
127+
fi
128+
129+
- name: Install uv
130+
if: steps.changes.outputs.python_changed == 'true'
131+
uses: astral-sh/setup-uv@v3
132+
with:
133+
enable-cache: true
134+
cache-dependency-glob: "services/apps/mailing_list_integration/uv.lock"
135+
136+
- name: Set up Python
137+
if: steps.changes.outputs.python_changed == 'true'
138+
run: uv python install 3.13
139+
140+
- name: Install dependencies
141+
if: steps.changes.outputs.python_changed == 'true'
142+
run: uv sync --group dev --frozen
143+
144+
- name: Check Python linting and formatting
145+
if: steps.changes.outputs.python_changed == 'true'
146+
run: |
147+
uv run ruff check src/ --output-format=github
148+
uv run ruff format --check src/
149+
150+
- name: Run tests
151+
if: steps.changes.outputs.python_changed == 'true'
152+
run: uv run pytest src/test/ -v
153+
107154
- name: Skip Python checks
108155
if: steps.changes.outputs.python_changed == 'false'
109156
run: echo "⏭️ No Python files changed, skipping linting checks"

backend/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
"script:fix-duplicate-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-duplicate-members.ts",
3434
"script:fix-members-activities-after-unaffilation": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-members-activities-after-unaffilation.ts",
3535
"script:process-bot-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/process-bot-members.ts",
36-
"script:backfill-email-domain-member-organization-dates": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/backfill-email-domain-member-organization-dates.ts"
36+
"script:backfill-email-domain-member-organization-dates": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/backfill-email-domain-member-organization-dates.ts",
37+
"script:onboard-default-tenant": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/onboard-default-tenant.ts",
38+
"script:onboard-default-tenant:local": "set -a && . ./.env.dist.local && . ./.env.override.local && set +a && pnpm run script:onboard-default-tenant",
39+
"script:create-mailing-list-integration": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/create-mailing-list-integration.ts",
40+
"script:create-mailing-list-integration:local": "set -a && . ./.env.dist.local && . ./.env.override.local && set +a && SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/create-mailing-list-integration.ts"
3741
},
3842
"lint-staged": {
3943
"**/*.ts": [
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { z } from 'zod'
2+
3+
import Permissions from '../../../security/permissions'
4+
import IntegrationService from '../../../services/integrationService'
5+
import PermissionChecker from '../../../services/user/permissionChecker'
6+
import { validateOrThrow } from '../../../utils/validation'
7+
8+
const MAX_LIST_NAME_LENGTH = 255
9+
10+
// `name` is a display label only — the worker keys the mirror path by the
11+
// DB-generated list id, not `name` (see mirror_service.py's
12+
// list_mirror_dir/_validate_list_id), so it's free to contain path-like
13+
// characters (lore lists are nested, e.g. "some-project/git"). Only reject
14+
// the null byte, which breaks C-string-based tooling downstream regardless
15+
// of context.
16+
const isSafeListName = (name: string): boolean =>
17+
name.length > 0 && name.length <= MAX_LIST_NAME_LENGTH && !name.includes('\0')
18+
19+
// public-inbox-clone in the worker fetches this URL as-is; restrict to
20+
// https so a caller can't point the worker at file://, javascript:, or a
21+
// bare non-URL string. Requiring https already blocks the classic SSRF
22+
// target (cloud-metadata IMDS is http-only, per securityTxt.ts precedent);
23+
// also reject obvious loopback/localhost literals.
24+
const isBlockedHost = (h: string): boolean =>
25+
h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.startsWith('127.')
26+
27+
const isSafeSourceUrl = (sourceUrl: string): boolean => {
28+
try {
29+
const url = new URL(sourceUrl)
30+
return (
31+
url.protocol === 'https:' &&
32+
!isBlockedHost(url.hostname.toLowerCase()) &&
33+
// Credentials/query/fragment have no meaning for a public-inbox archive
34+
// URL; rejecting them keeps canonicalizeSourceUrl a lossless
35+
// normalization instead of one that silently drops caller-supplied data.
36+
url.username === '' &&
37+
url.password === '' &&
38+
url.search === '' &&
39+
url.hash === ''
40+
)
41+
} catch {
42+
return false
43+
}
44+
}
45+
46+
// "https://host/list" and "https://HOST/list/" and "https://host:443/list"
47+
// must all resolve to the same DB row — scheme case, host case, the default
48+
// https port, and a trailing slash are not meaningful differences for the
49+
// same archive, but a plain trailing-slash strip left them as distinct
50+
// strings, bypassing the cross-project ownership check and causing
51+
// duplicate ingestion. The worker's ensure_mirror() already normalizes to
52+
// this same form before cloning (mirror_service.py), so storage must match.
53+
// Kept as a plain function (not a zod .transform()/.preprocess()) since
54+
// either makes the field optional in z.infer with the installed zod v4 —
55+
// reproduced in isolation, unrelated to this schema's nesting.
56+
export const canonicalizeSourceUrl = (sourceUrl: string): string => {
57+
const url = new URL(sourceUrl)
58+
const port = url.port && url.port !== '443' ? `:${url.port}` : ''
59+
const path = url.pathname.replace(/\/+$/, '') || '/'
60+
return `https://${url.hostname.toLowerCase()}${port}${path}`
61+
}
62+
63+
export const bodySchema = z.object({
64+
lists: z
65+
.array(
66+
z.object({
67+
name: z.string().trim().min(1).refine(isSafeListName, {
68+
message: 'Invalid mailing list name',
69+
}),
70+
sourceUrl: z.string().trim().min(1).refine(isSafeSourceUrl, {
71+
message: 'sourceUrl must be a valid https:// URL',
72+
}),
73+
}),
74+
)
75+
.min(1, 'lists must contain at least one mailing list')
76+
.refine(
77+
(lists) =>
78+
new Set(lists.map((l) => canonicalizeSourceUrl(l.sourceUrl))).size === lists.length,
79+
{ message: 'lists contains duplicate sourceUrl entries' },
80+
),
81+
})
82+
83+
export default async (req, res) => {
84+
new PermissionChecker(req).validateHas(Permissions.values.tenantEdit)
85+
const integrationData = validateOrThrow(bodySchema, req.body)
86+
integrationData.lists = integrationData.lists.map((l) => ({
87+
...l,
88+
sourceUrl: canonicalizeSourceUrl(l.sourceUrl),
89+
}))
90+
91+
const payload = await new IntegrationService(req).mailingListConnectOrUpdate(integrationData)
92+
await req.responseHandler.success(req, res, payload)
93+
}

backend/src/api/integration/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export default (app) => {
7777

7878
// Git
7979
app.put(`/git-connect`, safeWrap(require('./helpers/gitAuthenticate').default))
80+
81+
app.put(`/mailing-list-connect`, safeWrap(require('./helpers/mailingListAuthenticate').default))
8082
app.put(`/confluence-connect`, safeWrap(require('./helpers/confluenceAuthenticate').default))
8183
app.put(`/gerrit-connect`, safeWrap(require('./helpers/gerritAuthenticate').default))
8284
app.get('/devto-validate', safeWrap(require('./helpers/devtoValidators').default))
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/* eslint-disable no-console */
2+
3+
/* eslint-disable import/no-extraneous-dependencies */
4+
import commandLineArgs from 'command-line-args'
5+
import commandLineUsage from 'command-line-usage'
6+
import * as fs from 'fs'
7+
8+
import { DEFAULT_TENANT_ID, generateUUIDv1 } from '@crowd/common'
9+
10+
import {
11+
bodySchema,
12+
canonicalizeSourceUrl,
13+
} from '@/api/integration/helpers/mailingListAuthenticate'
14+
import SegmentRepository from '@/database/repositories/segmentRepository'
15+
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
16+
import IntegrationService from '@/services/integrationService'
17+
import { validateOrThrow } from '@/utils/validation'
18+
19+
const options = [
20+
{
21+
name: 'help',
22+
alias: 'h',
23+
type: Boolean,
24+
description: 'Print this usage guide.',
25+
},
26+
{
27+
name: 'file',
28+
alias: 'f',
29+
type: String,
30+
description:
31+
'Path to a JSON file with the lists array, each entry shaped as name + sourceUrl ' +
32+
'(e.g. name "linux-serial", sourceUrl https://lore.kernel.org/linux-serial).',
33+
},
34+
{
35+
name: 'segment',
36+
alias: 's',
37+
type: String,
38+
description: "Segment id. Optional — defaults to the tenant's first subproject.",
39+
},
40+
]
41+
42+
const sections = [
43+
{
44+
header: 'Create mailing list integration',
45+
content:
46+
'Calls IntegrationService.mailingListConnectOrUpdate() directly to create/update the ' +
47+
'mailinglist integration and onboard its lists for processing, until the frontend connect ' +
48+
'UI ships (CM-1318).',
49+
},
50+
{
51+
header: 'Options',
52+
optionList: options,
53+
},
54+
]
55+
56+
const usage = commandLineUsage(sections)
57+
// pnpm forwards a literal `--` separator when args are passed via `pnpm run ... -- <args>`;
58+
// strip it so command-line-args doesn't choke on it.
59+
const argv = process.argv.slice(2).filter((arg) => arg !== '--')
60+
const parameters = commandLineArgs(options, { argv })
61+
62+
if (parameters.help || !parameters.file) {
63+
console.log(usage)
64+
process.exit(parameters.help ? 0 : 1)
65+
} else {
66+
setImmediate(async () => {
67+
let fileContents: string
68+
try {
69+
fileContents = fs.readFileSync(parameters.file, 'utf-8')
70+
} catch (err) {
71+
console.error(`Could not read file at ${parameters.file}: ${(err as Error).message}`)
72+
process.exit(1)
73+
}
74+
75+
let parsed: unknown
76+
try {
77+
parsed = JSON.parse(fileContents)
78+
} catch (err) {
79+
console.error(`File at ${parameters.file} is not valid JSON: ${(err as Error).message}`)
80+
process.exit(1)
81+
}
82+
83+
// Same validation as the connect API endpoint (mailingListAuthenticate.ts),
84+
// so a malformed file (non-array, missing fields, duplicate/non-https
85+
// sourceUrl) fails fast here instead of reaching IntegrationService with
86+
// whatever shape the file happened to contain.
87+
const { lists: parsedLists } = validateOrThrow(bodySchema, { lists: parsed })
88+
const lists = parsedLists.map((l) => ({ ...l, sourceUrl: canonicalizeSourceUrl(l.sourceUrl) }))
89+
90+
const repoOptions = await SequelizeRepository.getDefaultIRepositoryOptions()
91+
repoOptions.currentTenant = { id: DEFAULT_TENANT_ID }
92+
;(repoOptions as unknown as { requestId: string }).requestId = generateUUIDv1()
93+
94+
const adminUser = await repoOptions.database.user.findOne({ where: {} })
95+
if (!adminUser) {
96+
console.error('No user found — run script:onboard-default-tenant first.')
97+
process.exit(1)
98+
}
99+
repoOptions.currentUser = adminUser
100+
101+
const segmentRepository = new SegmentRepository(repoOptions)
102+
repoOptions.currentSegments = parameters.segment
103+
? await segmentRepository.findInIds([parameters.segment])
104+
: (await segmentRepository.querySubprojects({ limit: 1, offset: 0 })).rows
105+
106+
if (repoOptions.currentSegments.length === 0) {
107+
console.error('No segment found/resolved — pass --segment explicitly.')
108+
process.exit(1)
109+
}
110+
111+
console.log(
112+
`Segment: ${repoOptions.currentSegments[0].id} (${repoOptions.currentSegments[0].name})`,
113+
)
114+
console.log('Lists:', JSON.stringify(lists, null, 2))
115+
116+
const integration = await new IntegrationService(repoOptions).mailingListConnectOrUpdate(
117+
{ lists },
118+
repoOptions,
119+
)
120+
121+
console.log('Integration:', JSON.stringify(integration, null, 2))
122+
process.exit(0)
123+
})
124+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/* eslint-disable no-console */
2+
3+
/* eslint-disable import/no-extraneous-dependencies */
4+
import commandLineArgs from 'command-line-args'
5+
import commandLineUsage from 'command-line-usage'
6+
7+
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
8+
import AuthService from '@/services/auth/authService'
9+
10+
const options = [
11+
{
12+
name: 'help',
13+
alias: 'h',
14+
type: Boolean,
15+
description: 'Print this usage guide.',
16+
},
17+
{
18+
name: 'email',
19+
alias: 'e',
20+
type: String,
21+
defaultValue: 'local-dev@example.com',
22+
description: 'Email for the dev user driving the onboarding (default: local-dev@example.com)',
23+
},
24+
]
25+
26+
const sections = [
27+
{
28+
header: 'Onboard default tenant',
29+
content:
30+
'Runs the same onboarding logic as a real Auth0 signup (AuthService.signinFromSSO -> ' +
31+
'handleOnboard -> TenantService.createOrJoinDefault), without needing a real Auth0 token. ' +
32+
'Creates the default tenant, default segment, settings, and an admin user/tenantUser row. ' +
33+
'Safe to re-run — createOrJoinDefault joins the existing tenant instead of duplicating it.',
34+
},
35+
{
36+
header: 'Options',
37+
optionList: options,
38+
},
39+
]
40+
41+
const usage = commandLineUsage(sections)
42+
const parameters = commandLineArgs(options)
43+
44+
if (parameters.help) {
45+
console.log(usage)
46+
} else {
47+
setImmediate(async () => {
48+
const repoOptions = await SequelizeRepository.getDefaultIRepositoryOptions()
49+
50+
await AuthService.signinFromSSO(
51+
'auth0',
52+
`dev|${parameters.email}`,
53+
parameters.email,
54+
true,
55+
'Local',
56+
'Dev',
57+
'Local Dev',
58+
null,
59+
null,
60+
null,
61+
repoOptions,
62+
)
63+
64+
const tenant = await repoOptions.database.tenant.findOne({ where: {} })
65+
const segment = await repoOptions.database.segment.findOne({
66+
where: { tenantId: tenant.id },
67+
})
68+
69+
console.log(`Tenant: ${tenant.id} (${tenant.name})`)
70+
console.log(`Segment: ${segment.id} (${segment.name})`)
71+
72+
process.exit(0)
73+
})
74+
}

0 commit comments

Comments
 (0)