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
87 changes: 87 additions & 0 deletions docs/contact-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# The contact address

How `info@cloudils.com` reaches a real inbox, and how to point another site at
the same mailbox.

## Why an alias and not a personal address

A personal address in a page footer is a personal address in every scraper's
list within a week. An alias on the apex domain gives the same reachability
with three properties a raw address does not have:

- the private destination never appears in public HTML, or in this repository;
- the destination can change without touching a deploy;
- one address serves every site on the domain, so a second project does not
need a second mailbox.

Cloudflare Email Routing is the cheapest way to get all three: it is free, it
needs no mail server, and forwarding is a rule on the zone rather than code.

## What is configured

On the `cloudils.com` zone:

| Piece | Value |
| ------------------- | ------------------------------------------------------------- |
| Routing rule | `to: info@cloudils.com` → forward to the personal address |
| Catch-all | Disabled, action `drop` |
| Destination address | Verified once from Cloudflare's confirmation mail |
| DNS | Three `route*.mx.cloudflare.net` MX records, SPF and DKIM TXT |

Routing is receive-only. Nothing in this app sends mail, so Email **Sending**
is deliberately not onboarded — replies go out from the personal mailbox, from
its own address. That is a visible seam (you write to `info@`, the reply comes
from somewhere else), and the price of not running a mail server.

The catch-all stays on `drop` on purpose. A catch-all that forwards means every
dictionary-attack guess at `@cloudils.com` lands in the destination inbox, and
the whole point of the alias was to keep that inbox quiet.

## Reusing it on another site

The address is domain-wide, not app-specific, so another site on
`cloudils.com` needs no Cloudflare change at all — put the same `mailto:` in
its footer and it works.

Only two cases need work:

**A site on a different domain.** Repeat the setup on that zone:

```bash
npx wrangler email routing addresses create you@example.com # then click the verification mail
npx wrangler email routing enable newdomain.com # adds the MX, SPF and DKIM records
npx wrangler email routing rules create newdomain.com \
--name "info forwarding" \
--match-type literal --match-field to --match-value "info@newdomain.com" \
--action-type forward --action-value "you@example.com"
```

Check for existing `MX` records on the apex before enabling: `routing enable`
installs Cloudflare's own, and a domain already receiving mail elsewhere will
stop receiving it. `nslookup -type=MX newdomain.com` answers that in a second.

A destination address is account-scoped, so one that is already verified can be
reused by a new zone without another confirmation mail.

**A per-project address**, if you ever want to filter by recipient — add a
second rule (`nextsode@cloudils.com`, say) pointing at the same destination.
Rules are literal matches, so they cost nothing until they exist.

## Changing where the mail lands

Add and verify the new destination, then repoint the rule:

```bash
npx wrangler email routing rules list cloudils.com # note the rule id
npx wrangler email routing rules update cloudils.com <rule-id> \
--action-type forward --action-value "new@example.com"
```

The published address does not change, so no deploy is involved.

## In the app

`src/lib/contact.ts` holds the address; the footer and both legal pages import
it. It is one constant because a privacy policy that promises a right to
erasure is only worth the address it gives you — a stale copy in one of the
three is a broken promise, not a typo.
20 changes: 18 additions & 2 deletions src/lib/components/ui/Footer.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import { resolve } from '$app/paths';
import tmdbLogo from '$lib/assets/tmdb.svg';
import { CONTACT_EMAIL, CONTACT_MAILTO } from '$lib/contact';

