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
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rssamplifier/web",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -11,6 +11,7 @@
},
"dependencies": {
"@logicsrc/openaccess": "^0.3.1",
"@profullstack/contact-form": "0.2.0",
"@profullstack/footer": "latest",
"@profullstack/leaderboard": "^0.3.1",
"@profullstack/notifications": "^0.1.4",
Expand Down
194 changes: 6 additions & 188 deletions apps/web/src/app/api/sales/contact/route.js
Original file line number Diff line number Diff line change
@@ -1,193 +1,11 @@
import { dataset } from '@rssamplifier/db';
import { sendEmail, emailEnabled } from '@rssamplifier/mail';

import { db } from '../../../../lib/db.js';
import { requestMeta } from '../../../../lib/auth.js';
import { salesContact } from '../../../../lib/salesContact.js';

export const dynamic = 'force-dynamic';

/**
* The only way into a corpus conversation.
*
* ## Why a form here when /contact deliberately has none
*
* That page's comment says it plainly: "no form, because a form on a site with
* no accounts is one more thing to spam", and it routes to published addresses
* instead. The reasoning holds and this is not a reversal of it — it is a
* different trade. /contact's job is to get a stranger to the right mailbox,
* which a list of links does perfectly well. This page's job is to start a
* negotiation, and the fields below (what they want it for, at what scale) are
* exactly the ones that decide whether there is a deal and what it looks like.
* An email that omits them costs a round trip; a form that asks for them does
* not. So the spam is paid for rather than avoided, by the two guards below.
*
* ## Why it is written down before it is sent
*
* `sendEmail` reports a failure instead of throwing, and every caller in this
* codebase treats mail as optional infrastructure. That is right for a sign-in
* link, which the reader can simply request again — it is wrong for a sales
* enquiry, which the sender believes is delivered and will never send twice. So
* the row lands first, the mail is best-effort on top of it, and a Resend outage
* costs a notification rather than a customer.
*/

/** How long the flood window is. */
const WINDOW_MS = 60 * 60 * 1000;

/**
* Enquiries one address may send in that window.
*
* Three, not one. A genuine sender who mistypes their own email address and
* resends is the most likely repeat here, and refusing them is worse than
* accepting a third message from a spammer we are also storing and rate
* limiting.
*/
const MAX_PER_WINDOW = 3;

/** Where an enquiry is announced, when mail is configured. */
const INBOX = process.env['SALES_EMAIL'] || 'hello@rssamplifier.com';

/**
* @param {Request} req
* @returns {Promise<Response>}
*/
export async function POST(req) {
const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html');
const body = await readBody(req);
if (!body) return respond(wantsHtml, 400, 'bad-request', 'That form could not be read.');

// The honeypot. A field no human sees, no browser fills and every naive bot
// completes. Answered with the same success the real path gives, because a
// bot told it failed is a bot that tries again differently.
if (body.website) {
return respond(wantsHtml, 200, null, null);
}

const email = body.email.trim().toLowerCase();
// Deliberately the weakest possible check. This address is going to be read by
// a person who will reply to it, not authenticated — a strict pattern here
// rejects the valid addresses nobody remembers are valid and gains nothing.
if (!email || !email.includes('@') || email.length > 200) {
return respond(wantsHtml, 400, 'bad-email', 'That does not look like an email address.');
}

const useCase = body.useCase.trim();
if (useCase.length < 10) {
return respond(
wantsHtml,
400,
'no-use-case',
'Please say what you want the corpus for — it is the field that decides what we can offer.',
);
}

const client = db();
const meta = await requestMeta();

const recent = await dataset.enquiryCountFrom(
client,
meta.ipHash,
new Date(Date.now() - WINDOW_MS).toISOString(),
);
if (recent >= MAX_PER_WINDOW) {
return respond(
wantsHtml,
429,
'too-many',
'That is several enquiries in an hour. Email hello@rssamplifier.com directly and we will pick it up there.',
);
}

await dataset.insertEnquiry(client, {
name: body.name.trim().slice(0, 120) || null,
email: email.slice(0, 200),
org: body.org.trim().slice(0, 160) || null,
useCase: useCase.slice(0, 4000),
ipHash: meta.ipHash,
userAgent: meta.userAgent,
});

if (emailEnabled()) {
// Not awaited into the response. The enquiry is already stored, so the
// sender's answer does not depend on Resend being reachable this second, and
// making them wait on it would only ever make the form feel broken.
void sendEmail({
to: INBOX,
subject: `Corpus enquiry: ${body.org.trim() || email}`,
text: [
`From: ${body.name.trim() || '(no name)'} <${email}>`,
`Company: ${body.org.trim() || '(none given)'}`,
'',
'What for:',
useCase.slice(0, 4000),
'',
// So a reply can be written without opening the database, and so the
// operator knows the row exists even if this mail is the only thing they
// ever see.
'Stored in dataset_enquiries. Grant access with an insert into dataset_grants.',
].join('\n'),
// Replying to the notification reaches the sender rather than ourselves,
// which is the difference between a queue and an inbox.
replyTo: email,
}).catch(() => {});
}

return respond(wantsHtml, 200, null, null);
}

/**
* HTML callers get a 303 back to the page; everyone else gets JSON.
*
* @param {boolean} wantsHtml
* @param {number} status
* @param {string|null} error
* @param {string|null} detail
* @returns {Response}
*/
function respond(wantsHtml, status, error, detail) {
if (wantsHtml) {
const to = error ? `/sales?error=${encodeURIComponent(error)}#enquire` : '/sales?sent=1#enquire';
return new Response(null, { status: 303, headers: { location: to } });
}

return Response.json(error ? { error, detail } : { ok: true }, {
status,
headers: { 'cache-control': 'no-store' },
});
}

/**
* Read a form or a JSON body into the same shape.
*
* @param {Request} req
* @returns {Promise<{ name: string, email: string, org: string, useCase: string, website: string }|null>}
*/
async function readBody(req) {
try {
const source = (req.headers.get('content-type') ?? '').includes('application/json')
? await req.json()
: Object.fromEntries(await req.formData());

return {
name: str(source?.name),
email: str(source?.email),
org: str(source?.org),
// `useCase` for a JSON caller, `use_case` for the form field — the form
// uses the name that reads correctly in HTML and the API uses the one that
// reads correctly in JavaScript, and neither should have to know about the
// other.
useCase: str(source?.useCase) || str(source?.use_case),
website: str(source?.website),
};
} catch {
return null;
}
}

/**
* @param {unknown} value
* @returns {string}
* The only way into a corpus conversation: checks, stores, then notifies.
* The why of all three is in lib/salesContact.js. A plain form post gets a 303
* back to /sales#enquire (?sent=1 or ?error=...); a JSON caller gets
* { ok: true } or { ok: false, error }.
*/
function str(value) {
return value == null ? '' : String(value);
}
export const POST = salesContact.POST;
51 changes: 51 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4210,3 +4210,54 @@ html:not([data-dock]) .playlist-hint {
margin: 0.75rem 0;
font-size: 0.95rem;
}

/* The corpus enquiry form on /sales (@profullstack/contact-form). It keeps the
look the hand-built form had: placeholders in the boxes, the labels kept for
screen readers only, the intro line as an eyebrow and the help as a hint. */
.sales-form .cf-field {
margin-bottom: 0.75rem;
}
.sales-form .cf-field > label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sales-form .cf-section {
font-family: var(--mono);
font-size: 0.72rem;
font-weight: inherit;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 0.75rem;
}
.sales-form .cf-help {
font-size: 0.86rem;
color: var(--muted);
margin: 0.6rem 0 0.9rem;
}
.sales-form .cf-error {
padding: 0.9rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--bg);
font-size: 0.92rem;
margin-bottom: 1rem;
}
.sales-form .cf-submit {
margin-top: 0.75rem;
}
.cf-sent .cf-notice {
font-size: 0.92rem;
}
/* The honeypot: no human sees it and no browser fills it (a visible one drops
the real people who fill it in). */
.cf-hp {
display: none;
}
76 changes: 18 additions & 58 deletions apps/web/src/app/sales/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { categoryStats } from '../../lib/crawlstats.js';
import { corpusFigures } from '../../lib/corpus.js';
import { latestClosedWindow } from '../../lib/datasetWindow.js';
import Toolbar from '../Toolbar.jsx';
import { ContactForm } from '@profullstack/contact-form/react';
import { formErrors, salesContact } from '../../lib/salesContact.js';

