Compare commits

9 Commits

Author SHA1 Message Date
fef29bd5a1 This is why templates and layouts are better...
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 13:33:18 -03:00
aa6d24be5a Wrong pixel URI
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:47:23 -03:00
4bc40b2be7 Umami tracking
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 12:18:55 -03:00
9a2d7ae222 Setup umami analytics
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-17 11:01:30 -03:00
c71ec612bb Accessibility fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 12:57:44 -03:00
635594aea8 robots.txt and sitemap.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 11:08:07 -03:00
115d63768c Slight tweaks and JSON-LD
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-16 11:00:51 -03:00
6bef0f8254 Updates to readme
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-13 18:19:29 -03:00
455bc18b3b Add pipelines
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
2026-02-13 18:12:11 -03:00
17 changed files with 1094 additions and 364 deletions

View File

@@ -1,8 +1,8 @@
{
"semi": false,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"trailingComma": "all",
"overrides": [
{
"files": "*.yml",

159
.woodpecker/build.yml Normal file
View File

@@ -0,0 +1,159 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
steps:
- name: Site build
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm build
- name: Docker image build
image: docker:latest
depends_on:
- Site build
environment:
REGISTRY_REPO: git.mifi.dev/mifi-holdings/mail-landing
DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: '1'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Building Docker image (BuildKit) ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
build() {
docker build \
--progress=plain \
--platform=linux/amd64 \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \
.
}
for attempt in 1 2 3; do
echo "Build attempt $attempt/3"
if build; then
echo "✓ Docker image built successfully"
exit 0
fi
echo "Build attempt $attempt failed, retrying in 30s..."
sleep 30
done
echo "All build attempts failed"
exit 1
- name: Send Docker Image Build Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Docker image build success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Site build
- Docker image build
when:
- status: [success]
- name: Send Docker Image Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Docker image build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Site build
- Docker image build
when:
- status: [failure]
- name: Push to registry
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-holdings/mail-landing
REGISTRY_USERNAME:
from_secret: gitea_registry_username
REGISTRY_PASSWORD:
from_secret: gitea_package_token
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- Site build
- Docker image build
- name: Send Push to Registry Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Push to registry
when:
- status: [success]
- name: Send Push to Registry Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Push to registry failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Push to registry
when:
- status: [failure]

118
.woodpecker/ci.yml Normal file
View File

@@ -0,0 +1,118 @@
# CI: lint and format check. Runs on every PR and every push to main.
when:
- event: pull_request
- branch: main
event: push
steps:
- name: install
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- name: Prettier Format Check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm format:check
depends_on:
- install
- name: Send Prettier Format Check Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Prettier Format Check failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Prettier Format Check
when:
- status: [failure]
- name: Lint Check
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm lint
depends_on:
- Prettier Format Check
- name: Send Lint Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Lint failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Lint Check
when:
- status: [failure]
- name: Build
image: node:22-alpine
commands:
- corepack enable
- corepack prepare pnpm@10.29.2 --activate
- pnpm install --frozen-lockfile
- pnpm build
depends_on:
- Lint Check
- name: Send Build Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Build failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- Build
when:
- status: [failure]
- name: Send CI Pipeline Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_tests_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] CI pipeline success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" $MATTERMOST_POST_API_URL
depends_on:
- install
- Prettier Format Check
- Lint Check
- Build
when:
- status: [success]

70
.woodpecker/deploy.yml Normal file
View File

@@ -0,0 +1,70 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
skip_clone: true
# Use writable workspace when clone is skipped (no root clone step to create /woodpecker/src)
workspace:
base: /tmp
path: deploy
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- build
steps:
- name: Trigger Portainer stack redeploy
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Production Deploy failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" -H "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" "$MATTERMOST_POST_API_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

122
README.md
View File

@@ -1,23 +1,129 @@
# mail-landing
Static landing site for mail.mifi.holdings (HTML/CSS/JS), built to `dist/` and served with nginx in production.
Static landing site for **mail.mifi.holdings** (HTML/CSS/JS). Source lives in `src/`, is built to `dist/`, and is served in production by **nginx** inside a Docker container, with **Traefik** as the reverse proxy.
## Dev Container (local development and preview)
---
## Tech stack & frameworks
| Layer | Technology |
| --------------------- | -------------------------------------------------------------------------------------- |
| **Runtime** | Node 22 (dev/build only); production is static files only |
| **Package manager** | pnpm 10.x |
| **Source** | Plain HTML, CSS, JS — no framework (Vite/React/etc.) |
| **Build** | Custom script (`scripts/build.js`): copy, minify, inline critical CSS |
| **Minification** | **Terser** (JS), **clean-css** (CSS) |
| **Critical CSS** | **Beasties** — inlines above-the-fold CSS into HTML (no browser/headless; works in CI) |
| **Lint / format** | ESLint, Stylelint, Prettier, yamllint (for Woodpecker & compose) |
| **Production server** | nginx (Alpine) in Docker |
| **Reverse proxy** | Traefik (via `docker-compose` labels; TLS, gzip, security middlewares) |
| **CI/CD** | Woodpecker CI (Gitea); images pushed to `git.mifi.dev` registry |
---
## Project structure
```
mail-landing/
├── src/ # Source (edited by hand)
│ ├── index.html
│ ├── help/index.html
│ └── assets/
│ ├── css/site.css
│ ├── js/ga-init.js
│ └── images/favicon.svg
├── dist/ # Build output (gitignored in practice; produced by pnpm build)
├── scripts/
│ └── build.js # Build: copy → minify JS/CSS → Beasties inline critical CSS
├── nginx/conf.d/ # nginx config for the container
├── docker-compose.yml # Service definition + Traefik labels for mail.mifi.holdings
├── Dockerfile # nginx:alpine + config + dist/
├── .woodpecker/
│ ├── ci.yml # Lint, format check, build (PR + push to main)
│ ├── build.yml # Build site → Docker image → push to registry
│ └── deploy.yml # Trigger Portainer webhook + Mattermost notifications
├── .devcontainer/ # Dev Container (Node 22 + pnpm) for Cursor/VS Code
└── package.json
```
---
## Architecture (high level)
1. **Develop** in `src/` (HTML/CSS/JS). No bundler; structure mirrors output.
2. **Build** (`pnpm build`): `src/``dist/` (copy, minify JS/CSS, inline critical CSS via Beasties).
3. **Docker image**: `Dockerfile` copies `nginx/conf.d/` and `dist/` into `nginx:alpine`; no Node in the image.
4. **Run**: Container serves `/usr/share/nginx/html` on port 80. Traefik (external) terminates TLS for `mail.mifi.holdings`, applies gzip and security middlewares, and routes to this service on `marina-net`.
5. **Deploy**: Woodpecker runs **ci****build** (site + image + push) → **deploy** (Portainer webhook redeploy + Mattermost).
---
## Local development (Dev Container)
1. **Open in Dev Container**
In Cursor/VS Code: _Command Palette_**Dev Containers: Reopen in Container** (or **Clone Repository in Container Volume** when opening the repo).
The first time will build the container (Node 22 + pnpm) and run `pnpm install`.
In Cursor/VS Code: **Command Palette****Dev Containers: Reopen in Container** (or **Clone Repository in Container Volume** when opening the repo).
First time: builds the container (Node 22 + pnpm), runs `pnpm install`.
2. **Preview the site**
In the container terminal:
- **Quick preview** (serves `src/` as-is, no build):
`pnpm preview`
- **Production-like preview** (build then serve `dist/`):
- **Production-like** (build then serve `dist/`):
`pnpm preview:prod`
Port **3000** is forwarded; open **http://localhost:3000** in the host browser (or use the “Preview” port notification).
Port **3000** is forwarded; open **http://localhost:3000** (or use the “Preview” port notification).
3. **Other commands**
- `pnpm build` — build `src/``dist/` (minify JS/CSS, inline critical CSS)
- `pnpm lint` / `pnpm format` — lint and format
- `pnpm docker:build` — build production image
- `pnpm docker:build` — build production image (for local testing; image: `git.mifi.dev/mifi-holdings/mail-landing:latest`)
---
## Build pipeline (what `pnpm build` does)
1. **Clean & copy**`dist/` is removed; `src/` is copied recursively to `dist/`.
2. **Minify JS** — All `.js` files in `dist/` are minified with Terser (no comments).
3. **Minify CSS** — All `.css` files are minified with clean-css (level 2).
4. **Inline critical CSS** — Beasties runs on every `.html` file in `dist/` (default preload behavior; no headless browser).
Output: `dist/` ready to be served or copied into the Docker image.
---
## Deployment (Woodpecker CI/CD)
Pipelines live under `.woodpecker/`. Execution order:
| Pipeline | When | What |
| ------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ci** (`ci.yml`) | Every PR and every push to `main` | Install → Prettier check → Lint (JS, CSS, YAML) → Build. Mattermost notifications on failure/success. |
| **build** (`build.yml`) | Push/tag/manual on `main`, or deployment event for production | Depends on **ci**. Site build → Docker image build (linux/amd64, tagged with commit SHA + `latest`) → Push to `git.mifi.dev/mifi-holdings/mail-landing`. Mattermost on success/failure. |
| **deploy** (`deploy.yml`) | Same as build (runs after build) | `skip_clone: true`; triggers Portainer webhook to redeploy the stack. Mattermost deploy success/failure. |
**Secrets** (in Woodpecker): `portainer_webhook_url`, `mattermost_*`, `gitea_registry_username`, `gitea_package_token`.
**Production**: Stack is defined in Portainer (using this repos `docker-compose.yml`). Redeploy pulls `git.mifi.dev/mifi-holdings/mail-landing:latest` and restarts the service. Traefik (on `marina-net`) routes `mail.mifi.holdings` to this container with TLS (e.g. Lets Encrypt) and middlewares (gzip, security).
---
## Production runtime (Docker + Traefik)
- **Image**: `git.mifi.dev/mifi-holdings/mail-landing:latest` (nginx:alpine + `nginx/conf.d/` + `dist/`).
- **Compose**: `docker-compose.yml` defines one service, `marina-net`, Traefik labels for `Host(\`mail.mifi.holdings\`)`, TLS, gzip, and security middlewares; healthcheck via `wget`on`/`.
- **nginx**: Serves `/usr/share/nginx/html`; HTML no-cache, JS/CSS long-lived cache; static assets and directory/index handling as in `nginx/conf.d/default.conf`.
---
## Quick reference (pnpm scripts)
| Command | Description |
| ------------------- | ---------------------------------------------- |
| `pnpm build` | Build `src/``dist/` (minify + critical CSS) |
| `pnpm preview` | Serve `src/` on port 3000 (no build) |
| `pnpm preview:prod` | Build then serve `dist/` on 3000 |
| `pnpm lint` | Lint JS, CSS, and Woodpecker/compose YAML |
| `pnpm lint:fix` | Auto-fix lint where supported |
| `pnpm format` | Prettier write |
| `pnpm format:check` | Prettier check only |
| `pnpm docker:build` | Build Docker image (linux/amd64) |
| `pnpm docker:push` | Push image to registry (manual) |

View File

@@ -1,4 +1,4 @@
import prettierConfig from 'eslint-config-prettier/flat'
import prettierConfig from 'eslint-config-prettier/flat';
export default [
{
@@ -9,12 +9,12 @@ export default [
globals: {
window: 'readonly',
document: 'readonly',
dataLayer: 'writable'
}
dataLayer: 'writable',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }]
}
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
prettierConfig
]
},
prettierConfig,
];

