diff --git a/.vscode/settings.json b/.vscode/settings.json index 08be46eea..85988bcf9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -211,6 +211,7 @@ "miscategorized", "misconfiguring", "Misrouted", + "mjml", "mlflow", "moloco", "Moodle", diff --git a/@types/mjml-template.d.ts b/@types/mjml-template.d.ts new file mode 100644 index 000000000..aa05b0123 --- /dev/null +++ b/@types/mjml-template.d.ts @@ -0,0 +1,4 @@ +declare module '*.mjml?raw' { + const content: string + export default content +} \ No newline at end of file diff --git a/_TODO.md b/_TODO.md index 1359d2ca0..936b66832 100644 --- a/_TODO.md +++ b/_TODO.md @@ -31,4 +31,8 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2 - `0/2000` characters should show number of characters left instead -## Content Issues +## Newsletter / MJML Templates + +- We need to make sure the images point to the full production URL, not a relative import +- Need to move the unsubscribe link into an Action and handle it entirely within our website instead of on Hubspot +- Need to add a newsletter publishing workflow as an action, using the newsletter static segment imported from Hubspot diff --git a/design/lists/list-1.gif b/design-comps/lists/list-1.gif similarity index 100% rename from design/lists/list-1.gif rename to design-comps/lists/list-1.gif diff --git a/design/lists/list-10.png b/design-comps/lists/list-10.png similarity index 100% rename from design/lists/list-10.png rename to design-comps/lists/list-10.png diff --git a/design/lists/list-11.png b/design-comps/lists/list-11.png similarity index 100% rename from design/lists/list-11.png rename to design-comps/lists/list-11.png diff --git a/design/lists/list-2.jpg b/design-comps/lists/list-2.jpg similarity index 100% rename from design/lists/list-2.jpg rename to design-comps/lists/list-2.jpg diff --git a/design/lists/list-3.jpg b/design-comps/lists/list-3.jpg similarity index 100% rename from design/lists/list-3.jpg rename to design-comps/lists/list-3.jpg diff --git a/design/lists/list-4.png b/design-comps/lists/list-4.png similarity index 100% rename from design/lists/list-4.png rename to design-comps/lists/list-4.png diff --git a/design/lists/list-5.jpeg b/design-comps/lists/list-5.jpeg similarity index 100% rename from design/lists/list-5.jpeg rename to design-comps/lists/list-5.jpeg diff --git a/design/lists/list-6.jpg b/design-comps/lists/list-6.jpg similarity index 100% rename from design/lists/list-6.jpg rename to design-comps/lists/list-6.jpg diff --git a/design/lists/list-7.jpeg b/design-comps/lists/list-7.jpeg similarity index 100% rename from design/lists/list-7.jpeg rename to design-comps/lists/list-7.jpeg diff --git a/design/lists/list-8.jpg b/design-comps/lists/list-8.jpg similarity index 100% rename from design/lists/list-8.jpg rename to design-comps/lists/list-8.jpg diff --git a/design/lists/list-9.jpg b/design-comps/lists/list-9.jpg similarity index 100% rename from design/lists/list-9.jpg rename to design-comps/lists/list-9.jpg diff --git a/docs/newsletter/example-issue-copy.md b/docs/newsletter/example-issue-copy.md new file mode 100644 index 000000000..f32c05c97 --- /dev/null +++ b/docs/newsletter/example-issue-copy.md @@ -0,0 +1,120 @@ +# The Kubernetes DNS Bug That Wasted 40 Engineering Hours + +*Monthly dispatch from Webstack Builders — platform engineering, DevOps, and cloud infrastructure* + +--- + +## The Deep Dive — Debugging ndots and Why Your DNS Is Slower Than You Think + +Three weeks ago, a client's platform team started seeing intermittent 5xx errors from a handful of microservices. Latency percentiles looked normal. CPU and memory were fine. The on-call engineer checked the usual suspects — upstream dependencies, recent deploys, connection pool exhaustion — and found nothing. + +Forty engineering hours later, the root cause turned out to be DNS. + +**The symptoms were misleading.** Under moderate load, roughly 2% of HTTP requests to internal services would time out. The timeouts were evenly distributed across services, which made it look like a network issue rather than a resolution issue. The team spent a full day chasing a phantom connectivity problem between nodes before someone finally ran `tcpdump` on a pod's network namespace and noticed something odd: every single DNS lookup was generating five queries instead of one. + +**The culprit: `ndots:5`.** Kubernetes sets `ndots:5` in every pod's `/etc/resolv.conf` by default. This means that any hostname with fewer than five dots gets treated as a relative name, and the resolver appends each search domain before trying the name as-is. A lookup for `auth-service.production.svc.cluster.local` (four dots) would first try `auth-service.production.svc.cluster.local.production.svc.cluster.local`, then three more permutations, before finally resolving correctly on the fifth attempt. + +Under normal load, this is invisible. Under sustained traffic, it was multiplying DNS query volume by 5x and saturating CoreDNS pods that were sized for the expected query rate — not five times the expected query rate. + +**What made this hard to find.** The standard CoreDNS metrics — `coredns_dns_requests_total` and `coredns_dns_responses_total` — were elevated but didn't trigger alerts because the team's thresholds were based on historical averages that had gradually crept up. The metrics told the truth; nobody was looking at the right graph. + +**The fix was two lines.** In the pod spec's `dnsConfig`: + +```yaml +dnsConfig: + options: + - name: ndots + value: "2" +``` + +This tells the resolver to treat any name with two or more dots as fully qualified, skipping the search domain dance. For internal service names that use the full `..svc.cluster.local` format, this eliminates four unnecessary queries per lookup. + +The team also added explicit search domain entries to avoid breaking short names used in legacy configuration: + +```yaml +dnsConfig: + searches: + - production.svc.cluster.local + - svc.cluster.local +``` + +**What should have caught this earlier.** Two monitoring changes went in immediately after the fix: + +- A Prometheus alert on `coredns_dns_requests_total` rate-of-change, not just absolute value. A 5x query spike in an hour is never normal. +- A dashboard panel showing cache hit ratio alongside query volume. During the incident, cache hit rate had dropped to 31% — a clear signal that pods were hammering CoreDNS with queries that could never be cached because they were for nonexistent names. + +The takeaway isn't "change your ndots setting." It's that default configurations optimized for convenience can become performance landmines at scale, and the monitoring that catches them is rarely the monitoring you set up on day one. + +--- + +## Quick Wins + +- **Terraform state backup before every apply.** Add this to your CI pipeline or local workflow. One line, zero regret when someone applies against the wrong workspace: + + ```bash + terraform state pull > "tfstate-backup-$(date +%Y%m%d-%H%M%S).json" && terraform apply + ``` + +- **Find abandoned Grafana dashboards.** This PromQL query surfaces dashboards with zero views in the last 90 days. Clean them out before your Grafana instance becomes a graveyard of dashboards nobody trusts: + + ```text + grafana_db_dashboard_last_viewed_at < (time() - 86400 * 90) + ``` + + Run it against your Grafana metrics endpoint, or use the Grafana API: `GET /api/search?query=&sort=viewed-asc` and filter by `meta.lastViewedAt`. + +- **Pre-commit secrets scanning that works with monorepos.** Most `gitleaks` setups choke on monorepos because they scan the entire history on every commit. This `.pre-commit-config.yaml` entry scans only staged changes: + + ```yaml + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.0 + hooks: + - id: gitleaks + args: ["protect", "--staged"] + ``` + +--- + +## From the Blog + +Our latest article covers **structured logging with correlation IDs** — how to thread a single request identifier through every service in a call chain so that debugging distributed failures doesn't require cross-referencing timestamps across six different log streams. + +If that DNS investigation above had used correlation IDs, the team could have traced a single failing request from the API gateway through to the DNS timeout in minutes instead of hours. The post walks through implementation patterns for Node.js and Go services, with examples using OpenTelemetry's trace context propagation. + +[Read the full article →](#) + +--- + +## What I'm Reading + +- **Cloudflare's routing incident post-mortem** — A BGP misconfiguration took down a significant chunk of their network for 17 minutes. The post-mortem is worth reading for the timeline alone: how a change that passed validation in staging behaved differently in production because of a subtle difference in route map evaluation order. [Read it →](#) + +- **OpenTelemetry Collector tail-sampling processor** — The new tail-sampling processor lets you make sampling decisions after a trace is complete, which means you can keep 100% of error traces and slow traces while sampling routine ones aggressively. If you're spending too much on trace storage, this is the feature to evaluate. [Read it →](#) + +- **Google SRE: A practical guide to SLO-based alerting** — Moves past the theory and into implementation. The section on multi-window, multi-burn-rate alerts is the clearest explanation of the concept available. If your alerts still fire on static thresholds, start here. [Read it →](#) + +--- + +## One Thing to Try This Month + +Check your CoreDNS cache hit rate. If it's below 80%, you're probably hammering upstream resolvers unnecessarily — and you might be one traffic spike away from the exact scenario described in this issue's deep dive. + +Here's the Prometheus query: + +```text +sum(rate(coredns_cache_hits_total[5m])) / +(sum(rate(coredns_cache_hits_total[5m])) + sum(rate(coredns_cache_misses_total[5m]))) +``` + +If the number is low, two things to check: your `ndots` setting (see above) and your CoreDNS Corefile's cache TTL. The default cache block caches positive responses for 30 seconds, which is usually too short for internal service names that rarely change. Bumping it to 300 seconds is safe for most clusters: + +```text +cache 300 +``` + +Add that line to the `Corefile` ConfigMap and roll the CoreDNS pods. Measure again the next day. + +--- + +*Webstack Builders, Inc. — You're receiving this because you subscribed at webstackbuilders.com.* + diff --git a/docs/newsletter/example-issue-outline.md b/docs/newsletter/example-issue-outline.md new file mode 100644 index 000000000..d0f4b2699 --- /dev/null +++ b/docs/newsletter/example-issue-outline.md @@ -0,0 +1,27 @@ +## Sample Issue Outline + +**Subject Line**: "The Kubernetes DNS Bug That Wasted 40 Engineering Hours" + +### 1 - Deep Dive: Debugging ndots and Why Your DNS Is Slower Than You Think + +Walk through a real scenario where the default Kubernetes `ndots:5` setting caused cascading DNS lookup failures under load. Cover the investigation process, the misleading metrics, the actual fix (adjusting ndots + adding search domain entries), and the monitoring changes that would have caught it earlier. + +### 2 - Quick Wins + +- **Terraform state backup**: A one-liner that snapshots your state file before every apply +- **Grafana dashboard hygiene**: A PromQL query to find dashboards nobody has viewed in 90 days +- **Git hook for secrets**: A pre-commit hook pattern using gitleaks that actually works with monorepos + +### 3 - From the Blog + +Highlight the latest article on structured logging with correlation IDs. Tie it back to the DNS debugging story (correlation IDs would have made the investigation faster). + +### 4 - What We're Reading + +- Cloudflare's post-mortem on their recent routing incident +- The OpenTelemetry Collector's new tail-sampling processor and what it means for costs +- A practical guide to SLO-based alerting from Google's SRE team + +### 5 - One Thing to Try This Month + +"Check your CoreDNS cache hit rate. If it's below 80%, you're probably hammering upstream resolvers unnecessarily. Here's the Prometheus query to check, and the CoreDNS Corefile change to fix it." \ No newline at end of file diff --git a/docs/newsletter/ideas.md b/docs/newsletter/ideas.md new file mode 100644 index 000000000..0dad42854 --- /dev/null +++ b/docs/newsletter/ideas.md @@ -0,0 +1,29 @@ +# Recurring Content Ideas by Topic Area + +## Platform Engineering + +- Internal developer portal patterns (Backstage, custom solutions) +- Golden paths vs. guardrails: finding the right balance +- Platform team metrics that actually matter +- Self-service infrastructure: what works, what doesn't + +## DevOps and CI/CD + +- Pipeline optimization techniques with real numbers +- Monorepo vs. polyrepo build strategies +- Feature flag infrastructure at scale +- Deployment strategies beyond blue/green + +## SRE and Reliability + +- Error budget policies that teams actually follow +- On-call rotation design for small teams +- Chaos engineering on a budget +- Incident response process improvements + +## Cloud Engineering + +- Multi-cloud networking pitfalls +- Cost optimization strategies with measurable results +- Infrastructure-as-code patterns and anti-patterns +- Cloud provider feature comparisons (without vendor bias) \ No newline at end of file diff --git a/docs/newsletter/infrastructure.md b/docs/newsletter/infrastructure.md new file mode 100644 index 000000000..3799346a8 --- /dev/null +++ b/docs/newsletter/infrastructure.md @@ -0,0 +1,35 @@ +# Newsletter Infrastructure + +You can access the list of names in the Newsletter segment and their email addresses via the HubSpot API on a **Starter plan**. However, there is a critical update to keep in mind: HubSpot has fully **deprecated legacy API keys** in favor of **Private Apps**. + +To retrieve your newsletter segment (static list), you will need to follow these steps: + +1. Create a Private App + +Instead of an API key, you must create a Private App within your HubSpot portal to generate an **Access Token**. + +- Navigate to **Settings > Integrations > Private Apps**. +- Create a new app and select the necessary **Scopes** (permissions). For your goal, you will at minimum need `crm.lists.read` and `crm.objects.contacts.read`. +- HubSpot will provide an **Access Token** that you will use in your API requests as a Bearer token. + +## Retrieve List Memberships + +Because you are using a "Newsletter" segment (list), the process is typically a two-step API call: + +1. **Get Member IDs**: Use the Lists API to get a list of all contact IDs belonging to your specific static list. + + - **Endpoint:** `GET /crm/v3/lists/{listId}/memberships` + +2. **Fetch Contact Details**: Once you have the IDs, use the Contacts API to pull the actual names and email addresses for those specific IDs. + + - **Endpoint:** `POST /crm/v3/objects/contacts/batch/read` + +3. Starter Plan Limitations to Watch + +- **Rate Limits:** On the Starter plan, you are limited to **100 requests per 10 seconds**. +- **Daily Limit:** You have a generous **250,000 API calls per day**. +- **Pagination:** If your newsletter list is large, the API will return results in "batches" (usually 100-250 at a time). You will need to check the `paging.next.after` value in the response to fetch the next set of contacts. + +**✅ Result Summary** + +You can use the **HubSpot API** on a **Starter plan** to programmatically extract your newsletter list members. You simply need to swap your old API key for a **Private App Access Token** and use the **Lists and Contacts API endpoints** to gather the names and emails. diff --git a/docs/newsletter/newsletter-structure.md b/docs/newsletter/newsletter-structure.md new file mode 100644 index 000000000..2b3bb4dbb --- /dev/null +++ b/docs/newsletter/newsletter-structure.md @@ -0,0 +1,71 @@ +# Newsletter Content Plan + +## Overview + +The Webstack Builders newsletter is a monthly dispatch covering platform engineering, DevOps, SRE, and cloud engineering topics. Each issue delivers practical, opinionated content aimed at engineers and engineering leaders who build and operate production infrastructure. + +**Cadence**: Monthly (first Tuesday of each month) + +**Audience**: Platform engineers, DevOps practitioners, SREs, cloud architects, and engineering managers + +**Tone**: Direct, practical, opinionated. No fluff, no hype cycles. Real problems, real solutions. + +## Newsletter Structure + +Each issue follows a consistent format with five sections. Target length is 1,200-1,500 words (5-7 minute read). + +### Section 1 - The Deep Dive (500-600 words) + +The anchor piece. A single topic explored with enough depth to be immediately useful. + +Pick from recurring themes: + +- **Incident Teardowns**: Anonymized analysis of real production incidents. What broke, why the monitoring missed it, what the fix looked like, and what systemic changes prevented recurrence. +- **Architecture Decisions**: A specific infrastructure choice (e.g., switching from polling to event-driven, adopting a service mesh, choosing a secrets manager) with the tradeoffs laid out honestly. +- **Tool Deep Dives**: Hands-on evaluation of a specific tool or approach. Not a product review, but a "here's what it's actually like to operate this in production" piece. +- **Process Improvements**: How to improve a specific engineering process (on-call rotations, incident response, deployment pipelines) with concrete before/after examples. + +**Example topics**: + +- "Why We Stopped Using Helm for Everything (and What Replaced It)" +- "The Three Kubernetes Monitoring Gaps That Bit Us Last Quarter" +- "Migrating from Jenkins to GitHub Actions: The Parts Nobody Warns You About" +- "What Actually Happens When You Set CPU Limits Too Low" + +### Section 2 - Quick Wins (200-250 words) + +Three to four bite-sized, immediately actionable tips. Each one should be something a reader can implement the same day. + +**Format**: Brief description + code snippet or config example where relevant. + +**Example entries**: + +- A Prometheus query that catches container OOM kills before they cascade +- A Terraform module pattern that eliminates drift in multi-environment setups +- A GitHub Actions workflow optimization that cut CI time by 40% +- A kubectl command chain for debugging intermittent pod failures + +### Section 3 - From the Blog (100-150 words) + +Highlight one or two recently published articles or downloadable guides from the Webstack Builders site. Brief context on why it matters and a direct link. + +### Section 4 - What We're Reading (150-200 words) + +Three to four curated links to notable content from across the industry. Prioritize: + +- Post-mortems and incident reports from companies willing to share +- Technical blog posts with real implementation details +- RFCs or proposals for emerging standards (OpenTelemetry, Gateway API, etc.) +- Conference talks worth the time investment + +Each link gets a one-sentence annotation explaining why it's worth reading. + +### Section 5 - One Thing to Try This Month (100-150 words) + +A single, specific challenge or experiment for the reader. Something they can do in their own environment to improve reliability, observability, or developer experience. + +**Examples**: + +- "Run a game day: Pick your most critical service and simulate its primary database going read-only. How long until your team notices? How long until they recover?" +- "Audit your alert rules: Count how many fired in the last 30 days. How many were actionable? Delete the ones that nobody acted on." +- "Measure your deployment lead time: From merged PR to production traffic. If you can't measure it, that's the first thing to fix." diff --git a/package-lock.json b/package-lock.json index 31cbc3955..b65ba8d62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,10 +52,13 @@ "@types/eslint-plugin-security": "3.0.1", "@types/glidejs__glide": "^3.6.6", "@types/hast": "^3.0.4", + "@types/html-to-text": "^9.0.4", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^28.0.1", + "@types/mjml": "^4.7.4", "@types/node": "^25.6.0", "@types/nodemailer": "^8.0.0", + "@types/nunjucks": "^3.2.6", "@types/pubsub-js": "^1.8.6", "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.1", @@ -107,6 +110,7 @@ "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "html-element-attributes": "^3.5.0", + "html-to-text": "^9.0.5", "husky": "^9.1.7", "install": "^0.13.0", "is-whitespace-character": "^2.0.1", @@ -119,10 +123,12 @@ "markdownlint-cli2": "^0.22.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.14.0", + "mjml": "^4.18.0", "nanostores": "^1.2.0", "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "npm": "^11.12.1", + "nunjucks": "^3.2.4", "pdf-lib": "^1.17.1", "playwright-lighthouse": "^4.0.0", "postcss": "8.5.9", @@ -299,6 +305,21 @@ "typescript": "^5.0.0" } }, + "node_modules/@astrojs/check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@astrojs/compiler": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", @@ -4827,6 +4848,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -6762,6 +6789,19 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@semantic-ui/astro-lit": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@semantic-ui/astro-lit/-/astro-lit-5.3.0.tgz", @@ -8439,6 +8479,12 @@ "@types/unist": "*" } }, + "node_modules/@types/html-to-text": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.4.tgz", + "integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==", + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -8520,6 +8566,21 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "license": "MIT" }, + "node_modules/@types/mjml": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz", + "integrity": "sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==", + "license": "MIT", + "dependencies": { + "@types/mjml-core": "*" + } + }, + "node_modules/@types/mjml-core": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-4.15.2.tgz", + "integrity": "sha512-Q7SxFXgoX979HP57DEVsRI50TV8x1V4lfCA4Up9AvfINDM5oD/X9ARgfoyX1qS987JCnDLv85JjkqAjt3hZSiQ==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -8578,6 +8639,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/nunjucks": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz", + "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==", + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.15.6", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", @@ -11822,6 +11889,12 @@ "node": ">=14.6" } }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -13582,6 +13655,12 @@ "node": ">=0.10.0" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -14337,6 +14416,18 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -14639,6 +14730,16 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, "node_modules/camelcase-keys": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-8.0.2.tgz", @@ -14896,18 +14997,51 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" } }, "node_modules/chownr": { @@ -15013,6 +15147,27 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/clean-git-ref": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", @@ -16584,6 +16739,12 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/deterministic-object-hash": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", @@ -17044,6 +17205,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -20331,6 +20531,33 @@ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", "license": "MIT" }, + "node_modules/html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", + "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", + "license": "MIT", + "dependencies": { + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" + }, + "bin": { + "html-minifier": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/html-minifier/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/html-tags": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", @@ -20343,6 +20570,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -20847,6 +21109,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -21661,6 +21935,51 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -21983,6 +22302,74 @@ "node": ">=4.0" } }, + "node_modules/juice": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", + "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/juice/node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/juice/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/juice/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/katex": { "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", @@ -22102,6 +22489,15 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/legacy-javascript": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", @@ -23375,6 +23771,12 @@ "integrity": "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==", "license": "MIT" }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", @@ -24982,6 +25384,12 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, "node_modules/meow": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", @@ -26139,6 +26547,452 @@ "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==", "license": "Apache-2.0" }, + "node_modules/mjml": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.18.0.tgz", + "integrity": "sha512-rQM4aqFRrNvV1k733e8hJSopBjZvoSdBpRYzNTMAN+As0jqJsO5eN0wTT2IFtfe4PREzzu5b06RkPiUQdd0IIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "mjml-cli": "4.18.0", + "mjml-core": "4.18.0", + "mjml-migrate": "4.18.0", + "mjml-preset-core": "4.18.0", + "mjml-validator": "4.18.0" + }, + "bin": { + "mjml": "bin/mjml" + } + }, + "node_modules/mjml-accordion": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.18.0.tgz", + "integrity": "sha512-9PUmy2JxIOGgAaVHvgVYX21nVAo3o/+wJckTTF/YTLGAqB+nm+44buxRzaXxVk7qXRwbCNfE8c8mlGVNh7vB1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-body": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.18.0.tgz", + "integrity": "sha512-34AwX70/7NkRIajPsa5j6NySRiNrlLatTKhiLwTVFiVtrEFlfCcbeMNmdVixI3Ldvs8209ZC6euaAnXDRyR1zw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-button": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.18.0.tgz", + "integrity": "sha512-ZsWMI0j7EcFCMqbqdVwMWhmsVc03FhmypWXokKopGhwySn4IAB4AOURonRmFrO7k6sDeQ+iJ9QtTu7jA+S8wmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-carousel": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.18.0.tgz", + "integrity": "sha512-wY4g1CHCOoVSZuar7CLFon/qkPbICu71IT+6pa4BDwkAiaAMAemZPyy+a+iIUgdc8kHgSuHGsGf6PQzBSMWRZA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-cli": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.18.0.tgz", + "integrity": "sha512-N6CnA4o/q/VRnGPxTzvVnjAEcF7WUVVQGYfS9SPAp0qwyf7RysMmewdS9yN8GwXwZV6L2sKdn+3ANNi2FNsJ7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "chokidar": "^3.0.0", + "glob": "^10.3.10", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "minimatch": "^9.0.3", + "mjml-core": "4.18.0", + "mjml-migrate": "4.18.0", + "mjml-parser-xml": "4.18.0", + "mjml-validator": "4.18.0", + "yargs": "^17.7.2" + }, + "bin": { + "mjml-cli": "bin/mjml" + } + }, + "node_modules/mjml-column": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.18.0.tgz", + "integrity": "sha512-0QZ1whxbHUmJaRT8tW+wmr3fWZ/kpsHKAd24c7Z/N1Otm/U2G0T/FFEFJ6cB25X6ZN0K40QZ8L9gdLfiSVuRbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-core": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.18.0.tgz", + "integrity": "sha512-yey72LszXvIo5p0R6DB+YU8er/nP2wPsqpLKQCB0H8vG0WRT1sbSUvnCUOkKGn7subuyWDTdzHKbQO3XYIOmvg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.18.0", + "mjml-parser-xml": "4.18.0", + "mjml-validator": "4.18.0" + } + }, + "node_modules/mjml-core/node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/mjml-core/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/mjml-divider": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.18.0.tgz", + "integrity": "sha512-FmGUVJqi4RYroh7y85vDx0aUKZgECkxHtMQ4pkLGQbZ2g93/Qt0Ek88DVCNJ5XwUAQQkE/TvrGMLHp3CIqpQ9Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-group": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.18.0.tgz", + "integrity": "sha512-28ABkXsKljBqj7XCC8GkQ94xz8HEU2XTyD+9LTlkDafzGp/MGJb8DcLh/7IkxCwqkQWyeMiDNLf1djsQ909Vxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.18.0.tgz", + "integrity": "sha512-DS0adpIAsVMDIk2DOsHzjg+RNjQU0fF8jiVP9BmdRHVGrLPmpL9wIHZk2KvsKvZe7VaXXBijFt3DZ5/CQ/+D7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-attributes": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.18.0.tgz", + "integrity": "sha512-nLzix1wrMnojE0RPGhk4iKqSRwHKjie2EPzgKT7CDzfqN+Ref03E5Q19x3cQTLgxvq3C3CnvCQBfnhoS3Eakug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-breakpoint": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.18.0.tgz", + "integrity": "sha512-k6rwff+7i+vTQYJ/CjBfE20qNqPaW60IRH2x2oEPuCzmwDmoVWOcplJIuotSqIAdfwF9hLkICknisp1BpczVlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-font": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.18.0.tgz", + "integrity": "sha512-ao8HB5nf+Dmxw4GO6lMMOlnj1lNZONai0GC9RobrZgPlghZw6hpURWGpkON7pQcy6XnOHwYwkV7Go/npzA2i7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-html-attributes": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.18.0.tgz", + "integrity": "sha512-xaQE1rthe0RrNotwEr71X1tE+QQ489Yc0ynMm3oNMrohDI/TaCeazx8GAHPMM7VLduDA8D4A5wkZ6PuEvlJu4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-preview": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.18.0.tgz", + "integrity": "sha512-2JvYqhbLyU/+Te6/1AXxzTNoHYCDYhXOVZP7wMvU4t7K34pXqyRUNO405atyHUY1MRafrl6RJ8cIx0x5vUX7PA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-style": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.18.0.tgz", + "integrity": "sha512-nEwDHkAqY3Fm7QWeAZc/a7MakZpXh6THfrE8/AWrfpgzTHrD/wihNUc09ztNpr6z/K1+JWgQfSF2BRc+X3P46g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-title": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.18.0.tgz", + "integrity": "sha512-0Hm8o50rPMUQLSCOOa4D4pz9NajmCDccLvBYE4fwKdeUXjSJ6bwAYeMpveel8oNZMDUVJ4Hx+PskisEGHMHM2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-hero": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.18.0.tgz", + "integrity": "sha512-rujm0ROM4QGWw77vnl3NaVaCKXrT4xTSHeAnkHKiY5AuRf6HPTgEtutq5pdel/y6Q9GrmxvN3HRESum7tpJCJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-image": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.18.0.tgz", + "integrity": "sha512-e09NkoYwvzMcTv7V6H5doWD6Te2E1y2EvOLQJoXKVdQpDwyBWGdfnZke0scJGdA58HLAB+0mLYogpLwmfLaP5Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-migrate": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.18.0.tgz", + "integrity": "sha512-qfNCgW9zhJIsbPyXFA5RT/WY4mlje3N0WhHHOsHc0nY89Q01DenyslUy9nLLGXwi4K5FHS58oCjwWbMhwDcj1w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.18.0", + "mjml-parser-xml": "4.18.0", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-navbar": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.18.0.tgz", + "integrity": "sha512-uho/MS2tfNAe+V9u2X7NoCco34MDbdp30ETA8009Qo1VCP/D8lZ+s69WGRPu6hvN/Y2pzBgZly++CMg3qFZqBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-parser-xml": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.18.0.tgz", + "integrity": "sha512-sHSsZg4afY1heThuJzxa1Kvfh/QzB7/9P5fFUHeVnnxb07ZTXnhXWA6YbobdND5/l9+5yjN5/UgqDZm3tIT4Uw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-preset-core": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.18.0.tgz", + "integrity": "sha512-x3l8vMVtsaqM/jauMeZIN7HFD2t5A28J4U0o4849yIlRxiWguLFV5l3BL8Byol+YLkoLuT9PjaZs9RYv+FGfeg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "mjml-accordion": "4.18.0", + "mjml-body": "4.18.0", + "mjml-button": "4.18.0", + "mjml-carousel": "4.18.0", + "mjml-column": "4.18.0", + "mjml-divider": "4.18.0", + "mjml-group": "4.18.0", + "mjml-head": "4.18.0", + "mjml-head-attributes": "4.18.0", + "mjml-head-breakpoint": "4.18.0", + "mjml-head-font": "4.18.0", + "mjml-head-html-attributes": "4.18.0", + "mjml-head-preview": "4.18.0", + "mjml-head-style": "4.18.0", + "mjml-head-title": "4.18.0", + "mjml-hero": "4.18.0", + "mjml-image": "4.18.0", + "mjml-navbar": "4.18.0", + "mjml-raw": "4.18.0", + "mjml-section": "4.18.0", + "mjml-social": "4.18.0", + "mjml-spacer": "4.18.0", + "mjml-table": "4.18.0", + "mjml-text": "4.18.0", + "mjml-wrapper": "4.18.0" + } + }, + "node_modules/mjml-raw": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.18.0.tgz", + "integrity": "sha512-F/kViAwXm3ccPP52kw++/mHQbcYbYYxC8JH15TZxH8GLVZkX5CGKgcBrHhDK7WoIlfEIsVRZ6IZdlHjH8vgyxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-section": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.18.0.tgz", + "integrity": "sha512-bB8My9zvIEkTOxej+TrjEeaeRT0lsypGeRADtdrRZXeqUClkkuCnCXlsNKSLGT8ZRqjUqWRc5z8ubDOvGk2+Gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-social": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.18.0.tgz", + "integrity": "sha512-iAQc9g59L6L3VHDd55BxeIvk/zHkxflxmvuyYyOOvpmmKAvUBC//ULfpxiiM4yupofsThqFfrO+wc8d4kTRkbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-spacer": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.18.0.tgz", + "integrity": "sha512-FK/0f5IBiONgaRpwNBs7G8EbLdAbmYqcIfHR8O8tP4LipAChLQKHO9vX3vrRMGLBZZNTESLObcFSVWmA40Mfpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-table": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.18.0.tgz", + "integrity": "sha512-vJysCPUL3CHcsQDAFpW+skzBtY0RYsmMBYswI4WX0B05GLKlOjXqpYOwcmAupWeGoBVL5r/t28ynu2PqnOlN3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-text": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.18.0.tgz", + "integrity": "sha512-hBLmF3JgveUKktKQFWHqHAr7qr92j1CxAvq7mtpDUgiWgyPFzqRX8mUsFYgZ7DmRxG4UE+Kzpt8/YFd9+E98lw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-validator": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.18.0.tgz", + "integrity": "sha512-JmpWAsNTUlAxJOz2zHYfF8Vod8OzM3Qp5JXtrVw5tivZQzq88ZfqVGuqsas51z0pp1/ilfD4lC17YGfGwKGyhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/mjml-wrapper": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.18.0.tgz", + "integrity": "sha512-TZeOvLjIhXEK60rjWNiYhEYNlv5GKYahE+96ifcT5OGkWkRA0DsQDfp+6VI32OS5VxsfKq2h/UdERPlQijjpAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0", + "mjml-section": "4.18.0" + } + }, "node_modules/mkdirp": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", @@ -26450,6 +27304,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "license": "MIT", + "dependencies": { + "lower-case": "^1.1.1" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -28420,6 +29283,40 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -28962,6 +29859,15 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -29283,6 +30189,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -29405,6 +30324,15 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -31101,6 +32029,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", @@ -33500,6 +34437,18 @@ "typescript": ">=4.9.5" } }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -33971,6 +34920,15 @@ "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", "license": "MIT" }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "license": "MIT (http://mootools.net/license.txt)", + "engines": { + "node": "*" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -36239,6 +37197,18 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid-promise": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/uid-promise/-/uid-promise-1.0.0.tgz", @@ -37236,6 +38206,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -37294,6 +38270,15 @@ "node": ">=8" } }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -38906,6 +39891,144 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 40d090d25..b64c84322 100644 --- a/package.json +++ b/package.json @@ -113,10 +113,13 @@ "@types/eslint-plugin-security": "3.0.1", "@types/glidejs__glide": "^3.6.6", "@types/hast": "^3.0.4", + "@types/html-to-text": "^9.0.4", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^28.0.1", + "@types/mjml": "^4.7.4", "@types/node": "^25.6.0", "@types/nodemailer": "^8.0.0", + "@types/nunjucks": "^3.2.6", "@types/pubsub-js": "^1.8.6", "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.1", @@ -168,6 +171,7 @@ "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "html-element-attributes": "^3.5.0", + "html-to-text": "^9.0.5", "husky": "^9.1.7", "install": "^0.13.0", "is-whitespace-character": "^2.0.1", @@ -180,10 +184,12 @@ "markdownlint-cli2": "^0.22.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.14.0", + "mjml": "^4.18.0", "nanostores": "^1.2.0", "node-html-parser": "^7.1.0", "nodemailer": "^8.0.5", "npm": "^11.12.1", + "nunjucks": "^3.2.4", "pdf-lib": "^1.17.1", "playwright-lighthouse": "^4.0.0", "postcss": "8.5.9", diff --git a/public/assets/images/newsletter/cloud-1.jpg b/public/assets/images/newsletter/cloud-1.jpg new file mode 100644 index 000000000..7ffd73352 Binary files /dev/null and b/public/assets/images/newsletter/cloud-1.jpg differ diff --git a/public/assets/images/newsletter/cloud-2.jpg b/public/assets/images/newsletter/cloud-2.jpg new file mode 100644 index 000000000..290330893 Binary files /dev/null and b/public/assets/images/newsletter/cloud-2.jpg differ diff --git a/public/assets/images/newsletter/cloud-3.jpg b/public/assets/images/newsletter/cloud-3.jpg new file mode 100644 index 000000000..7bd1fd137 Binary files /dev/null and b/public/assets/images/newsletter/cloud-3.jpg differ diff --git a/public/assets/images/newsletter/cloud-4.jpg b/public/assets/images/newsletter/cloud-4.jpg new file mode 100644 index 000000000..35deeaa23 Binary files /dev/null and b/public/assets/images/newsletter/cloud-4.jpg differ diff --git a/src/actions/contact/__tests__/action.spec.ts b/src/actions/contact/__tests__/action.spec.ts new file mode 100644 index 000000000..08d356a70 --- /dev/null +++ b/src/actions/contact/__tests__/action.spec.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ActionConfig = { + handler: (_input: Input, _context: unknown) => Promise + input?: unknown +} + +type ContactSubmitInput = Record + +type ContactSubmitOutput = { + success: true + message: string +} + +const getMockedHandler = (action: unknown): ActionConfig['handler'] => { + return (action as ActionConfig).handler +} + +const { + resendSendMock, + generateEmailContentMock, + generateAcknowledgementEmailContentMock, + handleActionsFunctionErrorMock, +} = vi.hoisted(() => { + return { + resendSendMock: vi.fn(), + generateEmailContentMock: vi.fn(), + generateAcknowledgementEmailContentMock: vi.fn(), + handleActionsFunctionErrorMock: vi.fn(), + } +}) + +vi.mock('astro:actions', () => { + class ActionError extends Error { + public code: string + + constructor({ code, message }: { code: string; message: string }) { + super(message) + this.code = code + this.name = 'ActionError' + } + } + + return { + ActionError, + defineAction: (config: unknown) => config, + } +}) + +vi.mock('resend', () => { + return { + Resend: class Resend { + emails = { + send: resendSendMock, + } + + constructor(_key: string) {} + }, + } +}) + +vi.mock('@actions/utils/environment/environmentActions', () => { + return { + getPrivacyPolicyVersion: vi.fn(() => 'privacy-version-1'), + getResendApiKey: vi.fn(() => 'resend-test-key'), + isProd: vi.fn(() => true), + } +}) + +vi.mock('@actions/utils/rateLimit', () => { + return { + checkContactRateLimit: vi.fn(() => true), + } +}) + +vi.mock('@actions/utils/requestContext', () => { + return { + buildRequestFingerprint: vi.fn(() => ({ fingerprint: 'fingerprint-1' })), + createRateLimitIdentifier: vi.fn(() => 'contact:fingerprint-1'), + } +}) + +vi.mock('@actions/gdpr/entities/consent', () => { + return { + createConsentRecord: vi.fn(async () => ({ id: 'consent-1' })), + } +}) + +vi.mock('@actions/utils/hubspot', () => { + return { + createOrUpdateContact: vi.fn(async () => ({ id: 'hubspot-1' })), + setMarketingOptIn: vi.fn(async () => undefined), + } +}) + +vi.mock('@actions/utils/errors', async () => { + const astro = await import('astro:actions') + + class ActionsFunctionError extends Error { + public status: number + public isServerError: boolean + + constructor(messageOrError: unknown, options?: { message?: string; status?: number }) { + const message = + typeof messageOrError === 'string' + ? messageOrError + : messageOrError instanceof Error + ? messageOrError.message + : options?.message ?? 'Internal server error' + super(message) + this.name = 'ActionsFunctionError' + this.status = options?.status ?? 500 + this.isServerError = this.status >= 500 + } + } + + function throwActionError( + _error: unknown, + _context: unknown, + options?: { fallbackMessage?: string } + ): never { + const message = options?.fallbackMessage ?? 'Internal server error' + throw new ( + astro as unknown as { ActionError: new (_opts: { code: string; message: string }) => Error } + ).ActionError({ + code: 'INTERNAL_SERVER_ERROR', + message, + }) + } + + return { + ActionsFunctionError, + handleActionsFunctionError: handleActionsFunctionErrorMock, + throwActionError: vi.fn(throwActionError), + } +}) + +vi.mock('../responder', () => { + return { + generateEmailContent: generateEmailContentMock, + generateAcknowledgementEmailContent: generateAcknowledgementEmailContentMock, + getFormDataFromInput: vi.fn(() => ({ + name: 'Jane Doe', + email: 'jane@example.com', + message: 'Hello there with enough detail.', + consent: false, + })), + parseAttachmentsFromInput: vi.fn(async () => []), + } +}) + +beforeEach(() => { + vi.clearAllMocks() + generateEmailContentMock.mockResolvedValue('admin-email') + generateAcknowledgementEmailContentMock.mockResolvedValue('ack-email') + resendSendMock.mockResolvedValue({ data: { id: 'email-1' } }) +}) + +describe('contact.submit.handler', () => { + it('sends the admin notification and the acknowledgement email', async () => { + const { contact } = await import('../action') + + const context = { + request: new Request('https://example.com/_actions/contact/submit', { + method: 'POST', + headers: { 'user-agent': 'ua-1' }, + }), + cookies: {} as unknown, + clientAddress: '203.0.113.10', + } + + const result = await getMockedHandler(contact.submit)( + {}, + context + ) + + expect(result).toEqual({ + success: true, + message: 'Thank you for your message. We will get back to you soon!', + }) + + expect(generateEmailContentMock).toHaveBeenCalledTimes(1) + expect(generateAcknowledgementEmailContentMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Jane Doe', + email: 'jane@example.com', + }), + 'info@webstackbuilders.com' + ) + + expect(resendSendMock).toHaveBeenCalledTimes(2) + + const firstPayload = resendSendMock.mock.calls[0]?.[0] + expect(firstPayload).toMatchObject({ + from: 'contact@contact.webstackbuilders.com', + to: 'info@webstackbuilders.com', + replyTo: 'jane@example.com', + subject: 'Contact Form: Jane Doe', + html: 'admin-email', + }) + + const secondPayload = resendSendMock.mock.calls[1]?.[0] + expect(secondPayload).toMatchObject({ + from: 'contact@contact.webstackbuilders.com', + to: 'jane@example.com', + replyTo: 'info@webstackbuilders.com', + subject: 'We received your message - Webstack Builders', + html: 'ack-email', + }) + }) + + it('logs but does not fail when the acknowledgement email send fails', async () => { + resendSendMock + .mockResolvedValueOnce({ data: { id: 'admin-email-1' } }) + .mockResolvedValueOnce({ error: { message: 'ack failed' } }) + + const { contact } = await import('../action') + + const context = { + request: new Request('https://example.com/_actions/contact/submit', { + method: 'POST', + headers: { 'user-agent': 'ua-2' }, + }), + cookies: {} as unknown, + clientAddress: '203.0.113.11', + } + + const result = await getMockedHandler(contact.submit)( + {}, + context + ) + + expect(result.success).toBe(true) + expect(handleActionsFunctionErrorMock).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + route: '/_actions/contact/submit', + operation: 'sendAcknowledgementEmail', + }) + ) + }) +}) \ No newline at end of file diff --git a/src/actions/contact/__tests__/responder.spec.ts b/src/actions/contact/__tests__/responder.spec.ts index 09b1b2f4c..4fed07feb 100644 --- a/src/actions/contact/__tests__/responder.spec.ts +++ b/src/actions/contact/__tests__/responder.spec.ts @@ -2,6 +2,9 @@ import { JSDOM } from 'jsdom' import { describe, expect, it } from 'vitest' import { + createContactAcknowledgementTemplateData, + createContactEmailTemplateData, + generateAcknowledgementEmailContent, generateEmailContent, getFormDataFromInput, parseAttachmentsFromInput, @@ -9,8 +12,8 @@ import { describe('contact responder', () => { describe('generateEmailContent', () => { - it('generates valid HTML with required elements', () => { - const html = generateEmailContent( + it('generates valid HTML with required elements', async () => { + const html = await generateEmailContent( { name: 'Jane Doe', email: 'jane@example.com', @@ -36,9 +39,11 @@ describe('contact responder', () => { expect(document.documentElement.outerHTML).toContain('<ASAP>') expect(document.documentElement.outerHTML).toContain('&') - // Header should exist and include the sender name. - expect(document.querySelector('meta[charset="utf-8"]')).not.toBeNull() - expect(document.querySelector('h1')?.textContent ?? '').toContain('New Contact Form Submission') + // Formatting sanity checks: the compiled email should include standard email structure. + expect(document.querySelectorAll('table').length).toBeGreaterThan(0) + expect(document.querySelectorAll('a').length).toBeGreaterThan(0) + + // Sender data should still appear in the rendered content. expect(document.body.textContent ?? '').toContain('Jane Doe') // Key fields should appear somewhere in the rendered content. @@ -48,8 +53,8 @@ describe('contact responder', () => { expect(document.body.textContent ?? '').toContain('$5k-$10k') }) - it('renders attachments section when attachments exist', () => { - const html = generateEmailContent( + it('renders attachments section when attachments exist', async () => { + const html = await generateEmailContent( { name: 'Jane Doe', email: 'jane@example.com', @@ -71,6 +76,79 @@ describe('contact responder', () => { const dom = new JSDOM(html) expect(dom.window.document.body.textContent ?? '').toContain('brief.pdf') }) + + it('builds template data with optional fields and escaped message markup', () => { + const templateData = createContactEmailTemplateData( + { + name: 'Jane Doe', + email: 'jane@example.com', + company: 'Acme Co', + service: 'Website redesign', + timeline: '2-3-months', + budget: '$5k-$10k', + message: 'Hi team!\nWe need help & would like a call.', + consent: true, + }, + [ + { + filename: 'brief.pdf', + content: Buffer.from('pdf-bytes'), + contentType: 'application/pdf', + size: 1234, + }, + ] + ) + + expect(templateData.fields).toEqual([ + { label: 'Name', value: 'Jane Doe' }, + { label: 'Email', value: 'jane@example.com' }, + { label: 'Company', value: 'Acme Co' }, + { label: 'Service', value: 'Website redesign' }, + { label: 'Budget', value: '$5k-$10k' }, + { label: 'Timeline', value: '2-3-months' }, + ]) + expect(templateData.attachments).toEqual([ + { filename: 'brief.pdf', sizeLabel: '1.21 KB' }, + ]) + expect(templateData.consentGiven).toBe('Yes') + expect(templateData.messageHtml).toContain('<ASAP>') + expect(templateData.messageHtml).toContain('&') + expect(templateData.messageHtml).toContain('
') + }) + + it('builds acknowledgement template data from the submitter name', () => { + const templateData = createContactAcknowledgementTemplateData( + { + name: 'Jane Doe', + email: 'jane@example.com', + message: 'Hello there with enough detail.', + }, + 'info@webstackbuilders.com' + ) + + expect(templateData).toEqual({ + greeting: 'Hi Jane', + replyToEmail: 'info@webstackbuilders.com', + }) + }) + + it('generates acknowledgement email HTML with reply instructions', async () => { + const html = await generateAcknowledgementEmailContent( + { + name: 'Jane Doe', + email: 'jane@example.com', + message: 'Hello there with enough detail.', + }, + 'info@webstackbuilders.com' + ) + + const dom = new JSDOM(html) + const bodyText = dom.window.document.body.textContent ?? '' + + expect(dom.window.document.querySelectorAll('table').length).toBeGreaterThan(0) + expect(bodyText).toContain('Hi Jane') + expect(bodyText).toContain('info@webstackbuilders.com') + }) }) describe('getFormDataFromInput', () => { diff --git a/src/actions/contact/action.ts b/src/actions/contact/action.ts index 7ef02a68b..68e9e54dd 100644 --- a/src/actions/contact/action.ts +++ b/src/actions/contact/action.ts @@ -9,12 +9,13 @@ import { isProd, } from '@actions/utils/environment/environmentActions' import { ActionsFunctionError, handleActionsFunctionError, throwActionError } from '@actions/utils/errors' -import { contactFormSender } from '@actions/utils/email/resendSenders' +import { contactFormSender, contactInbox, contactReplyTo } from '@actions/utils/email/resendSenders' import { createConsentRecord } from '@actions/gdpr/entities/consent' import { createOrUpdateContact, setMarketingOptIn } from '@actions/utils/hubspot' import type { FileAttachment, EmailData } from '@actions/contact/@types' import { contactFormInputSchema } from './domain' import { + generateAcknowledgementEmailContent, generateEmailContent, getFormDataFromInput, parseAttachmentsFromInput, @@ -125,11 +126,11 @@ export const contact = { }) } - const htmlContent = generateEmailContent(formData, files) + const htmlContent = await generateEmailContent(formData, files) await sendEmail( { from: contactFormSender, - to: 'info@webstackbuilders.com', + to: contactInbox, replyTo: formData.email.trim(), subject: `Contact Form: ${formData.name}`, html: htmlContent, @@ -137,6 +138,29 @@ export const contact = { files ) + try { + const acknowledgementHtml = await generateAcknowledgementEmailContent( + formData, + contactReplyTo + ) + + await sendEmail( + { + from: contactFormSender, + to: formData.email.trim(), + replyTo: contactReplyTo, + subject: 'We received your message - Webstack Builders', + html: acknowledgementHtml, + }, + [] + ) + } catch (emailError) { + handleActionsFunctionError(emailError, { + route, + operation: 'sendAcknowledgementEmail', + }) + } + if (formData.consent) { try { const contact = await createOrUpdateContact({ diff --git a/src/actions/contact/email/acknowledgement.mjml b/src/actions/contact/email/acknowledgement.mjml new file mode 100644 index 000000000..313c93556 --- /dev/null +++ b/src/actions/contact/email/acknowledgement.mjml @@ -0,0 +1,250 @@ +{% set heroImageUrl = 'https://www.webstackbuilders.com/assets/images/newsletter/cloud-4.jpg' %} + + + + + We Received Your Message + + + Thanks for contacting Webstack Builders. Our team has your message. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+ + + + Webstack
+ Builders +
+ + +
+
+
+ + + + + 👋 + + Thanks for reaching out! + + + + + + + + {{ greeting }}, + + + I received your message. I'll review it and follow up as soon as possible. + + + + + + + +
+

