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
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ GIT_AUTHOR_EMAIL=your-username@users.noreply.github.com
# NEXT_PUBLIC_SITE_TWITTER_HANDLE=TwitterHandle
# NEXT_PUBLIC_SITE_URL=https://yourdomain.com
# NEXT_PUBLIC_SOCIAL_PLATFORMS=github,linkedin,twitch,twitter
# A monitored address shown on /contact/ and offered when the contact FORM fails.
#
# UNSET = the mailto is not rendered anywhere, which is the correct default for a
# fork: publishing an inbox you do not own is the #392 failure. Set it to an address
# you actually read.
#
# This is NOT optional if you take payments. Stripe puts `support_url` on receipts,
# and ours points at /contact/ — a page that offered no working channel at all while
# NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY was empty, because the form throws without it
# (#784). The form depends on a third-party credential; an address does not.
# NEXT_PUBLIC_SUPPORT_EMAIL=support@yourdomain.com
# NO LONGER READ BY THE APP (#772). This was a single global Price id used for
# EVERY recurring SKU, so three Care Plan tiers at $49/$99/$249 would all have
# billed whatever it pointed at. Subscription prices now come from
Expand Down Expand Up @@ -316,6 +327,23 @@ GIT_AUTHOR_EMAIL=your-username@users.noreply.github.com
# Revocable in one click from the same page, which is why this is a token rather
# than a browser session.
# CLOUDFLARE_API_TOKEN=
# Where the contact form delivers, and the address it sends FROM (#784).
#
# These are EDGE FUNCTION secrets, not build-time values — set them on the Supabase
# project (Settings -> Edge Functions -> Secrets, or the Management API), not in the
# deploy workflow. `supabase/functions/contact-message` reads them via Deno.env.
#
# CONTACT_TO is read SERVER-SIDE and is never taken from the request. That is the
# security property, not a detail: an endpoint that lets the caller choose the
# recipient is an open relay, and this project has already had a form abused to
# email non-consenting third parties (#353).
#
# CONTACT_FROM must be an address on a domain VERIFIED in Resend, or delivery fails.
# Neither has a default: falling back to the upstream maintainer's domain would put
# their inbox behind every fork's contact form (#392) and send from a domain the
# fork does not own. Unset means the function returns 500 rather than pretending.
# CONTACT_TO=support@yourdomain.com
# CONTACT_FROM=Your Project <noreply@yourdomain.com>
# PayPal API base URL. DEFAULTS TO THE SANDBOX
# (https://api-m.sandbox.paypal.com) in supabase/functions/cancel-subscription
# and its siblings — so leaving this unset in production means cancellations and
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ jobs:
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_SOCIAL_PLATFORMS: ${{ vars.NEXT_PUBLIC_SOCIAL_PLATFORMS }}
# A variable, not a secret: it is rendered on the page for anyone to read,
# and it needs to be auditable — the whole defect in #784 was a contact
# channel that looked configured and was not.
NEXT_PUBLIC_SUPPORT_EMAIL: ${{ vars.NEXT_PUBLIC_SUPPORT_EMAIL }}
# Prefer the VARIABLE, fall back to the secret (#629). A Stripe publishable
# key is public by design — it ships in the bundle — so storing it write-only
# bought no security and cost auditability: the live site's payment MODE could
Expand Down
27 changes: 27 additions & 0 deletions src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import { ContactForm } from '@/components/forms/ContactForm';
import { projectConfig } from '@/config/project.config';