View File

@@ -8,71 +8,71 @@ import {
readFileSync,
writeFileSync,
cpSync,
readdirSync
} from 'fs'
import { join, dirname, extname } from 'path'
import { fileURLToPath } from 'url'
import Beasties from 'beasties'
import { minify as minifyJs } from 'terser'
import CleanCSS from 'clean-css'
readdirSync,
} from 'fs';
import { join, dirname, extname } from 'path';
import { fileURLToPath } from 'url';
import Beasties from 'beasties';
import { minify as minifyJs } from 'terser';
import CleanCSS from 'clean-css';
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const srcDir = join(root, 'src')
const distDir = join(root, 'dist')
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const srcDir = join(root, 'src');
const distDir = join(root, 'dist');
function getFiles(dir, files = []) {
const entries = readdirSync(dir, { withFileTypes: true })
const entries = readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory()) getFiles(full, files)
else files.push(full)
const full = join(dir, e.name);
if (e.isDirectory()) getFiles(full, files);
else files.push(full);
}
return files
return files;
}
async function main() {
// 1. Clean and copy src → dist
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
cpSync(srcDir, distDir, { recursive: true })
rmSync(distDir, { recursive: true, force: true });
mkdirSync(distDir, { recursive: true });
cpSync(srcDir, distDir, { recursive: true });
const distFiles = getFiles(distDir)
const distFiles = getFiles(distDir);
// 2. Minify JS
const jsFiles = distFiles.filter((f) => extname(f) === '.js')
const jsFiles = distFiles.filter((f) => extname(f) === '.js');
for (const f of jsFiles) {
const code = readFileSync(f, 'utf8')
const result = await minifyJs(code, { format: { comments: false } })
if (result.code) writeFileSync(f, result.code)
const code = readFileSync(f, 'utf8');
const result = await minifyJs(code, { format: { comments: false } });
if (result.code) writeFileSync(f, result.code);
}
// 3. Minify CSS
const cleanCss = new CleanCSS({ level: 2 })
const cssFiles = distFiles.filter((f) => extname(f) === '.css')
const cleanCss = new CleanCSS({ level: 2 });
const cssFiles = distFiles.filter((f) => extname(f) === '.css');
for (const f of cssFiles) {
const code = readFileSync(f, 'utf8')
const result = cleanCss.minify(code)
if (!result.errors.length) writeFileSync(f, result.styles)
const code = readFileSync(f, 'utf8');
const result = cleanCss.minify(code);
if (!result.errors.length) writeFileSync(f, result.styles);
}
// 4. Inline critical CSS with Beasties for all HTML files (no browser; works in CI)
const htmlFiles = distFiles.filter((f) => extname(f) === '.html')
const htmlFiles = distFiles.filter((f) => extname(f) === '.html');
const beasties = new Beasties({
path: distDir,
preload: 'default',
logLevel: 'warn'
})
logLevel: 'warn',
});
for (const htmlFile of htmlFiles) {
const html = readFileSync(htmlFile, 'utf8')
const inlined = await beasties.process(html)
writeFileSync(htmlFile, inlined)
const html = readFileSync(htmlFile, 'utf8');
const inlined = await beasties.process(html);
writeFileSync(htmlFile, inlined);
}
console.log('Build complete: dist/')
console.log('Build complete: dist/');
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1 @@
{}

