Skip to content

Commit 7eeda05

Browse files
AlaqmarGclaude
andauthored
Exec email: username fixes, mail stack, backfill plan (#76)
* Give execs @brockcsc.ca mailboxes: usernames, mail stack, deploy usernameFor folded accents away rather than mapping them, so "José Núñez" became "josnez" and non-Latin names became an empty string. Names are now folded properly, with any-ascii romanizing server-side; it stays out of the client bundle so the sign-up preview keeps no dependency. A name collision used to reject the second person outright with no way forward, which does not survive usernames becoming permanent addresses. allocateUsername picks the next free suffix, and the API returns the allocated name so the preview cannot drift from it. Mail runs as its own Komodo stack so it never fans out per-branch. Stalwart takes inbound 25 and relays outbound via OCI Email Delivery, since the port 25 exemption was denied (SR 4-0003535443). Its admin UI and JMAP stay on wayfarer-net; only the autoconfig paths are routed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add the mailbox backfill plan for current executives Selects approved sign-ups whose exec record is still current, so alumni keep their login without a mailbox and an exec who never signed up is skipped. Imports the shared folding module rather than restating it, so the addresses cannot drift from the usernames already in Keycloak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Use OIDC discovery for the Roundcube OAuth config Keycloak publishes a discovery document, so the auth, token and userinfo URIs do not need restating and cannot drift. Config keys verified against rcmail_oauth.php; PKCE S256 is advertised by the realm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 280fe61 commit 7eeda05

12 files changed

Lines changed: 325 additions & 16 deletions

File tree

‎.github/workflows/deploy-mail.yml‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: Deploy Mail
2+
3+
# Separate from Build & Deploy on purpose: mail is a single long-lived stack, so
4+
# it must never fan out per-branch, and website commits must not bounce IMAP.
5+
on:
6+
push:
7+
branches: [main]
8+
paths:
9+
- "deploy/mail/**"
10+
- ".github/workflows/deploy-mail.yml"
11+
workflow_dispatch:
12+
13+
concurrency:
14+
group: deploy-mail
15+
cancel-in-progress: false
16+
17+
jobs:
18+
deploy:
19+
runs-on: ubuntu-latest
20+
environment:
21+
name: mail
22+
url: https://mail.brockcsc.ca
23+
steps:
24+
- uses: actions/checkout@v5
25+
26+
- name: Sync mail secrets to Komodo Variables
27+
env:
28+
KOMODO_HOST: ${{ vars.KOMODO_HOST }}
29+
KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }}
30+
KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }}
31+
VAL_BROCKCSC_STALWART_ADMIN_USER: ${{ vars.BROCKCSC_STALWART_ADMIN_USER }}
32+
VAL_BROCKCSC_STALWART_ADMIN_SECRET: ${{ secrets.BROCKCSC_STALWART_ADMIN_SECRET }}
33+
VAL_BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_ID: ${{ vars.BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_ID }}
34+
VAL_BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_SECRET: ${{ secrets.BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_SECRET }}
35+
run: |
36+
source .github/scripts/komodo.sh
37+
38+
sync_var() {
39+
local name="$1" value="$2" is_secret="$3"
40+
if komodo /read "$(jq -n --arg name "$name" '{type:"GetVariable", params:{name:$name}}')" quiet >/dev/null; then
41+
komodo /write "$(jq -n --arg name "$name" --arg value "$value" \
42+
'{type:"UpdateVariableValue", params:{name:$name, value:$value}}')" >/dev/null
43+
else
44+
komodo /write "$(jq -n --arg name "$name" --arg value "$value" --argjson is_secret "$is_secret" \
45+
'{type:"CreateVariable", params:{name:$name, value:$value, is_secret:$is_secret}}')" >/dev/null
46+
fi
47+
}
48+
49+
sync_var BROCKCSC_STALWART_ADMIN_USER "$VAL_BROCKCSC_STALWART_ADMIN_USER" false
50+
sync_var BROCKCSC_STALWART_ADMIN_SECRET "$VAL_BROCKCSC_STALWART_ADMIN_SECRET" true
51+
sync_var BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_ID "$VAL_BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_ID" false
52+
sync_var BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_SECRET "$VAL_BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_SECRET" true
53+
54+
- name: Ensure and deploy the mail stack
55+
env:
56+
KOMODO_HOST: ${{ vars.KOMODO_HOST }}
57+
KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }}
58+
KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }}
59+
STACK_NAME: brockcsc-mail
60+
run: |
61+
source .github/scripts/komodo.sh
62+
63+
# auto_pull, unlike the app stack: these are upstream images, not ones
64+
# this repo builds, so a redeploy is how they pick up a new tag.
65+
config=$(jq -n '{
66+
server: "wayfarerbx-vps",
67+
repo: "BrockCSC/website",
68+
branch: "main",
69+
git_provider: "github.com",
70+
file_paths: ["deploy/mail/docker-compose.yml"],
71+
auto_pull: true,
72+
destroy_before_deploy: false,
73+
webhook_enabled: false,
74+
environment: (
75+
"STALWART_ADMIN_USER=[[BROCKCSC_STALWART_ADMIN_USER]]\n" +
76+
"STALWART_ADMIN_SECRET=[[BROCKCSC_STALWART_ADMIN_SECRET]]\n" +
77+
"KEYCLOAK_ISSUER=[[BROCKCSC_KEYCLOAK_ISSUER]]\n" +
78+
"ROUNDCUBE_OAUTH_CLIENT_ID=[[BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_ID]]\n" +
79+
"ROUNDCUBE_OAUTH_CLIENT_SECRET=[[BROCKCSC_ROUNDCUBE_OAUTH_CLIENT_SECRET]]"
80+
)
81+
}')
82+
83+
if komodo /read "$(jq -n --arg s "$STACK_NAME" '{type:"GetStack", params:{stack:$s}}')" quiet >/dev/null; then
84+
komodo /write "$(jq -n --arg id "$STACK_NAME" --argjson config "$config" \
85+
'{type:"UpdateStack", params:{id:$id, config:$config}}')" >/dev/null
86+
else
87+
komodo /write "$(jq -n --arg name "$STACK_NAME" --argjson config "$config" \
88+
'{type:"CreateStack", params:{name:$name, config:$config}}')" >/dev/null
89+
fi
90+
91+
update=$(komodo /execute "$(jq -n --arg s "$STACK_NAME" '{type:"DeployStack", params:{stack:$s}}')")
92+
komodo_await "$update" 36 >/dev/null

