diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 2815f6a..666af69 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -13,3 +13,18 @@ jobs:
- uses: actions/checkout@v4
- name: Run test suite
run: npm test
+
+ gitleaks:
+ name: Secret scan (gitleaks)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install gitleaks
+ run: |
+ curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz -o gitleaks.tar.gz
+ tar -xzf gitleaks.tar.gz gitleaks
+ # `gitleaks detect` exits non-zero on any finding, so this step fails the
+ # build on any secret hit. `--no-git` scans the checked-out tree only;
+ # `--redact` masks matched secrets in the log output.
+ - name: Run gitleaks (fail on any hit)
+ run: ./gitleaks detect --source . --no-git --redact -v
diff --git a/README.md b/README.md
index 97ce69b..dcbaccc 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,41 @@ vanilla JavaScript.
rendered from `data/reading-list.js`)
- `contact.html` — contact page (opens the visitor's mail client with the form
fields pre-filled via a `mailto:` link)
+- `newsletter.html` — newsletter signup page (posts the visitor's email to a
+ configured serverless endpoint; no self-hosted backend or subscriber list
+ management)
+
+## Newsletter signup
+
+The signup form on `newsletter.html` POSTs the visitor's email to a serverless
+endpoint. The client ships only the endpoint URL — no credential is ever sent
+in the request:
+
+- `NEWSLETTER_ENDPOINT` — the serverless endpoint the form POSTs to. This is
+ the **only** thing configured in `js/newsletter-config.js`, and it is
+ enforced `https://`-only at config load time and by tests (mirroring the
+ protocol allowlist in `js/reading-list.js`).
+
+**Authentication is server-side only.** The serverless function reads its API
+token from platform env/secrets at deploy time — never from this repository and
+never from any client-served asset. Do not add a token to `js/newsletter-config.js`
+or anywhere else in the tree; anything committed here is public.
+
+When the endpoint is unavailable or rejects the submission, the visitor sees a
+fixed, user-safe error message — the server-held token, the endpoint, the status
+code, and any raw response body are never surfaced. After a successful signup a
+confirmation message is shown.
+
+**Server-side validation (required follow-up on the function, not this diff):**
+the serverless function must authoritatively validate submissions before
+processing — email syntax, length caps, pinned `Content-Type`, and rejection of
+unknown fields. Client-side checks here are UX only and are bypassable by
+direct API calls.
+
+**Residual signup-abuse risk (accepted, out of scope):** this pass adds no spam
+filtering or captcha. Scripted signups remain possible, so the serverless
+function must mitigate abuse server-side with rate limiting and an origin
+allowlist.
## Reading list
@@ -56,11 +91,14 @@ npm test
index.html Home page
reading.html Reading list page
contact.html Contact page
+newsletter.html Newsletter signup page
css/style.css Global + responsive styles
data/reading-list.js Curated reading list data (edit to add links)
+js/newsletter-config.js Newsletter endpoint (non-secret, https-only) — edit at deploy time
+js/newsletter.js Newsletter signup wiring: credential-free POST + user-safe errors (tested)
js/mailto.js Pure mailto: URL builder (unit tested)
js/contact.js Contact form wiring (browser + tests)
js/reading-list.js Reading list renderer: data file -> grouped HTML (tested)
tests/ Node built-in test suite
-.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main
+.gitea/workflows/ ci.yml runs `npm test` plus a gitleaks secret scan (fails on any hit) on PRs and pushes to main
```
diff --git a/contact.html b/contact.html
index f442c69..cf224e0 100644
--- a/contact.html
+++ b/contact.html
@@ -14,6 +14,7 @@
diff --git a/js/newsletter-config.js b/js/newsletter-config.js
new file mode 100644
index 0000000..66f77f2
--- /dev/null
+++ b/js/newsletter-config.js
@@ -0,0 +1,44 @@
+/**
+ * Newsletter signup configuration.
+ *
+ * Only the non-secret serverless endpoint URL lives here. The serverless
+ * function authenticates with an API token it reads from platform env/secrets
+ * at deploy time — never from this module and never from any client-served
+ * asset. Do NOT add a token or any other secret to this file: the client must
+ * never carry a credential, and anything committed here is public.
+ */
+
+/** True when the value is an absolute URL whose protocol is https:. */
+export function isHttpsUrl(value) {
+ try {
+ return new URL(String(value)).protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Enforce the https-only rule on a configured endpoint, mirroring the protocol
+ * allowlist pattern in js/reading-list.js (tightened to https). Throws when the
+ * endpoint would silently downgrade submissions to plaintext.
+ *
+ * @param {string} endpoint
+ * @returns {true}
+ */
+export function validateEndpoint(endpoint) {
+ if (!isHttpsUrl(endpoint)) {
+ throw new Error("NEWSLETTER_ENDPOINT must be an https:// URL");
+ }
+ return true;
+}
+
+/**
+ * The serverless endpoint the signup form POSTs to.
+ *
+ * https-only is asserted here at config load time (and covered by tests), so a
+ * deployer pointing this at an http:// URL fails fast instead of shipping a
+ * downgraded endpoint.
+ */
+export const NEWSLETTER_ENDPOINT = "https://example.com/api/newsletter-subscribers";
+
+validateEndpoint(NEWSLETTER_ENDPOINT);
diff --git a/js/newsletter.js b/js/newsletter.js
new file mode 100644
index 0000000..6a0b5cd
--- /dev/null
+++ b/js/newsletter.js
@@ -0,0 +1,128 @@
+/**
+ * Newsletter signup — posts the visitor's email to a configured serverless
+ * endpoint. No credential is ever sent: the serverless function authenticates
+ * with an API token it reads from platform env/secrets at deploy time.
+ *
+ * Pure-ish by design (no DOM, injectable fetch), so every behaviour is unit
+ * testable in Node; the browser wiring at the bottom is guarded accordingly.
+ */
+import { NEWSLETTER_ENDPOINT } from "./newsletter-config.js";
+
+/** User-safe message shown when the signup cannot be completed. */
+export const NEWSLETTER_ERROR_MESSAGE =
+ "Sorry, the newsletter signup isn't available right now. Please try again later.";
+
+/** Confirmation message shown after a successful signup. */
+export const NEWSLETTER_SUCCESS_MESSAGE = "Thanks for subscribing!";
+
+/** Simple email shape check; the HTML5 `type="email"` input is the primary gate. */
+export function isValidEmail(value) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim());
+}
+
+/**
+ * Read the trimmed email value from a form.
+ * Works with a real DOM form and with the minimal fake form used in tests.
+ *
+ * @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form
+ * @returns {string}
+ */
+export function readFormEmail(form) {
+ for (const el of form.querySelectorAll("[name]")) {
+ if (el.name === "email") return el.value.trim();
+ }
+ return "";
+}
+
+/**
+ * POST the email to the serverless endpoint with no credential in the request
+ * — no auth header, no token in the body or URL. The serverless function reads
+ * its API token from platform env/secrets, never from the client. A clearly
+ * invalid email fails fast with a user-safe error and no network call. Any
+ * non-2xx response and any network failure map to the same generic message, so
+ * the endpoint internals, any status code, and any raw response body are never
+ * surfaced to the visitor.
+ *
+ * @param {string} email
+ * @param {{endpoint?: string, fetchImpl?: typeof fetch}} [options]
+ * @returns {Promise<{ok: boolean, message: string}>}
+ */
+export async function submitNewsletterSignup(
+ email,
+ { endpoint = NEWSLETTER_ENDPOINT, fetchImpl = fetch } = {},
+) {
+ if (!isValidEmail(email)) {
+ return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
+ }
+
+ let response;
+ try {
+ response = await fetchImpl(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ email }),
+ });
+ } catch {
+ return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
+ }
+
+ if (!response.ok) {
+ return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
+ }
+ return { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE };
+}
+
+/**
+ * Handle a newsletter form submit: validate the email, POST it, and surface
+ * the result (confirmation or user-safe error) through `setStatus`.
+ *
+ * @param {HTMLFormElement} form
+ * @param {{endpoint?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options]
+ * @returns {Promise<{ok: boolean, message: string}>}
+ */
+export async function handleNewsletterSubmit(
+ form,
+ {
+ endpoint = NEWSLETTER_ENDPOINT,
+ fetchImpl = fetch,
+ setStatus = defaultSetStatus,
+ } = {},
+) {
+ const email = readFormEmail(form);
+ if (!isValidEmail(email)) {
+ setStatus(NEWSLETTER_ERROR_MESSAGE, "error");
+ return { ok: false, message: NEWSLETTER_ERROR_MESSAGE };
+ }
+
+ const result = await submitNewsletterSignup(email, { endpoint, fetchImpl });
+ setStatus(result.message, result.ok ? "success" : "error");
+ return result;
+}
+
+/** Default status renderer — puts the message into `#newsletter-status`. */
+function defaultSetStatus(message, kind) {
+ const el = document.getElementById("newsletter-status");
+ if (el) {
+ el.textContent = message;
+ el.dataset.kind = kind;
+ }
+}
+
+/**
+ * Wire the submit handler onto a newsletter form.
+ * @param {HTMLFormElement} form
+ */
+export function initNewsletterForm(form, options = {}) {
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ handleNewsletterSubmit(form, options);
+ });
+}
+
+// Browser-only wiring — guarded so this module stays importable in Node tests.
+if (typeof window !== "undefined" && typeof document !== "undefined") {
+ const form = document.getElementById("newsletter-form");
+ if (form) initNewsletterForm(form);
+}
diff --git a/newsletter.html b/newsletter.html
new file mode 100644
index 0000000..3bf2871
--- /dev/null
+++ b/newsletter.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Newsletter | My Personal Blog
+
+
+
+
+
+
+
+
Newsletter
+
Occasional updates on new posts — no spam, unsubscribe anytime. Enter your email to subscribe.