MEGAINFRA-WEBAPP/docs/setup/email.md
DEls 8acc27466e feat(users): welcome and password-reset emails
Admins can email a new user a welcome with a one-time link to choose
their password, and send a reset link from the users list. The sign-in
page gets "Forgot password?". Mail goes out through Resend; the emails
show the person's sign-in address.

- password_links collection (migration applied): only the SHA-256 of each
  token is stored; invites last 7 days, resets 1 hour, one use each
- /set-password and /forgot-password pages and their /api/auth routes
- forgot-password never reveals whether an account exists, throttles
  repeat mails, and builds links only from APP_URL
- env: RESEND_API_KEY, EMAIL_FROM, EMAIL_REPLY_TO, APP_URL (docs/setup/email.md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:39:38 +02:00

3.7 KiB

Email from the dashboard

The dashboard sends two mails: a welcome invite when an admin adds a user or presses "Send welcome email", and a password reset when someone uses "Forgot password?" on the sign-in page.

Both go out through Resend — one HTTP POST to https://api.resend.com/emails, no SDK and no new dependency. The key stays on the server; the browser never sees it.

Environment variables

Set these in the Next.js server environment (Coolify env vars, .env.local, or whatever your host uses):

Variable Required What it does
RESEND_API_KEY Yes The Resend API key (re_…). Without it the mail feature is off and the admin button answers "Email is not set up yet".
EMAIL_FROM No The sender shown on the mail. Defaults to MEGA Dashboard <support@apphub.co.za>.
EMAIL_REPLY_TO No The address a reply goes to. Falls back to the sender.
APP_URL No The dashboard's public URL, used to build the link in the mail and to point the logo at your own server. Falls back to the request's own origin.

RESEND_API_KEY is the same key you already use as the PocketBase SMTP password. Resend's "API key" and its "SMTP password" are different strings — use the API key here (re_…), not the SMTP password.

RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM=MEGA Dashboard <support@apphub.co.za>
EMAIL_REPLY_TO=support@apphub.co.za
APP_URL=https://wfdash.mega.infra.apphub.co.za

APP_URL must be the address people actually open. If it is wrong, every "Choose your password" link points somewhere nobody can reach.

Run the migration

The links live in a PocketBase collection. Run this once against the environment you are deploying to:

PB_URL=https://pb.mega.infra.apphub.co.za \
PB_ADMIN_EMAIL=admin@mega.co.za \
PB_ADMIN_PASSWORD=... \
python3 scripts/migrations/2026-11-02-password-links.py

The script is idempotent — it creates password_links when it is missing and verifies it when it is there. Every API rule on the collection stays null, so only the server's admin client can read or write these rows.

The collection holds no passwords and no live links: only the SHA-256 hash of each token. A row lifted from the database cannot be turned back into a working link.

Moving to megainfra.co.za later

Until the domain is verified, mail from support@apphub.co.za may land in spam or be rejected. To send as your own domain:

  1. Verify the domain in Resend. Resend → Domains → Add Domain. Use megainfra.co.za, or a sub-domain such as mail.megainfra.co.za if the root domain is used for something else.
  2. Add the DNS records Resend shows you (SPF, DKIM, and a DMARC record if offered) to the DNS zone for the domain. Wait until Resend marks the domain as verified — usually minutes, sometimes longer if the zone is slow.
  3. Change EMAIL_FROM to the verified sender, for example MEGA Dashboard <no-reply@megainfra.co.za>, and restart the app.

Keep EMAIL_FROM on a domain you have actually verified. Sending from an unverified domain is the usual reason mail disappears.

What the user sees

  • Welcome — a greeting, what the dashboard (or the clock app, for a clock user) is for, a "Choose your password" button, three short getting-started steps, and the plain link for mail clients that will not show a button. The link is valid for 7 days.
  • Reset — a "Reset password" button, valid for 1 hour, and a line telling them to ignore the mail if they did not ask for it.

Both pages also work by hand: /forgot-password asks for the email and /set-password?token=… sets the new password. The forgot-password route always answers the same way, so it never reveals whether an address has an account.