/**
* Attribution and the legal links.
* Attribution, the way to reach us, and the legal links.
*
* The TMDB notice is not decoration: their API terms require this exact
* sentence and their unmodified logo, kept less prominent than the app's own
Expand Down Expand Up @@ -35,8 +36,23 @@
`-my-2.5` with matching padding: the links keep their position and their
spacing, and gain the vertical hit area a thumb needs. A 16px-tall legal
link is a link you have to aim at.

The address is spelled out rather than hidden behind the word "Contact":
it is the one thing on this page somebody may want to copy, write down or
reach from a machine that has no mail client wired up.

`rel="external"` on the mailto: it hands the click to the mail client
instead of the SvelteKit router, which is what a scheme the router cannot
navigate needs anyway.
-->
<nav aria-label="Legal" class="-my-2.5 flex flex-shrink-0 items-center gap-2">
<nav aria-label="Footer" class="-my-2.5 flex flex-shrink-0 items-center gap-2">
<a
href={CONTACT_MAILTO}
rel="external"
class="px-2 py-2.5 transition-colors duration-200 hover:text-ink"
>
{CONTACT_EMAIL}
</a>
<a href={resolve('/terms')} class="px-2 py-2.5 transition-colors duration-200 hover:text-ink">
Terms
</a>
Expand Down
16 changes: 16 additions & 0 deletions src/lib/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The public contact address, declared once.
*
* It appears in the footer of every page and in the Contact section of both
* legal documents, and those three have to agree: a privacy policy that
* promises a right to erasure is only worth the address it gives you, so a
* stale copy in one of them is a broken promise, not a typo.
*
* The mailbox is a Cloudflare Email Routing alias on the apex domain rather
* than a personal inbox, which keeps the private address off a public page and
* lets the destination change without a deploy. See `docs/contact-email.md`.
*/
export const CONTACT_EMAIL = 'info@cloudils.com';

/** The `href` form, so no caller has to remember the scheme. */
export const CONTACT_MAILTO = `mailto:${CONTACT_EMAIL}`;
9 changes: 4 additions & 5 deletions src/routes/privacy/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Seo from '$lib/components/Seo.svelte';
import { pageSchema } from '$lib/format/seo';
import Prose from '$lib/components/ui/Prose.svelte';
import { CONTACT_EMAIL, CONTACT_MAILTO } from '$lib/contact';

const signedIn = $derived(Boolean(page.data.user));

Expand All @@ -23,7 +24,7 @@
schema={pageSchema(page.data.origin, '/privacy', TITLE, DESCRIPTION)}
/>

<Prose title="Privacy" updated="15 August 2026">
<Prose title="Privacy" updated="20 September 2026">
<p>
The short version: Nextsode keeps the least it can get away with, shows you no advertising,
tracks you nowhere, and lets you delete everything from this page in one step.
Expand Down Expand Up @@ -150,9 +151,7 @@

<h2>Contact</h2>
<p>
For anything about your data — including a copy of it — use the address listed on the
<a href="https://github.com/Isma-L154/nextsode" target="_blank" rel="noopener noreferrer">
project repository
</a>.
For anything about your data — including a copy of it — write to
<a href={CONTACT_MAILTO} rel="external">{CONTACT_EMAIL}</a>.
</p>
</Prose>
9 changes: 6 additions & 3 deletions src/routes/terms/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import Seo from '$lib/components/Seo.svelte';
import { pageSchema } from '$lib/format/seo';
import Prose from '$lib/components/ui/Prose.svelte';
import { CONTACT_EMAIL, CONTACT_MAILTO } from '$lib/contact';

// Declared once: the meta tags and the structured data have to agree, and
// two copies of a sentence are two chances for them to drift apart.
Expand All @@ -20,7 +21,7 @@
schema={pageSchema(page.data.origin, '/terms', TITLE, DESCRIPTION)}
/>

<Prose title="Terms of Use" updated="15 August 2026">
<Prose title="Terms of Use" updated="20 September 2026">
<p>
Nextsode is a free service for keeping a personal list of films and TV shows and tracking how
far into a series you are. By using it you agree to what follows. If you do not, please do not
Expand Down Expand Up @@ -101,9 +102,11 @@

<h2>Contact</h2>
<p>
Questions about these terms, or about the service, can be sent to the address listed on the
Questions about these terms, or about the service, can be sent to
<a href={CONTACT_MAILTO} rel="external">{CONTACT_EMAIL}</a>. Bugs and feature requests are
better raised on the
<a href="https://github.com/Isma-L154/nextsode" target="_blank" rel="noopener noreferrer">
project repository
</a>.
</a>, where other people can see them.
</p>
</Prose>
Loading