View File

@@ -44,9 +44,53 @@ body {
}
body {
display: flex;
align-items: center;
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: space-between;
}
.content {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
}
.footer {
color: #374151;
font-size: 0.94em;
letter-spacing: 0.01em;
padding: 1rem;
text-align: center;
width: 100%;
}
@media (prefers-color-scheme: dark) {
.footer {
color: #aab2bd;
}
}
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: 0.75rem 1.25rem;
background: var(--accent);
color: var(--button-text);
font-weight: 600;
text-decoration: none;
z-index: 100;
transform: translateY(-100%);
transition: transform 0.2s;
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid var(--accent-light);
outline-offset: 2px;
}
h1 {
@@ -89,6 +133,42 @@ p {
padding-bottom: 2rem;
}
.breadcrumb {
list-style: none;
margin: 0 0 1rem;
padding: 0;
font-size: 0.9rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.breadcrumb li {
display: inline-flex;
align-items: center;
}
.breadcrumb li:not(:last-child)::after {
content: '';
margin-left: 0.35rem;
color: var(--text-muted);
opacity: 0.8;
}
.breadcrumb a {
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb [aria-current='page'] {
color: var(--text-main);
font-weight: 500;
}
.text-center {
text-align: center;
}
@@ -158,9 +238,10 @@ td:first-child {
white-space: nowrap;
}
/* Tip colors chosen for WCAG 2.2 AAA (≥7:1 contrast) */
.tip {
background: #eef2ff;
color: var(--accent-light);
color: #3730a3;
border-radius: 0.7em;
font-size: 0.98em;
padding: 0.48em 0.8em;
@@ -171,7 +252,7 @@ td:first-child {
@media (prefers-color-scheme: dark) {
.tip {
background: #232555;
color: #a5b4fc;
color: #c7d2fe;
}
}
@@ -259,14 +340,6 @@ td:first-child {
color: var(--faq-a);
}
.footer {
margin-top: 2.5em;
text-align: center;
color: #bbb;
font-size: 0.94em;
letter-spacing: 0.01em;
}
@media (width <= 600px) {
.container {
padding: 1.1rem 0.5rem 1rem;

View File

@@ -0,0 +1,37 @@
// Native accessible accordion
document.querySelectorAll('.accordion-trigger').forEach((btn) => {
btn.addEventListener('click', function () {
const section = btn.closest('.accordion-section');
const expanded = btn.getAttribute('aria-expanded') === 'true';
document.querySelectorAll('.accordion-section').forEach((s) => {
if (s === section) {
s.classList.toggle('open', !expanded);
btn.setAttribute('aria-expanded', String(!expanded));
const content = btn.nextElementSibling;
content.style.maxHeight = !expanded
? content.scrollHeight + 40 + 'px'
: '0px';
} else {
s.classList.remove('open');
s.querySelector('.accordion-trigger').setAttribute(
'aria-expanded',
'false',
);
s.querySelector('.accordion-content').style.maxHeight = '0px';
}
});
});
// Allow arrow navigation
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const triggers = Array.from(
document.querySelectorAll('.accordion-trigger'),
);
let idx = triggers.indexOf(e.target);
if (e.key === 'ArrowDown') idx = (idx + 1) % triggers.length;
else idx = (idx - 1 + triggers.length) % triggers.length;
triggers[idx].focus();
}
});
});

View File

@@ -0,0 +1,4 @@
(function () {
const year = new Date().getFullYear();
document.getElementById('current-year').textContent = `${year}`;
})();

View File

@@ -1,11 +1,11 @@
;(function () {
var script = document.currentScript
var id = script && script.getAttribute('data-ga-id')
if (!id) return
window.dataLayer = window.dataLayer || []
(function () {
var script = document.currentScript;
var id = script && script.getAttribute('data-ga-id');
if (!id) return;
window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments)
window.dataLayer.push(arguments);
}
gtag('js', new Date())
gtag('config', id, { anonymize_ip: true })
})()
gtag('js', new Date());
gtag('config', id, { anonymize_ip: true });
})();

View File

@@ -11,6 +11,11 @@
src="/assets/js/ga-init.js"
data-ga-id="G-NF64QMKWX6"
></script>
<script
defer
src="https://analytics.mifi.holdings/script.js"
data-website-id="80f4013d-dd3f-4a10-af75-2e788090990d"
></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -39,15 +44,89 @@
href="/assets/images/apple-touch-icon.png"
/>
<link rel="stylesheet" href="/assets/css/site.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "My email won't send?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Check that you're using your full email address for both incoming and outgoing username, and that the port is 587 or 465."
}
},
{
"@type": "Question",
"name": "SSL/TLS errors?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Ensure SSL or STARTTLS is enabled for both incoming and outgoing mail."
}
},
{
"@type": "Question",
"name": "Still stuck?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Contact postmaster@mifi.holdings. Please include any error messages, your mail app, and a screenshot if you can!"
}
}
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://mail.mifi.holdings"
},
{
"@type": "ListItem",
"position": 2,
"name": "Email Setup & Help",
"item": "https://mail.mifi.holdings/help"
}
]
}
</script>
</head>
<body>
<div class="container faq">
<a
href="#main-content"
class="skip-link"
data-umami-event="skip to main content"
>Skip to main content</a
>
<div class="content">
<main id="main-content" class="container faq">
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li>
<a
href="/"
data-umami-event="breadcrumb"
data-umami-event-label="home"
>Home</a
>
</li>
<li aria-current="page">Email Setup & Help</li>
</ol>
</nav>
<h1 class="text-center">Welcome to Email from mifi Ventures</h1>
<div class="intro">
<strong>Let&apos;s get your inbox ready! 📬</strong><br />
<p>
Friendly help for setting up your email—works with Outlook,
Apple Mail, Thunderbird, phones, and more.
Friendly help for setting up your email—works with
Outlook, Apple Mail, Thunderbird, phones, and more.
</p>
</div>
@@ -107,7 +186,12 @@
<div class="accordion" id="helpAccordion">
<!-- Outlook -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="microsoft outlook"
>
<span class="icon"></span> Microsoft Outlook
</button>
<div class="accordion-content">
@@ -115,8 +199,8 @@
<li>Go to <b>File → Add Account</b></li>
<li>Enter your full email address</li>
<li>
Choose <b>Advanced options</b> → check “Set up
manually”
Choose <b>Advanced options</b> → check “Set
up manually”
</li>
<li>Select <b>IMAP</b> (recommended) or POP</li>
<li>
@@ -127,7 +211,8 @@
<li>
Outgoing server:
<code>mail.mifi.holdings</code>, port
<b>587</b> (STARTTLS) or <b>465</b> (SSL/TLS)
<b>587</b> (STARTTLS) or
<b>465</b> (SSL/TLS)
</li>
<li>
Username: full email address; Password: your
@@ -136,15 +221,20 @@
<li>Click <b>Connect</b></li>
</ol>
<span class="tip"
>If sending fails, make sure “Require logon using
SPA” is <b>unchecked</b>.</span
>If sending fails, make sure “Require logon
using SPA” is <b>unchecked</b>.</span
>
</div>
</section>
<!-- Apple Mail -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="apple mail (macos, ios)"
>
<span class="icon"></span> Apple Mail (macOS, iOS)
</button>
<div class="accordion-content">
@@ -168,12 +258,19 @@
<!-- Thunderbird -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="thunderbird"
>
<span class="icon"></span> Thunderbird
</button>
<div class="accordion-content">
<ol>
<li>Menu → Account Settings → Add Mail Account</li>
<li>
Menu → Account Settings → Add Mail Account
</li>
<li>Fill in your name, email, and password</li>
<li>
Click “Configure manually” and use settings
@@ -185,21 +282,27 @@
<!-- Mobile -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<span class="icon"></span> iOS / Android Mail / Gmail
App
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="ios / android mail / gmail app"
>
<span class="icon"></span> iOS / Android Mail /
Gmail App
</button>
<div class="accordion-content">
<ul>
<li>Add Account → Other</li>
<li>Enter your email and password</li>
<li>
Manual setup: <code>mail.mifi.holdings</code>,
correct ports, SSL/TLS required
Manual setup:
<code>mail.mifi.holdings</code>, correct
ports, SSL/TLS required
</li>
<li>
Gmail app: tap profile → Add account → Other,
fill in details, use IMAP
Gmail app: tap profile → Add account →
Other, fill in details, use IMAP
</li>
</ul>
</div>
@@ -207,20 +310,25 @@
<!-- FAQ -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="faq / troubleshooting"
>
<span class="icon"></span> FAQ / Troubleshooting
</button>
<div class="accordion-content">
<div class="faq-q">Q: My email wont send?</div>
<div class="faq-a">
Check that youre using your full email address for
both incoming and outgoing username, and that the
port is 587 or 465.
Check that youre using your full email address
for both incoming and outgoing username, and
that the port is 587 or 465.
</div>
<div class="faq-q">Q: SSL/TLS errors?</div>
<div class="faq-a">
Ensure SSL or STARTTLS is enabled for both incoming
and outgoing mail.
Ensure SSL or STARTTLS is enabled for both
incoming and outgoing mail.
</div>
<div class="faq-q">Q: Still stuck?</div>
<div class="faq-a">
@@ -228,30 +336,36 @@
<a href="mailto:postmaster@mifi.holdings"
>postmaster@mifi.holdings</a
>.<br />
Please include any error messages, your mail app,
and a screenshot if you can!
Please include any error messages, your mail
app, and a screenshot if you can!
</div>
</div>
</section>
<!-- Pro Tips -->
<section class="accordion-section">
<button class="accordion-trigger" aria-expanded="false">
<button
class="accordion-trigger"
aria-expanded="false"
data-umami-event="accordion"
data-umami-event-label="pro tips & advanced"
>
<span class="icon"></span> Pro Tips & Advanced
</button>
<div class="accordion-content">
<ul>
<li>
<b>IMAP syncs</b> your mail everywhere—choose
IMAP unless you know you want POP3.
<b>IMAP syncs</b> your mail
everywhere—choose IMAP unless you know you
want POP3.
</li>
<li>
Your login is always your
<b>full email address</b>.
</li>
<li>
Check your Spam/Junk folder for misfiled good
emails.
Check your Spam/Junk folder for misfiled
good emails.
</li>
<li>
Advanced: IMAP path prefix =
@@ -262,56 +376,27 @@
</div>
</section>
</div>
<div class="footer">
Email from mifi Ventures &middot; Help Page &ndash; &copy; 2025
mifi Ventures, LLC
</main>
</div>
</div>
<script>
// Native accessible accordion
document.querySelectorAll('.accordion-trigger').forEach((btn) => {
btn.addEventListener('click', function () {
const section = btn.closest('.accordion-section')
const expanded =
btn.getAttribute('aria-expanded') === 'true'
document
.querySelectorAll('.accordion-section')
.forEach((s) => {
if (s === section) {
s.classList.toggle('open', !expanded)
btn.setAttribute(
'aria-expanded',
String(!expanded)
)
const content = btn.nextElementSibling
content.style.maxHeight = !expanded
? content.scrollHeight + 40 + 'px'
: '0px'
} else {
s.classList.remove('open')
s.querySelector(
'.accordion-trigger'
).setAttribute('aria-expanded', 'false')
s.querySelector(
'.accordion-content'
).style.maxHeight = '0px'
}
})
})
// Allow arrow navigation
btn.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const triggers = Array.from(
document.querySelectorAll('.accordion-trigger')
)
let idx = triggers.indexOf(e.target)
if (e.key === 'ArrowDown')
idx = (idx + 1) % triggers.length
else idx = (idx - 1 + triggers.length) % triggers.length
triggers[idx].focus()
}
})
})
</script>
<footer class="footer">
Email from mifi Holdings &middot; Help Page &middot; &copy;
2025<span id="current-year"></span>
<a
href="https://mifi.ventures"
data-umami-event="footer link"
data-umami-event-label="mifi ventures"
>mifi Ventures, LLC</a
>
</footer>
<img
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
alt=""
width="1"
height="1"
role="presentation"
loading="eager"
/>
<script defer src="/assets/js/current-year.js"></script>
<script defer src="/assets/js/accordion.js"></script>
</body>
</html>

