Skip to content
Merged
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
9 changes: 7 additions & 2 deletions src/app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { logger } from '@/lib/logger'
import { ERROR_MESSAGES } from '@/config/error-messages'
import { checkRateLimit, getClientIp } from '@/lib/auth/rate-limiter'
import { validateBody, ForgotPasswordSchema } from '@/lib/schemas'
import { ORG } from '@/config/org'
import { ORG, CONTACT } from '@/config/org'
import { getPasswordResetUrl } from '@/config/urls'

export async function POST(request: NextRequest) {
Expand Down Expand Up @@ -86,7 +86,12 @@ export async function POST(request: NextRequest) {
// delivery failure so they are not stuck behind a false success message.
return apiError(
new Error('Password reset email not delivered'),
'E-Mail konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere uns unter kontakt@revamp-it.ch.',
// Address comes from CONTACT, never typed here: this message is shown
// precisely WHEN mail delivery just failed, so the address in it is the
// user's only remaining way back in. It used to name a hardcoded
// kontakt@revamp-it.ch, which is neither the org's address nor
// guaranteed to be monitored.
`E-Mail konnte nicht gesendet werden. Bitte versuche es später erneut oder kontaktiere uns unter ${CONTACT.email}.`,
503,
)
}
Expand Down
104 changes: 104 additions & 0 deletions src/config/__tests__/contact-address-reachable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @jest-environment node
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { CONTACT, ORG } from '@/config/org'

/**
* The address we tell users to write to must reach a mailbox, and must come
* from ORG config rather than being typed into a message.
*
* It was `hallo@evig.ch`. evig.ch is not registered — no MX, no A record — so
* every message a would-be buyer sent bounced. That mattered because it is the
* ONLY route to a human: online payment is deliberately unset until go-live
* (`.env.selfhost.local.example`: "leave unset until go-live"), the checkout
* wall tells people to "kontaktiere evig", and `CONTACT.phone` is empty. A
* catalogue of 200+ live listings with a dead-end call to action.
*
* Two rules, both cheap to check:
* 1. Do not publish an address on a domain that is not set up yet.
* 2. Do not type a contact address inline — it drifts from config, which is
* how a password-reset failure ended up naming kontakt@revamp-it.ch.
*/

// Domains evig does not (yet) receive mail on. Remove an entry the same day
// the domain is registered AND its mail is authenticated — not before.
const UNREACHABLE_DOMAINS = ['evig.ch']

const SRC = join(process.cwd(), 'src')
const MESSAGES = join(process.cwd(), 'messages')

/**
* Files that legitimately name a non-CONTACT address:
* - org.ts itself defines it
* - staff identity / logins are Layer B and really are @revamp-it.ch until the
* infra cutover (see .claude/CLAUDE.md)
* - form placeholders showing the STAFF address format are accurate
*/
const ALLOW = [
/src\/config\/org\.ts$/,
/src\/lib\/permissions\.ts$/,
/src\/config\/e2e-test-accounts\.ts$/,
/src\/config\/blog-authors\.ts$/,
/src\/lib\/marketplace\/publish-revampit-listing\.ts$/,
/src\/lib\/ai\/config\/prompts\//,
/src\/components\/admin\/teams\//,
/src\/app\/einladung\//,
/__tests__/,
]

function files(dir: string, exts: RegExp): string[] {
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return []
return readdirSync(dir).flatMap((e) => {
const full = join(dir, e)
if (statSync(full).isDirectory()) return files(full, exts)
return exts.test(e) ? [full] : []
})
}

describe('the published contact address is reachable', () => {
it('is not on a domain that receives no mail', () => {
const domain = CONTACT.email.split('@')[1]
expect(UNREACHABLE_DOMAINS).not.toContain(domain)
})

it('supportEmail falls back to the same reachable address', () => {
const domain = CONTACT.supportEmail.split('@')[1]
expect(UNREACHABLE_DOMAINS).not.toContain(domain)
})

it('ORG.emailDomain stays the brand domain (it is not the contact mailbox)', () => {
// Deliberate: emailDomain drives staff-email detection and the default
// sender. It is NOT where users write to — do not repoint it at gmail.
expect(ORG.emailDomain).toBe('evig.ch')
})

it('no user-facing message hardcodes an org contact address', () => {
const offenders: string[] = []
for (const file of [...files(SRC, /\.tsx?$/), ...files(MESSAGES, /\.json$/)]) {
const rel = relative(process.cwd(), file)
if (ALLOW.some((rx) => rx.test(rel))) continue
let src = readFileSync(file, 'utf8')
// Strip comments in TS/TSX: prose EXPLAINING this rule legitimately names
// the old address, and a gate that trips on its own documentation just
// teaches people to delete the documentation. JSON has no comments.
if (/\.tsx?$/.test(file)) {
src = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
.join('\n')
}
// An org address written as a literal, not interpolated from CONTACT.
const hits = src.match(/[a-z0-9._-]+@(evig\.ch|revamp-it\.ch|revampit\.ch)/gi)
if (hits) offenders.push(`${rel}: ${[...new Set(hits)].join(', ')}`)
}
expect(offenders).toEqual([])
})

it('scans a meaningful number of files (guards an always-green sweep)', () => {
expect(files(SRC, /\.tsx?$/).length).toBeGreaterThan(500)
expect(files(MESSAGES, /\.json$/).length).toBeGreaterThan(3)
})
})
17 changes: 16 additions & 1 deletion src/config/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,22 @@ export const BASE_REGION = {
// CONTACT
// ============================================================================

const DEFAULT_CONTACT_EMAIL = 'hallo@evig.ch' as const // TODO: register evig.ch mailbox
/**
* The address users are told to write to. It must be a mailbox that ACTUALLY
* RECEIVES MAIL — this string is printed on public pages and is the fallback
* the checkout wall points at ("Bitte kontaktiere evig, wenn du sofort bezahlen
* möchtest") while Payrexx is unset.
*
* It was `hallo@evig.ch`. `evig.ch` is not purchased yet — it publishes no MX
* and no A record — so every message a would-be buyer sent to it bounced, on a
* catalogue of 200+ live listings with online payment deliberately switched off
* and no phone number configured. The one route to a human was a dead end.
*
* Gmail is not the long-term brand address; it is the one that works today.
* When evig.ch is registered and its mail is authenticated (Brevo SPF+DKIM),
* change this ONE line back — everything user-facing reads it from here.
*/
const DEFAULT_CONTACT_EMAIL = 'butaeff@gmail.com' as const

export const CONTACT = {
email: DEFAULT_CONTACT_EMAIL,
Expand Down
Loading