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 <noreply@anthropic.com>
This commit is contained in:
parent
a99d51c831
commit
3a2604ea2e
|
|
@ -1,6 +1,8 @@
|
||||||
# Email notifications via Resend (required for access request feature)
|
# SMTP settings (Mailcow)
|
||||||
# Get your API key from https://resend.com
|
SMTP_HOST=mx.jeffemmett.com
|
||||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=noreply@jefflix.lol
|
||||||
|
SMTP_PASS=your-mailbox-password
|
||||||
|
|
||||||
# Admin email to receive access request notifications
|
# Admin email to receive access request notifications
|
||||||
ADMIN_EMAIL=jeff@jeffemmett.com
|
ADMIN_EMAIL=jeff@jeffemmett.com
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -22,10 +23,11 @@ export async function POST(request: NextRequest) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification email via Resend
|
const smtpHost = process.env.SMTP_HOST
|
||||||
const resendApiKey = process.env.RESEND_API_KEY
|
const smtpUser = process.env.SMTP_USER
|
||||||
if (!resendApiKey) {
|
const smtpPass = process.env.SMTP_PASS
|
||||||
console.error('RESEND_API_KEY not configured')
|
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||||
|
console.error('SMTP credentials not configured')
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Email service not configured' },
|
{ error: 'Email service not configured' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|
@ -34,58 +36,49 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
|
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
|
||||||
|
|
||||||
const emailResponse = await fetch('https://api.resend.com/emails', {
|
const transporter = nodemailer.createTransport({
|
||||||
method: 'POST',
|
host: smtpHost,
|
||||||
headers: {
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
'Authorization': `Bearer ${resendApiKey}`,
|
secure: false,
|
||||||
'Content-Type': 'application/json',
|
auth: { user: smtpUser, pass: smtpPass },
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: 'Jefflix <noreply@jefflix.lol>',
|
|
||||||
to: adminEmail,
|
|
||||||
subject: `[Jefflix] New Access Request from ${name}`,
|
|
||||||
html: `
|
|
||||||
<h2>New Jefflix Access Request</h2>
|
|
||||||
<p>Someone has requested access to Jefflix:</p>
|
|
||||||
<table style="border-collapse: collapse; margin: 20px 0;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Name:</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(name)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Email:</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Reason:</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(reason || 'Not provided')}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Requested:</td>
|
|
||||||
<td style="padding: 8px; border: 1px solid #ddd;">${new Date().toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p>To approve this request:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Go to <a href="https://movies.jefflix.lol">Jellyfin Dashboard</a></li>
|
|
||||||
<li>Navigate to Dashboard → Users → Add User</li>
|
|
||||||
<li>Create an account for ${escapeHtml(name)} (${escapeHtml(email)})</li>
|
|
||||||
<li>Reply to this email to let them know their account is ready</li>
|
|
||||||
</ol>
|
|
||||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
|
||||||
<p style="color: #666; font-size: 12px;">This is an automated message from Jefflix.</p>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!emailResponse.ok) {
|
await transporter.sendMail({
|
||||||
const errorData = await emailResponse.json()
|
from: `Jefflix <${smtpUser}>`,
|
||||||
console.error('Resend API error:', errorData)
|
to: adminEmail,
|
||||||
return NextResponse.json(
|
subject: `[Jefflix] New Access Request from ${name}`,
|
||||||
{ error: 'Failed to send notification' },
|
html: `
|
||||||
{ status: 500 }
|
<h2>New Jefflix Access Request</h2>
|
||||||
)
|
<p>Someone has requested access to Jefflix:</p>
|
||||||
}
|
<table style="border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Name:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(name)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Email:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Reason:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(reason || 'Not provided')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Requested:</td>
|
||||||
|
<td style="padding: 8px; border: 1px solid #ddd;">${new Date().toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>To approve this request:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Go to <a href="https://movies.jefflix.lol">Jellyfin Dashboard</a></li>
|
||||||
|
<li>Navigate to Dashboard → Users → Add User</li>
|
||||||
|
<li>Create an account for ${escapeHtml(name)} (${escapeHtml(email)})</li>
|
||||||
|
<li>Reply to this email to let them know their account is ready</li>
|
||||||
|
</ol>
|
||||||
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">This is an automated message from Jefflix.</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ services:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-day-picker": "9.8.0",
|
"react-day-picker": "9.8.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|
@ -62,6 +63,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
|
@ -2474,11 +2476,21 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
|
|
@ -2488,7 +2500,7 @@
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|
@ -3408,6 +3420,15 @@
|
||||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -3427,7 +3448,6 @@
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -3801,7 +3821,6 @@
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss-animate": {
|
"node_modules/tailwindcss-animate": {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-day-picker": "9.8.0",
|
"react-day-picker": "9.8.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|
@ -63,6 +64,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.9",
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue