Email Sender: Secure Domain SMTP Implementation
Send emails from your website using the client's own domain SMTP server — secure, spam-preventable, and fully authenticated.
Prerequisites
Client Requirements (Non-Negotiable):
- •Domain name owned and controlled by client
- •Access to domain email/SMTP settings (usually via hosting provider or business email service)
- •DKIM, SPF, and DMARC records configured on domain (for deliverability)
- •Dedicated email account for website (e.g.,
noreply@clientdomain.comorcontact@clientdomain.com)
Without these, email security and deliverability will fail.
Architecture
Client Request (form submission) ↓ Rate Limiter (max 5 submissions per IP per hour) ↓ Input Validation + CAPTCHA check ↓ Spam Detection (content analysis) ↓ SMTP Credentials (loaded from .env, never exposed) ↓ Authenticated Email Send (using domain SMTP) ↓ Audit Log (request logged for security review) ↓ Response to client (success or rate-limited message)
Step 1: Environment Configuration
Create .env.local (never commit to git):
# Email Sender Configuration SMTP_HOST=mail.clientdomain.com # SMTP server (ask hosting provider) SMTP_PORT=587 # Standard SMTP port (465 for SSL) SMTP_SECURE=true # Use TLS/SSL SMTP_USER=noreply@clientdomain.com # Dedicated email account SMTP_PASSWORD=secure_password_here # Generated by provider, never hardcode SMTP_FROM_NAME="Client Company Name" # Display name in "From" field SMTP_FROM_EMAIL=noreply@clientdomain.com # Must match SMTP_USER # Rate Limiting & Security RATE_LIMIT_EMAILS_PER_HOUR=5 # Max emails per IP per hour RATE_LIMIT_WINDOW_MINUTES=60 # Rolling window SPAM_CHECK_ENABLED=true # Enable content-based spam detection CAPTCHA_SECRET_KEY=your_captcha_key # For bot prevention # Audit & Monitoring AUDIT_LOG_PATH=/var/log/website-emails.log ALERT_ADMIN_ON_RATE_LIMIT=true # Email admin if rate limit hit ALERT_EMAIL=admin@clientdomain.com # Admin email for alerts
CRITICAL SECURITY RULES:
- •
.env.localis .gitignored — never commit credentials - •
.env.exampleshows template (no real values) - •Credentials stored server-side only — never exposed to client
- •Each project has unique SMTP credentials per domain
Step 2: DKIM/SPF/DMARC Setup (Required for Deliverability)
Before enabling email sending, configure domain authentication:
SPF Record
Tells mail servers which servers are allowed to send email for your domain:
DNS TXT Record: v=spf1 include:_spf.google.com include:mail.clientdomain.com ~all
(Replace with client's email provider SPF record)
DKIM Record
Adds digital signature to emails so they can't be spoofed:
DNS CNAME/TXT Record: selector1._domainkey.clientdomain.com → [generated by email provider]
(Get DKIM record details from hosting provider or email service)
DMARC Record
Policy for what happens when SPF/DKIM fail:
DNS TXT Record: v=DMARC1; p=quarantine; rua=mailto:dmarc@clientdomain.com; ruf=mailto:dmarc@clientdomain.com
Verification:
- •Use MXToolbox.com or similar to verify SPF/DKIM/DMARC setup
- •SPF: "pass" ✓
- •DKIM: "pass" ✓
- •DMARC: "pass" ✓
Without these, emails go to spam.
Step 3: Contact Form Implementation
Backend (Node.js + Express Example)
// src/api/email.ts (Astro API endpoint)
import nodemailer from 'nodemailer';
import rateLimit from 'express-rate-limit';
import { validateEmail, detectSpam } from '../utils/email-utils';
// Rate limiter: 5 emails per IP per hour
const emailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 requests per window
message: 'Too many emails sent. Please try again in an hour.',
standardHeaders: false,
skip: (req) => req.clientIP === process.env.ADMIN_IP, // Skip for admin
});
// SMTP Transporter (created once, reused)
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for 587
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
export async function POST({ request }) {
try {
const clientIP = request.headers.get('x-forwarded-for') || 'unknown';
// 1. Rate limiting
const limitCheck = emailLimiter.skip
? { skip: false }
: { rateLimitRemaining: 5 };
if (limitCheck.rateLimitRemaining <= 0) {
// Log rate limit hit
console.warn(`Rate limit exceeded from IP: ${clientIP}`);
return new Response(
JSON.stringify({
error: 'Too many submission attempts. Please try later.',
retry_after: 3600
}),
{
status: 429,
headers: { 'Content-Type': 'application/json' }
}
);
}
// 2. Parse form data
const formData = await request.formData();
const name = formData.get('name');
const email = formData.get('email');
const phone = formData.get('phone');
const message = formData.get('message');
const captchaToken = formData.get('captchaToken');
// 3. Input validation
if (!name || !email || !message) {
return new Response(
JSON.stringify({ error: 'Missing required fields' }),
{ status: 400 }
);
}
if (!validateEmail(email)) {
return new Response(
JSON.stringify({ error: 'Invalid email address' }),
{ status: 400 }
);
}
// 4. CAPTCHA verification (reCAPTCHA v3)
if (process.env.CAPTCHA_SECRET_KEY) {
const captchaVerified = await verifyCaptcha(
captchaToken,
process.env.CAPTCHA_SECRET_KEY
);
if (!captchaVerified) {
return new Response(
JSON.stringify({ error: 'CAPTCHA verification failed' }),
{ status: 400 }
);
}
}
// 5. Spam detection (content analysis)
const spamScore = detectSpam(message);
if (spamScore > 0.8) {
console.warn(`High spam score (${spamScore}) from ${email}`);
// Silently reject but respond as if successful (don't reveal spam detection)
return new Response(
JSON.stringify({
success: true,
message: 'Your message has been received.'
}),
{ status: 200 }
);
}
// 6. Prepare email content
const htmlContent = `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${sanitize(name)}</p>
<p><strong>Email:</strong> ${sanitize(email)}</p>
${phone ? `<p><strong>Phone:</strong> ${sanitize(phone)}</p>` : ''}
<p><strong>Message:</strong></p>
<p>${sanitize(message).replace(/\n/g, '<br>')}</p>
<hr>
<p><small>Submitted from: ${clientIP}</small></p>
`;
const textContent = `
New Contact Form Submission
Name: ${name}
Email: ${email}
${phone ? `Phone: ${phone}` : ''}
Message:
${message}
---
Submitted from: ${clientIP}
`;
// 7. Send email to admin
const adminMailOptions = {
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_FROM_EMAIL}>`,
to: process.env.CONTACT_EMAIL || process.env.SMTP_USER,
subject: `New Contact Form: ${name}`,
html: htmlContent,
text: textContent,
replyTo: email,
};
await transporter.sendMail(adminMailOptions);
// 8. Send confirmation email to user
const userMailOptions = {
from: `"${process.env.SMTP_FROM_NAME}" <${process.env.SMTP_FROM_EMAIL}>`,
to: email,
subject: 'We received your message',
html: `
<p>Hi ${sanitize(name)},</p>
<p>Thank you for contacting us. We've received your message and will respond within 24 hours.</p>
<hr>
<p><small>This is an automated confirmation email. Please do not reply.</small></p>
`,
text: `Hi ${name},\n\nThank you for contacting us. We've received your message and will respond within 24 hours.`,
};
await transporter.sendMail(userMailOptions);
// 9. Audit log
logEmailSubmission({
timestamp: new Date(),
name,
email,
ip: clientIP,
status: 'success',
spamScore,
});
return new Response(
JSON.stringify({
success: true,
message: 'Your message has been sent. We will respond within 24 hours.'
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('Email send error:', error);
// Log error
logEmailSubmission({
timestamp: new Date(),
error: error.message,
status: 'error',
});
// Don't expose error details to user
return new Response(
JSON.stringify({
error: 'Failed to send message. Please try again later.'
}),
{ status: 500 }
);
}
}
// Helper: Verify reCAPTCHA token
async function verifyCaptcha(token, secretKey) {
try {
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: secretKey,
response: token,
}),
});
const data = await response.json();
return data.success && data.score > 0.5;
} catch (error) {
console.error('CAPTCHA verification error:', error);
return false;
}
}
// Helper: Sanitize HTML
function sanitize(input) {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Helper: Spam detection (simple content analysis)
function detectSpam(content) {
const spamPatterns = [
/viagra|cialis|casino|lottery|prize|click here|buy now/gi,
/http[s]?:\/\/[^\s]{20,}/g, // Long URLs
/<script|<iframe|<img|onclick/gi, // Malicious tags
];
let spamScore = 0;
spamPatterns.forEach(pattern => {
const matches = content.match(pattern);
if (matches) {
spamScore += matches.length * 0.2;
}
});
return Math.min(spamScore, 1.0);
}
// Helper: Audit logging
function logEmailSubmission(data) {
const logPath = process.env.AUDIT_LOG_PATH;
if (logPath) {
const fs = require('fs');
fs.appendFileSync(logPath, JSON.stringify(data) + '\n');
}
}
Frontend (Contact Form with CAPTCHA)
---
// src/components/ContactForm.astro
import ReCAPTCHA from '@astrojs/recaptcha';
const reCaptchaKey = import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY;
---
<form id="contactForm" class="space-y-4" method="POST" action="/api/email">
<input type="hidden" name="captchaToken" id="captchaToken" />
<div>
<label for="name" class="block text-sm font-medium">Name</label>
<input
id="name"
name="name"
type="text"
required
class="w-full px-4 py-2 border rounded"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
required
class="w-full px-4 py-2 border rounded"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium">Phone (optional)</label>
<input
id="phone"
name="phone"
type="tel"
class="w-full px-4 py-2 border rounded"
/>
</div>
<div>
<label for="message" class="block text-sm font-medium">Message</label>
<textarea
id="message"
name="message"
rows="5"
required
class="w-full px-4 py-2 border rounded"
></textarea>
</div>
<div data-sitekey={reCaptchaKey} data-action="submit" class="g-recaptcha"></div>
<button
type="submit"
class="px-6 py-2 bg-primary text-white rounded hover:opacity-90"
>
Send Message
</button>
</form>
<script>
const form = document.getElementById('contactForm');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Get reCAPTCHA token
const token = await grecaptcha.execute(
import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY,
{ action: 'submit' }
);
document.getElementById('captchaToken').value = token;
// Submit form
const formData = new FormData(form);
const response = await fetch('/api/email', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok) {
alert('Message sent successfully!');
form.reset();
} else {
alert(result.error || 'Failed to send message.');
}
});
}
</script>
Step 4: Security Hardening
Never Expose Credentials
// ❌ WRONG - Credentials exposed to client
export const smtpConfig = {
user: 'noreply@domain.com',
password: 'secret123', // EXPOSED!
};
// ✓ CORRECT - Credentials server-side only
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // Server-side env var only
auth: {
user: process.env.SMTP_USER, // Never exposed
pass: process.env.SMTP_PASSWORD, // Never exposed
},
});
Input Validation
// Validate all inputs strictly
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email) && email.length <= 255;
}
function validateName(name) {
return name.length > 0 && name.length <= 100 && !/[<>]/g.test(name);
}
function validateMessage(message) {
return message.length > 5 && message.length <= 5000;
}
Rate Limiting Strategy
- •Max 5 emails per IP per hour (configurable)
- •Resets after window expires
- •Admin/trusted IPs can bypass
- •Log all rate limit events
Spam Detection
Combination approach:
- •Content pattern detection (URLs, spam keywords)
- •reCAPTCHA v3 integration (bot detection)
- •SPF/DKIM/DMARC verification (email authentication)
- •User reputation (first-time sender vs. repeat)
Step 5: Deployment Checklist
- • SMTP credentials configured in
.env.local - •
.env.localadded to.gitignore - • SPF/DKIM/DMARC records verified at domain
- • Test email sent successfully
- • reCAPTCHA keys obtained from Google Cloud Console
- • Rate limiting tested (try 6 submissions)
- • Spam detection tested with keyword payloads
- • Audit logs configured and verified
- • Admin alert emails tested
- • User confirmation emails tested
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| "Authentication failed" | Wrong SMTP credentials | Verify credentials with hosting provider |
| Emails go to spam | SPF/DKIM/DMARC not configured | Set up domain authentication records |
| Rate limit too aggressive | Window too short or limit too low | Adjust RATE_LIMIT_EMAILS_PER_HOUR |
| Legitimate emails flagged as spam | Spam detection too sensitive | Lower spam score threshold or disable |
| Credentials leaking in logs | Logging sensitive data | Never log passwords, only sanitized info |
Key Principle: SMTP credentials belong on the server only. Never expose them to the client. Use rate limiting and CAPTCHA to prevent bot abuse.