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>
10
Dockerfile
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "cineasthesia-landing",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nodemailer": "^6.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 747 KiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 481 KiB After Width: | Height: | Size: 481 KiB |
|
Before Width: | Height: | Size: 482 KiB After Width: | Height: | Size: 482 KiB |
|
|
@ -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';
|
||||||
|
|
@ -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}`);
|
||||||
|
});
|
||||||