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
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY img/ /usr/share/nginx/html/img/
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ services:
|
|||
restart: unless-stopped
|
||||
networks:
|
||||
- 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:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.cineasthesia-landing.rule=Host(`cineasthesia.com`) || Host(`www.cineasthesia.com`)"
|
||||
|
|
@ -14,3 +22,6 @@ services:
|
|||
networks:
|
||||
traefik-public:
|
||||
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');
|
||||
}
|
||||
} catch (err) {
|
||||
status.className = 'form-status success';
|
||||
status.textContent = 'Thank you for your interest. Please email us directly at hello@cineasthesia.com';
|
||||
status.className = 'form-status error';
|
||||
status.textContent = 'Something went wrong. Please try again or email noire.michelle@gmail.com directly.';
|
||||
}
|
||||
|
||||
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}`);
|
||||
});
|
||||