export const metadata: Metadata = {
// This route claims its own URL (#668).
Expand Down Expand Up @@ -112,6 +113,32 @@ export default function ContactPage() {
Other Ways to Connect
</h3>
<div className="space-y-2">
{/* EMAIL FIRST, and rendered independently of the form (#784).
The form posts to a third-party provider; production shipped an
empty access key, so every submission threw and this page offered
no way to reach anyone — while Stripe's `support_url` pointed
customers here. An address does not depend on a credential being
present, which is the whole reason it goes first.

Hidden when unset so a fork never advertises an inbox it does not
own (#392). */}
{projectConfig.supportEmail && (
<a
href={`mailto:${projectConfig.supportEmail}`}
className="link link-primary flex items-center"
>
<svg
className="mr-2 h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
{projectConfig.supportEmail}
</a>
)}
<a
href="https://github.com/TortoiseWolfe/ScriptHammer/issues"
className="link link-primary flex items-center"
Expand Down
24 changes: 23 additions & 1 deletion src/components/forms/ContactForm/ContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useWeb3Forms } from '@/hooks/useWeb3Forms';
import { type Web3FormsResponse } from '@/utils/web3forms';
import { contactSchema, type ContactFormData } from '@/schemas/contact.schema';
import { projectConfig } from '@/config/project.config';
import { useEffect } from 'react';

export interface ContactFormProps {
Expand Down Expand Up @@ -197,7 +198,28 @@ export const ContactForm: React.FC<ContactFormProps> = ({
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
{/* The address belongs HERE, not only further up the page (#784).
This branch renders at the exact moment the visitor's message did
not get through — telling them "something went wrong" and nothing
else is how an enquiry is lost silently. `!text-current` because the
alert owns the colour on its own surface; a link colour of its own
measured 1.66:1 there (#459). */}
<span>
{error}
{projectConfig.supportEmail && (
<>
{' '}
You can email{' '}
<a
href={`mailto:${projectConfig.supportEmail}`}
className="link font-semibold !text-current"
>
{projectConfig.supportEmail}
</a>{' '}
instead.
</>
)}
</span>
</div>
)}

Expand Down
35 changes: 35 additions & 0 deletions src/config/__tests__/project.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,39 @@ describe('Project Configuration', () => {
expect(configWithBase.deployUrl).toBe('https://custom-domain.com');
});
});

/**
* `supportEmail` drives a `mailto:` on /contact/ and inside the contact form's
* error state (#784). The DEFAULT is the load-bearing half: a fork that inherits
* an address publishes an inbox it does not own and cannot read — the #392
* failure, one person's identity shipped to everyone, reappearing on the page
* paying customers are sent to by Stripe receipts.
*/
describe('supportEmail (#784)', () => {
beforeEach(() => {
delete process.env.NEXT_PUBLIC_SUPPORT_EMAIL;
});

it('defaults to empty so a fork never advertises an inbox it does not own', () => {
expect(getProjectConfig().supportEmail).toBe('');
});

it('never falls back to a hardcoded address', () => {
// Stated as a property, not a value: ANY future default is the defect,
// whichever address someone picks.
expect(getProjectConfig().supportEmail).not.toMatch(/@/);
});

it('uses the configured address when set', () => {
process.env.NEXT_PUBLIC_SUPPORT_EMAIL = 'help@example.org';

expect(getProjectConfig().supportEmail).toBe('help@example.org');
});

it('treats an empty value as unset rather than rendering an empty mailto', () => {
process.env.NEXT_PUBLIC_SUPPORT_EMAIL = '';

expect(getProjectConfig().supportEmail).toBe('');
});
});
});
10 changes: 10 additions & 0 deletions src/config/project.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export function getProjectConfig() {
process.env.NEXT_PUBLIC_PROJECT_OWNER || defaultConfig.projectOwner,
projectDescription: defaultConfig.projectDescription,
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? defaultConfig.basePath,
// A monitored address a visitor can write to when the contact FORM cannot
// deliver. The form depends on a third-party key, and production shipped an
// EMPTY one, so `/contact/` offered no working channel at all while Stripe
// was pointing paying customers straight at it (#784).
//
// DEFAULTS TO EMPTY ON PURPOSE. This is a forkable template, and a hardcoded
// address would put the upstream maintainer's inbox on every fork's contact
// page — the #392 failure where one person's identity shipped to everyone.
// Unset simply means the mailto is not rendered.
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL || '',
};

