feat: add email notifications for contact form submissions

- Add nodemailer dependency for SMTP email delivery
- Create src/services/email.js with reusable email service
- Update POST /api/contact to send email notification after saving
- Email is sent fire-and-forget (doesn't block API response)
- Emails include both plain-text and HTML versions
- Reply-To header set to submitter's email for easy replies
- Add SMTP environment variables to .env.example
- Document email setup in README with provider-specific guides
This commit is contained in:
Z User 2026-05-05 21:05:37 +00:00
parent 447f0fa747
commit b9dbd59e7d
6 changed files with 241 additions and 0 deletions

View File

@ -25,3 +25,15 @@ STRIPE_WEBHOOK_SECRET=
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_WEBHOOK_ID=
# SMTP Email Configuration (for contact form notifications)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_SECURE=false
SMTP_FROM=Moxie <your-email@gmail.com>
# Destination email for contact form submissions
# Defaults to SMTP_USER if not set
CONTACT_EMAIL=your-email@gmail.com

View File

@ -232,6 +232,61 @@ UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';
3. Add credentials to environment variables
4. Configure webhook in PayPal dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/paypal`
## Email Configuration (Contact Form)
Contact form submissions are automatically emailed to a configured address using Nodemailer with SMTP.
### Setup
Add the following to your `.env` file:
```env
# SMTP Email Configuration
SMTP_HOST=smtp.gmail.com # Your SMTP server
SMTP_PORT=587 # Usually 587 (TLS) or 465 (SSL)
SMTP_USER=your-email@gmail.com # Email address to send from
SMTP_PASS=your-app-password # Password or app-specific password
SMTP_SECURE=false # true for port 465, false for 587
SMTP_FROM=Moxie <your-email@gmail.com> # From display name & address
# Where contact form emails are sent (defaults to SMTP_USER)
CONTACT_EMAIL=notifications@yourdomain.com
```
### Gmail Users
If you use Gmail, you must generate an **App Password** because regular passwords no longer work with SMTP:
1. Go to [Google Account Security](https://myaccount.google.com/security)
2. Enable **2-Step Verification** (if not already enabled)
3. Go to **App passwords** (search in security settings)
4. Create a new app password for "Mail"
5. Use that 16-character password as `SMTP_PASS`
### Other Providers
| Provider | SMTP_HOST | SMTP_PORT | Notes |
|----------|-----------|-----------|-------|
| Gmail | smtp.gmail.com | 587 | Requires App Password |
| Outlook / Office 365 | smtp.office365.com | 587 | |
| SendGrid | smtp.sendgrid.net | 587 | Use API key as password |
| Mailgun | smtp.mailgun.org | 587 | |
| AWS SES | email-smtp.[region].amazonaws.com | 587 | Use SMTP credentials |
### How It Works
When someone submits the contact form (`POST /api/contact`):
1. The submission is **saved to the database** (always succeeds)
2. An email notification is **sent in the background** (fire-and-forget)
3. If email sending fails, the form still saves — a warning is logged but the user is not affected
The email includes:
- Plain-text and HTML versions
- The submitter's name, email, and company (if provided)
- The full message
- A reply-to header so you can reply directly to the submitter
## License
ISC

10
package-lock.json generated
View File

@ -16,6 +16,7 @@
"express-oauth2-jwt-bearer": "^1.7.4",
"helmet": "^8.1.0",
"jose": "^6.2.2",
"nodemailer": "^8.0.7",
"sql.js": "^1.14.1",
"uuid": "^13.0.0"
}
@ -603,6 +604,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"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",

View File

@ -23,6 +23,7 @@
"express-oauth2-jwt-bearer": "^1.7.4",
"helmet": "^8.1.0",
"jose": "^6.2.2",
"nodemailer": "^8.0.7",
"sql.js": "^1.14.1",
"uuid": "^13.0.0"
}

View File

@ -1,6 +1,7 @@
import express from 'express';
import { dbRun, dbAll, dbGet } from '../db/database.js';
import { generateId, ApiResponse, asyncHandler } from '../utils/helpers.js';
import { sendContactEmail } from '../services/email.js';
const router = express.Router();
@ -64,6 +65,19 @@ router.post('/', asyncHandler(async (req, res) => {
console.log(`New contact submission: ${submissionId} from ${email}`);
// Send email notification (fire-and-forget — don't block the response)
sendContactEmail({ name: name.trim(), email: email.trim().toLowerCase(), company: company?.trim(), message: message.trim() })
.then((result) => {
if (result.success) {
console.log(`[Contact] Email sent for submission ${submissionId}`);
} else {
console.warn(`[Contact] Email failed for submission ${submissionId}: ${result.error}`);
}
})
.catch((err) => {
console.error(`[Contact] Email error for submission ${submissionId}:`, err.message);
});
res.status(201).json(ApiResponse(true, {
id: submissionId,
message: 'Thank you for your message! We\'ll get back to you soon.'

149
src/services/email.js Normal file
View File

@ -0,0 +1,149 @@
import nodemailer from 'nodemailer';
/**
* Create a Nodemailer transport using SMTP environment variables.
*
* Required env vars:
* SMTP_HOST - e.g. smtp.gmail.com
* SMTP_PORT - e.g. 587
* SMTP_USER - email address used for sending
* SMTP_PASS - password or app-specific password
*
* Optional env vars:
* SMTP_FROM - "From" display name & address (defaults to SMTP_USER)
* SMTP_SECURE - true / false (defaults to false; use true for port 465)
* CONTACT_EMAIL - destination for contact-form notifications
* (defaults to SMTP_USER)
*/
const createTransport = () => {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT, 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !port || !user || !pass) {
throw new Error(
'Email service is not configured. ' +
'Please set SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASS in your .env file.'
);
}
return nodemailer.createTransport({
host,
port,
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for 587/other
auth: { user, pass },
});
};
const getFromAddress = () =>
process.env.SMTP_FROM || process.env.SMTP_USER;
const getContactEmail = () =>
process.env.CONTACT_EMAIL || process.env.SMTP_USER;
/**
* Verify that the SMTP transport can connect.
* Call this at startup (optional but recommended).
* @returns {Promise<boolean>}
*/
export const verifyTransport = async () => {
try {
const transporter = createTransport();
await transporter.verify();
console.log('[Email] SMTP transport verified successfully');
return true;
} catch (err) {
console.warn('[Email] SMTP transport verification failed:', err.message);
return false;
}
};
/**
* Send the contact-form submission as an email notification.
*
* @param {Object} params
* @param {string} params.name - Submitter's name
* @param {string} params.email - Submitter's email
* @param {string} [params.company]- Submitter's company (optional)
* @param {string} params.message - Message body
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
export const sendContactEmail = async ({ name, email, company, message }) => {
try {
const transporter = createTransport();
const from = getFromAddress();
const to = getContactEmail();
const companyLine = company ? `Company: ${company}\n` : '';
const info = await transporter.sendMail({
from: `"Moxie Contact Form" <${from}>`,
to,
replyTo: email, // allow replying directly to the submitter
subject: `[Moxie] New Contact Form Submission from ${name}`,
text: [
`You received a new contact form submission.`,
'',
`Name: ${name}`,
`Email: ${email}`,
companyLine,
`Message:`,
message,
'',
`---`,
`Submitted at: ${new Date().toISOString()}`,
].join('\n'),
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">New Contact Form Submission</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold; width: 100px;">Name</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">${escapeHtml(name)}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">Email</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">
<a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a>
</td>
</tr>
${company ? `
<tr>
<td style="padding: 8px; border-bottom: 1px solid #eee; font-weight: bold;">Company</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">${escapeHtml(company)}</td>
</tr>` : ''}
</table>
<h3 style="color: #333; margin-top: 20px;">Message</h3>
<p style="background: #f9f9f9; padding: 16px; border-radius: 4px; white-space: pre-wrap;">${escapeHtml(message)}</p>
<hr style="border: none; border-top: 1px solid #eee; margin-top: 24px;" />
<p style="color: #999; font-size: 12px;">
Submitted at: ${new Date().toISOString()}
</p>
</div>
`,
});
console.log(`[Email] Contact notification sent to ${to} (messageId: ${info.messageId})`);
return { success: true, messageId: info.messageId };
} catch (err) {
console.error('[Email] Failed to send contact notification:', err.message);
return { success: false, error: err.message };
}
};
/**
* Minimal HTML entity escaping for user-submitted content.
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export default { verifyTransport, sendContactEmail };