View File

@@ -11,6 +11,11 @@
src="/assets/js/ga-init.js"
data-ga-id="G-NF64QMKWX6"
></script>
<script
defer
src="https://analytics.mifi.holdings/script.js"
data-website-id="80f4013d-dd3f-4a10-af75-2e788090990d"
></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
@@ -39,9 +44,27 @@
href="/assets/images/apple-touch-icon.png"
/>
<link rel="stylesheet" href="/assets/css/site.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "mifi Holdings Mail",
"url": "https://mail.mifi.holdings",
"description": "The mifi Holdings mail services hub—here you can find help with setting up your email, changing your password, and accessing webmail.",
"publisher": {
"@type": "Organization",
"name": "mifi Ventures",
"url": "https://mifi.ventures",
"email": "postmaster@mifi.holdings"
}
}
</script>
</head>
<body>
<div class="container text-center">
<a href="#main-content" class="skip-link">Skip to main content</a>
<div class="content">
<main id="main-content" class="container text-center">
<div class="emoji">📮</div>
<h1>This is just a mailbox.</h1>
<p>
@@ -50,15 +73,52 @@
here—just some gears whirring and mail being sorted.<br />
Looking for your messages?
</p>
<a class="button" href="/help">Email Setup Help</a>
<a class="button" href="https://webmail.mifi.holdings"
>Go to Webmail</a
<a
class="button"
href="/help"
data-umami-event="button"
data-umami-event-label="email setup help"
>Email Setup Help</a
>
<a
class="button"
href="https://postmaster.mifi.holdings/users/login.php"
>Change/Forgot Password</a
href="https://webmail.mifi.holdings"
target="_blank"
data-umami-event="button"
data-umami-event-label="webmail"
>
Go to Webmail
</a>
<a
class="button"
href="https://postmaster.mifi.holdings/users/login.php"
target="_blank"
data-umami-event="button"
data-umami-event-label="change/forgot password"
>
Change/Forgot Password
</a>
</main>
</div>
<footer class="footer">
Email from mifi Holdings &middot; &copy; 2025<span
id="current-year"
></span>
<a
href="https://mifi.ventures"
data-umami-event="footer link"
data-umami-event-label="mifi ventures"
>mifi Ventures, LLC</a
>
</footer>
<img
src="https://analytics.mifi.holdings/p/wQ9GYnLIg"
alt=""
width="1"
height="1"
role="presentation"
loading="eager"
/>
<script defer src="/assets/js/current-year.js"></script>
</body>
</html>

4
src/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://mail.mifi.holdings/sitemap.xml

13
src/sitemap.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mail.mifi.holdings/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://mail.mifi.holdings/help</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

View File

@@ -2,7 +2,7 @@ export default {
extends: ['stylelint-config-standard'],
overrides: [
{
files: ['src/**/*.css']
}
]
}
files: ['src/**/*.css'],
},
],
};