export const metadata = {
title: 'Training data',
Expand Down Expand Up @@ -54,7 +56,12 @@ export default async function SalesPage({ searchParams }) {
const sent = params?.sent === '1';
const error = typeof params?.error === 'string' ? params.error : null;

const [stats, corpus] = await Promise.all([categoryStats(), corpusFigures()]);
const [stats, corpus, form] = await Promise.all([
categoryStats(),
corpusFigures(),
// The enquiry form's token is minted here, per request; this page is never cached.
salesContact.props(),
]);

const feeds = Number(stats?.total ?? 0);
const posts = (stats?.categories ?? []).reduce((n, c) => n + Number(c.items ?? 0), 0);
Expand Down Expand Up @@ -205,64 +212,15 @@ export default async function SalesPage({ searchParams }) {
volume does. Tell us what you want it for and we will come back with terms.
</p>

{/* A plain form posting to /api/*, like every other control on this site:
it works with JavaScript off, and the route answers an HTML caller with
a 303 back to this anchor and a JSON caller with JSON. */}
<form className="submit-box" action="/api/sales/contact" method="post">
<p className="eyebrow">Only the email and the last box are required</p>
<input
name="name"
type="text"
autoComplete="name"
maxLength={120}
placeholder="Your name"
aria-label="Your name"
/>
<input
name="email"
type="email"
autoComplete="email"
maxLength={200}
required
placeholder="you@example.com"
aria-label="Your email address"
/>
<input
name="org"
type="text"
autoComplete="organization"
maxLength={160}
placeholder="Company or project"
aria-label="Company or project"
/>
<textarea
name="use_case"
rows={5}
maxLength={4000}
required
placeholder={
'What do you want the corpus for?\n\nWhich streams you need, roughly what scale, how often you would pull, and what it is being used to build.'
}
aria-label="What do you want the corpus for?"
/>
<p className="hint">
The last box is the one that decides what we can offer, so it is the one worth writing.
Rough is fine; vague costs us both a round trip.
</p>

{/* The honeypot: no human sees it, no browser fills it, and a bot that
completes it gets the same success answer as everyone else — told it
failed, it would only try again differently. Hidden from assistive
technology as well as from sight, so a screen reader does not
announce a field its user must leave alone. */}
<p hidden aria-hidden="true">
<input name="website" type="text" tabIndex={-1} autoComplete="off" />
{/* @profullstack/contact-form, posting to /api/*, like every other control on
this site: with JavaScript it sends JSON and answers in place; with it
off it is a plain form post, answered with a 303 back to this anchor. */}
<ContactForm {...form} className="submit-box sales-form" idPrefix="sales">
<p>
If you would rather chase it, <a href="mailto:hello@rssamplifier.com">hello@rssamplifier.com</a>{' '}
reaches the same people.
</p>

<div className="submit-actions">
<button type="submit">Send enquiry</button>
</div>
</form>
</ContactForm>

<p>
Prefer email? <a href="mailto:hello@rssamplifier.com">hello@rssamplifier.com</a>. For
Expand All @@ -280,6 +238,8 @@ export default async function SalesPage({ searchParams }) {
* @returns {string}
*/
function errorMessage(code) {
// A message from the form's own checks, sent back by a plain form post.
if (formErrors().has(code)) return code;
switch (code) {
case 'bad-email':
return 'That does not look like an email address — we would have no way to reply.';
Expand Down
Loading
Loading