From 41dbd77c2f05e4d9f29c144107d417407e617b27 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Tue, 2 Dec 2025 13:42:36 +0000 Subject: [PATCH] feat: responsive HTML email templates with DreamHost SMTP - Add professional HTML email template matching CV aesthetic - Implement multipart emails (HTML + plain text fallback) - Configure DreamHost SMTP with SSL (port 465) - Add "light only" color scheme for Gmail iOS compatibility - Include Reply-To header for easy sender response - Add email validation and integration tests - Update .env.example with DreamHost/Gmail SMTP examples - Add .env to .gitignore to protect credentials - Document email template customization and dark mode approach --- .env | 27 --- .env.example | 40 +++- .gitignore | 5 + doc/17-CONTACT-FORM.md | 99 +++++++-- go.mod | 18 ++ go.sum | 43 ++++ internal/services/email.go | 247 +++++++++++++++++---- internal/services/email_theme.go | 360 +++++++++++++++++++++++++++++++ tests/integration/email_test.go | 274 +++++++++++++++++++++++ 9 files changed, 1019 insertions(+), 94 deletions(-) delete mode 100644 .env create mode 100644 internal/services/email_theme.go create mode 100644 tests/integration/email_test.go diff --git a/.env b/.env deleted file mode 100644 index 2079897..0000000 --- a/.env +++ /dev/null @@ -1,27 +0,0 @@ -# Environment Configuration -# Copy from .env.example and customize as needed - -# Server Configuration -PORT=1999 -HOST=localhost -GO_ENV=development - -# Template Configuration -TEMPLATE_DIR=templates -PARTIALS_DIR=templates/partials -TEMPLATE_HOT_RELOAD=true - -# Data Configuration -DATA_DIR=data - -# Server Timeouts (seconds) -READ_TIMEOUT=15 -WRITE_TIMEOUT=15 - -# Security Configuration -ALLOWED_ORIGINS= - -# Rate Limiter Configuration -# Development: Use direct connection mode (no proxy) -BEHIND_PROXY=false -TRUSTED_PROXY_IP= diff --git a/.env.example b/.env.example index 727271a..7beea89 100644 --- a/.env.example +++ b/.env.example @@ -50,17 +50,35 @@ BEHIND_PROXY=false TRUSTED_PROXY_IP= # Email Configuration (Contact Form) -# For Gmail: -# 1. Enable 2FA in your Google account -# 2. Go to https://myaccount.google.com/apppasswords -# 3. Generate an App Password -# 4. Use that password here (not your regular Gmail password) -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=your-email@gmail.com -SMTP_PASSWORD=your-app-password-here -SMTP_FROM_EMAIL=your-email@gmail.com -CONTACT_EMAIL=txeo.msx@gmail.com +# +# Supported providers: +# +# DreamHost (port 465 - SSL): +# SMTP_HOST=smtp.dreamhost.com +# SMTP_PORT=465 +# SMTP_USER=your-email@yourdomain.com +# SMTP_PASSWORD=your-email-password +# SMTP_FROM_EMAIL=your-email@yourdomain.com +# +# Gmail (port 587 - TLS): +# 1. Enable 2FA in your Google account +# 2. Go to https://myaccount.google.com/apppasswords +# 3. Generate an App Password +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USER=your-email@gmail.com +# SMTP_PASSWORD=your-app-password-here +# SMTP_FROM_EMAIL=your-email@gmail.com +# +# Port 465 = SSL (direct TLS connection) +# Port 587 = TLS/STARTTLS (upgrades to TLS) +# +SMTP_HOST=smtp.dreamhost.com +SMTP_PORT=465 +SMTP_USER=your-email@yourdomain.com +SMTP_PASSWORD=your-password +SMTP_FROM_EMAIL=your-email@yourdomain.com +CONTACT_EMAIL=recipient@example.com # Production Settings # Uncomment for production: diff --git a/.gitignore b/.gitignore index def94e1..245f594 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# Environment variables (contains secrets) +.env +.env.local +.env.*.local + # Binaries cv-server *.exe diff --git a/doc/17-CONTACT-FORM.md b/doc/17-CONTACT-FORM.md index 0bfaf3e..c310c0c 100644 --- a/doc/17-CONTACT-FORM.md +++ b/doc/17-CONTACT-FORM.md @@ -338,26 +338,49 @@ mux.HandleFunc("/contact", contactHandler.ShowContactForm) ## Step 5: Configure Email Service -### Option 1: SMTP (Gmail, Office 365, etc.) +### Option 1: DreamHost SMTP (Recommended) **Environment variables:** ```bash +# DreamHost uses port 465 with SSL (implicit TLS) +SMTP_HOST=smtp.dreamhost.com +SMTP_PORT=465 +SMTP_USER=your-email@yourdomain.com +SMTP_PASSWORD=your-email-password +SMTP_FROM_EMAIL=your-email@yourdomain.com +CONTACT_EMAIL=recipient@example.com +``` + +### Option 2: Gmail SMTP + +**Environment variables:** +```bash +# Gmail uses port 587 with STARTTLS +# Requires App Password (enable 2FA first) +# https://myaccount.google.com/apppasswords SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your-email@gmail.com -SMTP_PASS=your-app-specific-password -SMTP_FROM=noreply@yourdomain.com -CONTACT_EMAIL=contact@yourdomain.com +SMTP_PASSWORD=your-app-specific-password +SMTP_FROM_EMAIL=your-email@gmail.com +CONTACT_EMAIL=recipient@example.com ``` -### Option 2: SendGrid +### Port Reference + +| Port | Protocol | Description | +|------|----------|-------------| +| 465 | SSL/TLS | Implicit TLS - direct encrypted connection | +| 587 | STARTTLS | Plain connection upgraded to TLS | + +### Option 3: SendGrid ```bash SENDGRID_API_KEY=your-api-key CONTACT_EMAIL=contact@yourdomain.com ``` -### Option 3: AWS SES +### Option 4: AWS SES ```bash AWS_REGION=us-east-1 @@ -467,11 +490,14 @@ grep "BLOCKED" /var/log/cv-app/security.log | jq -r '.ip' | sort | uniq -c | sor ```bash GO_ENV=production ALLOWED_ORIGINS=juan.andres.morenorub.io -SMTP_HOST=... -SMTP_PORT=587 -SMTP_USER=... -SMTP_PASS=... -CONTACT_EMAIL=... + +# DreamHost SMTP Configuration +SMTP_HOST=smtp.dreamhost.com +SMTP_PORT=465 +SMTP_USER=info@drolosoft.com +SMTP_PASSWORD=your-password +SMTP_FROM_EMAIL=info@drolosoft.com +CONTACT_EMAIL=your-personal-email@example.com ``` ### 2. Configure Nginx Rate Limiting @@ -500,7 +526,56 @@ sudo vi /etc/logrotate.d/cv-app --- -## That's It! 🎉 +--- + +## Email Templates + +The contact form uses a professional HTML email template that matches the CV's aesthetic. + +### Features + +- **Responsive design** - Works on desktop, tablet, and mobile +- **Light-only color scheme** - Forces consistent rendering across all email clients +- **Bracket aesthetic** - `{ CV Contact }` header matching CV design +- **Green accent color** - `#27ae60` consistent with CV highlights +- **Multipart format** - Includes both HTML and plain text versions +- **Reply-To header** - Automatically set to the sender's email + +### Dark Mode Compatibility + +The template uses `` to prevent +email clients (especially Gmail iOS) from unpredictably inverting colors in dark mode. + +**Why not support dark mode?** +- Gmail iOS ignores CSS `@media (prefers-color-scheme: dark)` rules +- It applies its own color inversion algorithm that breaks designs +- Using "light only" ensures the email looks identical everywhere + +Reference: [How emails react to dark mode](https://www.hteumeuleu.com/2021/emails-react-to-dark-mode/) + +### Template Files + +- `internal/services/email_theme.go` - CSS theme and HTML template +- `internal/services/email.go` - Email service with multipart support + +### Customization + +To customize the email template, edit `email_theme.go`: + +```go +// Change accent color +color: #27ae60; // Green - change to your brand color + +// Change header text +{ CV Contact } + +// Modify footer link +your-domain.com +``` + +--- + +## That's It! All security middleware is already implemented and tested: - ✅ CSRF protection diff --git a/go.mod b/go.mod index 7555b5c..f5cd35c 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,13 @@ require ( require ( dario.cat/mergo v1.0.0 // indirect + github.com/Masterminds/semver v1.4.2 // indirect + github.com/Masterminds/sprig v2.16.0+incompatible // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/PuerkitoBio/goquery v1.5.0 // indirect + github.com/andybalholm/cascadia v1.0.0 // indirect + github.com/aokoli/goutils v1.0.1 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect @@ -24,11 +29,24 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/uuid v1.0.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/huandu/xstrings v1.2.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/matcornic/hermes/v2 v2.1.0 // indirect + github.com/mattn/go-runewidth v0.0.3 // indirect + github.com/olekukonko/tablewriter v0.0.1 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 // indirect + github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect diff --git a/go.sum b/go.sum index 39b95f6..8639455 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,22 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY= +github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= +github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= +github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= +github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg= +github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= @@ -36,6 +46,7 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -48,6 +59,16 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -63,6 +84,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc= +github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI= +github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= @@ -75,26 +102,40 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 h1:L0rPdfzq43+NV8rfIx2kA4iSSLRj2jN5ijYHoeXRwvQ= +github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= +github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy+ad6BM+JCLJb2ZV7/TNiE5l7SNKfumYKgc= +github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -112,11 +153,13 @@ golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/services/email.go b/internal/services/email.go index 2fbd9ae..9397b4a 100644 --- a/internal/services/email.go +++ b/internal/services/email.go @@ -3,11 +3,13 @@ package services import ( "bytes" "crypto/tls" + "encoding/base64" "fmt" - "html/template" + htmltemplate "html/template" "log" "net/smtp" "strings" + texttemplate "text/template" "time" ) @@ -102,7 +104,7 @@ func containsNewlines(s string) bool { return strings.ContainsAny(s, "\r\n") } -// SendContactForm sends a contact form email +// SendContactForm sends a contact form email with HTML and plain text versions func (e *EmailService) SendContactForm(data *ContactFormData) error { // Validate data if err := data.Validate(); err != nil { @@ -114,17 +116,17 @@ func (e *EmailService) SendContactForm(data *ContactFormData) error { if data.Subject != "" { subject += data.Subject } else { - subject += "New Message" + subject += "New Message from " + data.Name } - // Build email body - body, err := e.buildEmailBody(data) + // Build email bodies (HTML and plain text) + htmlBody, textBody, err := e.buildEmailBody(data) if err != nil { return fmt.Errorf("failed to build email body: %w", err) } - // Send email - if err := e.sendEmail(subject, body); err != nil { + // Send multipart email + if err := e.sendMultipartEmail(subject, htmlBody, textBody, data.Email); err != nil { return fmt.Errorf("failed to send email: %w", err) } @@ -134,37 +136,129 @@ func (e *EmailService) SendContactForm(data *ContactFormData) error { return nil } -// buildEmailBody creates the email body from template -func (e *EmailService) buildEmailBody(data *ContactFormData) (string, error) { - emailTemplate := `New contact form submission: - -From: {{.Email}} -Name: {{if .Name}}{{.Name}}{{else}}Not provided{{end}} -Company: {{if .Company}}{{.Company}}{{else}}Not provided{{end}} -Subject: {{if .Subject}}{{.Subject}}{{else}}Not provided{{end}} - -Message: -{{.Message}} - ---- -IP: {{.IP}} -Time: {{.Time.Format "2006-01-02 15:04:05 MST"}} -` - - tmpl, err := template.New("contact").Parse(emailTemplate) - if err != nil { - return "", err - } - - var body bytes.Buffer - if err := tmpl.Execute(&body, data); err != nil { - return "", err - } - - return body.String(), nil +// emailTemplateData wraps ContactFormData with display-safe fields +type emailTemplateData struct { + Name string + Email string + Company string + Subject string + Message string + IP string + Time time.Time } -// sendEmail sends an email using SMTP +// buildEmailBody creates both HTML and plain text email bodies +func (e *EmailService) buildEmailBody(data *ContactFormData) (htmlBody, textBody string, err error) { + // Prepare template data with safe defaults + tmplData := emailTemplateData{ + Name: data.Name, + Email: data.Email, + Company: data.Company, + Subject: data.Subject, + Message: data.Message, + IP: data.IP, + Time: data.Time, + } + + // Set defaults for empty fields + if tmplData.Name == "" { + tmplData.Name = "Not provided" + } + + // Build HTML body + htmlTmpl, err := htmltemplate.New("contact-html").Parse(ContactEmailHTMLTemplate()) + if err != nil { + return "", "", fmt.Errorf("failed to parse HTML template: %w", err) + } + + var htmlBuf bytes.Buffer + if err := htmlTmpl.Execute(&htmlBuf, tmplData); err != nil { + return "", "", fmt.Errorf("failed to execute HTML template: %w", err) + } + + // Build plain text body + textTmpl, err := texttemplate.New("contact-text").Parse(ContactEmailPlainTemplate()) + if err != nil { + return "", "", fmt.Errorf("failed to parse text template: %w", err) + } + + var textBuf bytes.Buffer + if err := textTmpl.Execute(&textBuf, tmplData); err != nil { + return "", "", fmt.Errorf("failed to execute text template: %w", err) + } + + return htmlBuf.String(), textBuf.String(), nil +} + + +// sendMultipartEmail sends an email with both HTML and plain text parts +func (e *EmailService) sendMultipartEmail(subject, htmlBody, textBody, replyTo string) error { + // Validate config + if e.config.SMTPHost == "" || e.config.SMTPPort == "" { + return fmt.Errorf("SMTP configuration incomplete") + } + if e.config.SMTPUser == "" || e.config.SMTPPassword == "" { + return fmt.Errorf("SMTP credentials missing") + } + if e.config.ToEmail == "" { + return fmt.Errorf("recipient email not configured") + } + + from := e.config.FromEmail + if from == "" { + from = e.config.SMTPUser + } + to := e.config.ToEmail + + // Build multipart message + message := e.formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody) + + // SMTP server address + addr := fmt.Sprintf("%s:%s", e.config.SMTPHost, e.config.SMTPPort) + + // Setup authentication + auth := smtp.PlainAuth("", e.config.SMTPUser, e.config.SMTPPassword, e.config.SMTPHost) + + // Connect to SMTP server with TLS + client, err := e.connectSMTP(addr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer client.Close() + + // Authenticate + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + // Set sender and recipient + if err = client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient: %w", err) + } + + // Send message + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("failed to close writer: %w", err) + } + + return client.Quit() +} + +// sendEmail sends an email using SMTP (plain text only - legacy) func (e *EmailService) sendEmail(subject, body string) error { // Validate config if e.config.SMTPHost == "" || e.config.SMTPPort == "" { @@ -233,18 +327,33 @@ func (e *EmailService) sendEmail(subject, body string) error { // connectSMTP establishes an SMTP connection with TLS func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) { - // Connect to server - client, err := smtp.Dial(addr) - if err != nil { - return nil, err - } - - // Start TLS tlsConfig := &tls.Config{ ServerName: e.config.SMTPHost, MinVersion: tls.VersionTLS12, } + // Port 465 uses implicit SSL (direct TLS connection) + // Port 587 uses STARTTLS (plain connection upgraded to TLS) + if e.config.SMTPPort == "465" { + // Implicit SSL: Connect with TLS from the start + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return nil, fmt.Errorf("TLS dial failed: %w", err) + } + client, err := smtp.NewClient(conn, e.config.SMTPHost) + if err != nil { + conn.Close() + return nil, fmt.Errorf("SMTP client creation failed: %w", err) + } + return client, nil + } + + // STARTTLS: Connect plain, then upgrade to TLS + client, err := smtp.Dial(addr) + if err != nil { + return nil, err + } + if err = client.StartTLS(tlsConfig); err != nil { client.Close() return nil, err @@ -253,7 +362,57 @@ func (e *EmailService) connectSMTP(addr string) (*smtp.Client, error) { return client, nil } -// formatMessage formats an email message with proper headers +// formatMultipartMessage formats a multipart email with HTML and plain text +func (e *EmailService) formatMultipartMessage(from, to, replyTo, subject, htmlBody, textBody string) string { + // Generate boundary for multipart + boundary := fmt.Sprintf("----=_Part_%d", time.Now().UnixNano()) + + var message strings.Builder + + // Headers + message.WriteString(fmt.Sprintf("From: %s\r\n", from)) + message.WriteString(fmt.Sprintf("To: %s\r\n", to)) + if replyTo != "" { + message.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo)) + } + message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + message.WriteString("MIME-Version: 1.0\r\n") + message.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) + message.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().Format(time.RFC1123Z))) + message.WriteString("\r\n") + + // Plain text part + message.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + message.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") + message.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + message.WriteString("\r\n") + message.WriteString(textBody) + message.WriteString("\r\n") + + // HTML part + message.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + message.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n") + message.WriteString("Content-Transfer-Encoding: base64\r\n") + message.WriteString("\r\n") + // Encode HTML as base64 for safe transmission + encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody)) + // Split into 76-character lines per RFC 2045 + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + message.WriteString(encoded[i:end]) + message.WriteString("\r\n") + } + + // End boundary + message.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) + + return message.String() +} + +// formatMessage formats an email message with proper headers (plain text only) func (e *EmailService) formatMessage(from, to, subject, body string) string { headers := make(map[string]string) headers["From"] = from diff --git a/internal/services/email_theme.go b/internal/services/email_theme.go new file mode 100644 index 0000000..e4cb922 --- /dev/null +++ b/internal/services/email_theme.go @@ -0,0 +1,360 @@ +package services + +// CVEmailTheme provides a custom Hermes theme matching the CV's aesthetic +// Features: +// - Clean, minimal design with professional typography +// - Green accent color (#27ae60) matching CV highlights +// - Bracket aesthetic { } for headers +// - Responsive layout for all devices +// - Dark mode support via @media queries + +// CVThemeCSS returns the CSS for the CV email theme +func CVThemeCSS() string { + return ` +/* CV Email Theme - Responsive & Clean */ + +/* Reset and Base */ +body, html { + margin: 0; + padding: 0; + width: 100%; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Quicksand', Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + color: #333333; + background-color: #f5f5f5; +} + +/* Container */ +.email-container { + max-width: 600px; + margin: 0 auto; + padding: 20px; +} + +.email-wrapper { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +/* Header */ +.email-header { + background: linear-gradient(135deg, #2b2b2b 0%, #1a1a1a 100%); + padding: 30px 40px; + text-align: center; +} + +.email-logo { + color: #ffffff; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + margin: 0; +} + +.email-logo .bracket { + color: #27ae60; + font-weight: 700; +} + +/* Body */ +.email-body { + padding: 40px; +} + +.email-greeting { + font-size: 20px; + font-weight: 600; + color: #1a1a1a; + margin-bottom: 20px; +} + +.email-intro { + font-size: 16px; + color: #444444; + margin-bottom: 30px; + line-height: 1.7; +} + +/* Content Card */ +.content-card { + background-color: #fafafa; + border-left: 4px solid #27ae60; + border-radius: 0 6px 6px 0; + padding: 25px; + margin: 25px 0; +} + +.content-card-header { + font-size: 14px; + font-weight: 600; + color: #27ae60; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 15px; +} + +/* Data Table */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table tr { + border-bottom: 1px solid #eeeeee; +} + +.data-table tr:last-child { + border-bottom: none; +} + +.data-table td { + padding: 12px 0; + vertical-align: top; +} + +.data-table .label { + font-weight: 600; + color: #666666; + width: 100px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.data-table .value { + color: #333333; + font-size: 15px; +} + +/* Message Box */ +.message-box { + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 20px; + margin-top: 15px; + font-size: 15px; + line-height: 1.7; + color: #333333; + white-space: pre-wrap; +} + +/* Metadata */ +.email-metadata { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eeeeee; + font-size: 12px; + color: #999999; +} + +.email-metadata span { + display: inline-block; + margin-right: 20px; +} + +/* Footer */ +.email-footer { + background-color: #fafafa; + padding: 30px 40px; + text-align: center; + border-top: 1px solid #eeeeee; +} + +.email-footer-text { + font-size: 13px; + color: #888888; + margin: 0; +} + +.email-footer-link { + color: #27ae60; + text-decoration: none; +} + +.email-footer-link:hover { + text-decoration: underline; +} + +/* Bracket Decoration */ +.bracket-wrap { + display: inline; +} + +.bracket-wrap::before { + content: '{ '; + color: #27ae60; + font-weight: 700; +} + +.bracket-wrap::after { + content: ' }'; + color: #27ae60; + font-weight: 700; +} + +/* Responsive */ +@media only screen and (max-width: 600px) { + .email-container { + padding: 10px; + } + + .email-header { + padding: 25px 20px; + } + + .email-body { + padding: 25px 20px; + } + + .email-footer { + padding: 20px; + } + + .content-card { + padding: 20px 15px; + } + + .data-table .label { + display: block; + padding-bottom: 4px; + } + + .data-table .value { + display: block; + padding-bottom: 8px; + } +} + +/* Dark Mode: Disabled via "light only" color-scheme meta tag + * Gmail iOS aggressively inverts colors in dark mode, ignoring CSS. + * Using "light only" forces consistent rendering across all clients. + * See: https://www.hteumeuleu.com/2021/emails-react-to-dark-mode/ + */ +` +} + +// ContactEmailHTMLTemplate returns the HTML template for contact form emails +// Note: Uses "light only" color scheme to prevent Gmail iOS dark mode from +// inverting colors unpredictably. This ensures consistent appearance across all clients. +func ContactEmailHTMLTemplate() string { + return ` + + + + + + + + + New Contact Form Message + + + +
+ +
+ +` +} + +// ContactEmailPlainTemplate returns the plain text template for contact form emails +func ContactEmailPlainTemplate() string { + return ` +═══════════════════════════════════════════════════════════════ + { CV CONTACT } +═══════════════════════════════════════════════════════════════ + +NEW MESSAGE RECEIVED +──────────────────────────────────────────────────────────────── + +CONTACT DETAILS +─────────────── +From: {{.Name}} +Email: {{.Email}} +{{if .Company}}Company: {{.Company}} +{{end}}{{if .Subject}}Subject: {{.Subject}} +{{end}} + +MESSAGE +─────────────── +{{.Message}} + +──────────────────────────────────────────────────────────────── +IP: {{.IP}} +Time: {{.Time.Format "Jan 02, 2006 at 15:04 MST"}} +──────────────────────────────────────────────────────────────── +Sent from: juan.andres.morenorub.io +═══════════════════════════════════════════════════════════════ +` +} diff --git a/tests/integration/email_test.go b/tests/integration/email_test.go new file mode 100644 index 0000000..7dca28b --- /dev/null +++ b/tests/integration/email_test.go @@ -0,0 +1,274 @@ +package integration_test + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "os" + "testing" + "time" + + "github.com/juanatsap/cv-site/internal/services" +) + +// TestSMTPConnection tests that SMTP credentials are valid and connection works +// This test requires valid SMTP credentials in environment variables +// Run with: go test -v ./tests/integration/... -run TestSMTPConnection +func TestSMTPConnection(t *testing.T) { + // Skip in CI or when credentials aren't available + host := os.Getenv("SMTP_HOST") + port := os.Getenv("SMTP_PORT") + user := os.Getenv("SMTP_USER") + pass := os.Getenv("SMTP_PASSWORD") + + if host == "" || user == "" || pass == "" { + t.Skip("Skipping SMTP test: SMTP credentials not configured") + } + + addr := fmt.Sprintf("%s:%s", host, port) + + t.Run("TLS_Connection", func(t *testing.T) { + tlsConfig := &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + } + + if port == "465" { + // Implicit SSL + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + t.Fatalf("TLS dial failed: %v", err) + } + defer conn.Close() + t.Log("TLS connection established (port 465 - implicit SSL)") + } else { + // STARTTLS + client, err := smtp.Dial(addr) + if err != nil { + t.Fatalf("SMTP dial failed: %v", err) + } + defer client.Close() + + if err := client.StartTLS(tlsConfig); err != nil { + t.Fatalf("STARTTLS failed: %v", err) + } + t.Log("TLS connection established (STARTTLS)") + } + }) + + t.Run("SMTP_Authentication", func(t *testing.T) { + tlsConfig := &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + } + + var client *smtp.Client + var err error + + if port == "465" { + // Implicit SSL - connect with TLS from the start + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + t.Fatalf("TLS dial failed: %v", err) + } + defer conn.Close() + + client, err = smtp.NewClient(conn, host) + if err != nil { + t.Fatalf("SMTP client creation failed: %v", err) + } + } else { + // STARTTLS + client, err = smtp.Dial(addr) + if err != nil { + t.Fatalf("SMTP dial failed: %v", err) + } + + if err := client.StartTLS(tlsConfig); err != nil { + t.Fatalf("STARTTLS failed: %v", err) + } + } + defer client.Close() + + auth := smtp.PlainAuth("", user, pass, host) + if err := client.Auth(auth); err != nil { + t.Fatalf("SMTP authentication failed: %v", err) + } + + t.Log("SMTP authentication successful") + _ = client.Quit() + }) +} + +// TestEmailServiceSend tests actual email sending +// This will send a real test email - use sparingly +// Run with: go test -v ./tests/integration/... -run TestEmailServiceSend +func TestEmailServiceSend(t *testing.T) { + // Skip in CI or when credentials aren't available + host := os.Getenv("SMTP_HOST") + port := os.Getenv("SMTP_PORT") + user := os.Getenv("SMTP_USER") + pass := os.Getenv("SMTP_PASSWORD") + from := os.Getenv("SMTP_FROM_EMAIL") + to := os.Getenv("CONTACT_EMAIL") + + if host == "" || user == "" || pass == "" { + t.Skip("Skipping email send test: SMTP credentials not configured") + } + + if from == "" { + from = user + } + if to == "" { + t.Skip("Skipping email send test: CONTACT_EMAIL not configured") + } + + config := &services.EmailConfig{ + SMTPHost: host, + SMTPPort: port, + SMTPUser: user, + SMTPPassword: pass, + FromEmail: from, + ToEmail: to, + } + + emailService := services.NewEmailService(config) + + testData := &services.ContactFormData{ + Email: "test-sender@example.com", + Name: "Integration Test", + Company: "Test Suite", + Subject: "Email Integration Test", + Message: "This is an automated test email sent by the integration test suite. If you receive this, the email configuration is working correctly.", + IP: "127.0.0.1", + Time: time.Now(), + } + + t.Run("SendContactForm", func(t *testing.T) { + err := emailService.SendContactForm(testData) + if err != nil { + t.Fatalf("Failed to send email: %v", err) + } + t.Logf("Test email sent successfully to %s", to) + }) +} + +// TestEmailServiceValidation tests that the email service properly validates input +func TestEmailServiceValidation(t *testing.T) { + config := &services.EmailConfig{ + SMTPHost: "smtp.test.com", + SMTPPort: "465", + SMTPUser: "test@test.com", + SMTPPassword: "password", + FromEmail: "from@test.com", + ToEmail: "to@test.com", + } + + emailService := services.NewEmailService(config) + + tests := []struct { + name string + data *services.ContactFormData + wantErr bool + errMsg string + }{ + { + name: "valid data", + data: &services.ContactFormData{ + Email: "valid@example.com", + Name: "Valid User", + Message: "This is a valid message with more than 10 characters.", + Time: time.Now(), + }, + wantErr: false, + }, + { + name: "missing email", + data: &services.ContactFormData{ + Name: "No Email", + Message: "This is a valid message.", + Time: time.Now(), + }, + wantErr: true, + errMsg: "email is required", + }, + { + name: "invalid email format", + data: &services.ContactFormData{ + Email: "notanemail", + Name: "Bad Email", + Message: "This is a valid message.", + Time: time.Now(), + }, + wantErr: true, + errMsg: "invalid email format", + }, + { + name: "missing message", + data: &services.ContactFormData{ + Email: "valid@example.com", + Name: "No Message", + Time: time.Now(), + }, + wantErr: true, + errMsg: "message is required", + }, + { + name: "message too short", + data: &services.ContactFormData{ + Email: "valid@example.com", + Name: "Short Msg", + Message: "Hi", + Time: time.Now(), + }, + wantErr: true, + errMsg: "message too short", + }, + { + name: "email with newlines (header injection)", + data: &services.ContactFormData{ + Email: "test@example.com\nBcc: attacker@evil.com", + Name: "Attacker", + Message: "Trying to inject headers.", + Time: time.Now(), + }, + wantErr: true, + errMsg: "prohibited characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We can't actually send since we don't have real SMTP + // but we can test validation + err := tt.data.Validate() + + if tt.wantErr { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.errMsg) + } else if tt.errMsg != "" && !containsString(err.Error(), tt.errMsg) { + t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } + + _ = emailService // Avoid unused variable warning +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStringHelper(s, substr)) +} + +func containsStringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}