Add contact form email backend via Mailcow SMTP

Switch from nginx to Node.js server with nodemailer.
Contact form sends to noire.michelle@gmail.com via
Mailcow internal postfix. Static files moved to public/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-19 14:22:22 -07:00
parent 87d701ae5c
commit e37fc900b7
19 changed files with 150 additions and 5 deletions

View File

@ -1,4 +1,8 @@
FROM nginx:alpine FROM node:20-alpine
COPY index.html /usr/share/nginx/html/ WORKDIR /app
COPY img/ /usr/share/nginx/html/img/ COPY package.json ./
RUN npm install --production
COPY server.js ./
COPY public/ ./public/
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"]

View File

@ -5,6 +5,14 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- traefik-public - traefik-public
- mailcow-network
environment:
- SMTP_HOST=mailcowdockerized-postfix-mailcow-1
- SMTP_PORT=587
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- SMTP_FROM=${SMTP_FROM:-noreply@jeffemmett.com}
- CONTACT_TO=noire.michelle@gmail.com
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.cineasthesia-landing.rule=Host(`cineasthesia.com`) || Host(`www.cineasthesia.com`)" - "traefik.http.routers.cineasthesia-landing.rule=Host(`cineasthesia.com`) || Host(`www.cineasthesia.com`)"
@ -14,3 +22,6 @@ services:
networks: networks:
traefik-public: traefik-public:
external: true external: true
mailcow-network:
external: true
name: mailcowdockerized_mailcow-network

10
package.json Normal file
View File

@ -0,0 +1,10 @@
{
"name": "cineasthesia-landing",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"nodemailer": "^6.9.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 411 KiB

View File

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 535 KiB

View File

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

View File

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 533 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 594 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View File

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 447 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 361 KiB

View File

Before

Width:  |  Height:  |  Size: 747 KiB

After

Width:  |  Height:  |  Size: 747 KiB

View File

Before

Width:  |  Height:  |  Size: 481 KiB

After

Width:  |  Height:  |  Size: 481 KiB

View File

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 482 KiB

View File

@ -961,8 +961,8 @@ form.addEventListener('submit', async (e) => {
throw new Error('Server error'); throw new Error('Server error');
} }
} catch (err) { } catch (err) {
status.className = 'form-status success'; status.className = 'form-status error';
status.textContent = 'Thank you for your interest. Please email us directly at hello@cineasthesia.com'; status.textContent = 'Something went wrong. Please try again or email noire.michelle@gmail.com directly.';
} }
btn.textContent = 'Send Message'; btn.textContent = 'Send Message';

120
server.js Normal file
View File

@ -0,0 +1,120 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const PORT = 80;
const STATIC_DIR = path.join(__dirname, 'public');
const SMTP_HOST = process.env.SMTP_HOST || 'mail.rmail.online';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587');
const SMTP_USER = process.env.SMTP_USER;
const SMTP_PASS = process.env.SMTP_PASS;
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER;
const CONTACT_TO = process.env.CONTACT_TO || 'noire.michelle@gmail.com';
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false,
auth: { user: SMTP_USER, pass: SMTP_PASS },
tls: { rejectUnauthorized: false }
});
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
};
function serveStatic(req, res) {
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
filePath = path.normalize(filePath);
if (!filePath.startsWith(STATIC_DIR)) {
res.writeHead(403);
res.end();
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=86400'
});
res.end(data);
});
}
function handleContact(req, res) {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
try {
const { name, email, subject, message } = JSON.parse(body);
if (!name || !email || !message) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Name, email, and message are required' }));
return;
}
await transporter.sendMail({
from: `"Cineasthesia Contact" <${SMTP_FROM}>`,
replyTo: `"${name}" <${email}>`,
to: CONTACT_TO,
subject: `[Cineasthesia] ${subject || 'Contact Form Message'}`,
text: `Name: ${name}\nEmail: ${email}\nSubject: ${subject || '(none)'}\n\nMessage:\n${message}`,
html: `
<div style="font-family: sans-serif; max-width: 600px;">
<h2 style="color: #3C2415; border-bottom: 2px solid #C68B3F; padding-bottom: 8px;">New Contact Form Message</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
<p><strong>Subject:</strong> ${subject || '(none)'}</p>
<hr style="border: none; border-top: 1px solid #E8DBC8; margin: 16px 0;">
<p style="white-space: pre-wrap;">${message}</p>
<hr style="border: none; border-top: 1px solid #E8DBC8; margin: 16px 0;">
<p style="font-size: 12px; color: #7a6f64;">Sent from cineasthesia.com contact form</p>
</div>
`
});
console.log(`Contact form sent: ${name} <${email}> — ${subject || '(no subject)'}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('Contact form error:', err.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send message' }));
}
});
}
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/contact') {
handleContact(req, res);
} else {
serveStatic(req, res);
}
});
server.listen(PORT, () => {
console.log(`Cineasthesia server running on port ${PORT}`);
console.log(`Contact emails → ${CONTACT_TO}`);
});