From 3a2604ea2eab77f857cfe5259c85eca5b2b75174 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Feb 2026 01:52:24 +0000 Subject: [PATCH] feat: switch email from Resend to Mailcow SMTP Resend required domain verification that wasn't set up. Switch to self-hosted Mailcow (mx.jeffemmett.com) using nodemailer for SMTP, matching the pattern used by other projects (cosmolocal, etc). Co-Authored-By: Claude Opus 4.6 --- .env.example | 8 ++- app/api/request-access/route.ts | 101 +++++++++++++++----------------- docker-compose.yml | 5 +- package-lock.json | 27 +++++++-- package.json | 4 +- 5 files changed, 82 insertions(+), 63 deletions(-) diff --git a/.env.example b/.env.example index a116d76..c46d0aa 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ -# Email notifications via Resend (required for access request feature) -# Get your API key from https://resend.com -RESEND_API_KEY=re_xxxxxxxxxxxx +# SMTP settings (Mailcow) +SMTP_HOST=mx.jeffemmett.com +SMTP_PORT=587 +SMTP_USER=noreply@jefflix.lol +SMTP_PASS=your-mailbox-password # Admin email to receive access request notifications ADMIN_EMAIL=jeff@jeffemmett.com diff --git a/app/api/request-access/route.ts b/app/api/request-access/route.ts index 28b962c..62a2379 100644 --- a/app/api/request-access/route.ts +++ b/app/api/request-access/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' +import nodemailer from 'nodemailer' export async function POST(request: NextRequest) { try { @@ -22,10 +23,11 @@ export async function POST(request: NextRequest) { ) } - // Send notification email via Resend - const resendApiKey = process.env.RESEND_API_KEY - if (!resendApiKey) { - console.error('RESEND_API_KEY not configured') + const smtpHost = process.env.SMTP_HOST + const smtpUser = process.env.SMTP_USER + const smtpPass = process.env.SMTP_PASS + if (!smtpHost || !smtpUser || !smtpPass) { + console.error('SMTP credentials not configured') return NextResponse.json( { error: 'Email service not configured' }, { status: 500 } @@ -34,58 +36,49 @@ export async function POST(request: NextRequest) { const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com' - const emailResponse = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${resendApiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - from: 'Jefflix ', - to: adminEmail, - subject: `[Jefflix] New Access Request from ${name}`, - html: ` -

New Jefflix Access Request

-

Someone has requested access to Jefflix:

- - - - - - - - - - - - - - - - - -
Name:${escapeHtml(name)}
Email:${escapeHtml(email)}
Reason:${escapeHtml(reason || 'Not provided')}
Requested:${new Date().toLocaleString()}
-

To approve this request:

-
    -
  1. Go to Jellyfin Dashboard
  2. -
  3. Navigate to Dashboard → Users → Add User
  4. -
  5. Create an account for ${escapeHtml(name)} (${escapeHtml(email)})
  6. -
  7. Reply to this email to let them know their account is ready
  8. -
-
-

This is an automated message from Jefflix.

- `, - }), + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: Number(process.env.SMTP_PORT) || 587, + secure: false, + auth: { user: smtpUser, pass: smtpPass }, }) - if (!emailResponse.ok) { - const errorData = await emailResponse.json() - console.error('Resend API error:', errorData) - return NextResponse.json( - { error: 'Failed to send notification' }, - { status: 500 } - ) - } + await transporter.sendMail({ + from: `Jefflix <${smtpUser}>`, + to: adminEmail, + subject: `[Jefflix] New Access Request from ${name}`, + html: ` +

New Jefflix Access Request

+

Someone has requested access to Jefflix:

+ + + + + + + + + + + + + + + + + +
Name:${escapeHtml(name)}
Email:${escapeHtml(email)}
Reason:${escapeHtml(reason || 'Not provided')}
Requested:${new Date().toLocaleString()}
+

To approve this request:

+
    +
  1. Go to Jellyfin Dashboard
  2. +
  3. Navigate to Dashboard → Users → Add User
  4. +
  5. Create an account for ${escapeHtml(name)} (${escapeHtml(email)})
  6. +
  7. Reply to this email to let them know their account is ready
  8. +
+
+

This is an automated message from Jefflix.

+ `, + }) return NextResponse.json({ success: true }) } catch (error) { diff --git a/docker-compose.yml b/docker-compose.yml index a0569e3..7bdcc70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,10 @@ services: build: . restart: unless-stopped environment: - - RESEND_API_KEY=${RESEND_API_KEY} + - SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} - ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com} labels: - "traefik.enable=true" diff --git a/package-lock.json b/package-lock.json index 5edca80..2c80526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "lucide-react": "^0.454.0", "next": "16.0.10", "next-themes": "^0.4.6", + "nodemailer": "^8.0.1", "react": "19.2.0", "react-day-picker": "9.8.0", "react-dom": "19.2.0", @@ -62,6 +63,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.9", "@types/node": "^22", + "@types/nodemailer": "^7.0.9", "@types/react": "^19", "@types/react-dom": "^19", "postcss": "^8.5", @@ -2474,11 +2476,21 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2488,7 +2500,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3408,6 +3420,15 @@ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3427,7 +3448,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3801,7 +3821,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, "license": "MIT" }, "node_modules/tailwindcss-animate": { diff --git a/package.json b/package.json index 253edb0..a83fad5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "lucide-react": "^0.454.0", "next": "16.0.10", "next-themes": "^0.4.6", + "nodemailer": "^8.0.1", "react": "19.2.0", "react-day-picker": "9.8.0", "react-dom": "19.2.0", @@ -63,6 +64,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.1.9", "@types/node": "^22", + "@types/nodemailer": "^7.0.9", "@types/react": "^19", "@types/react-dom": "^19", "postcss": "^8.5", @@ -70,4 +72,4 @@ "tw-animate-css": "1.3.3", "typescript": "^5" } -} \ No newline at end of file +}