// Computed values
Expand Down
13 changes: 12 additions & 1 deletion src/utils/email/email-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SupabaseResendProvider } from './providers/supabase-resend';
import { Web3FormsProvider } from './providers/web3forms';
import { EmailJSProvider } from './providers/emailjs';
import {
Expand Down Expand Up @@ -35,8 +36,18 @@ export class EmailService {
constructor(options: EmailServiceOptions = {}) {
this.config = { ...DEFAULT_CONFIG, ...options.config };

// Register providers in priority order
// Register providers in priority order.
//
// SupabaseResend is FIRST deliberately (#784). Web3Forms was the only path,
// its key shipped empty, and `/contact/` therefore delivered nothing while
// Stripe pointed customers at it. The Supabase + Resend path is the one this
// project owns end to end — verified domain, DKIM/SPF live, key already an
// Edge Function secret — so it needs no third-party credential to work.
//
// The other two stay registered as genuine failover: a fork with no Supabase,
// or one that prefers Web3Forms, still sends. Ordering is the only change.
this.providers = options.providers || [
new SupabaseResendProvider(),
new Web3FormsProvider(),
new EmailJSProvider(),
];
Expand Down
113 changes: 113 additions & 0 deletions src/utils/email/providers/__tests__/supabase-resend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* The Supabase+Resend provider is what makes `/contact/` work without a
* third-party key (#784). Production shipped an empty `NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY`,
* so the only registered provider threw on every submission and the page delivered
* nothing — while Stripe pointed paying customers at it.
*
* The assertions that matter here are the negative ones: that a failure is reported
* as a failure. A contact provider which resolves successfully while delivering
* nothing is strictly worse than one that throws, because it silently eats enquiries.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SupabaseResendProvider } from '../supabase-resend';
import { EmailProviderError } from '../../types';

const DATA = {
name: 'Ada',
email: 'ada@example.com',
subject: 'Hello',
message: 'Testing',
};

const originalEnv = process.env;

describe('SupabaseResendProvider', () => {
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv };
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://proj.supabase.co';
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key';
});

afterEach(() => {
process.env = originalEnv;
vi.unstubAllGlobals();
});

it('is unavailable when no Supabase URL is configured, so the service fails over', async () => {
delete process.env.NEXT_PUBLIC_SUPABASE_URL;

// A fork without Supabase must fall through to Web3Forms rather than have this
// provider claim it can send and then fail.
expect(await new SupabaseResendProvider().isAvailable()).toBe(false);
});

it('is available once the URL exists', async () => {
expect(await new SupabaseResendProvider().isAvailable()).toBe(true);
});

it('posts to the contact function and reports success', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, id: 'msg_123' }),
});
vi.stubGlobal('fetch', fetchMock);

const result = await new SupabaseResendProvider().send(DATA);

expect(result.success).toBe(true);
expect(result.messageId).toBe('msg_123');

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://proj.supabase.co/functions/v1/contact-message');

// The browser must NOT be able to name the recipient — the function reads it
// server-side. If a `to` ever appears in this payload, the endpoint has become
// an open relay (#353).
const body = JSON.parse(init.body);
expect(body).not.toHaveProperty('to');
expect(body).not.toHaveProperty('CONTACT_TO');
expect(body).toMatchObject({ name: 'Ada', email: 'ada@example.com' });
});

it('THROWS when the function reports a non-OK status', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'Contact delivery is not configured' }),
})
);

await expect(new SupabaseResendProvider().send(DATA)).rejects.toThrow(
EmailProviderError
);
});

it('THROWS when the response is 200 but does not confirm success', async () => {
// The failure mode worth guarding: a proxy or gateway returning 200 with a body
// that is not a delivery confirmation. Treating that as sent loses the enquiry.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ message: 'queued somewhere, maybe' }),
})
);

await expect(new SupabaseResendProvider().send(DATA)).rejects.toThrow(
EmailProviderError
);
});

it('THROWS on a network failure rather than resolving', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));

await expect(new SupabaseResendProvider().send(DATA)).rejects.toThrow(
EmailProviderError
);
});
});
Loading
Loading