+ What happens next: +

+

+ 1. I'll review the details you shared. +

+

+ 2. You'll get a response or a clear next step within 24 hours. +

+
+
+ + + + + + +
+

+ Need to add more context? +

+

+ Reply to this email or send any follow-up notes directly to our team. +

+ + Email Our Team + +
+
+
+
+ + + + + Questions? Contact us at {{ replyToEmail }} + + + © {{ common.currentYear }} {{ common.company.name }}, Inc.
+ {{ common.company.address }}, {{ common.company.cityStatePostal }}
+ {{ common.company.telephoneTollFree }} • {{ common.company.telephoneLocal }} +
+
+
+
+
+
\ No newline at end of file diff --git a/src/actions/contact/email/message.mjml b/src/actions/contact/email/message.mjml new file mode 100644 index 000000000..f1b62c988 --- /dev/null +++ b/src/actions/contact/email/message.mjml @@ -0,0 +1,259 @@ + + + + New Contact Form Submission + + + A new contact form submission is ready for internal review. + + + + + + + + + + + + + + + + .contact-field strong { + color: #003d86; + } + + .message-html p:first-child, + .message-html ul:first-child, + .message-html ol:first-child { + margin-top: 0 !important; + } + + .message-html p:last-child, + .message-html ul:last-child, + .message-html ol:last-child { + margin-bottom: 0 !important; + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+ + + + Webstack
+ Builders +
+ + +
+
+
+ + + + + New contact form submission + + + Internal notification for review and follow-up. + + + + + + + + + + + +
+

+ Submission details +

+ {% for field in fields %} +

+ {{ field.label }}: {{ field.value }} +

+ {% endfor %} +

+ Consent Given: {{ consentGiven }} +

+
+
+ + + + + + +
+

+ Message +

+
+ {{ messageHtml | safe }} +
+
+
+ + {% if attachments | length %} + + + + + +
+

+ Attachments +

+ {% for attachment in attachments %} +

+ • {{ attachment.filename }} ({{ attachment.sizeLabel }}) +

+ {% endfor %} +
+
+ {% endif %} +
+
+ + + + + Internal notification for {{ common.company.name }}. + + + © {{ common.currentYear }} {{ common.company.name }}, Inc.
+ {{ common.company.address }}, {{ common.company.cityStatePostal }}
+ {{ common.company.telephoneTollFree }} • {{ common.company.telephoneLocal }} +
+
+
+
+
+
\ No newline at end of file diff --git a/src/actions/contact/responder.ts b/src/actions/contact/responder.ts index f99dd40e9..d62dc99c2 100644 --- a/src/actions/contact/responder.ts +++ b/src/actions/contact/responder.ts @@ -1,5 +1,8 @@ import { Buffer } from 'node:buffer' import { ActionsFunctionError } from '@actions/utils/errors' +import acknowledgementTemplateContent from '@actions/contact/email/acknowledgement.mjml?raw' +import messageTemplateContent from '@actions/contact/email/message.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' import { escapeHtml, formatFileSize } from './utils' import type { ContactFormData, ContactTimeline, FileAttachment } from '@actions/contact/@types' @@ -58,50 +61,114 @@ const readInputBoolean = (input: Record, key: string): boolean return false } -export function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string { +const contactMessageTemplate = createEmailTemplate( + new URL('./email/message.mjml', import.meta.url), + messageTemplateContent +) + +const contactAcknowledgementTemplate = createEmailTemplate( + new URL('./email/acknowledgement.mjml', import.meta.url), + acknowledgementTemplateContent +) + +type ContactEmailTemplateData = { + attachments: Array<{ filename: string; sizeLabel: string }> + consentGiven: 'Yes' | 'No' + fields: Array<{ label: string; value: string }> + messageHtml: string +} + +type ContactAcknowledgementTemplateData = { + greeting: string + replyToEmail: string +} + +const createGreeting = (name: string): string => { + const trimmedName = name.trim() + if (!trimmedName) { + return 'Hello' + } + + const [firstName] = trimmedName.split(/\s+/) + return firstName ? `Hi ${firstName}` : 'Hello' +} + +/** + * Builds the template data required by the contact MJML email. + */ +export function createContactEmailTemplateData( + data: ContactFormData, + files: FileAttachment[] +): ContactEmailTemplateData { const fields = [ - `

Name: ${escapeHtml(data.name)}

`, - `

Email: ${escapeHtml(data.email)}

`, + { label: 'Name', value: data.name }, + { label: 'Email', value: data.email }, ] - if (data.company) fields.push(`

Company: ${escapeHtml(data.company)}

`) - if (data.phone) fields.push(`

Phone: ${escapeHtml(data.phone)}

`) - if (data.service) fields.push(`

Service: ${escapeHtml(data.service)}

`) - if (data.budget) fields.push(`

Budget: ${escapeHtml(data.budget)}

`) - if (data.timeline) fields.push(`

Timeline: ${escapeHtml(data.timeline)}

`) - if (data.website) fields.push(`

Website: ${escapeHtml(data.website)}

`) - - fields.push('

Message:

') - fields.push(`

${escapeHtml(data.message).replace(/\n/g, '
')}

`) - - if (files.length > 0) { - fields.push('

Attachments:

') - fields.push('
    ') - files.forEach(file => { - fields.push(`
  • ${escapeHtml(file.filename)} (${formatFileSize(file.size)})
  • `) - }) - fields.push('
') + if (data.company) fields.push({ label: 'Company', value: data.company }) + if (data.phone) fields.push({ label: 'Phone', value: data.phone }) + if (data.service) fields.push({ label: 'Service', value: data.service }) + if (data.budget) fields.push({ label: 'Budget', value: data.budget }) + if (data.timeline) fields.push({ label: 'Timeline', value: data.timeline }) + if (data.website) fields.push({ label: 'Website', value: data.website }) + + return { + attachments: files.map(file => ({ + filename: file.filename, + sizeLabel: formatFileSize(file.size), + })), + consentGiven: data.consent ? 'Yes' : 'No', + fields, + messageHtml: escapeHtml(data.message).replace(/\n/g, '
'), + } +} + +/** + * Builds the template data required by the contact acknowledgement MJML email. + */ +export function createContactAcknowledgementTemplateData( + data: ContactFormData, + replyToEmail: string +): ContactAcknowledgementTemplateData { + return { + greeting: createGreeting(data.name), + replyToEmail, } +} + +/* +Template data shape for src/actions/contact/email/message.mjml: + +fields: Array<{ label: string; value: string }> +messageHtml: string +attachments: Array<{ filename: string; sizeLabel: string }> +consentGiven: 'Yes' | 'No' +*/ +export async function generateEmailContent( + data: ContactFormData, + files: FileAttachment[] +): Promise { + const { html } = await compileEmailTemplate( + contactMessageTemplate, + createContactEmailTemplateData(data, files) + ) + + return html +} - fields.push(`

Consent Given: ${data.consent ? 'Yes' : 'No'}

`) - - return ` - - - - - - - -

New Contact Form Submission

-${fields.join('\n')} - - -`.trim() +/** + * Generates the acknowledgement email HTML sent back to the contact submitter. + */ +export async function generateAcknowledgementEmailContent( + data: ContactFormData, + replyToEmail: string +): Promise { + const { html } = await compileEmailTemplate( + contactAcknowledgementTemplate, + createContactAcknowledgementTemplateData(data, replyToEmail) + ) + + return html } export function getFormDataFromInput(input: Record): ContactFormData { diff --git a/src/actions/gdpr/email/dsarHtml.ts b/src/actions/gdpr/email/dsarHtml.ts deleted file mode 100644 index 6f2f780f8..000000000 --- a/src/actions/gdpr/email/dsarHtml.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Template for DSAR (Data Subject Access Request) verification - * emails for data access and deletion requests - */ -import type { DSARVerificationEmailPropsHtml } from '@actions/gdpr/@types' - -export const dsarVerificationEmailHtml = (props: DSARVerificationEmailPropsHtml) => { - const { subject, requestType, actionText, verifyUrl, expiresIn } = props - const html = ` - - - - - - ${subject} - - - -
-

Data ${requestType === 'ACCESS' ? 'Access' : 'Deletion'} Request

-
- -
-

Hello,

- -

We received a request to ${actionText} from Webstack Builders. To complete this request, please verify your email address by clicking the button below:

- -
- Verify Request -
- -

Or copy and paste this link into your browser:

-

${verifyUrl}

- - ${ - requestType === 'DELETE' - ? ` -
- ⚠️ Important: This action will permanently delete all your data from our systems. This cannot be undone. -
- ` - : '' - } - -

This link will expire in ${expiresIn}.

- -

If you didn't make this request, you can safely ignore this email. No action will be taken without verification.

-
- - - - - `.trim() - return html -} diff --git a/src/actions/gdpr/email/dsarText.ts b/src/actions/gdpr/email/dsarText.ts index 518beb229..2e84109b2 100644 --- a/src/actions/gdpr/email/dsarText.ts +++ b/src/actions/gdpr/email/dsarText.ts @@ -1,17 +1,20 @@ /** * Template for DSAR (Data Subject Access Request) verification - * emails for data access and deletion requests + * emails for data access and deletion requests. */ +import contactContent from '@content/contact.json' import type { DSARVerificationEmailPropsText } from '@actions/gdpr/@types' -export const dsarVerificationEmailText = (props: DSARVerificationEmailPropsText) => { +export const dsarVerificationEmailText = (props: DSARVerificationEmailPropsText): string => { const { requestType, actionText, verifyUrl, expiresIn } = props - const text = ` + const { company } = contactContent + + return ` Data ${requestType === 'ACCESS' ? 'Access' : 'Deletion'} Request Hello, -We received a request to ${actionText} from Webstack Builders. To complete this request, please verify your email address by visiting this link: +We received a request to ${actionText} from ${company.name}. To complete this request, please verify your email address by visiting this link: ${verifyUrl} @@ -27,9 +30,8 @@ This link will expire in ${expiresIn}. If you didn't make this request, you can safely ignore this email. No action will be taken without verification. -Questions? Contact us at privacy@webstackbuilders.com +Questions? Contact us at ${company.dataProtectionOfficer.email} -© ${new Date().getFullYear()} Webstack Builders. All rights reserved. +© ${new Date().getFullYear()} ${company.name}. All rights reserved. `.trim() - return text -} +} \ No newline at end of file diff --git a/src/actions/gdpr/email/verification.mjml b/src/actions/gdpr/email/verification.mjml new file mode 100644 index 000000000..8be16fc2f --- /dev/null +++ b/src/actions/gdpr/email/verification.mjml @@ -0,0 +1,326 @@ +{% set isDeleteRequest = requestType == 'DELETE' %} +{% set requestLabel = 'Deletion' if requestType == 'DELETE' else 'Access' %} +{% set buttonText = 'Yes, Delete My Data' if isDeleteRequest else 'Verify Request' %} +{% set buttonHoverBackground = '#b91c1c' if isDeleteRequest else '#0057ad' %} +{% set linkHoverColor = '#0057ad' if isDeleteRequest else '#004a94' %} + + + + {{ subject }} + + {{ subject }} from Webstack Builders + + + + + + + + + + + + + + + + + + + + + + .cta-hover td:hover, + .cta-hover a:hover { + background-color: {{ buttonHoverBackground }} !important; + } + + .cta-hover a:hover { + color: #ffffff !important; + } + + .link-hover a:hover { + color: {{ linkHoverColor }} !important; + } + + .url-break { + word-break: break-all; + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+ + + + Webstack
+ Builders +
+ + +
+
+
+ + + + 👋 + + We received a request to
+ {{ actionText }}. +
+
+
+ + + + + Hello, + + + To complete this {{ requestLabel | lower }} request and{% if isDeleteRequest %} permanently remove your + data,{% endif %} please verify that you requested this action. This link is valid for + {{ expiresIn }}. + + {% if isDeleteRequest %} + + What happens next? + + +
    +
  • Your account profile will be permanently deleted.
  • +
  • All usage history and subscriptions will be canceled.
  • +
  • You will immediately be unsubscribed from all communications.
  • +
+
+ {% endif %} + {% if isDeleteRequest %} + + {{ buttonText }} + + {% else %} + + {{ buttonText }} + + {% endif %} + + If the button doesn't work, copy and paste this link:
+
+ + + {{ verifyUrl }} + + + + + Didn't request this? You can safely ignore this email.
+ No action will be taken without your verification. +
+
+
+ + + + + Questions? Contact us at + + {{ common.company.dataProtectionOfficer.email }} + + + + © {{ common.currentYear }} {{ common.company.name }}
+ {{ common.company.address }}, {{ common.company.cityStatePostal }}
+ {{ common.company.telephoneTollFree }} • {{ common.company.telephoneLocal }} +
+
+
+
+
+
\ No newline at end of file diff --git a/src/actions/gdpr/entities/email.ts b/src/actions/gdpr/entities/email.ts index 264f44f83..bd9d40518 100644 --- a/src/actions/gdpr/entities/email.ts +++ b/src/actions/gdpr/entities/email.ts @@ -1,11 +1,46 @@ import { Resend } from 'resend' -import { dsarVerificationEmailHtml } from '@actions/gdpr/email/dsarHtml' +import verificationTemplateContent from '@actions/gdpr/email/verification.mjml?raw' import { dsarVerificationEmailText } from '@actions/gdpr/email/dsarText' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' import { getResendApiKey, isProd } from '@actions/utils/environment/environmentActions' import { gdprReplyTo, gdprSender } from '@actions/utils/email/resendSenders' import { getSiteUrl } from '@actions/utils/environment/siteUrlActions' import { ActionsFunctionError } from '@actions/utils/errors/ActionsFunctionError' +const verificationTemplate = createEmailTemplate( + new URL('../email/verification.mjml', import.meta.url), + verificationTemplateContent +) + +type DsarVerificationTemplateData = { + actionText: string + expiresIn: string + requestType: 'ACCESS' | 'DELETE' + subject: string + verifyUrl: string +} + +const createDsarVerificationTemplateData = ( + requestType: 'ACCESS' | 'DELETE', + actionText: string, + verifyUrl: string, + expiresIn: string, + subject: string +): DsarVerificationTemplateData => ({ + actionText, + expiresIn, + requestType, + subject, + verifyUrl, +}) + +const generateDsarVerificationEmailHtml = async ( + templateData: DsarVerificationTemplateData +): Promise => { + const { html } = await compileEmailTemplate(verificationTemplate, templateData) + return html +} + export async function sendDsarVerificationEmail( email: string, token: string, @@ -45,13 +80,15 @@ export async function sendDsarVerificationEmail( ? 'Verify Your Data Access Request' : 'Verify Your Data Deletion Request' - const html = dsarVerificationEmailHtml({ - subject, + const templateData = createDsarVerificationTemplateData( requestType, actionText, verifyUrl, expiresIn, - }) + subject + ) + + const html = await generateDsarVerificationEmailHtml(templateData) const text = dsarVerificationEmailText({ requestType, diff --git a/src/actions/newsletter/email/confirmation.mjml b/src/actions/newsletter/email/confirmation.mjml new file mode 100644 index 000000000..312e690c1 --- /dev/null +++ b/src/actions/newsletter/email/confirmation.mjml @@ -0,0 +1,296 @@ +{% set unsubscribeUrl = common.company.url ~ '/privacy#unsubscribe' %} +{% set heroImageUrl = 'https://www.webstackbuilders.com/assets/images/newsletter/cloud-1.jpg' %} + + + + + Confirm Your Newsletter Subscription + + + Confirm your email to start receiving Webstack Weekly. + + + + + + + + + + + + + + + + .url-break { + word-break: break-all; + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+ + + + Webstack Weekly Newsletter + + +   + +
+
+
+ + + + + + One last step + + + Confirm your subscription + + + Confirm your Webstack Weekly subscription to start receiving concise notes on platform engineering, architecture, and delivery. + + + + + + + + + + + +
+

+ {{ greeting }}, confirm your email so we only send Webstack Weekly to people who asked for it. +

+ + + + +
+ + Confirm My Subscription + +
+

+ This link works once and expires in {{ expiresIn }}. +

+
+
+ + + + + + +
+

+ What happens next: +

+ + + + + + + + + + + + + +
📬 + Weekly delivery: Each issue brings practical notes on architecture, delivery, and platform changes worth tracking. +
🔒 + Clear consent: Your subscription turns on only after verification, and you can unsubscribe from any issue. +
🧭 + Useful reads first: We keep the focus on practical material teams can use, not generic roundup filler. +
+
+
+ + + + + + +
+

+ If the button doesn't work +

+

+ Paste this confirmation link into your browser: +

+

+ {{ confirmUrl }} +

+
+
+ + + If you didn't request this, ignore this email and nothing will change. + +
+
+ + + + + You are receiving this email because a newsletter signup was submitted for this address.
+ + Unsubscribe + . +
+ + © {{ common.currentYear }} {{ common.company.name }}, Inc.
+ {{ common.company.address }}, {{ common.company.cityStatePostal }}
+ {{ common.company.telephoneTollFree }} • {{ common.company.telephoneLocal }} +
+
+
+
+
+
\ No newline at end of file diff --git a/src/actions/newsletter/email/confirmationHtml.ts b/src/actions/newsletter/email/confirmationHtml.ts new file mode 100644 index 000000000..ac5359fdd --- /dev/null +++ b/src/actions/newsletter/email/confirmationHtml.ts @@ -0,0 +1,53 @@ +import confirmationTemplateContent from './confirmation.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +const confirmationTemplate = createEmailTemplate( + new URL('./confirmation.mjml', import.meta.url), + confirmationTemplateContent +) + +const createGreeting = (firstName?: string): string => { + return firstName?.trim() ? `Hi ${firstName.trim()}` : 'Hello' +} + +type ConfirmationTemplateData = { + confirmUrl: string + expiresIn: string + greeting: string +} + +const createConfirmationTemplateData = ( + firstName: string | undefined, + confirmUrl: string, + expiresIn: string +): ConfirmationTemplateData => ({ + confirmUrl, + expiresIn, + greeting: createGreeting(firstName), +}) + +export async function generateConfirmationEmailHtml( + firstName: string | undefined, + confirmUrl: string, + expiresIn: string = '24 hours' +): Promise { + const { html } = await compileEmailTemplate( + confirmationTemplate, + createConfirmationTemplateData(firstName, confirmUrl, expiresIn) + ) + + return html +} + +export async function generateConfirmationEmailText( + firstName: string | undefined, + confirmUrl: string, + expiresIn: string = '24 hours' +): Promise { + const { text } = await compileEmailTemplate( + confirmationTemplate, + createConfirmationTemplateData(firstName, confirmUrl, expiresIn) + ) + + return text +} \ No newline at end of file diff --git a/src/actions/newsletter/templates/index.ts b/src/actions/newsletter/email/index.ts similarity index 50% rename from src/actions/newsletter/templates/index.ts rename to src/actions/newsletter/email/index.ts index 343c06790..e1080b58c 100644 --- a/src/actions/newsletter/templates/index.ts +++ b/src/actions/newsletter/email/index.ts @@ -1,4 +1,4 @@ export { generateConfirmationEmailHtml } from './confirmationHtml' -export { generateConfirmationEmailText } from './confirmationText' +export { generateConfirmationEmailText } from './confirmationHtml' export { generateWelcomeEmailHtml } from './welcomeHtml' -export { generateWelcomeEmailText } from './welcomeText' +export { generateWelcomeEmailText } from './welcomeHtml' \ No newline at end of file diff --git a/src/actions/newsletter/email/welcome.mjml b/src/actions/newsletter/email/welcome.mjml new file mode 100644 index 000000000..1ee2e23bc --- /dev/null +++ b/src/actions/newsletter/email/welcome.mjml @@ -0,0 +1,277 @@ +{% set unsubscribeUrl = common.company.url ~ '/privacy#unsubscribe' %} +{% set heroImageUrl = 'https://www.webstackbuilders.com/assets/images/newsletter/cloud-2.jpg' %} + + + + + Welcome to Webstack Builders + + + You're subscribed. Practical notes on platform engineering start here. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+ + + + Webstack Weekly Newsletter + + +   + +
+
+
+ + + + + + You're in! + + + {{ greeting }}, welcome to Webstack Weekly. Expect concise notes on platform engineering, cloud infrastructure, and delivery systems that need to hold up in production. + + + + + + + + What you'll get: + + + + + + +
+ + + + + + + + + + + + + +
+ Deep Dives: Practical breakdowns of platform decisions, delivery workflows, and the tradeoffs behind them. +
📰 + Tech Radar: Clear signal on cloud, DevOps, observability, and the tooling changes worth tracking. +
💡 + Practical Wins: Tactics your team can apply quickly to improve reliability, visibility, and delivery speed. +
+
+
+ + + While you wait for the next issue, start with a few of our most useful guides. + + + Explore Latest Articles + +
+
+ + + + + You are receiving this email because you opted in to receive our newsletter.
+ + Unsubscribe from these emails + . +
+ + © {{ common.currentYear }} {{ common.company.name }}, Inc.
+ {{ common.company.address }}, {{ common.company.cityStatePostal }}
+ {{ common.company.telephoneTollFree }} • {{ common.company.telephoneLocal }} +
+
+
+
+
+
\ No newline at end of file diff --git a/src/actions/newsletter/email/welcomeHtml.ts b/src/actions/newsletter/email/welcomeHtml.ts new file mode 100644 index 000000000..2188f6d3b --- /dev/null +++ b/src/actions/newsletter/email/welcomeHtml.ts @@ -0,0 +1,37 @@ +import welcomeTemplateContent from './welcome.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +const welcomeTemplate = createEmailTemplate( + new URL('./welcome.mjml', import.meta.url), + welcomeTemplateContent +) + +const createGreeting = (firstName?: string): string => { + return firstName?.trim() ? `Hi ${firstName.trim()}` : 'Hello' +} + +type WelcomeTemplateData = { + greeting: string +} + +const createWelcomeTemplateData = (firstName?: string): WelcomeTemplateData => ({ + greeting: createGreeting(firstName), +}) + +export async function generateWelcomeEmailHtml(firstName?: string): Promise { + const { html } = await compileEmailTemplate( + welcomeTemplate, + createWelcomeTemplateData(firstName) + ) + + return html +} + +export async function generateWelcomeEmailText(firstName?: string): Promise { + const { text } = await compileEmailTemplate( + welcomeTemplate, + createWelcomeTemplateData(firstName) + ) + + return text +} \ No newline at end of file diff --git a/src/actions/newsletter/entities/email.ts b/src/actions/newsletter/entities/email.ts index bb5e70e60..1b4df7829 100644 --- a/src/actions/newsletter/entities/email.ts +++ b/src/actions/newsletter/entities/email.ts @@ -8,7 +8,7 @@ import { generateConfirmationEmailText, generateWelcomeEmailHtml, generateWelcomeEmailText, -} from '@actions/newsletter/templates' +} from '../email/index' function getResendClient(): Resend { return new Resend(getResendApiKey()) @@ -34,8 +34,8 @@ export async function sendConfirmationEmail( replyTo: newsletterReplyTo, to: email, subject: 'Confirm your newsletter subscription - Webstack Builders', - html: generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), - text: generateConfirmationEmailText(firstName, confirmUrl, expiresIn), + html: await generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), + text: await generateConfirmationEmailText(firstName, confirmUrl, expiresIn), tags: [ { name: 'type', value: 'newsletter-confirmation' }, { name: 'flow', value: 'double-optin' }, @@ -83,8 +83,8 @@ export async function sendWelcomeEmail(email: string, firstName?: string): Promi replyTo: newsletterReplyTo, to: email, subject: '🎉 Welcome to Webstack Builders!', - html: generateWelcomeEmailHtml(firstName), - text: generateWelcomeEmailText(firstName), + html: await generateWelcomeEmailHtml(firstName), + text: await generateWelcomeEmailText(firstName), tags: [ { name: 'type', value: 'newsletter-welcome' }, { name: 'flow', value: 'post-confirmation' }, diff --git a/src/actions/newsletter/templates/confirmationHtml.ts b/src/actions/newsletter/templates/confirmationHtml.ts deleted file mode 100644 index 9918a37ab..000000000 --- a/src/actions/newsletter/templates/confirmationHtml.ts +++ /dev/null @@ -1,122 +0,0 @@ -export function generateConfirmationEmailHtml( - firstName: string | undefined, - confirmUrl: string, - expiresIn: string = '24 hours' -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - return ` - - - - - - Confirm Your Newsletter Subscription - - - -
- -
- -
-

Confirm Your Subscription

- -

${greeting},

- -

Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address.

- - - -

Or copy and paste this link into your browser:

-

${confirmUrl}

- -
-

Why did I receive this?

-

You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email.

-
- -
-

⏰ This confirmation link expires in ${expiresIn}

-

For security reasons, this confirmation link will only work once and will expire after ${expiresIn}.

-
- -

What You're Consenting To

-
    -
  • Purpose: Receiving marketing emails and newsletters
  • -
  • Frequency: Weekly articles and occasional updates
  • -
  • Your Rights: You can unsubscribe at any time using the link in every email
  • -
  • Data Usage: We'll only use your email to send you the content you signed up for
  • -
-
- - - - - `.trim() -} diff --git a/src/actions/newsletter/templates/confirmationText.ts b/src/actions/newsletter/templates/confirmationText.ts deleted file mode 100644 index 64d461f99..000000000 --- a/src/actions/newsletter/templates/confirmationText.ts +++ /dev/null @@ -1,36 +0,0 @@ -export function generateConfirmationEmailText( - firstName: string | undefined, - confirmUrl: string, - expiresIn: string = '24 hours' -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - return ` -Webstack Builders - Confirm Your Subscription - -${greeting}, - -Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address. - -Confirm your subscription by clicking this link: -${confirmUrl} - -WHY DID I RECEIVE THIS? -You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email. - -IMPORTANT: This confirmation link expires in ${expiresIn} -For security reasons, this confirmation link will only work once and will expire after ${expiresIn}. - -WHAT YOU'RE CONSENTING TO: -- Purpose: Receiving marketing emails and newsletters -- Frequency: Weekly articles and occasional updates -- Your Rights: You can unsubscribe at any time using the link in every email -- Data Usage: We'll only use your email to send you the content you signed up for - -Questions? Contact us at hello@webstackbuilders.com -Privacy Policy: www.webstackbuilders.com/privacy -Unsubscribe: www.webstackbuilders.com/privacy#unsubscribe - -© ${new Date().getFullYear()} Webstack Builders. All rights reserved. - `.trim() -} diff --git a/src/actions/newsletter/templates/welcomeHtml.ts b/src/actions/newsletter/templates/welcomeHtml.ts deleted file mode 100644 index 8ea5a69d5..000000000 --- a/src/actions/newsletter/templates/welcomeHtml.ts +++ /dev/null @@ -1,94 +0,0 @@ -export function generateWelcomeEmailHtml( - firstName: string | undefined, -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - return ` - - - - - - Welcome to Webstack Builders - - - -
- -
- -
-

🎉 Welcome to Webstack Builders!

- -

${greeting},

- -

Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts.

- -

You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences.

- - - -

What to Expect

-
    -
  • Weekly articles on web development, design, and best practices
  • -
  • Tutorials and guides for modern web technologies
  • -
  • Case studies and real-world examples
  • -
  • Occasional updates about new features and offerings
  • -
- -

Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you.

-

If you'd like to unsubscribe right now, click here.

-
- - - - - `.trim() -} diff --git a/src/actions/newsletter/templates/welcomeText.ts b/src/actions/newsletter/templates/welcomeText.ts deleted file mode 100644 index 96ce03ec9..000000000 --- a/src/actions/newsletter/templates/welcomeText.ts +++ /dev/null @@ -1,30 +0,0 @@ -export function generateWelcomeEmailText( - firstName: string | undefined, -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - return ` -Webstack Builders - Welcome! - -${greeting}, - -Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts. - -You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences. - -Browse our articles: https://www.webstackbuilders.com/articles - -WHAT TO EXPECT: -- Weekly articles on web development, design, and best practices -- Tutorials and guides for modern web technologies -- Case studies and real-world examples -- Occasional updates about new features and offerings - -Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you. -Unsubscribe: https://www.webstackbuilders.com/privacy#unsubscribe - -Questions? Reply to this email or contact us at hello@webstackbuilders.com - -© ${new Date().getFullYear()} Webstack Builders. All rights reserved. -`.trim() -} diff --git a/src/actions/utils/email/__fixtures__/example.mjml b/src/actions/utils/email/__fixtures__/example.mjml new file mode 100644 index 000000000..c27d9a1d9 --- /dev/null +++ b/src/actions/utils/email/__fixtures__/example.mjml @@ -0,0 +1,26 @@ + + + + + + Hello, {{ name }}! + + + + We found {{ items | length }} items for you: + + +
    + {% for item in items %} +
  • {{ item }}
  • + {% endfor %} +
+
+ + {{ htmlMessage | safe }} + + +
+
+
+
\ No newline at end of file diff --git a/src/actions/utils/email/__fixtures__/invalid.mjml b/src/actions/utils/email/__fixtures__/invalid.mjml new file mode 100644 index 000000000..85cd9140b --- /dev/null +++ b/src/actions/utils/email/__fixtures__/invalid.mjml @@ -0,0 +1,9 @@ + + + + + This fixture is intentionally invalid because mj-text must be inside mj-column. + + + + \ No newline at end of file diff --git a/src/actions/utils/email/__fixtures__/partials/common-contact.mjml b/src/actions/utils/email/__fixtures__/partials/common-contact.mjml new file mode 100644 index 000000000..d9a9a7ff3 --- /dev/null +++ b/src/actions/utils/email/__fixtures__/partials/common-contact.mjml @@ -0,0 +1,4 @@ + + Contact {{ common.company.name }} at + {{ common.company.email }}. + \ No newline at end of file diff --git a/src/actions/utils/email/__fixtures__/partials/footer-macro.mjml b/src/actions/utils/email/__fixtures__/partials/footer-macro.mjml new file mode 100644 index 000000000..9e5c6b16d --- /dev/null +++ b/src/actions/utils/email/__fixtures__/partials/footer-macro.mjml @@ -0,0 +1,5 @@ +{% macro renderFooter(websiteLabel, websiteUrl) %} + + Visit {{ websiteLabel }}. + +{% endmacro %} \ No newline at end of file diff --git a/src/actions/utils/email/__fixtures__/partials/footer.mjml b/src/actions/utils/email/__fixtures__/partials/footer.mjml new file mode 100644 index 000000000..07c3d0905 --- /dev/null +++ b/src/actions/utils/email/__fixtures__/partials/footer.mjml @@ -0,0 +1,3 @@ + + View the privacy policy. + \ No newline at end of file diff --git a/src/actions/utils/email/__fixtures__/with-common-data.mjml b/src/actions/utils/email/__fixtures__/with-common-data.mjml new file mode 100644 index 000000000..072f5f289 --- /dev/null +++ b/src/actions/utils/email/__fixtures__/with-common-data.mjml @@ -0,0 +1,13 @@ +{% import "./partials/footer-macro.mjml" as footer %} + + + + + + Hello, {{ name }}! + {{ footer.renderFooter(common.company.websiteLabel, common.company.url) }} + {% include "./partials/common-contact.mjml" %} + + + + \ No newline at end of file diff --git a/src/actions/utils/email/__tests__/templateCompiler.spec.ts b/src/actions/utils/email/__tests__/templateCompiler.spec.ts new file mode 100644 index 000000000..0e42b264e --- /dev/null +++ b/src/actions/utils/email/__tests__/templateCompiler.spec.ts @@ -0,0 +1,132 @@ +import { fileURLToPath } from 'node:url' +import { JSDOM } from 'jsdom' +import { describe, expect, it } from 'vitest' +import { ActionsFunctionError } from '@actions/utils/errors' +import contactMessageTemplateContent from '../../../contact/email/message.mjml?raw' +import exampleTemplateContent from '../__fixtures__/example.mjml?raw' +import invalidTemplateContent from '../__fixtures__/invalid.mjml?raw' +import withCommonDataTemplateContent from '../__fixtures__/with-common-data.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '../templateCompiler' + +const exampleTemplate = createEmailTemplate( + new URL('../__fixtures__/example.mjml', import.meta.url), + exampleTemplateContent +) + +const invalidTemplate = createEmailTemplate( + new URL('../__fixtures__/invalid.mjml', import.meta.url), + invalidTemplateContent +) + +const contactMessageTemplate = createEmailTemplate( + new URL('../../../contact/email/message.mjml', import.meta.url), + contactMessageTemplateContent +) + +const withCommonDataTemplate = createEmailTemplate( + new URL('../__fixtures__/with-common-data.mjml', import.meta.url), + withCommonDataTemplateContent +) + +describe('compileEmailTemplate', () => { + it('compiles the moved contact message template from its imported source', async () => { + const result = await compileEmailTemplate(contactMessageTemplate, { + attachments: [{ filename: 'brief.pdf', sizeLabel: '2.4 MB' }], + consentGiven: 'Yes', + fields: [ + { label: 'Name', value: 'Alex Example' }, + { label: 'Email', value: 'alex@example.com' }, + ], + messageHtml: 'Need help with a launch plan.
Timeline is flexible.', + }) + + const dom = new JSDOM(result.html) + + expect(dom.window.document.querySelector('html')).not.toBeNull() + expect(dom.window.document.querySelector('head')).not.toBeNull() + expect(dom.window.document.querySelector('body')).not.toBeNull() + expect(dom.window.document.querySelectorAll('table').length).toBeGreaterThan(0) + expect(dom.window.document.body.textContent ?? '').toContain('Alex Example') + expect(dom.window.document.body.textContent ?? '').toContain('brief.pdf (2.4 MB)') + expect(result.text).toContain('Need help with a launch plan.') + expect(result.text).toContain('Timeline is flexible.') + expect(result.text).toContain('Alex Example') + expect(result.text).toContain('brief.pdf (2.4 MB)') + }) + + it('renders nunjucks data, supports mj-include, and returns html and plain text', async () => { + const result = await compileEmailTemplate(exampleTemplate, { + htmlMessage: 'Urgent message body', + items: ['Coffee Beans', 'Espresso Machine'], + name: '', + }) + + expect(result.html).toContain('Hello, <Alex & Co>!') + expect(result.html).toContain('Coffee Beans') + expect(result.html).toContain('Urgent message body') + expect(result.html).toContain('privacy policy') + + expect(result.text).toContain('Hello, !') + expect(result.text).toContain('We found 2 items for you:') + expect(result.text).toContain('Coffee Beans') + expect(result.text).toContain('Espresso Machine') + expect(result.text).toContain('Urgent message body') + expect(result.text).toContain('privacy policy') + }) + + it('injects common contact data and resolves relative nunjucks imports', async () => { + const result = await compileEmailTemplate(withCommonDataTemplate, { + name: 'Alex', + }) + + expect(result.html).toContain('Hello, Alex!') + expect(result.html).toContain('www.webstackbuilders.com') + expect(result.html).toContain('https://www.webstackbuilders.com') + expect(result.html).toContain('Contact Webstack Builders at') + expect(result.html).toContain('support@webstackbuilders.com') + + expect(result.text).toContain('Hello, Alex!') + expect(result.text).toContain('Visit www.webstackbuilders.com [https://www.webstackbuilders.com].') + expect(result.text).toContain('Contact Webstack Builders at support@webstackbuilders.com.') + }) + + it('throws ActionsFunctionError when MJML compilation reports errors', async () => { + await expect(compileEmailTemplate(invalidTemplate)).rejects.toMatchObject({ + appCode: 'EMAIL_TEMPLATE_COMPILE_FAILED', + details: expect.objectContaining({ + templatePath: 'src/actions/utils/email/__fixtures__/invalid.mjml', + }), + name: 'ActionsFunctionError', + operation: 'compileEmailTemplate', + route: 'actions:utils:email', + status: 500, + }) + }) + + it('throws ActionsFunctionError when the template path is outside the project root', async () => { + expect(() => createEmailTemplate('/tmp/outside-project-template.mjml', '')).toThrow( + expect.objectContaining({ + appCode: 'EMAIL_TEMPLATE_PATH_INVALID', + name: 'ActionsFunctionError', + }) + ) + }) + + it('wraps unexpected render failures in ActionsFunctionError', async () => { + const brokenTemplate = createEmailTemplate( + new URL('../__fixtures__/example.mjml', import.meta.url), + '{% if name %}{{ name }' + ) + + await expect(compileEmailTemplate(brokenTemplate)).rejects.toBeInstanceOf(ActionsFunctionError) + await expect(compileEmailTemplate(brokenTemplate)).rejects.toMatchObject({ + appCode: 'EMAIL_TEMPLATE_RENDER_FAILED', + details: { + templatePath: fileURLToPath(new URL('../__fixtures__/example.mjml', import.meta.url)), + }, + operation: 'compileEmailTemplate', + route: 'actions:utils:email', + status: 500, + }) + }) +}) \ No newline at end of file diff --git a/src/actions/utils/email/footer/index.mjml b/src/actions/utils/email/footer/index.mjml new file mode 100644 index 000000000..9ff498852 --- /dev/null +++ b/src/actions/utils/email/footer/index.mjml @@ -0,0 +1,66 @@ +{% macro renderCommonFooter( + notePrefix='', + contactEmailHref=common.company.emailHref, + contactEmailLabel=common.company.email, + privacyUrl='', + unsubscribeUrl='', + showTopDivider=true +) %} + {% set footerEmailHref = contactEmailHref %} + {% set footerEmailLabel = contactEmailLabel %} + {% set footerNotePrefix = notePrefix %} + {% set footerPrivacyUrl = privacyUrl %} + {% set footerUnsubscribeUrl = unsubscribeUrl %} + + {% if showTopDivider %} + + + + + + {% endif %} + + {% if footerNotePrefix or footerPrivacyUrl or footerUnsubscribeUrl %} + + + {% if footerNotePrefix %} + + {{ footerNotePrefix }} {{ footerEmailLabel }} + + {% endif %} + + {% if footerPrivacyUrl %} + + Read our Privacy Policy for more information about how we handle + your data. + + {% endif %} + + {% if footerUnsubscribeUrl %} + + If you no longer wish to receive these emails, you can + unsubscribe here + at any time. + + {% endif %} + + + {% endif %} + + {% include './partials/layout.mjml' %} +{% endmacro %} \ No newline at end of file diff --git a/src/actions/utils/email/footer/partials/contact-info.mjml b/src/actions/utils/email/footer/partials/contact-info.mjml new file mode 100644 index 000000000..1dcc41763 --- /dev/null +++ b/src/actions/utils/email/footer/partials/contact-info.mjml @@ -0,0 +1,54 @@ + + Phone: + + {{ common.company.telephoneTollFree }} + +
+ + + {{ common.company.telephoneLocal }} + + +
+ + + Email: + + {{ footerEmailLabel }} + + + + + Website: + + {{ common.company.websiteLabel }} + + + + + Address: + + {{ common.company.address }}
+ {{ common.company.cityStatePostal }} +
+
\ No newline at end of file diff --git a/src/actions/utils/email/footer/partials/disclaimer.mjml b/src/actions/utils/email/footer/partials/disclaimer.mjml new file mode 100644 index 000000000..d165f4b62 --- /dev/null +++ b/src/actions/utils/email/footer/partials/disclaimer.mjml @@ -0,0 +1,8 @@ + + {{ footerDisclaimer or 'The content of this email is confidential and intended only for the recipient named in the message. If you received this email in error, please reply to the sender and delete it so we can help prevent the mistake from happening again.' }} + \ No newline at end of file diff --git a/src/actions/utils/email/footer/partials/layout.mjml b/src/actions/utils/email/footer/partials/layout.mjml new file mode 100644 index 000000000..898f7074d --- /dev/null +++ b/src/actions/utils/email/footer/partials/layout.mjml @@ -0,0 +1,163 @@ + + + + + + + + + + + + +
+ + {% include './logo-table.mjml' %} + +
+ + Webstack
+ Builders, Inc. +
+
+ + + + + + + + + + + + + + + +
+ Phone: + + {{ common.company.telephoneTollFree }} + +
+ + + {{ common.company.telephoneLocal }} + + +
+ Email: + + {{ footerEmailLabel }} + +
+ Website: + + {{ common.company.websiteLabel }} + +
+ Address: + + {{ common.company.address }}
+ {{ common.company.cityStatePostal }} +
+
+ + +
+
+
+ + + + + + {% include './logo-table.mjml' %} + + + + + Webstack
+ Builders, Inc. +
+
+
+
+ + + + {% include './contact-info.mjml' %} + + + + + + {% include './disclaimer.mjml' %} + + © {{ common.currentYear }} {{ common.company.name }}. All rights reserved. + + + \ No newline at end of file diff --git a/src/actions/utils/email/footer/partials/logo-table.mjml b/src/actions/utils/email/footer/partials/logo-table.mjml new file mode 100644 index 000000000..f2da3ea7d --- /dev/null +++ b/src/actions/utils/email/footer/partials/logo-table.mjml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+
 
+ + + + + + + +
+
 
+ + + + + + + + + +
+
diff --git a/src/actions/utils/email/footer/styles.mjml b/src/actions/utils/email/footer/styles.mjml new file mode 100644 index 000000000..693b1cc82 --- /dev/null +++ b/src/actions/utils/email/footer/styles.mjml @@ -0,0 +1,42 @@ + + .footer-mobile { + display: none !important; + mso-hide: all !important; + max-height: 0 !important; + overflow: hidden !important; + } + + .footer-desktop { + display: block !important; + max-height: none !important; + overflow: visible !important; + } + + @media only screen and (max-width: 480px) { + div.footer-desktop, + table.footer-desktop, + tbody.footer-desktop, + tr.footer-desktop, + td.footer-desktop { + display: none !important; + mso-hide: all !important; + max-height: 0 !important; + overflow: hidden !important; + } + + div.footer-mobile, + table.footer-mobile, + tbody.footer-mobile, + tr.footer-mobile, + td.footer-mobile { + display: block !important; + width: 100% !important; + max-height: none !important; + overflow: visible !important; + } + + td.footer-mobile { + box-sizing: border-box !important; + } + } + \ No newline at end of file diff --git a/src/actions/utils/email/header/default/index.mjml b/src/actions/utils/email/header/default/index.mjml new file mode 100644 index 000000000..6d449af90 --- /dev/null +++ b/src/actions/utils/email/header/default/index.mjml @@ -0,0 +1,23 @@ + + + + + {% include '../partials/logo-table.mjml' %} + + + + + + + {{ common.company.name }} + + + + \ No newline at end of file diff --git a/src/actions/utils/email/header/newsletter/index.mjml b/src/actions/utils/email/header/newsletter/index.mjml new file mode 100644 index 000000000..732fc78f0 --- /dev/null +++ b/src/actions/utils/email/header/newsletter/index.mjml @@ -0,0 +1,23 @@ + + + + + {% include '../partials/logo-table.mjml' %} + + + + + + + Webstack Weekly + + + + \ No newline at end of file diff --git a/src/actions/utils/email/header/partials/logo-table.mjml b/src/actions/utils/email/header/partials/logo-table.mjml new file mode 100644 index 000000000..4ad79045a --- /dev/null +++ b/src/actions/utils/email/header/partials/logo-table.mjml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+
 
+ + + + + + +
+
 
+ + + + + + + + +
+
\ No newline at end of file diff --git a/src/actions/utils/email/resendSenders.ts b/src/actions/utils/email/resendSenders.ts index b0809554e..9ddd1b7ce 100644 --- a/src/actions/utils/email/resendSenders.ts +++ b/src/actions/utils/email/resendSenders.ts @@ -1,5 +1,7 @@ const resendSendingDomain = 'contact.webstackbuilders.com' +export const contactInbox = 'info@webstackbuilders.com' +export const contactReplyTo = contactInbox export const contactFormSender = `contact@${resendSendingDomain}` export const newsletterSender = `Webstack Builders ` export const newsletterReplyTo = 'hello@webstackbuilders.com' diff --git a/src/actions/utils/email/templateCompiler.ts b/src/actions/utils/email/templateCompiler.ts new file mode 100644 index 000000000..ca039e1c1 --- /dev/null +++ b/src/actions/utils/email/templateCompiler.ts @@ -0,0 +1,282 @@ +import { isAbsolute, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { convert } from 'html-to-text' +import nunjucks from 'nunjucks' +import contactContent from '@content/contact.json' +import { ActionsFunctionError } from '@actions/utils/errors' + +export interface CompiledEmailTemplate { + html: string + text: string +} + +export type EmailTemplateData = Record + +export interface EmailTemplateSource { + content: string + filePath: string +} + +export interface CommonEmailTemplateData { + currentYear: number + company: { + address: string + author: { + email: string + emailHref: string + name: string + } + city: string + cityStatePostal: string + country: string + dataProtectionOfficer: { + email: string + emailHref: string + name: string + } + description: string + email: string + emailHref: string + fullAddress: string + mapLink: string + name: string + postalCode: string + social: Array<{ + blurb: string + displayName: string + iconName: string + name: string + network: string + order: number + url: string + }> + state: string + telephoneLocal: string + telephoneLocalHref: string + telephoneMobile: string + telephoneMobileHref: string + telephoneTollFree: string + telephoneTollFreeHref: string + url: string + websiteLabel: string + } +} + +interface MjmlError { + line?: number + message: string + tagName?: string +} + +interface MjmlRenderResult { + html: string + errors: MjmlError[] +} + +const projectRoot = process.cwd() + +const templateEnvironment = new nunjucks.Environment( + new nunjucks.FileSystemLoader(projectRoot, { + noCache: true, + }), + { + autoescape: true, + } +) + +const contactData = contactContent.company + +const createMailtoHref = (email: string): string => `mailto:${email}` + +const normalizeWebsiteLabel = (url: string): string => { + return url.replace(/^https?:\/\//, '').replace(/\/$/, '') +} + +const createCommonEmailTemplateData = (): CommonEmailTemplateData => { + const cityStatePostal = `${contactData.city}, ${contactData.state} ${contactData.index}` + + return { + currentYear: new Date().getFullYear(), + company: { + address: contactData.address, + author: { + email: contactData.author.email, + emailHref: createMailtoHref(contactData.author.email), + name: contactData.author.name, + }, + city: contactData.city, + cityStatePostal, + country: contactData.country, + dataProtectionOfficer: { + email: contactData.dataProtectionOfficer.email, + emailHref: createMailtoHref(contactData.dataProtectionOfficer.email), + name: contactData.dataProtectionOfficer.name, + }, + description: contactData.description, + email: contactData.email, + emailHref: createMailtoHref(contactData.email), + fullAddress: `${contactData.address}, ${cityStatePostal}`, + mapLink: contactData.mapLink, + name: contactData.name, + postalCode: contactData.index, + social: contactData.social, + state: contactData.state, + telephoneLocal: contactData.telephoneLocal, + telephoneLocalHref: `tel:${contactData.telephoneLocal}`, + telephoneMobile: contactData.telephoneMobile, + telephoneMobileHref: `tel:${contactData.telephoneMobile}`, + telephoneTollFree: contactData.telephoneTollFree, + telephoneTollFreeHref: `tel:${contactData.telephoneTollFree}`, + url: contactData.url, + websiteLabel: normalizeWebsiteLabel(contactData.url), + }, + } +} + +const defaultCommonEmailTemplateData = createCommonEmailTemplateData() + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +const mergeCommonEmailTemplateData = (data: EmailTemplateData): EmailTemplateData => { + const commonValue = data['common'] + const commonOverrides = isRecord(commonValue) ? commonValue : {} + const companyValue = commonOverrides['company'] + const companyOverrides = isRecord(companyValue) ? companyValue : {} + + return { + ...data, + common: { + ...defaultCommonEmailTemplateData, + ...commonOverrides, + company: { + ...defaultCommonEmailTemplateData.company, + ...companyOverrides, + }, + }, + } +} + +const renderTemplateString = (template: EmailTemplateSource, data: EmailTemplateData): string => { + const { relativePath } = normalizeTemplatePath(template.filePath) + const nunjucksTemplate = new nunjucks.Template( + template.content, + templateEnvironment, + relativePath, + true + ) + + return nunjucksTemplate.render(mergeCommonEmailTemplateData(data)) +} + +const normalizeTemplatePath = (templatePath: string | URL): { absolutePath: string; relativePath: string } => { + const rawPath = templatePath instanceof URL ? fileURLToPath(templatePath) : templatePath + const absolutePath = isAbsolute(rawPath) + ? rawPath + : resolve(projectRoot, rawPath) + const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/') + + if (!relativePath || relativePath.startsWith('..')) { + throw new ActionsFunctionError({ + message: 'Email template path must be inside the project root.', + appCode: 'EMAIL_TEMPLATE_PATH_INVALID', + status: 500, + route: 'actions:utils:email', + operation: 'compileEmailTemplate', + details: { + templatePath, + }, + }) + } + + return { + absolutePath, + relativePath, + } +} + +export const createEmailTemplate = ( + filePath: string | URL, + content: string +): EmailTemplateSource => { + const { absolutePath } = normalizeTemplatePath(filePath) + + return { + content, + filePath: absolutePath, + } +} + +const createPlainText = (html: string): string => + convert(html, { + wordwrap: 130, + selectors: [ + { selector: 'img', format: 'skip' }, + { selector: 'a', options: { hideLinkHrefIfSameAsText: true } }, + ], + }) + +/** + * Creates a bundle-safe template descriptor from imported MJML source. + */ +export const createImportedEmailTemplate = createEmailTemplate + +/** + * Renders imported MJML source with Nunjucks data and returns HTML plus plain text. + */ +export async function compileEmailTemplate( + template: EmailTemplateSource, + data: EmailTemplateData = {} +): Promise { + try { + const { absolutePath, relativePath } = normalizeTemplatePath(template.filePath) + const mjmlWithData = renderTemplateString(template, data) + const mjmlModule = await import('mjml') + const mjml2html = ( + 'default' in mjmlModule ? mjmlModule.default : mjmlModule + ) as (_input: string, _options?: { filePath?: string; keepComments?: boolean }) => MjmlRenderResult + const { html, errors } = mjml2html(mjmlWithData, { + filePath: absolutePath, + keepComments: false, + }) + + if (errors.length > 0) { + throw new ActionsFunctionError({ + message: 'Failed to compile MJML email template.', + appCode: 'EMAIL_TEMPLATE_COMPILE_FAILED', + status: 500, + route: 'actions:utils:email', + operation: 'compileEmailTemplate', + details: { + errors: errors.map((error: MjmlError) => ({ + line: error.line, + message: error.message, + tagName: error.tagName, + })), + templatePath: relativePath, + }, + }) + } + + return { + html, + text: createPlainText(html), + } + } catch (error) { + if (error instanceof ActionsFunctionError) { + throw error + } + + throw new ActionsFunctionError(error, { + message: 'Failed to render email template.', + appCode: 'EMAIL_TEMPLATE_RENDER_FAILED', + status: 500, + route: 'actions:utils:email', + operation: 'compileEmailTemplate', + details: { + templatePath: template.filePath, + }, + }) + } +} \ No newline at end of file diff --git a/src/components/Pages/Newsletter/Confirm/index.astro b/src/components/Pages/Newsletter/Confirm/index.astro index 9984ff1d4..8e6a5ccf6 100644 --- a/src/components/Pages/Newsletter/Confirm/index.astro +++ b/src/components/Pages/Newsletter/Confirm/index.astro @@ -45,7 +45,7 @@ const { token } = Astro.props classes="animate-spin" /> -

+

Confirming Your Subscription

@@ -71,7 +71,7 @@ const { token } = Astro.props size={10} /> -

+

Subscription Confirmed!

@@ -140,7 +140,7 @@ const { token } = Astro.props size={10} /> -

+

Confirmation Link Expired

@@ -192,7 +192,7 @@ const { token } = Astro.props size={10} /> -

+

Confirmation Error

diff --git a/src/middleware.ts b/src/middleware.ts index 914964480..8323a33d9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,15 @@ import { defineMiddleware } from 'astro:middleware' -export const onRequest = defineMiddleware(async (_context, next) => { +export const onRequest = defineMiddleware(async (context, next) => { + if (!import.meta.env.DEV && context.url.pathname.startsWith('/testing')) { + return new Response('Not found', { + status: 404, + headers: { + 'content-type': 'text/plain; charset=utf-8', + }, + }) + } + const response = await next() const contentType = response.headers.get('content-type') diff --git a/src/pages/testing/bug-reporter.astro b/src/pages/testing/bug-reporter.astro index 8b31e9f23..d5ac85556 100644 --- a/src/pages/testing/bug-reporter.astro +++ b/src/pages/testing/bug-reporter.astro @@ -3,7 +3,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import Button from '@components/Button/index.astro' import BugReporterComponent from '@components/BugReporter/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Bug Reporter Component Test Fixture' const fixtureDescription = diff --git a/src/pages/testing/calendar.astro b/src/pages/testing/calendar.astro index 07ac5905c..db6671312 100644 --- a/src/pages/testing/calendar.astro +++ b/src/pages/testing/calendar.astro @@ -2,7 +2,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import CalendarComponent from '@components/Calendar/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Calendar Component Test Fixture' const fixtureDescription = diff --git a/src/pages/testing/code.astro b/src/pages/testing/code.astro index d834ce2b1..d15e45cee 100644 --- a/src/pages/testing/code.astro +++ b/src/pages/testing/code.astro @@ -3,7 +3,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import CodeBlockRegister from '@components/Code/CodeBlock/index.astro' import CodeTabsRegister from '@components/Code/CodeTabs/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Code Components Test Fixture' const fixtureDescription = diff --git a/src/pages/testing/comps/scratchpad.astro b/src/pages/testing/comps/scratchpad.astro index 09f98912c..a0a239fdc 100644 --- a/src/pages/testing/comps/scratchpad.astro +++ b/src/pages/testing/comps/scratchpad.astro @@ -7,5 +7,7 @@ const path = '/testing/comps/scratchpad' --- -

Empty

+
+ +
diff --git a/src/pages/testing/comps/scratchpad.astro.orig b/src/pages/testing/comps/scratchpad.astro.orig new file mode 100644 index 000000000..4f2a49895 --- /dev/null +++ b/src/pages/testing/comps/scratchpad.astro.orig @@ -0,0 +1,1578 @@ +--- +import BaseLayout from '@layouts/BaseLayout.astro' +import './_scratchpad.css' + +const pageTitle = 'Scratchpad' +const pageDescription = 'Troubleshooting Component Variants' +const path = '/testing/comps/scratchpad' +--- + + +
+

Improved Dsar Email Layout

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
 
+ + + + + + + + +
+
 
+ + + + + + + + + + +
+
+
+
+ + Webstack
+ Builders +
+
+
+
👋
+

+ We received a request to
delete your data. +

+
+

+ Hello, +

+

+ To complete this request and permanently remove your data, please verify that you requested this action. This link is valid for 24 hours. +

+ + + + + + +
+

What happens next?

+
    +
  • Your account profile will be permanently deleted.
  • +
  • All usage history and subscriptions will be canceled.
  • +
  • You will immediately be unsubscribed from all communications.
  • +
+
+ + + + + + +
+ + Yes, Delete My Data + +
+ +

+ If the button doesn't work, copy and paste this link:
+ https://www.webstackbuilders.com/privacy/my-data?token=mock-dsar-token-123 +

+ +
+ +

+ Didn't request this? You can safely ignore this email.
No action will be taken without your verification. +

+
+

+ Questions? Contact us at privacy@webstackbuilders.com +

+

+ © 2026 Webstack Builders, Inc.
+ 1032 E. Brandon Boulevard, Suite 5230, Brandon, FL 33511
+ 1 (888) 987 1881 • 1 (302) 608 6864 +

+
+
+
+
+ +
+

Default Welcome Email Layout

+
You're subscribed. Start exploring the latest Webstack Builders articles.
+
+ + + +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
🎉 Welcome to Webstack Weekly!
+
+
Hi Jane,
+
+
Your subscription is now confirmed! Thanks for joining our community of platform engineers, SREs, and cloud architects dedicated to building resilient, scalable systems.
+
+
Get ready for deep dives into infrastructure-as-code, kubernetes patterns, and reliability best practices. I've gathered these resources to help you tackle the complexities of building and scaling mission-critical systems.
+
+ + + + + + +
+ + Browse Our Articles + +
+
+
What to Expect
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ +
+ + + + + + +
+
+
+
+ +
+ + + + + + +
+
Weekly articles on web development, design, and best practices
+
+
+ +
+ +
+
+ +
+ + + + + + +
+ +
+ +
+ + + + + + +
+
+
+
+ +
+ + + + + + +
+
Tutorials and guides for modern web technologies
+
+
+ +
+ +
+
+ +
+ + + + + + +
+ +
+ +
+ + + + + + +
+
+
+
+ +
+ + + + + + +
+
Case studies and real-world examples
+
+
+ +
+ +
+
+ +
+ + + + + + +
+ +
+ +
+ + + + + + +
+
+
+
+ +
+ + + + + + +
+
Occasional updates about new features and offerings
+
+
+ +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
Need to manage your subscription? You can unsubscribe at any time using the link at the + bottom of any email we send you. +
+
+
If you'd like to unsubscribe right now, + + click here + . +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+

+

+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Questions? Reply to this email or contact us at support@webstackbuilders.com
+
+
+ +
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+
The content of this email is confidential and intended only for the recipient named in the message. If you received this email in error, please reply to the sender and delete it so we can help prevent the mistake from happening again.
+
+
© 2026 Webstack Builders. All rights reserved.
+
+
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
 
 
+
+
+ + Webstack Weekly
+ Newsletter +
+
+
+
🚀
+

+ You're in! +

+

+ Welcome to the Webstack Weekly. Get ready for insights on modern web dev, architecture, and engineering. +

+
+ + + + + +
+

Here's what to expect:

+ + + + + + + + + + + + + +
Deep Dives: Detailed tutorials and case studies on solving complex engineering challenges.
📰Tech Radar: The latest industry news, framework updates, and architectural trends.
💡Team Spotlight: Exclusive articles authored directly by our engineering teams.
+
+ +

+ While you wait for our next issue, check out some of our most popular guides! +

+ + + + + + +
+ + Explore Latest Articles + +
+ +
+

+ You are receiving this email because you opted in to receive our newsletter.
+ Unsubscribe from these emails. +

+

+ © 2026 Webstack Builders, Inc.
+ 1032 E. Brandon Boulevard, Suite 5230, Brandon, FL 33511
+ 1 (888) 987 1881 • 1 (302) 608 6864 +

+
+ + + + + +
+ +
+
+
diff --git a/src/pages/testing/diagram.astro b/src/pages/testing/diagram.astro index f12bdf9cc..fa1e7c8df 100644 --- a/src/pages/testing/diagram.astro +++ b/src/pages/testing/diagram.astro @@ -3,7 +3,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import DiagramComponent from '@components/Diagram/index.astro' import backstageBackground from '@assets/images/backstage-background.jpg' -export const prerender = true +export const prerender = false const pageTitle = 'Diagram Component Test Fixture' const fixtureDescription = diff --git a/src/pages/testing/emails/acknowledgement-html.astro b/src/pages/testing/emails/acknowledgement-html.astro new file mode 100644 index 000000000..6f0e3c221 --- /dev/null +++ b/src/pages/testing/emails/acknowledgement-html.astro @@ -0,0 +1,18 @@ +--- +import acknowledgementTemplateContent from '@actions/contact/email/acknowledgement.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const acknowledgementTemplate = createEmailTemplate( + new URL('../../../actions/contact/email/acknowledgement.mjml', import.meta.url), + acknowledgementTemplateContent +) + +const { html } = await compileEmailTemplate(acknowledgementTemplate, { + greeting: 'Hi Alex', + replyToEmail: 'info@webstackbuilders.com', +}) +--- + + \ No newline at end of file diff --git a/src/pages/testing/emails/acknowledgement-text.astro b/src/pages/testing/emails/acknowledgement-text.astro new file mode 100644 index 000000000..541aa6381 --- /dev/null +++ b/src/pages/testing/emails/acknowledgement-text.astro @@ -0,0 +1,18 @@ +--- +import acknowledgementTemplateContent from '@actions/contact/email/acknowledgement.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const acknowledgementTemplate = createEmailTemplate( + new URL('../../../actions/contact/email/acknowledgement.mjml', import.meta.url), + acknowledgementTemplateContent +) + +const { text } = await compileEmailTemplate(acknowledgementTemplate, { + greeting: 'Hi Alex', + replyToEmail: 'info@webstackbuilders.com', +}) +--- + +
{text}
\ No newline at end of file diff --git a/src/pages/testing/emails/confirmation-html.astro b/src/pages/testing/emails/confirmation-html.astro new file mode 100644 index 000000000..fa9d14461 --- /dev/null +++ b/src/pages/testing/emails/confirmation-html.astro @@ -0,0 +1,24 @@ +--- +import confirmationTemplateContent from '@actions/newsletter/email/confirmation.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const firstName = 'Jane' +const greeting = `Hi ${firstName}` +const confirmUrl = 'https://www.webstackbuilders.com/newsletter/confirm/mock-token-123' +const expiresIn = '24 hours' + +const confirmationTemplate = createEmailTemplate( + new URL('../../../actions/newsletter/email/confirmation.mjml', import.meta.url), + confirmationTemplateContent +) + +const { html } = await compileEmailTemplate(confirmationTemplate, { + confirmUrl, + expiresIn, + greeting, +}) +--- + + diff --git a/src/pages/testing/emails/confirmation-text.astro b/src/pages/testing/emails/confirmation-text.astro new file mode 100644 index 000000000..a175a4821 --- /dev/null +++ b/src/pages/testing/emails/confirmation-text.astro @@ -0,0 +1,13 @@ +--- +import { generateConfirmationEmailText } from '@actions/newsletter/email/confirmationHtml' + +export const prerender = false + +const text = await generateConfirmationEmailText( + 'Jane', + 'https://www.webstackbuilders.com/newsletter/confirm/mock-token-123', + '24 hours' +) +--- + +
{text}
diff --git a/src/pages/testing/emails/index.astro b/src/pages/testing/emails/index.astro new file mode 100644 index 000000000..e0fb49b79 --- /dev/null +++ b/src/pages/testing/emails/index.astro @@ -0,0 +1,89 @@ +--- +import BaseLayout from '@layouts/BaseLayout.astro' + +export const prerender = false + +const pageTitle = 'Email Template Preview Index' +const pageDescription = + 'Preview routes for the HTML and text versions of the email templates used by the application.' +const pagePath = '/testing/emails' + +const emailPreviewLinks = [ + { + description: 'Contact acknowledgement email rendered from the MJML template.', + href: '/testing/emails/acknowledgement-html', + label: 'Acknowledgement HTML preview', + }, + { + description: 'Plain-text output for the contact acknowledgement email.', + href: '/testing/emails/acknowledgement-text', + label: 'Acknowledgement text preview', + }, + { + description: 'Contact form message email rendered from the MJML template.', + href: '/testing/emails/message-html', + label: 'Message HTML preview', + }, + { + description: 'Plain-text output for the contact form message email.', + href: '/testing/emails/message-text', + label: 'Message text preview', + }, + { + description: 'Newsletter confirmation email rendered from the MJML template.', + href: '/testing/emails/confirmation-html', + label: 'Confirmation HTML preview', + }, + { + description: 'Plain-text output for the newsletter confirmation email.', + href: '/testing/emails/confirmation-text', + label: 'Confirmation text preview', + }, + { + description: 'Newsletter welcome email rendered from the MJML template.', + href: '/testing/emails/welcome-html', + label: 'Welcome HTML preview', + }, + { + description: 'Plain-text output for the newsletter welcome email.', + href: '/testing/emails/welcome-text', + label: 'Welcome text preview', + }, + { + description: 'GDPR verification email rendered from the MJML template.', + href: '/testing/emails/verification-html', + label: 'Verification HTML preview', + }, + { + description: 'Plain-text output for the GDPR verification email.', + href: '/testing/emails/verification-text', + label: 'Verification text preview', + }, +] as const +--- + + +
+
+

Testing Fixture

+

{pageTitle}

+

+ These links open the HTML and plain-text preview routes for the email templates in the + testing/emails directory. +

+
+ +
+
    + { + emailPreviewLinks.map(({ description, href, label }) => ( +
  • + {label} +

    {description}

    +
  • + )) + } +
+
+
+
\ No newline at end of file diff --git a/src/pages/testing/emails/message-html.astro b/src/pages/testing/emails/message-html.astro new file mode 100644 index 000000000..54b206542 --- /dev/null +++ b/src/pages/testing/emails/message-html.astro @@ -0,0 +1,36 @@ +--- +import messageTemplateContent from '@actions/contact/email/message.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const messageTemplate = createEmailTemplate( + new URL('../../../actions/contact/email/message.mjml', import.meta.url), + messageTemplateContent +) + +const { html } = await compileEmailTemplate(messageTemplate, { + attachments: [ + { filename: 'project-brief.pdf', sizeLabel: '2.4 MB' }, + { filename: 'homepage-wireframe.png', sizeLabel: '684 KB' }, + ], + consentGiven: 'Yes', + fields: [ + { label: 'Name', value: 'Alex Example' }, + { label: 'Email', value: 'alex@example.com' }, + { label: 'Company', value: 'Northwind Studio' }, + { label: 'Phone', value: '+1 (555) 010-4422' }, + { label: 'Service', value: 'Website redesign and content migration' }, + { label: 'Budget', value: '$12,000 - $20,000' }, + { label: 'Timeline', value: '2-3 months' }, + { label: 'Website', value: 'https://northwind.example' }, + ], + messageHtml: [ + 'We need help modernizing our marketing site before a fall launch.', + 'Primary goals are faster performance, cleaner content architecture, and easier publishing for the internal team.', + 'Please review the attached brief and wireframe before our kickoff call.', + ].join('

'), +}) +--- + + diff --git a/src/pages/testing/emails/message-text.astro b/src/pages/testing/emails/message-text.astro new file mode 100644 index 000000000..a9cdd7ece --- /dev/null +++ b/src/pages/testing/emails/message-text.astro @@ -0,0 +1,36 @@ +--- +import messageTemplateContent from '@actions/contact/email/message.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const messageTemplate = createEmailTemplate( + new URL('../../../actions/contact/email/message.mjml', import.meta.url), + messageTemplateContent +) + +const { text } = await compileEmailTemplate(messageTemplate, { + attachments: [ + { filename: 'project-brief.pdf', sizeLabel: '2.4 MB' }, + { filename: 'homepage-wireframe.png', sizeLabel: '684 KB' }, + ], + consentGiven: 'Yes', + fields: [ + { label: 'Name', value: 'Alex Example' }, + { label: 'Email', value: 'alex@example.com' }, + { label: 'Company', value: 'Northwind Studio' }, + { label: 'Phone', value: '+1 (555) 010-4422' }, + { label: 'Service', value: 'Website redesign and content migration' }, + { label: 'Budget', value: '$12,000 - $20,000' }, + { label: 'Timeline', value: '2-3 months' }, + { label: 'Website', value: 'https://northwind.example' }, + ], + messageHtml: [ + 'We need help modernizing our marketing site before a fall launch.', + 'Primary goals are faster performance, cleaner content architecture, and easier publishing for the internal team.', + 'Please review the attached brief and wireframe before our kickoff call.', + ].join('

'), +}) +--- + +
{text}
diff --git a/src/pages/testing/emails/verification-html.astro b/src/pages/testing/emails/verification-html.astro new file mode 100644 index 000000000..336acefae --- /dev/null +++ b/src/pages/testing/emails/verification-html.astro @@ -0,0 +1,27 @@ +--- +import verificationTemplateContent from '@actions/gdpr/email/verification.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const requestType = 'DELETE' +const actionText = 'delete your data' +const expiresIn = '24 hours' +const subject = 'Verify Your Data Deletion Request' +const verifyUrl = 'https://www.webstackbuilders.com/privacy/my-data?token=mock-dsar-token-123' + +const verificationTemplate = createEmailTemplate( + new URL('../../../actions/gdpr/email/verification.mjml', import.meta.url), + verificationTemplateContent +) + +const { html } = await compileEmailTemplate(verificationTemplate, { + actionText, + expiresIn, + requestType, + subject, + verifyUrl, +}) +--- + + diff --git a/src/pages/testing/emails/verification-text.astro b/src/pages/testing/emails/verification-text.astro new file mode 100644 index 000000000..25ab62f57 --- /dev/null +++ b/src/pages/testing/emails/verification-text.astro @@ -0,0 +1,14 @@ +--- +import { dsarVerificationEmailText } from '@actions/gdpr/email/dsarText' + +export const prerender = false + +const text = dsarVerificationEmailText({ + actionText: 'delete your data', + expiresIn: '24 hours', + requestType: 'DELETE', + verifyUrl: 'https://www.webstackbuilders.com/privacy/my-data?token=mock-dsar-token-123', +}) +--- + +
{text}
diff --git a/src/pages/testing/emails/welcome-html.astro b/src/pages/testing/emails/welcome-html.astro new file mode 100644 index 000000000..607381a19 --- /dev/null +++ b/src/pages/testing/emails/welcome-html.astro @@ -0,0 +1,20 @@ +--- +import welcomeTemplateContent from '@actions/newsletter/email/welcome.mjml?raw' +import { compileEmailTemplate, createEmailTemplate } from '@actions/utils/email/templateCompiler' + +export const prerender = false + +const firstName = 'Jane' +const greeting = `Hi ${firstName}` + +const welcomeTemplate = createEmailTemplate( + new URL('../../../actions/newsletter/email/welcome.mjml', import.meta.url), + welcomeTemplateContent +) + +const { html } = await compileEmailTemplate(welcomeTemplate, { + greeting, +}) +--- + + diff --git a/src/pages/testing/emails/welcome-text.astro b/src/pages/testing/emails/welcome-text.astro new file mode 100644 index 000000000..47cdeaf1d --- /dev/null +++ b/src/pages/testing/emails/welcome-text.astro @@ -0,0 +1,9 @@ +--- +import { generateWelcomeEmailText } from '@actions/newsletter/email/welcomeHtml' + +export const prerender = false + +const text = await generateWelcomeEmailText('Jane') +--- + +
{text}
diff --git a/src/pages/testing/environment-api.astro b/src/pages/testing/environment-api.astro index 994a6acd0..44fb33b9f 100644 --- a/src/pages/testing/environment-api.astro +++ b/src/pages/testing/environment-api.astro @@ -6,7 +6,7 @@ import { getPackageRelease, isDev, isProd, isTest } from '@pages/api/_utils/envi * This testing page intentionally renders as part of the client bundle so * Playwright can verify the statically generated output. */ -export const prerender = true +export const prerender = false const pageTitle = 'Environment API Diagnostics' const pageDescription = 'Server environment helper snapshot for automated testing only.' diff --git a/src/pages/testing/network-status.astro b/src/pages/testing/network-status.astro index cd0d1abc9..a84dfd329 100644 --- a/src/pages/testing/network-status.astro +++ b/src/pages/testing/network-status.astro @@ -2,7 +2,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import NetworkStatusComponent from '@components/Toasts/NetworkStatus/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Network Status Component Fixture' const fixtureDescription = diff --git a/src/pages/testing/newsletter.astro b/src/pages/testing/newsletter.astro index 027b6446f..049120037 100644 --- a/src/pages/testing/newsletter.astro +++ b/src/pages/testing/newsletter.astro @@ -2,7 +2,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import NewsletterCta from '@components/CallToAction/Newsletter/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Newsletter Component Test Fixture' const fixtureDescription = diff --git a/src/pages/testing/social-shares.astro b/src/pages/testing/social-shares.astro index 6a8788825..026427ab1 100644 --- a/src/pages/testing/social-shares.astro +++ b/src/pages/testing/social-shares.astro @@ -2,7 +2,7 @@ import BaseLayout from '@layouts/BaseLayout.astro' import SocialSharesComponent from '@components/Social/Shares/index.astro' -export const prerender = true +export const prerender = false const pageTitle = 'Social Shares Component Test Fixture' const fixtureDescription =