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:
parent
447f0fa747
commit
b9dbd59e7d
12
.env.example
12
.env.example
@ -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
|
||||
|
||||
55
README.md
55
README.md
@ -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
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
149
src/services/email.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export default { verifyTransport, sendContactEmail };
|
||||
Loading…
Reference in New Issue
Block a user