‎app/api/signup/route.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { randomInt } from "node:crypto";
2+
import anyAscii from "any-ascii";
23
import { NextResponse, type NextRequest } from "next/server";
34
import type { SignupRecord } from "@/lib/api/types";
45
import { isValidInviteCode } from "@/lib/auth/invite-code";
56
import {
7+
allocateUsername,
68
createDisabledUser,
79
deleteUser,
8-
findUserByUsername,
9-
usernameFor,
1010
} from "@/lib/auth/keycloak-admin";
11+
import { fold, usernameFor } from "@/lib/auth/username";
1112
import { create } from "@/lib/db/repository";
1213
import { rateLimit } from "@/lib/rate-limit";
1314
import { badJson, jsonObject } from "@/lib/json";
@@ -90,13 +91,13 @@ export const POST = async (req: NextRequest) => {
9091
return badRequest("Passwords do not match.");
9192
}
9293

93-
const username = usernameFor(firstName, lastName);
94-
if (await findUserByUsername(username)) {
95-
return NextResponse.json(
96-
{ error: "That username is already taken." },
97-
{ status: 409 },
98-
);
94+
const base =
95+
usernameFor(firstName, lastName) ||
96+
fold(anyAscii(`${firstName}${lastName}`));
97+
if (!base) {
98+
return badRequest("We could not build a username from that name.");
9999
}
100+
const username = await allocateUsername(base);
100101

101102
const keycloakUserId = await createDisabledUser({
102103
username,
@@ -129,5 +130,8 @@ export const POST = async (req: NextRequest) => {
129130
throw err;
130131
}
131132

132-
return NextResponse.json({ confirmationCode: confirmation }, { status: 201 });
133+
return NextResponse.json(
134+
{ confirmationCode: confirmation, username },
135+
{ status: 201 },
136+
);
133137
};

‎app/signup/page.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export default function SignupPage() {
4646
const [error, setError] = useState<string | null>(null);
4747
const [submitting, setSubmitting] = useState(false);
4848
const [confirmation, setConfirmation] = useState<string | null>(null);
49+
const [assigned, setAssigned] = useState("");
4950

5051
const set =
5152
(key: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
@@ -64,7 +65,9 @@ export default function SignupPage() {
6465
}
6566
setSubmitting(true);
6667
try {
67-
setConfirmation(await signup(form));
68+
const result = await signup(form);
69+
setAssigned(result.username);
70+
setConfirmation(result.confirmationCode);
6871
} catch (err) {
6972
setError(err instanceof Error ? err.message : "Something went wrong");
7073
} finally {
@@ -80,7 +83,7 @@ export default function SignupPage() {
8083
Request submitted
8184
</h1>
8285
<p className="text-sm text-muted-foreground">
83-
Your account <span className="font-bold">{username}</span> is
86+
Your account <span className="font-bold">{assigned}</span> is
8487
waiting for a co-president to approve it.
8588
</p>
8689

‎deploy/mail/docker-compose.yml‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Its own Komodo stack, not per-branch: mailboxes outlive any deploy of the app.
2+
# OCI blocks outbound 25, so Stalwart relays through Email Delivery on 587.
3+
networks:
4+
wayfarer-net:
5+
external: true
6+
7+
services:
8+
stalwart:
9+
image: stalwartlabs/stalwart:${STALWART_TAG:-latest}
10+
container_name: brockcsc-stalwart
11+
restart: unless-stopped
12+
networks: [wayfarer-net]
13+
ports:
14+
- "25:25"
15+
- "465:465"
16+
- "587:587"
17+
- "993:993"
18+
volumes:
19+
- stalwart-data:/opt/stalwart
20+
environment:
21+
- FALLBACK_ADMIN_USER=${STALWART_ADMIN_USER}
22+
- FALLBACK_ADMIN_SECRET=${STALWART_ADMIN_SECRET}
23+
# Only the autoconfig paths are routed. The admin UI and JMAP authenticate
24+
# against Stalwart's own credentials, not Keycloak, so they stay unreachable
25+
# from outside: `ssh -L 8080:localhost:8080 opc@129.153.49.190`.
26+
labels:
27+
- "traefik.enable=true"
28+
- "traefik.http.routers.stalwart-autoconfig.rule=(Host(`autoconfig.brockcsc.ca`) && PathPrefix(`/mail/config-v1.1.xml`)) || (Host(`autodiscover.brockcsc.ca`) && PathPrefix(`/autodiscover/`))"
29+
- "traefik.http.routers.stalwart-autoconfig.entrypoints=websecure"
30+
- "traefik.http.routers.stalwart-autoconfig.tls.certresolver=letsencrypt"
31+
- "traefik.http.services.stalwart-autoconfig.loadbalancer.server.port=8080"
32+
33+
# IMAP over the shared network, so 143 never leaves the host.
34+
roundcube:
35+
image: roundcube/roundcubemail:${ROUNDCUBE_TAG:-latest}
36+
container_name: brockcsc-roundcube
37+
restart: unless-stopped
38+
networks: [wayfarer-net]
39+
depends_on: [stalwart]
40+
volumes:
41+
- roundcube-data:/var/roundcube/db
42+
- ./roundcube-oauth.php:/var/roundcube/config/oauth.inc.php:ro
43+
# Installed by BrockCSC/brand-themes, not built here.
44+
- /opt/wayfarer/mail/skins/brockcsc:/var/www/html/skins/brockcsc:ro
45+
environment:
46+
- ROUNDCUBEMAIL_DB_TYPE=sqlite
47+
- ROUNDCUBEMAIL_DEFAULT_HOST=stalwart
48+
- ROUNDCUBEMAIL_DEFAULT_PORT=143
49+
- ROUNDCUBEMAIL_SMTP_SERVER=stalwart
50+
- ROUNDCUBEMAIL_SMTP_PORT=587
51+
- ROUNDCUBEMAIL_PLUGINS=archive,zipdownload,managesieve
52+
- ROUNDCUBEMAIL_SKIN=brockcsc
53+
- ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M
54+
- KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER}
55+
- ROUNDCUBE_OAUTH_CLIENT_ID=${ROUNDCUBE_OAUTH_CLIENT_ID}
56+
- ROUNDCUBE_OAUTH_CLIENT_SECRET=${ROUNDCUBE_OAUTH_CLIENT_SECRET}
57+
labels:
58+
- "traefik.enable=true"
59+
- "traefik.http.routers.roundcube.rule=Host(`mail.brockcsc.ca`)"
60+
- "traefik.http.routers.roundcube.entrypoints=websecure"
61+
- "traefik.http.routers.roundcube.tls.certresolver=letsencrypt"
62+
- "traefik.http.services.roundcube.loadbalancer.server.port=80"
63+
64+
volumes:
65+
stalwart-data:
66+
name: brockcsc-stalwart-data
67+
roundcube-data:
68+
name: brockcsc-roundcube-data

‎deploy/mail/roundcube-oauth.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
// Keycloak OIDC. Stalwart accepts the resulting token over IMAP/SMTP via
3+
// OAUTHBEARER, so execs never hold a separate mail password.
4+
$issuer = getenv('KEYCLOAK_ISSUER');
5+
6+
$config['oauth_provider'] = 'generic';
7+
$config['oauth_provider_name'] = 'BrockCSC';
8+
$config['oauth_client_id'] = getenv('ROUNDCUBE_OAUTH_CLIENT_ID');
9+
$config['oauth_client_secret'] = getenv('ROUNDCUBE_OAUTH_CLIENT_SECRET');
10+
$config['oauth_issuer'] = $issuer;
11+
$config['oauth_config_uri'] = $issuer . '/.well-known/openid-configuration';
12+
$config['oauth_scope'] = 'openid email profile';
13+
$config['oauth_identity_fields'] = ['email'];
14+
$config['oauth_login_redirect'] = true;
15+
$config['oauth_pkce'] = 'S256';

‎lib/api/auth.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export const logout = async (): Promise<void> => {
2626
};
2727

2828
/** Not apiFetch: the sign-up form shows the server's rejection reason. */
29-
export const signup = async (input: SignupInput): Promise<string> => {
29+
export const signup = async (
30+
input: SignupInput,
31+
): Promise<{ confirmationCode: string; username: string }> => {
3032
const res = await fetch("/api/signup", {
3133
method: "POST",
3234
headers: { "Content-Type": "application/json" },
@@ -35,9 +37,13 @@ export const signup = async (input: SignupInput): Promise<string> => {
3537
const body = (await res.json().catch(() => ({}))) as {
3638
error?: string;
3739
confirmationCode?: string;
40+
username?: string;
3841
};
3942
if (!res.ok) {
4043
throw new Error(body.error ?? "Could not submit your request right now.");
4144
}
42-
return body.confirmationCode ?? "";
45+
return {
46+
confirmationCode: body.confirmationCode ?? "",
47+
username: body.username ?? "",
48+
};
4349
};

‎lib/auth/keycloak-admin.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ export const findUserByUsername = async (
9393
return users[0] ?? null;
9494
};
9595

96+
/** First free of base, base2, base3... `reserved` rules out retired mailbox
97+
* local-parts, which outlive their Keycloak user and must never be reissued. */
98+
export const allocateUsername = async (
99+
base: string,
100+
reserved: (candidate: string) => Promise<boolean> = async () => false,
101+
): Promise<string> => {
102+
for (let suffix = 1; suffix <= 50; suffix++) {
103+
const candidate = suffix === 1 ? base : `${base}${suffix}`;
104+
if (
105+
!(await findUserByUsername(candidate)) &&
106+
!(await reserved(candidate))
107+
) {
108+
return candidate;
109+
}
110+
}
111+
throw new Error(`Could not allocate a username from "${base}".`);
112+
};
113+
96114
/** Creates a disabled user. Approval enables it; the password never touches our DB. */
97115
export const createDisabledUser = async (
98116
user: NewKeycloakUser,

‎lib/auth/username.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,35 @@
1-
/** firstname + lastname, lowercased, non-alphanumerics stripped. Shared by the
2-
* sign-up form and the server, so the preview can't drift from what's created. */
1+
/** firstname + lastname, accents folded, non-alphanumerics stripped. Shared by
2+
* the sign-up form and the server, so it must stay dependency-free; scripts that
3+
* fold away entirely are romanized server-side in the sign-up route. */
4+
5+
/** Distinct letters, not accented bases, so NFKD leaves them intact. */
6+
const STANDALONE: Record<string, string> = {
7+
ł: "l",
8+
đ: "d",
9+
ð: "d",
10+
ø: "o",
11+
æ: "ae",
12+
œ: "oe",
13+
þ: "th",
14+
ß: "ss",
15+
ı: "i",
16+
};
17+
18+
export const fold = (value: string): string =>
19+
value
20+
.normalize("NFKD")
21+
.replace(/\p{M}/gu, "")
22+
.toLowerCase()
23+
.replace(/[łđðøæœþßı]/g, (char) => STANDALONE[char])
24+
.replace(/[^a-z0-9]/g, "");
25+
326
export const usernameFor = (firstName: string, lastName: string): string =>
4-
`${firstName}${lastName}`.toLowerCase().replace(/[^a-z0-9]/g, "");
27+
fold(`${firstName}${lastName}`);
28+
29+
/** Alias form: alaqmar.gandhi reaches alaqmargandhi. Empty when either name
30+
* folds away, since a leading or trailing dot is not a valid local-part. */
31+
export const dottedAliasFor = (firstName: string, lastName: string): string => {
32+
const first = fold(firstName);
33+
const last = fold(lastName);
34+
return first && last ? `${first}.${last}` : "";
35+
};

‎package-lock.json‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"dependencies": {
2020
"@radix-ui/react-slot": "^1.2.4",
21+
"any-ascii": "^0.3.3",
2122
"class-variance-authority": "^0.7.1",
2223
"clsx": "^2.1.1",
2324
"drizzle-orm": "^0.45.2",

0 commit comments

Comments
 (0)