diff --git a/.env.example b/.env.example
index dbf2087..727271a 100644
--- a/.env.example
+++ b/.env.example
@@ -49,6 +49,19 @@ ALLOWED_ORIGINS=
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
+
# Production Settings
# Uncomment for production:
# GO_ENV=production
diff --git a/data/ui-en.json b/data/ui-en.json
index 2ebac50..d2d3a36 100644
--- a/data/ui-en.json
+++ b/data/ui-en.json
@@ -159,6 +159,34 @@
"viewSource": "View Project in Github",
"viewSourceSubtext": "Want to know how it's built?"
},
+ "contactModal": {
+ "title": "Get in Touch",
+ "subtitle": "Let's connect!",
+ "description": "Have a question or interested in working together? Fill out the form below and I'll get back to you as soon as possible.",
+ "close": "Close",
+ "form": {
+ "email": "Email",
+ "emailPlaceholder": "your.email@example.com",
+ "name": "Name",
+ "namePlaceholder": "Your name",
+ "company": "Company",
+ "companyPlaceholder": "Company",
+ "subject": "Subject",
+ "subjectPlaceholder": "Subject",
+ "message": "Message",
+ "messagePlaceholder": "Your message...",
+ "submit": "Send Message",
+ "sending": "Sending...",
+ "note": "* Required fields"
+ },
+ "success": {
+ "title": "Message Sent!",
+ "message": "Thank you for your message. I'll get back to you soon."
+ },
+ "error": {
+ "title": "Error"
+ }
+ },
"widgets": {
"backToTop": {
"ariaLabel": "Back to top",
@@ -196,9 +224,15 @@
"title": "Preparing PDF",
"closeLabel": "Close notification"
},
+ "contact": {
+ "ariaLabel": "Contact me",
+ "tooltip": "Contact me"
+ },
"actionButtons": {
"downloadPdf": "Download as PDF",
- "printFriendly": "Print Friendly"
+ "printFriendly": "Print Friendly",
+ "plainText": "Plain Text",
+ "contact": "Contact"
}
}
}
diff --git a/data/ui-es.json b/data/ui-es.json
index 2e1277d..3552e1c 100644
--- a/data/ui-es.json
+++ b/data/ui-es.json
@@ -159,6 +159,34 @@
"viewSource": "Ver proyecto en Github",
"viewSourceSubtext": "¿Quieres saber cómo está hecho?"
},
+ "contactModal": {
+ "title": "Ponerse en contacto",
+ "subtitle": "¡Conectemos!",
+ "description": "¿Tienes alguna pregunta o estás interesado en trabajar juntos? Rellena el formulario a continuación y me pondré en contacto contigo lo antes posible.",
+ "close": "Cerrar",
+ "form": {
+ "email": "Correo electrónico",
+ "emailPlaceholder": "tu.email@ejemplo.com",
+ "name": "Nombre",
+ "namePlaceholder": "Tu nombre",
+ "company": "Empresa",
+ "companyPlaceholder": "Empresa",
+ "subject": "Asunto",
+ "subjectPlaceholder": "Asunto",
+ "message": "Mensaje",
+ "messagePlaceholder": "Tu mensaje...",
+ "submit": "Enviar mensaje",
+ "sending": "Enviando...",
+ "note": "* Campos obligatorios"
+ },
+ "success": {
+ "title": "¡Mensaje enviado!",
+ "message": "Gracias por tu mensaje. Me pondré en contacto contigo pronto."
+ },
+ "error": {
+ "title": "Error"
+ }
+ },
"widgets": {
"backToTop": {
"ariaLabel": "Volver arriba",
@@ -196,9 +224,15 @@
"title": "Preparando PDF",
"closeLabel": "Cerrar notificación"
},
+ "contact": {
+ "ariaLabel": "Contáctame",
+ "tooltip": "Contáctame"
+ },
"actionButtons": {
"downloadPdf": "Descargar como PDF",
- "printFriendly": "Imprimir amigable"
+ "printFriendly": "Imprimir amigable",
+ "plainText": "Texto Plano",
+ "contact": "Contacto"
}
}
}
diff --git a/go.mod b/go.mod
index 77cd396..4cc6c72 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,9 @@ go 1.25.1
require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
+ github.com/go-git/go-git/v5 v5.16.4
github.com/joho/godotenv v1.5.1
+ github.com/k3a/html2text v1.2.1
)
require (
@@ -18,7 +20,6 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
- github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
diff --git a/go.sum b/go.sum
index 2168fda..65d1184 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
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/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/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=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
@@ -16,13 +20,20 @@ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZ
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
+github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
+github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
+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-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
@@ -35,39 +46,68 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
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=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
+github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+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/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=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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/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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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/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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20190215142949-d0b11bdaac8a/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=
@@ -78,12 +118,21 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+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=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/handlers/contact.go b/internal/handlers/contact.go
new file mode 100644
index 0000000..ed46191
--- /dev/null
+++ b/internal/handlers/contact.go
@@ -0,0 +1,215 @@
+package handlers
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/juanatsap/cv-site/internal/services"
+ "github.com/juanatsap/cv-site/internal/templates"
+)
+
+// ContactHandler handles contact form submissions
+type ContactHandler struct {
+ templates *templates.Manager
+ emailService *services.EmailService
+}
+
+// NewContactHandler creates a new contact handler
+func NewContactHandler(tmpl *templates.Manager, emailService *services.EmailService) *ContactHandler {
+ return &ContactHandler{
+ templates: tmpl,
+ emailService: emailService,
+ }
+}
+
+// ContactFormRequest represents the contact form submission
+type ContactFormRequest struct {
+ Email string
+ Name string
+ Company string
+ Subject string
+ Message string
+ Honeypot string // Hidden field - should be empty
+ SubmitTime time.Time // Set by client, checked server-side
+ CSRFToken string
+}
+
+// Submit handles POST /api/contact
+func (h *ContactHandler) Submit(w http.ResponseWriter, r *http.Request) {
+ // Only accept POST
+ if r.Method != http.MethodPost {
+ HandleError(w, r, NewAppError(nil, "Method not allowed", http.StatusMethodNotAllowed, false))
+ return
+ }
+
+ // Parse form
+ if err := r.ParseForm(); err != nil {
+ log.Printf("ERROR parsing contact form: %v", err)
+ h.renderError(w, r, "Invalid form data. Please try again.")
+ return
+ }
+
+ // Extract form data
+ req := &ContactFormRequest{
+ Email: strings.TrimSpace(r.FormValue("email")),
+ Name: strings.TrimSpace(r.FormValue("name")),
+ Company: strings.TrimSpace(r.FormValue("company")),
+ Subject: strings.TrimSpace(r.FormValue("subject")),
+ Message: strings.TrimSpace(r.FormValue("message")),
+ Honeypot: r.FormValue("website"), // Honeypot field
+ CSRFToken: r.FormValue("csrf_token"),
+ }
+
+ // Bot protection: Honeypot check
+ if req.Honeypot != "" {
+ log.Printf("SECURITY: Honeypot triggered from IP %s", getClientIP(r))
+ // Don't reveal that we detected a bot - just show success
+ h.renderSuccess(w, r)
+ return
+ }
+
+ // Bot protection: Timing check
+ submitTimeStr := r.FormValue("submit_time")
+ if submitTimeStr != "" {
+ // Parse submit time (Unix timestamp in milliseconds)
+ var submitTimeMs int64
+ if _, err := fmt.Sscanf(submitTimeStr, "%d", &submitTimeMs); err == nil {
+ submitTime := time.Unix(0, submitTimeMs*int64(time.Millisecond))
+ elapsed := time.Since(submitTime)
+
+ // Reject if submitted too fast (< 2 seconds)
+ if elapsed < 2*time.Second {
+ log.Printf("SECURITY: Form submitted too fast (%v) from IP %s", elapsed, getClientIP(r))
+ h.renderError(w, r, "Please take your time filling out the form.")
+ return
+ }
+ }
+ }
+
+ // CSRF validation is handled by middleware
+
+ // Validate required fields
+ if req.Email == "" {
+ h.renderError(w, r, "Email address is required.")
+ return
+ }
+ if req.Message == "" {
+ h.renderError(w, r, "Message is required.")
+ return
+ }
+
+ // Create email data
+ emailData := &services.ContactFormData{
+ Email: req.Email,
+ Name: req.Name,
+ Company: req.Company,
+ Subject: req.Subject,
+ Message: req.Message,
+ IP: getClientIP(r),
+ Time: time.Now(),
+ }
+
+ // Send email
+ if err := h.emailService.SendContactForm(emailData); err != nil {
+ log.Printf("ERROR sending contact email: %v", err)
+
+ // Check if it's a validation error or server error
+ if strings.Contains(err.Error(), "validation failed") {
+ h.renderError(w, r, err.Error())
+ return
+ }
+
+ // Internal server error
+ h.renderError(w, r, "Failed to send message. Please try again later.")
+ return
+ }
+
+ // Log successful submission (without sensitive data)
+ log.Printf("Contact form submitted successfully from %s (%s)", req.Email, getClientIP(r))
+
+ // Render success response
+ h.renderSuccess(w, r)
+}
+
+// renderSuccess renders the success partial
+func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+
+ tmpl, err := h.templates.Render("contact_success.html")
+ if err != nil {
+ log.Printf("ERROR loading success template: %v", err)
+ // Fallback to simple HTML
+ _, _ = w.Write([]byte(`
+
Message Sent!
+
Thank you for your message. I'll get back to you soon.
+
`))
+ return
+ }
+
+ if err := tmpl.Execute(w, nil); err != nil {
+ log.Printf("ERROR rendering success template: %v", err)
+ _, _ = w.Write([]byte(`
+
Message Sent!
+
Thank you for your message. I'll get back to you soon.
+
`))
+ }
+}
+
+// renderError renders the error partial
+func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+
+ data := map[string]interface{}{
+ "Message": message,
+ }
+
+ tmpl, err := h.templates.Render("contact_error.html")
+ if err != nil {
+ log.Printf("ERROR loading error template: %v", err)
+ // Fallback to simple HTML
+ _, _ = w.Write([]byte(`
+
Error
+
` + message + `
+
`))
+ return
+ }
+
+ if err := tmpl.Execute(w, data); err != nil {
+ log.Printf("ERROR rendering error template: %v", err)
+ _, _ = w.Write([]byte(`
+
Error
+
` + message + `
+
`))
+ }
+}
+
+// getClientIP extracts the client IP address from the request
+func getClientIP(r *http.Request) string {
+ // Check X-Forwarded-For header (for proxies)
+ ip := r.Header.Get("X-Forwarded-For")
+ if ip != "" {
+ // X-Forwarded-For can contain multiple IPs, take the first one
+ ips := strings.Split(ip, ",")
+ return strings.TrimSpace(ips[0])
+ }
+
+ // Check X-Real-IP header
+ ip = r.Header.Get("X-Real-IP")
+ if ip != "" {
+ return ip
+ }
+
+ // Fallback to RemoteAddr
+ ip = r.RemoteAddr
+ // Remove port if present
+ if colonIndex := strings.LastIndex(ip, ":"); colonIndex != -1 {
+ ip = ip[:colonIndex]
+ }
+
+ return ip
+}
diff --git a/internal/handlers/cv_contact.go b/internal/handlers/cv_contact.go
new file mode 100644
index 0000000..1e3c3d3
--- /dev/null
+++ b/internal/handlers/cv_contact.go
@@ -0,0 +1,253 @@
+package handlers
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ uimodel "github.com/juanatsap/cv-site/internal/models/ui"
+)
+
+// ==============================================================================
+// CONTACT FORM SUBMISSION HANDLER
+// Part of CVHandler - handles POST /api/contact
+// ==============================================================================
+
+// ContactFormData represents the contact form submission
+type ContactFormData struct {
+ Email string
+ Name string
+ Company string
+ Subject string
+ Message string
+ Website string // Honeypot field - should be empty
+ FormLoadedAt string // Timing field - Unix timestamp in milliseconds
+ Lang string
+}
+
+// HandleContact handles contact form submissions
+func (h *CVHandler) HandleContact(w http.ResponseWriter, r *http.Request) {
+ // Only accept POST requests
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse form data
+ if err := r.ParseForm(); err != nil {
+ log.Printf("Error parsing contact form: %v", err)
+ h.renderContactError(w, r, "Invalid form data. Please try again.")
+ return
+ }
+
+ // Get language from query parameter
+ lang := r.URL.Query().Get("lang")
+ if lang == "" {
+ lang = "en"
+ }
+
+ // Validate language
+ if lang != "en" && lang != "es" {
+ lang = "en"
+ }
+
+ // Extract form data
+ formData := &ContactFormData{
+ Email: strings.TrimSpace(r.FormValue("email")),
+ Name: strings.TrimSpace(r.FormValue("name")),
+ Company: strings.TrimSpace(r.FormValue("company")),
+ Subject: strings.TrimSpace(r.FormValue("subject")),
+ Message: strings.TrimSpace(r.FormValue("message")),
+ Website: r.FormValue("website"), // Honeypot
+ FormLoadedAt: r.FormValue("form_loaded_at"), // Timing
+ Lang: lang,
+ }
+
+ // Validate form data (includes bot protection)
+ if err := validateContactForm(formData, r); err != nil {
+ log.Printf("Contact form validation failed from IP %s: %v", getClientIP(r), err)
+
+ // Don't reveal specific errors to potential bots
+ if strings.Contains(err.Error(), "spam detected") {
+ // Silently succeed for bots
+ h.renderContactSuccess(w, r, lang)
+ return
+ }
+
+ h.renderContactError(w, r, err.Error())
+ return
+ }
+
+ // Log the contact form submission (in production, send email or save to database)
+ log.Printf("Contact form submission from %s (IP: %s)", formData.Email, getClientIP(r))
+ log.Printf(" Name: %s, Company: %s", formData.Name, formData.Company)
+ log.Printf(" Subject: %s", formData.Subject)
+ log.Printf(" Message length: %d characters", len(formData.Message))
+
+ // TODO: Implement actual email sending or database storage here
+ // For now, we just log and return success
+
+ // Render success response
+ h.renderContactSuccess(w, r, lang)
+}
+
+// validateContactForm validates the contact form data and performs bot protection
+func validateContactForm(data *ContactFormData, r *http.Request) error {
+ // Bot protection: Honeypot check - website field should be empty
+ if data.Website != "" {
+ return fmt.Errorf("spam detected: honeypot field filled")
+ }
+
+ // Bot protection: Timing check - form should take at least 2 seconds to fill
+ if data.FormLoadedAt != "" {
+ loadedAt, err := strconv.ParseInt(data.FormLoadedAt, 10, 64)
+ if err == nil {
+ now := time.Now().UnixMilli()
+ elapsed := now - loadedAt
+
+ // Form filled too quickly (< 2 seconds) - likely a bot
+ if elapsed < 2000 {
+ return fmt.Errorf("spam detected: form filled too quickly (%dms)", elapsed)
+ }
+
+ // Form took too long (> 1 hour) - timestamp expired
+ if elapsed > 3600000 {
+ return fmt.Errorf("form session expired, please refresh and try again")
+ }
+ }
+ }
+
+ // Required field validation
+ if data.Email == "" {
+ return fmt.Errorf("email address is required")
+ }
+
+ if data.Message == "" {
+ return fmt.Errorf("message is required")
+ }
+
+ // Email format validation (basic)
+ if !strings.Contains(data.Email, "@") || !strings.Contains(data.Email, ".") {
+ return fmt.Errorf("invalid email format")
+ }
+
+ // Message length validation
+ if len(data.Message) < 10 {
+ return fmt.Errorf("message is too short (minimum 10 characters)")
+ }
+
+ if len(data.Message) > 5000 {
+ return fmt.Errorf("message is too long (maximum 5000 characters)")
+ }
+
+ return nil
+}
+
+// renderContactSuccess renders the contact success partial
+func (h *CVHandler) renderContactSuccess(w http.ResponseWriter, r *http.Request, lang string) {
+ // Load UI data for the specified language
+ ui, err := uimodel.LoadUI(lang)
+ if err != nil {
+ log.Printf("Error loading UI data: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Create template data
+ data := map[string]interface{}{
+ "UI": ui,
+ "Lang": lang,
+ }
+
+ // Render the success template
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+
+ tmpl, err := h.templates.Render("contact-success")
+ if err != nil {
+ log.Printf("Error loading contact success template: %v", err)
+ // Fallback to simple HTML
+ _, _ = w.Write([]byte(``))
+ return
+ }
+
+ if err := tmpl.Execute(w, data); err != nil {
+ log.Printf("Error rendering contact success template: %v", err)
+ _, _ = w.Write([]byte(``))
+ }
+}
+
+// renderContactError renders the contact error partial
+func (h *CVHandler) renderContactError(w http.ResponseWriter, r *http.Request, errorMessage string) {
+ // Get language from query parameter
+ lang := r.URL.Query().Get("lang")
+ if lang == "" {
+ lang = "en"
+ }
+
+ // Validate language
+ if lang != "en" && lang != "es" {
+ lang = "en"
+ }
+
+ // Load UI data for the specified language
+ ui, err := uimodel.LoadUI(lang)
+ if err != nil {
+ log.Printf("Error loading UI data: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ // Create template data
+ data := map[string]interface{}{
+ "UI": ui,
+ "Lang": lang,
+ "ErrorMessage": errorMessage,
+ }
+
+ // Render the error template
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+
+ tmpl, err := h.templates.Render("contact-error")
+ if err != nil {
+ log.Printf("Error loading contact error template: %v", err)
+ // Fallback to simple HTML
+ _, _ = w.Write([]byte(``))
+ return
+ }
+
+ if err := tmpl.Execute(w, data); err != nil {
+ log.Printf("Error rendering contact error template: %v", err)
+ _, _ = w.Write([]byte(``))
+ }
+}
+
+// Note: getClientIP is defined in contact.go
diff --git a/internal/handlers/cv_text.go b/internal/handlers/cv_text.go
new file mode 100644
index 0000000..f919e6e
--- /dev/null
+++ b/internal/handlers/cv_text.go
@@ -0,0 +1,68 @@
+package handlers
+
+import (
+ "bytes"
+ "log"
+ "net/http"
+
+ "github.com/k3a/html2text"
+)
+
+// ==============================================================================
+// PLAIN TEXT HANDLER
+// Converts HTML CV to readable plain text for terminal/AI consumption
+// ==============================================================================
+
+// PlainText renders the CV as plain text
+// Useful for: curl users, AI crawlers, accessibility, copy-paste
+func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
+ // Get language from query parameter, default to English
+ lang := r.URL.Query().Get("lang")
+ if lang == "" {
+ lang = "en"
+ }
+
+ // Validate language
+ if lang != "en" && lang != "es" {
+ http.Error(w, "Unsupported language. Use 'en' or 'es'", http.StatusBadRequest)
+ return
+ }
+
+ // Prepare template data using shared helper
+ data, err := h.prepareTemplateData(lang)
+ if err != nil {
+ log.Printf("PlainText: Failed to load CV data: %v", err)
+ http.Error(w, "Failed to load CV data", http.StatusInternalServerError)
+ return
+ }
+
+ // Add preferences for full CV display (show everything)
+ data["CVLengthClass"] = "cv-long"
+ data["ShowIcons"] = false // Icons don't render in text
+ data["ThemeClean"] = false
+
+ // Render HTML template to buffer
+ tmpl, err := h.templates.Render("index.html")
+ if err != nil {
+ log.Printf("PlainText: Failed to load template: %v", err)
+ http.Error(w, "Failed to load template", http.StatusInternalServerError)
+ return
+ }
+
+ var htmlBuffer bytes.Buffer
+ if err := tmpl.Execute(&htmlBuffer, data); err != nil {
+ log.Printf("PlainText: Failed to execute template: %v", err)
+ http.Error(w, "Failed to render template: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Convert HTML to plain text
+ text := html2text.HTML2Text(htmlBuffer.String())
+
+ // Set response headers
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+
+ // Write plain text response
+ _, _ = w.Write([]byte(text))
+}
diff --git a/internal/middleware/browser_only.go b/internal/middleware/browser_only.go
new file mode 100644
index 0000000..16a2bde
--- /dev/null
+++ b/internal/middleware/browser_only.go
@@ -0,0 +1,120 @@
+package middleware
+
+import (
+ "log"
+ "net/http"
+ "strings"
+)
+
+const (
+ // Custom header that browser JavaScript must set
+ browserHeaderName = "X-Requested-With"
+ browserHeaderValue = "XMLHttpRequest"
+)
+
+// BrowserOnly restricts endpoint access to browser requests only
+// Blocks curl, Postman, and other HTTP clients
+// Requires:
+// 1. User-Agent header (not curl/wget/etc)
+// 2. Referer or Origin header from same domain
+// 3. Custom header set by JavaScript (X-Requested-With: XMLHttpRequest)
+func BrowserOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Check 1: User-Agent validation
+ userAgent := r.Header.Get("User-Agent")
+ if userAgent == "" || isBotUserAgent(userAgent) {
+ log.Printf("SECURITY: Blocked non-browser User-Agent from IP %s: %s", getRequestIP(r), userAgent)
+ http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
+ return
+ }
+
+ // Check 2: Require Referer or Origin header
+ referer := r.Header.Get("Referer")
+ origin := r.Header.Get("Origin")
+
+ if referer == "" && origin == "" {
+ log.Printf("SECURITY: Blocked request without Referer/Origin from IP %s", getRequestIP(r))
+ http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
+ return
+ }
+
+ // Check 3: Custom header validation (set by JavaScript)
+ // For HTMX requests, check HX-Request header
+ // For fetch/XMLHttpRequest, check X-Requested-With header
+ hasHTMXHeader := r.Header.Get("HX-Request") == "true"
+ hasXMLHTTPHeader := r.Header.Get(browserHeaderName) == browserHeaderValue
+ hasCustomBrowserHeader := r.Header.Get("X-Browser-Request") == "true"
+
+ if !hasHTMXHeader && !hasXMLHTTPHeader && !hasCustomBrowserHeader {
+ log.Printf("SECURITY: Blocked request without browser headers from IP %s", getRequestIP(r))
+ http.Error(w, "Forbidden: Browser access only", http.StatusForbidden)
+ return
+ }
+
+ // All checks passed
+ next.ServeHTTP(w, r)
+ })
+}
+
+// isBotUserAgent checks if the User-Agent is from a known HTTP client/bot
+func isBotUserAgent(ua string) bool {
+ ua = strings.ToLower(ua)
+
+ // Known HTTP clients and tools
+ blockedAgents := []string{
+ "curl",
+ "wget",
+ "postman",
+ "insomnia",
+ "httpie",
+ "python-requests",
+ "python-urllib",
+ "java",
+ "okhttp",
+ "go-http-client",
+ "axios", // Node.js axios without proper browser headers
+ "node-fetch",
+ "apache-httpclient",
+ "libwww-perl",
+ "php",
+ "ruby",
+ "scrapy",
+ "bot",
+ "crawler",
+ "spider",
+ }
+
+ for _, blocked := range blockedAgents {
+ if strings.Contains(ua, blocked) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// getRequestIP extracts the client IP from the request
+func getRequestIP(r *http.Request) string {
+ // Try X-Forwarded-For first (for proxies/load balancers)
+ ip := r.Header.Get("X-Forwarded-For")
+ if ip != "" {
+ // Take first IP if multiple
+ ips := strings.Split(ip, ",")
+ return strings.TrimSpace(ips[0])
+ }
+
+ // Try X-Real-IP
+ ip = r.Header.Get("X-Real-IP")
+ if ip != "" {
+ return ip
+ }
+
+ // Fallback to RemoteAddr
+ ip = r.RemoteAddr
+ // Remove port
+ if idx := strings.LastIndex(ip, ":"); idx != -1 {
+ ip = ip[:idx]
+ }
+
+ return ip
+}
diff --git a/internal/middleware/contact_rate_limit.go b/internal/middleware/contact_rate_limit.go
new file mode 100644
index 0000000..c587333
--- /dev/null
+++ b/internal/middleware/contact_rate_limit.go
@@ -0,0 +1,131 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// contactRateLimitEntry tracks rate limiting for contact form per IP
+type contactRateLimitEntry struct {
+ count int
+ resetTime time.Time
+}
+
+// ContactRateLimiter provides rate limiting specifically for contact form
+// Allows 5 submissions per hour per IP address
+type ContactRateLimiter struct {
+ mu sync.RWMutex
+ clients map[string]*contactRateLimitEntry
+}
+
+// NewContactRateLimiter creates a new contact form rate limiter
+// Default: 5 requests per hour per IP
+func NewContactRateLimiter() *ContactRateLimiter {
+ rl := &ContactRateLimiter{
+ clients: make(map[string]*contactRateLimitEntry),
+ }
+
+ // Cleanup expired entries every 10 minutes
+ go rl.cleanup()
+
+ return rl
+}
+
+// Middleware returns rate limiting middleware for contact form
+func (rl *ContactRateLimiter) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Get client IP (handle X-Forwarded-For for proxies)
+ ip := r.Header.Get("X-Forwarded-For")
+ if ip == "" {
+ ip = r.Header.Get("X-Real-IP")
+ }
+ if ip == "" {
+ ip = strings.Split(r.RemoteAddr, ":")[0]
+ }
+
+ // Extract first IP if multiple IPs in X-Forwarded-For
+ if strings.Contains(ip, ",") {
+ ip = strings.TrimSpace(strings.Split(ip, ",")[0])
+ }
+
+ if !rl.allow(ip) {
+ // Check if HTMX request
+ isHTMX := r.Header.Get("HX-Request") != ""
+
+ if isHTMX {
+ // Return HTMX-friendly error
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`
+
Too Many Requests
+
You've submitted too many contact forms. Please wait an hour before trying again.
+
`))
+ } else {
+ w.Header().Set("Retry-After", "3600") // 1 hour
+ http.Error(w, "Too many contact form submissions. Please try again in an hour.", http.StatusTooManyRequests)
+ }
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+// allow checks if the request is allowed based on rate limit
+// Limit: 5 submissions per hour
+func (rl *ContactRateLimiter) allow(ip string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ now := time.Now()
+ limit := 5
+ window := 1 * time.Hour
+
+ entry, exists := rl.clients[ip]
+ if !exists || now.After(entry.resetTime) {
+ // New client or window expired
+ rl.clients[ip] = &contactRateLimitEntry{
+ count: 1,
+ resetTime: now.Add(window),
+ }
+ return true
+ }
+
+ if entry.count >= limit {
+ return false
+ }
+
+ entry.count++
+ return true
+}
+
+// cleanup removes expired entries periodically
+func (rl *ContactRateLimiter) cleanup() {
+ ticker := time.NewTicker(10 * time.Minute)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ rl.mu.Lock()
+ now := time.Now()
+ for ip, entry := range rl.clients {
+ if now.After(entry.resetTime) {
+ delete(rl.clients, ip)
+ }
+ }
+ rl.mu.Unlock()
+ }
+}
+
+// GetStats returns current rate limit statistics (for monitoring/debugging)
+func (rl *ContactRateLimiter) GetStats() map[string]interface{} {
+ rl.mu.RLock()
+ defer rl.mu.RUnlock()
+
+ return map[string]interface{}{
+ "total_clients": len(rl.clients),
+ "limit": 5,
+ "window": "1 hour",
+ }
+}
diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go
new file mode 100644
index 0000000..7bb1f3b
--- /dev/null
+++ b/internal/middleware/csrf.go
@@ -0,0 +1,200 @@
+package middleware
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+)
+
+const (
+ csrfTokenLength = 32
+ csrfCookieName = "csrf_token"
+ csrfFormField = "csrf_token"
+ csrfTokenTTL = 24 * time.Hour
+)
+
+// csrfTokenEntry stores token with expiration
+type csrfTokenEntry struct {
+ token string
+ expiresAt time.Time
+}
+
+// CSRFProtection provides CSRF token generation and validation
+type CSRFProtection struct {
+ mu sync.RWMutex
+ tokens map[string]*csrfTokenEntry // map[token]entry
+}
+
+// NewCSRFProtection creates a new CSRF protection instance
+func NewCSRFProtection() *CSRFProtection {
+ csrf := &CSRFProtection{
+ tokens: make(map[string]*csrfTokenEntry),
+ }
+
+ // Cleanup expired tokens every hour
+ go csrf.cleanup()
+
+ return csrf
+}
+
+// Middleware provides CSRF protection for state-changing operations
+// GET requests: Generate and set CSRF token
+// POST/PUT/DELETE: Validate CSRF token
+func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Only validate on state-changing methods
+ if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
+ if !c.validateToken(r) {
+ log.Printf("SECURITY: CSRF validation failed from IP %s", getClientIP(r))
+
+ // Check if HTMX request
+ isHTMX := r.Header.Get("HX-Request") != ""
+
+ if isHTMX {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`
+
Security Error
+
Invalid security token. Please refresh the page and try again.
+
`))
+ } else {
+ http.Error(w, "CSRF validation failed", http.StatusForbidden)
+ }
+ return
+ }
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+// generateToken creates a new CSRF token
+func (c *CSRFProtection) generateToken() (string, error) {
+ bytes := make([]byte, csrfTokenLength)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+
+ token := base64.URLEncoding.EncodeToString(bytes)
+
+ // Store token with expiration
+ c.mu.Lock()
+ c.tokens[token] = &csrfTokenEntry{
+ token: token,
+ expiresAt: time.Now().Add(csrfTokenTTL),
+ }
+ c.mu.Unlock()
+
+ return token, nil
+}
+
+// GetToken retrieves or generates a CSRF token for the request
+// This should be called when rendering forms
+func (c *CSRFProtection) GetToken(w http.ResponseWriter, r *http.Request) (string, error) {
+ // Check if token exists in cookie
+ cookie, err := r.Cookie(csrfCookieName)
+ if err == nil && cookie.Value != "" {
+ // Validate existing token
+ c.mu.RLock()
+ entry, exists := c.tokens[cookie.Value]
+ c.mu.RUnlock()
+
+ if exists && time.Now().Before(entry.expiresAt) {
+ // Token is valid, return it
+ return cookie.Value, nil
+ }
+ }
+
+ // Generate new token
+ token, err := c.generateToken()
+ if err != nil {
+ return "", fmt.Errorf("failed to generate CSRF token: %w", err)
+ }
+
+ // Set cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookieName,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: r.TLS != nil, // Only set Secure flag if using HTTPS
+ SameSite: http.SameSiteStrictMode,
+ MaxAge: int(csrfTokenTTL.Seconds()),
+ })
+
+ return token, nil
+}
+
+// validateToken validates the CSRF token from the request
+func (c *CSRFProtection) validateToken(r *http.Request) bool {
+ // Get token from form
+ var formToken string
+
+ // Try form value first
+ if err := r.ParseForm(); err == nil {
+ formToken = r.FormValue(csrfFormField)
+ }
+
+ // If not in form, try header (for AJAX requests)
+ if formToken == "" {
+ formToken = r.Header.Get("X-CSRF-Token")
+ }
+
+ if formToken == "" {
+ log.Printf("CSRF: No token in request")
+ return false
+ }
+
+ // Get token from cookie
+ cookie, err := r.Cookie(csrfCookieName)
+ if err != nil || cookie.Value == "" {
+ log.Printf("CSRF: No token in cookie")
+ return false
+ }
+
+ // Tokens must match
+ if formToken != cookie.Value {
+ log.Printf("CSRF: Token mismatch")
+ return false
+ }
+
+ // Validate token exists and is not expired
+ c.mu.RLock()
+ entry, exists := c.tokens[formToken]
+ c.mu.RUnlock()
+
+ if !exists {
+ log.Printf("CSRF: Token not found in store")
+ return false
+ }
+
+ if time.Now().After(entry.expiresAt) {
+ log.Printf("CSRF: Token expired")
+ return false
+ }
+
+ return true
+}
+
+// cleanup removes expired tokens periodically
+func (c *CSRFProtection) cleanup() {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ c.mu.Lock()
+ now := time.Now()
+ for token, entry := range c.tokens {
+ if now.After(entry.expiresAt) {
+ delete(c.tokens, token)
+ }
+ }
+ c.mu.Unlock()
+ }
+}
+
+// Note: getClientIP is defined in security_logger.go
diff --git a/internal/middleware/security_logger.go b/internal/middleware/security_logger.go
new file mode 100644
index 0000000..ccab2fd
--- /dev/null
+++ b/internal/middleware/security_logger.go
@@ -0,0 +1,228 @@
+package middleware
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+)
+
+// SecurityEvent represents a security-related event
+type SecurityEvent struct {
+ Timestamp time.Time `json:"timestamp"`
+ EventType string `json:"event_type"`
+ Severity string `json:"severity"`
+ IP string `json:"ip"`
+ UserAgent string `json:"user_agent"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Details string `json:"details"`
+}
+
+// EventSeverity levels
+const (
+ SeverityCritical = "CRITICAL"
+ SeverityHigh = "HIGH"
+ SeverityMedium = "MEDIUM"
+ SeverityLow = "LOW"
+ SeverityInfo = "INFO"
+)
+
+// Event types
+const (
+ EventBlocked = "BLOCKED"
+ EventCSRFViolation = "CSRF_VIOLATION"
+ EventOriginViolation = "ORIGIN_VIOLATION"
+ EventRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
+ EventValidationFailed = "VALIDATION_FAILED"
+ EventSuspiciousUserAgent = "SUSPICIOUS_USER_AGENT"
+ EventContactFormSent = "CONTACT_FORM_SENT"
+ EventContactFormFailed = "CONTACT_FORM_FAILED"
+ EventPDFGenerated = "PDF_GENERATED"
+ EventPDFGenerationFailed = "PDF_GENERATION_FAILED"
+ EventEmailSendFailed = "EMAIL_SEND_FAILED"
+ EventBotDetected = "BOT_DETECTED"
+)
+
+// LogSecurityEvent logs a security event in structured JSON format
+func LogSecurityEvent(eventType string, r *http.Request, details string) {
+ severity := getSeverity(eventType)
+
+ event := SecurityEvent{
+ Timestamp: time.Now(),
+ EventType: eventType,
+ Severity: severity,
+ IP: getClientIP(r),
+ UserAgent: r.Header.Get("User-Agent"),
+ Method: r.Method,
+ Path: r.URL.Path,
+ Details: details,
+ }
+
+ // JSON format for easy parsing by SIEM systems
+ eventJSON, err := json.Marshal(event)
+ if err != nil {
+ log.Printf("ERROR: Failed to marshal security event: %v", err)
+ return
+ }
+
+ // Log to stdout (captured by systemd/Docker)
+ log.Printf("[SECURITY] %s", eventJSON)
+
+ // Also log to separate security log file in production
+ if os.Getenv("GO_ENV") == "production" {
+ logToSecurityFile(eventJSON)
+ }
+}
+
+// getSeverity determines the severity level based on event type
+func getSeverity(eventType string) string {
+ switch eventType {
+ case EventBlocked, EventCSRFViolation, EventOriginViolation:
+ return SeverityHigh
+ case EventRateLimitExceeded, EventValidationFailed, EventSuspiciousUserAgent,
+ EventContactFormFailed, EventPDFGenerationFailed, EventEmailSendFailed:
+ return SeverityMedium
+ case EventBotDetected:
+ return SeverityLow
+ case EventContactFormSent, EventPDFGenerated:
+ return SeverityInfo
+ default:
+ return SeverityLow
+ }
+}
+
+// getClientIP extracts the real client IP from request headers
+func getClientIP(r *http.Request) string {
+ // Check X-Forwarded-For header (proxy/load balancer)
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ // Take first IP from comma-separated list
+ ips := strings.Split(xff, ",")
+ return strings.TrimSpace(ips[0])
+ }
+
+ // Check X-Real-IP header
+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
+ return xri
+ }
+
+ // Fallback to RemoteAddr (remove port)
+ ip := r.RemoteAddr
+ if idx := strings.LastIndex(ip, ":"); idx != -1 {
+ ip = ip[:idx]
+ }
+ return ip
+}
+
+// logToSecurityFile appends security events to a dedicated log file
+func logToSecurityFile(eventJSON []byte) {
+ // Create log directory if it doesn't exist
+ logDir := "/var/log/cv-app"
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ log.Printf("WARNING: Failed to create log directory: %v", err)
+ return
+ }
+
+ // Open security log file (append mode)
+ logPath := logDir + "/security.log"
+ f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ log.Printf("WARNING: Failed to open security log file: %v", err)
+ return
+ }
+ defer f.Close()
+
+ // Write event with newline
+ if _, err := f.Write(eventJSON); err != nil {
+ log.Printf("WARNING: Failed to write to security log: %v", err)
+ return
+ }
+ if _, err := f.WriteString("\n"); err != nil {
+ log.Printf("WARNING: Failed to write newline to security log: %v", err)
+ }
+}
+
+// SecurityLogger middleware logs all requests with security context
+func SecurityLogger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+
+ // Wrap response writer to capture status
+ wrapped := &responseWriter{
+ ResponseWriter: w,
+ status: http.StatusOK,
+ }
+
+ // Process request
+ next.ServeHTTP(wrapped, r)
+
+ // Log security-relevant requests
+ duration := time.Since(start)
+
+ // Log high-value endpoints
+ if isSecurityRelevantPath(r.URL.Path) {
+ details := map[string]interface{}{
+ "status": wrapped.status,
+ "duration_ms": duration.Milliseconds(),
+ "bytes": wrapped.written,
+ }
+ detailsJSON, _ := json.Marshal(details)
+
+ event := SecurityEvent{
+ Timestamp: time.Now(),
+ EventType: "REQUEST",
+ Severity: SeverityInfo,
+ IP: getClientIP(r),
+ UserAgent: r.Header.Get("User-Agent"),
+ Method: r.Method,
+ Path: r.URL.Path,
+ Details: string(detailsJSON),
+ }
+
+ eventJSON, _ := json.Marshal(event)
+ log.Printf("[SECURITY] %s", eventJSON)
+ }
+
+ // Log errors and security failures
+ if wrapped.status >= 400 {
+ severity := SeverityLow
+ if wrapped.status == 403 || wrapped.status == 429 {
+ severity = SeverityMedium
+ }
+
+ event := SecurityEvent{
+ Timestamp: time.Now(),
+ EventType: "HTTP_ERROR",
+ Severity: severity,
+ IP: getClientIP(r),
+ UserAgent: r.Header.Get("User-Agent"),
+ Method: r.Method,
+ Path: r.URL.Path,
+ Details: http.StatusText(wrapped.status),
+ }
+
+ eventJSON, _ := json.Marshal(event)
+ log.Printf("[SECURITY] %s", eventJSON)
+ }
+ })
+}
+
+// isSecurityRelevantPath determines if a path should be logged for security
+func isSecurityRelevantPath(path string) bool {
+ securityPaths := []string{
+ "/api/contact",
+ "/export/pdf",
+ "/toggle/",
+ "/switch-language",
+ }
+
+ for _, sp := range securityPaths {
+ if strings.HasPrefix(path, sp) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/models/ui/ui.go b/internal/models/ui/ui.go
index 7226bf0..1ca9325 100644
--- a/internal/models/ui/ui.go
+++ b/internal/models/ui/ui.go
@@ -12,6 +12,7 @@ type UI struct {
PdfModal PdfModal `json:"pdfModal"`
ShortcutsModal ShortcutsModal `json:"shortcutsModal"`
InfoModal InfoModal `json:"infoModal"`
+ ContactModal ContactModal `json:"contactModal"`
Widgets Widgets `json:"widgets"`
}
@@ -142,6 +143,38 @@ type TechStack struct {
CSS3 string `json:"css3"`
}
+// ContactModal labels
+type ContactModal struct {
+ Title string `json:"title"`
+ Subtitle string `json:"subtitle"`
+ Description string `json:"description"`
+ Close string `json:"close"`
+ Form ContactFormLabel `json:"form"`
+ Success ContactResult `json:"success"`
+ Error ContactResult `json:"error"`
+}
+
+type ContactFormLabel struct {
+ Email string `json:"email"`
+ EmailPlaceholder string `json:"emailPlaceholder"`
+ Name string `json:"name"`
+ NamePlaceholder string `json:"namePlaceholder"`
+ Company string `json:"company"`
+ CompanyPlaceholder string `json:"companyPlaceholder"`
+ Subject string `json:"subject"`
+ SubjectPlaceholder string `json:"subjectPlaceholder"`
+ Message string `json:"message"`
+ MessagePlaceholder string `json:"messagePlaceholder"`
+ Submit string `json:"submit"`
+ Sending string `json:"sending"`
+ Note string `json:"note"`
+}
+
+type ContactResult struct {
+ Title string `json:"title"`
+ Message string `json:"message,omitempty"`
+}
+
// Widget label types
type Widgets struct {
BackToTop WidgetLabel `json:"backToTop"`
@@ -152,6 +185,7 @@ type Widgets struct {
ZoomToggle WidgetLabel `json:"zoomToggle"`
ZoomControl ZoomControlLabel `json:"zoomControl"`
PdfToast PdfToastLabel `json:"pdfToast"`
+ Contact WidgetLabel `json:"contact"`
ActionButtons ActionButtonsLabel `json:"actionButtons"`
}
@@ -177,4 +211,6 @@ type PdfToastLabel struct {
type ActionButtonsLabel struct {
DownloadPdf string `json:"downloadPdf"`
PrintFriendly string `json:"printFriendly"`
+ PlainText string `json:"plainText"`
+ Contact string `json:"contact"`
}
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index 6d9cf01..472d5c3 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -9,7 +9,7 @@ import (
)
// Setup configures all application routes and middleware
-func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
+func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, contactHandler *handlers.ContactHandler) http.Handler {
mux := http.NewServeMux()
// Shortcut routes for default CV (year-aware) - MUST be before "/" route
@@ -19,6 +19,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
// Public routes
mux.HandleFunc("/", cvHandler.Home)
mux.HandleFunc("/cv", cvHandler.CVContent)
+ mux.HandleFunc("/text", cvHandler.PlainText) // Plain text version for curl/AI
mux.HandleFunc("/health", healthHandler.Check)
// HTMX endpoints for interactive controls
@@ -27,6 +28,13 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler)
mux.HandleFunc("/toggle/icons", cvHandler.ToggleIcons)
mux.HandleFunc("/toggle/theme", cvHandler.ToggleTheme)
+ // Contact form endpoint (simple rate limiting)
+ contactRateLimiter := middleware.NewRateLimiter(5, 1*time.Hour)
+ protectedContactHandler := contactRateLimiter.Middleware(
+ http.HandlerFunc(contactHandler.Submit),
+ )
+ mux.Handle("/api/contact", protectedContactHandler)
+
// Protected PDF endpoint with rate limiting (3 requests/minute per IP)
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
protectedPDFHandler := middleware.OriginChecker(
diff --git a/internal/services/email.go b/internal/services/email.go
new file mode 100644
index 0000000..2fbd9ae
--- /dev/null
+++ b/internal/services/email.go
@@ -0,0 +1,274 @@
+package services
+
+import (
+ "bytes"
+ "crypto/tls"
+ "fmt"
+ "html/template"
+ "log"
+ "net/smtp"
+ "strings"
+ "time"
+)
+
+// EmailConfig holds SMTP configuration
+type EmailConfig struct {
+ SMTPHost string
+ SMTPPort string
+ SMTPUser string
+ SMTPPassword string
+ FromEmail string
+ ToEmail string
+}
+
+// EmailService handles email sending operations
+type EmailService struct {
+ config *EmailConfig
+}
+
+// NewEmailService creates a new email service
+func NewEmailService(config *EmailConfig) *EmailService {
+ return &EmailService{
+ config: config,
+ }
+}
+
+// ContactFormData represents contact form submission data
+type ContactFormData struct {
+ Email string
+ Name string
+ Company string
+ Subject string
+ Message string
+ IP string
+ Time time.Time
+}
+
+// Validate performs validation on contact form data
+func (c *ContactFormData) Validate() error {
+ // Sanitize inputs
+ c.Email = strings.TrimSpace(c.Email)
+ c.Name = strings.TrimSpace(c.Name)
+ c.Company = strings.TrimSpace(c.Company)
+ c.Subject = strings.TrimSpace(c.Subject)
+ c.Message = strings.TrimSpace(c.Message)
+
+ // Required fields
+ if c.Email == "" {
+ return fmt.Errorf("email is required")
+ }
+ if c.Message == "" {
+ return fmt.Errorf("message is required")
+ }
+
+ // Email format validation (basic)
+ if !strings.Contains(c.Email, "@") || !strings.Contains(c.Email, ".") {
+ return fmt.Errorf("invalid email format")
+ }
+
+ // Prevent email header injection
+ if containsNewlines(c.Email) {
+ return fmt.Errorf("invalid email: contains prohibited characters")
+ }
+ if containsNewlines(c.Subject) {
+ return fmt.Errorf("invalid subject: contains prohibited characters")
+ }
+
+ // Length validation
+ if len(c.Email) > 254 {
+ return fmt.Errorf("email too long (max 254 characters)")
+ }
+ if len(c.Name) > 100 {
+ return fmt.Errorf("name too long (max 100 characters)")
+ }
+ if len(c.Company) > 100 {
+ return fmt.Errorf("company too long (max 100 characters)")
+ }
+ if len(c.Subject) > 200 {
+ return fmt.Errorf("subject too long (max 200 characters)")
+ }
+ if len(c.Message) > 5000 {
+ return fmt.Errorf("message too long (max 5000 characters)")
+ }
+ if len(c.Message) < 10 {
+ return fmt.Errorf("message too short (min 10 characters)")
+ }
+
+ return nil
+}
+
+// containsNewlines checks for newline characters that could enable header injection
+func containsNewlines(s string) bool {
+ return strings.ContainsAny(s, "\r\n")
+}
+
+// SendContactForm sends a contact form email
+func (e *EmailService) SendContactForm(data *ContactFormData) error {
+ // Validate data
+ if err := data.Validate(); err != nil {
+ return fmt.Errorf("validation failed: %w", err)
+ }
+
+ // Prepare email content
+ subject := "[CV Contact] "
+ if data.Subject != "" {
+ subject += data.Subject
+ } else {
+ subject += "New Message"
+ }
+
+ // Build email body
+ body, 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 {
+ return fmt.Errorf("failed to send email: %w", err)
+ }
+
+ // Log successful send (without sensitive data)
+ log.Printf("Contact form email sent successfully to %s from %s", e.config.ToEmail, data.Email)
+
+ 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
+}
+
+// sendEmail sends an email using SMTP
+func (e *EmailService) sendEmail(subject, body 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")
+ }
+
+ // Build email message
+ from := e.config.FromEmail
+ if from == "" {
+ from = e.config.SMTPUser
+ }
+
+ to := e.config.ToEmail
+ message := e.formatMessage(from, to, subject, body)
+
+ // 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()
+}
+
+// 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,
+ }
+
+ if err = client.StartTLS(tlsConfig); err != nil {
+ client.Close()
+ return nil, err
+ }
+
+ return client, nil
+}
+
+// formatMessage formats an email message with proper headers
+func (e *EmailService) formatMessage(from, to, subject, body string) string {
+ headers := make(map[string]string)
+ headers["From"] = from
+ headers["To"] = to
+ headers["Subject"] = subject
+ headers["MIME-Version"] = "1.0"
+ headers["Content-Type"] = "text/plain; charset=\"utf-8\""
+ headers["Date"] = time.Now().Format(time.RFC1123Z)
+
+ var message strings.Builder
+ for k, v := range headers {
+ message.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
+ }
+ message.WriteString("\r\n")
+ message.WriteString(body)
+
+ return message.String()
+}
diff --git a/internal/validation/contact.go b/internal/validation/contact.go
new file mode 100644
index 0000000..9f18e97
--- /dev/null
+++ b/internal/validation/contact.go
@@ -0,0 +1,352 @@
+package validation
+
+import (
+ "errors"
+ "html"
+ "regexp"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+// ContactFormRequest represents a validated contact form submission
+type ContactFormRequest struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Company string `json:"company"`
+ Subject string `json:"subject"`
+ Message string `json:"message"`
+ Honeypot string `json:"website"` // Should always be empty (bot trap)
+ Timestamp int64 `json:"timestamp"` // Form load time (set by server)
+}
+
+// ValidationError represents a validation error with field context
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+// Error implements the error interface
+func (e *ValidationError) Error() string {
+ return e.Field + ": " + e.Message
+}
+
+// ValidateContactForm performs comprehensive validation on contact form data
+func ValidateContactForm(req *ContactFormRequest) error {
+ // 1. Honeypot check (bot detection)
+ if req.Honeypot != "" {
+ return &ValidationError{
+ Field: "website",
+ Message: "Bot detected",
+ }
+ }
+
+ // 2. Timing check (form must be displayed for at least 2 seconds)
+ if req.Timestamp > 0 {
+ now := time.Now().Unix()
+ timeTaken := now - req.Timestamp
+ if timeTaken < 2 {
+ return &ValidationError{
+ Field: "timestamp",
+ Message: "Form submitted too quickly (bot detected)",
+ }
+ }
+ // Also reject if timestamp is in the future or too old (> 24 hours)
+ if timeTaken < 0 || timeTaken > 86400 {
+ return &ValidationError{
+ Field: "timestamp",
+ Message: "Invalid timestamp",
+ }
+ }
+ }
+
+ // 3. Required fields
+ if strings.TrimSpace(req.Name) == "" {
+ return &ValidationError{
+ Field: "name",
+ Message: "Name is required",
+ }
+ }
+
+ if strings.TrimSpace(req.Email) == "" {
+ return &ValidationError{
+ Field: "email",
+ Message: "Email is required",
+ }
+ }
+
+ if strings.TrimSpace(req.Subject) == "" {
+ return &ValidationError{
+ Field: "subject",
+ Message: "Subject is required",
+ }
+ }
+
+ if strings.TrimSpace(req.Message) == "" {
+ return &ValidationError{
+ Field: "message",
+ Message: "Message is required",
+ }
+ }
+
+ // 4. Length validation
+ if utf8.RuneCountInString(req.Name) > 100 {
+ return &ValidationError{
+ Field: "name",
+ Message: "Name must be 100 characters or less",
+ }
+ }
+
+ if utf8.RuneCountInString(req.Email) > 254 {
+ return &ValidationError{
+ Field: "email",
+ Message: "Email must be 254 characters or less",
+ }
+ }
+
+ if utf8.RuneCountInString(req.Company) > 100 {
+ return &ValidationError{
+ Field: "company",
+ Message: "Company must be 100 characters or less",
+ }
+ }
+
+ if utf8.RuneCountInString(req.Subject) > 200 {
+ return &ValidationError{
+ Field: "subject",
+ Message: "Subject must be 200 characters or less",
+ }
+ }
+
+ if utf8.RuneCountInString(req.Message) > 5000 {
+ return &ValidationError{
+ Field: "message",
+ Message: "Message must be 5000 characters or less",
+ }
+ }
+
+ // 5. Email validation (RFC 5322)
+ if !IsValidEmail(req.Email) {
+ return &ValidationError{
+ Field: "email",
+ Message: "Invalid email address format",
+ }
+ }
+
+ // 6. Email header injection prevention
+ if ContainsEmailInjection(req.Name) {
+ return &ValidationError{
+ Field: "name",
+ Message: "Name contains invalid characters",
+ }
+ }
+
+ if ContainsEmailInjection(req.Email) {
+ return &ValidationError{
+ Field: "email",
+ Message: "Email contains invalid characters",
+ }
+ }
+
+ if ContainsEmailInjection(req.Subject) {
+ return &ValidationError{
+ Field: "subject",
+ Message: "Subject contains invalid characters",
+ }
+ }
+
+ // 7. Name validation (letters, spaces, hyphens, apostrophes only)
+ if !IsValidName(req.Name) {
+ return &ValidationError{
+ Field: "name",
+ Message: "Name can only contain letters, spaces, hyphens, and apostrophes",
+ }
+ }
+
+ // 8. Subject validation (alphanumeric + safe punctuation)
+ if !IsValidSubject(req.Subject) {
+ return &ValidationError{
+ Field: "subject",
+ Message: "Subject contains invalid characters",
+ }
+ }
+
+ // 9. Company validation (optional, but if provided must be alphanumeric)
+ if req.Company != "" && !IsValidCompany(req.Company) {
+ return &ValidationError{
+ Field: "company",
+ Message: "Company name contains invalid characters",
+ }
+ }
+
+ return nil
+}
+
+// IsValidEmail validates email format per RFC 5322 (simplified)
+func IsValidEmail(email string) bool {
+ email = strings.TrimSpace(email)
+
+ // Basic length check
+ if len(email) < 3 || len(email) > 254 {
+ return false
+ }
+
+ // Must contain @
+ parts := strings.Split(email, "@")
+ if len(parts) != 2 {
+ return false
+ }
+
+ local := parts[0]
+ domain := parts[1]
+
+ // Local part validation
+ if len(local) == 0 || len(local) > 64 {
+ return false
+ }
+
+ // Domain must have at least one dot (TLD required)
+ if !strings.Contains(domain, ".") {
+ return false
+ }
+
+ // RFC 5322 simplified regex
+ // This is a reasonable approximation - full RFC 5322 is extremely complex
+ emailRegex := regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$`)
+
+ return emailRegex.MatchString(email)
+}
+
+// ContainsEmailInjection checks for email header injection attempts
+// Email header injection: attacker tries to inject additional headers via newlines
+func ContainsEmailInjection(s string) bool {
+ // Check for newlines (CRLF or LF)
+ if strings.ContainsAny(s, "\r\n") {
+ return true
+ }
+
+ // Check for email header patterns (case-insensitive)
+ sLower := strings.ToLower(s)
+
+ dangerousPatterns := []string{
+ "content-type:",
+ "mime-version:",
+ "content-transfer-encoding:",
+ "bcc:",
+ "cc:",
+ "to:",
+ "from:",
+ "subject:",
+ "reply-to:",
+ "x-mailer:",
+ }
+
+ for _, pattern := range dangerousPatterns {
+ if strings.Contains(sLower, pattern) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// IsValidName validates name format
+// Allows: letters (any language), spaces, hyphens, apostrophes
+func IsValidName(name string) bool {
+ name = strings.TrimSpace(name)
+
+ if name == "" {
+ return false
+ }
+
+ // Allow unicode letters, spaces, hyphens, apostrophes
+ // This supports international names (Juan José, François, 田中, etc.)
+ nameRegex := regexp.MustCompile(`^[\p{L}\s'-]+$`)
+
+ return nameRegex.MatchString(name)
+}
+
+// IsValidSubject validates subject format
+// Allows: alphanumeric, spaces, and common punctuation (including #)
+func IsValidSubject(subject string) bool {
+ subject = strings.TrimSpace(subject)
+
+ if subject == "" {
+ return false
+ }
+
+ // Allow alphanumeric (any language), spaces, and safe punctuation (including #)
+ subjectRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,!?'"()\-:;#]+$`)
+
+ return subjectRegex.MatchString(subject)
+}
+
+// IsValidCompany validates company name format
+// Allows: alphanumeric, spaces, and common business punctuation
+func IsValidCompany(company string) bool {
+ company = strings.TrimSpace(company)
+
+ if company == "" {
+ return true // Optional field
+ }
+
+ // Allow alphanumeric (any language), spaces, and business punctuation
+ companyRegex := regexp.MustCompile(`^[\p{L}\p{N}\s.,&'()\-]+$`)
+
+ return companyRegex.MatchString(company)
+}
+
+// SanitizeContactForm sanitizes contact form data
+// This should be called AFTER validation
+func SanitizeContactForm(req *ContactFormRequest) {
+ // 1. Trim whitespace
+ req.Name = strings.TrimSpace(req.Name)
+ req.Email = strings.TrimSpace(req.Email)
+ req.Company = strings.TrimSpace(req.Company)
+ req.Subject = strings.TrimSpace(req.Subject)
+ req.Message = strings.TrimSpace(req.Message)
+
+ // 2. Remove any newlines from header fields (belt-and-suspenders)
+ req.Name = removeNewlines(req.Name)
+ req.Email = removeNewlines(req.Email)
+ req.Company = removeNewlines(req.Company)
+ req.Subject = removeNewlines(req.Subject)
+
+ // 3. HTML escape message body (prevent XSS in email clients)
+ req.Message = html.EscapeString(req.Message)
+
+ // 4. Normalize whitespace in message (collapse multiple spaces/newlines)
+ req.Message = normalizeWhitespace(req.Message)
+}
+
+// removeNewlines removes all newline characters
+func removeNewlines(s string) string {
+ s = strings.ReplaceAll(s, "\r", "")
+ s = strings.ReplaceAll(s, "\n", "")
+ return s
+}
+
+// normalizeWhitespace collapses multiple spaces/newlines to single instances
+func normalizeWhitespace(s string) string {
+ // Replace multiple newlines with double newline (paragraph break)
+ newlineRegex := regexp.MustCompile(`\n{3,}`)
+ s = newlineRegex.ReplaceAllString(s, "\n\n")
+
+ // Replace multiple spaces (but not newlines) with single space
+ spaceRegex := regexp.MustCompile(`[^\S\n]+`)
+ s = spaceRegex.ReplaceAllString(s, " ")
+
+ return strings.TrimSpace(s)
+}
+
+// Common validation errors
+var (
+ ErrBotDetected = errors.New("bot detected")
+ ErrInvalidEmail = errors.New("invalid email format")
+ ErrEmailInjection = errors.New("email injection attempt detected")
+ ErrInvalidName = errors.New("invalid name format")
+ ErrInvalidSubject = errors.New("invalid subject format")
+ ErrRequiredField = errors.New("required field missing")
+ ErrFieldTooLong = errors.New("field exceeds maximum length")
+ ErrSubmittedTooFast = errors.New("form submitted too quickly")
+)
diff --git a/internal/validation/contact_test.go b/internal/validation/contact_test.go
new file mode 100644
index 0000000..0efc26a
--- /dev/null
+++ b/internal/validation/contact_test.go
@@ -0,0 +1,524 @@
+package validation
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// ==============================================================================
+// EMAIL VALIDATION TESTS
+// ==============================================================================
+
+func TestIsValidEmail(t *testing.T) {
+ tests := []struct {
+ name string
+ email string
+ want bool
+ }{
+ // Valid emails
+ {"Valid standard", "test@example.com", true},
+ {"Valid with subdomain", "user@mail.example.com", true},
+ {"Valid with plus", "user+tag@example.com", true},
+ {"Valid with dot", "first.last@example.com", true},
+ {"Valid with hyphen", "user-name@example.com", true},
+ {"Valid with numbers", "user123@example.com", true},
+
+ // Invalid emails
+ {"Empty", "", false},
+ {"No @", "userexample.com", false},
+ {"Multiple @", "user@@example.com", false},
+ {"No domain", "user@", false},
+ {"No local part", "@example.com", false},
+ {"Spaces", "user @example.com", false},
+ {"Missing TLD", "user@example", false},
+ {"Too long", strings.Repeat("a", 250) + "@example.com", false},
+ {"Special chars", "user<>@example.com", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsValidEmail(tt.email); got != tt.want {
+ t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// EMAIL INJECTION TESTS
+// ==============================================================================
+
+func TestContainsEmailInjection(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool // true if injection detected
+ }{
+ // Safe inputs
+ {"Normal text", "Hello World", false},
+ {"Name with apostrophe", "O'Connor", false},
+ {"Hyphenated name", "Anne-Marie", false},
+
+ // Injection attempts
+ {"CRLF injection", "Name\r\nBcc: attacker@evil.com", true},
+ {"LF injection", "Name\nBcc: attacker@evil.com", true},
+ {"Content-Type header", "Name\r\nContent-Type: text/html", true},
+ {"BCC header", "bcc: attacker@evil.com", true},
+ {"CC header", "cc: attacker@evil.com", true},
+ {"To header", "to: victim@example.com", true},
+ {"From header", "from: fake@example.com", true},
+ {"Reply-To header", "reply-to: attacker@evil.com", true},
+ {"MIME-Version", "MIME-Version: 1.0", true},
+ {"X-Mailer", "X-Mailer: Evil", true},
+ {"Case insensitive", "BCC: attacker@evil.com", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ContainsEmailInjection(tt.input); got != tt.want {
+ t.Errorf("ContainsEmailInjection(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// NAME VALIDATION TESTS
+// ==============================================================================
+
+func TestIsValidName(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ // Valid names
+ {"Simple name", "John", true},
+ {"Full name", "John Smith", true},
+ {"Hyphenated", "Anne-Marie", true},
+ {"Apostrophe", "O'Connor", true},
+ {"Multiple words", "Juan José García", true},
+ {"Spanish characters", "José María", true},
+ {"French characters", "François Dubois", true},
+ {"German characters", "Müller", true},
+
+ // Invalid names
+ {"Empty", "", false},
+ {"Numbers", "John123", false},
+ {"Special chars", "John@Smith", false},
+ {"HTML tags", "", false},
+ {"Email", "john@example.com", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsValidName(tt.input); got != tt.want {
+ t.Errorf("IsValidName(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// SUBJECT VALIDATION TESTS
+// ==============================================================================
+
+func TestIsValidSubject(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ // Valid subjects
+ {"Simple", "Hello", true},
+ {"With spaces", "Hello World", true},
+ {"With punctuation", "Question about your services!", true},
+ {"With numbers", "Order #12345", true},
+ {"Complex", "Re: Your inquiry (urgent)", true},
+
+ // Invalid subjects
+ {"Empty", "", false},
+ {"HTML tags", "", false},
+ {"Email injection", "Subject\nBcc: evil@example.com", false},
+ {"Special chars", "Subject $ % ^ &", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsValidSubject(tt.input); got != tt.want {
+ t.Errorf("IsValidSubject(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// COMPANY VALIDATION TESTS
+// ==============================================================================
+
+func TestIsValidCompany(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ // Valid company names
+ {"Empty (optional)", "", true},
+ {"Simple", "Acme Corp", true},
+ {"With punctuation", "Smith & Sons, Inc.", true},
+ {"With hyphen", "Coca-Cola", true},
+ {"With parentheses", "Example (Spain)", true},
+
+ // Invalid company names
+ {"HTML tags", "\n\n\nHello World\n\n\n\n",
+ }
+
+ SanitizeContactForm(req)
+
+ // Check whitespace trimmed
+ if req.Name != "John Smith" {
+ t.Errorf("Name not properly sanitized: %q", req.Name)
+ }
+
+ // Check newlines removed from headers
+ if strings.Contains(req.Email, "\n") || strings.Contains(req.Email, "\r") {
+ t.Errorf("Email still contains newlines: %q", req.Email)
+ }
+
+ // Check HTML escaped
+ if !strings.Contains(req.Message, "<script>") {
+ t.Errorf("Message not properly HTML escaped: %q", req.Message)
+ }
+
+ // Check whitespace normalized
+ if strings.Contains(req.Message, " ") {
+ t.Errorf("Message whitespace not normalized: %q", req.Message)
+ }
+}
+
+// ==============================================================================
+// FULL VALIDATION TESTS
+// ==============================================================================
+
+func TestValidateContactForm(t *testing.T) {
+ tests := []struct {
+ name string
+ req *ContactFormRequest
+ wantError bool
+ errorField string
+ }{
+ {
+ name: "Valid form",
+ req: &ContactFormRequest{
+ Name: "John Smith",
+ Email: "john@example.com",
+ Company: "Acme Corp",
+ Subject: "Inquiry",
+ Message: "Hello, I have a question.",
+ Honeypot: "",
+ Timestamp: time.Now().Unix() - 10, // 10 seconds ago
+ },
+ wantError: false,
+ },
+ {
+ name: "Bot detected - honeypot filled",
+ req: &ContactFormRequest{
+ Name: "Bot",
+ Email: "bot@example.com",
+ Subject: "Spam",
+ Message: "Spam message",
+ Honeypot: "http://evil.com", // Bot filled this
+ },
+ wantError: true,
+ errorField: "website",
+ },
+ {
+ name: "Bot detected - submitted too fast",
+ req: &ContactFormRequest{
+ Name: "John",
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: "Test",
+ Timestamp: time.Now().Unix(), // Just now (< 2 seconds)
+ },
+ wantError: true,
+ errorField: "timestamp",
+ },
+ {
+ name: "Missing required field - name",
+ req: &ContactFormRequest{
+ Name: "",
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "name",
+ },
+ {
+ name: "Invalid email format",
+ req: &ContactFormRequest{
+ Name: "John",
+ Email: "invalid-email",
+ Subject: "Test",
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "email",
+ },
+ {
+ name: "Email injection attempt",
+ req: &ContactFormRequest{
+ Name: "Evil\nBcc: attacker@evil.com",
+ Email: "evil@example.com",
+ Subject: "Test",
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "name",
+ },
+ {
+ name: "Name too long",
+ req: &ContactFormRequest{
+ Name: strings.Repeat("a", 101),
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "name",
+ },
+ {
+ name: "Subject too long",
+ req: &ContactFormRequest{
+ Name: "John",
+ Email: "john@example.com",
+ Subject: strings.Repeat("a", 201),
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "subject",
+ },
+ {
+ name: "Message too long",
+ req: &ContactFormRequest{
+ Name: "John",
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: strings.Repeat("a", 5001),
+ },
+ wantError: true,
+ errorField: "message",
+ },
+ {
+ name: "Invalid name characters",
+ req: &ContactFormRequest{
+ Name: "John123",
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: "Test",
+ },
+ wantError: true,
+ errorField: "name",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateContactForm(tt.req)
+
+ if tt.wantError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ return
+ }
+
+ // Check error is ValidationError
+ valErr, ok := err.(*ValidationError)
+ if !ok {
+ t.Errorf("Expected ValidationError, got %T", err)
+ return
+ }
+
+ // Check correct field
+ if valErr.Field != tt.errorField {
+ t.Errorf("Expected error field %q, got %q", tt.errorField, valErr.Field)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Expected no error, got: %v", err)
+ }
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// SECURITY TESTS (Attack Simulations)
+// ==============================================================================
+
+func TestSecurityAttacks(t *testing.T) {
+ attacks := []struct {
+ name string
+ field string
+ value string
+ reason string
+ }{
+ {
+ name: "SQL Injection in name",
+ field: "name",
+ value: "Robert'; DROP TABLE users; --",
+ reason: "Should reject SQL injection attempts",
+ },
+ // NOTE: XSS in message is allowed during validation - it's escaped during sanitization
+ // This is intentional - we don't reject valid messages that happen to contain < or >
+ // The sanitization step handles HTML escaping before sending email
+ {
+ name: "Email header injection",
+ field: "email",
+ value: "test@example.com\nBcc: attacker@evil.com",
+ reason: "Should block email header injection",
+ },
+ {
+ name: "Command injection",
+ field: "name",
+ value: "Test; rm -rf /",
+ reason: "Should block command injection attempts",
+ },
+ {
+ name: "Path traversal",
+ field: "subject",
+ value: "../../../etc/passwd",
+ reason: "Should reject path traversal attempts",
+ },
+ }
+
+ for _, attack := range attacks {
+ t.Run(attack.name, func(t *testing.T) {
+ req := &ContactFormRequest{
+ Name: "John Smith",
+ Email: "john@example.com",
+ Subject: "Test",
+ Message: "Test",
+ Timestamp: time.Now().Unix() - 10,
+ }
+
+ // Inject attack into specified field
+ switch attack.field {
+ case "name":
+ req.Name = attack.value
+ case "email":
+ req.Email = attack.value
+ case "subject":
+ req.Subject = attack.value
+ case "message":
+ req.Message = attack.value
+ }
+
+ err := ValidateContactForm(req)
+
+ if err == nil {
+ t.Errorf("%s: Expected validation to fail, but it passed", attack.reason)
+ }
+ })
+ }
+}
+
+// ==============================================================================
+// NORMALIZATION TESTS
+// ==============================================================================
+
+func TestRemoveNewlines(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"No newlines", "No newlines"},
+ {"With\nnewline", "Withnewline"},
+ {"With\r\nCRLF", "WithCRLF"},
+ {"Multiple\n\n\nnewlines", "Multiplenewlines"},
+ }
+
+ for _, tt := range tests {
+ if got := removeNewlines(tt.input); got != tt.want {
+ t.Errorf("removeNewlines(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeWhitespace(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"Normal text", "Normal text"},
+ {"Multiple spaces", "Multiple spaces"},
+ {"Multiple\n\n\n\nnewlines", "Multiple\n\nnewlines"},
+ {" Leading and trailing ", "Leading and trailing"},
+ }
+
+ for _, tt := range tests {
+ if got := normalizeWhitespace(tt.input); got != tt.want {
+ t.Errorf("normalizeWhitespace(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+// ==============================================================================
+// BENCHMARK TESTS
+// ==============================================================================
+
+func BenchmarkIsValidEmail(b *testing.B) {
+ email := "test@example.com"
+ for i := 0; i < b.N; i++ {
+ IsValidEmail(email)
+ }
+}
+
+func BenchmarkContainsEmailInjection(b *testing.B) {
+ text := "Normal text without injection"
+ for i := 0; i < b.N; i++ {
+ ContainsEmailInjection(text)
+ }
+}
+
+func BenchmarkValidateContactForm(b *testing.B) {
+ req := &ContactFormRequest{
+ Name: "John Smith",
+ Email: "john@example.com",
+ Company: "Acme Corp",
+ Subject: "Inquiry",
+ Message: "Hello, I have a question.",
+ Timestamp: time.Now().Unix() - 10,
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ValidateContactForm(req)
+ }
+}
diff --git a/main.go b/main.go
index 518ae1f..2dfa9eb 100644
--- a/main.go
+++ b/main.go
@@ -14,6 +14,7 @@ import (
"github.com/juanatsap/cv-site/internal/config"
"github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/routes"
+ "github.com/juanatsap/cv-site/internal/services"
"github.com/juanatsap/cv-site/internal/templates"
)
@@ -41,12 +42,23 @@ func main() {
log.Fatalf("❌ Failed to initialize templates: %v", err)
}
+ // Initialize email service
+ emailService := services.NewEmailService(&services.EmailConfig{
+ SMTPHost: cfg.Email.SMTPHost,
+ SMTPPort: cfg.Email.SMTPPort,
+ SMTPUser: cfg.Email.SMTPUser,
+ SMTPPassword: cfg.Email.SMTPPassword,
+ FromEmail: cfg.Email.FromEmail,
+ ToEmail: cfg.Email.ContactEmail,
+ })
+
// Initialize handlers
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address())
healthHandler := handlers.NewHealthHandler(version)
+ contactHandler := handlers.NewContactHandler(templateMgr, emailService)
// Setup routes and middleware
- handler := routes.Setup(cvHandler, healthHandler)
+ handler := routes.Setup(cvHandler, healthHandler, contactHandler)
// Create server with timeouts
server := &http.Server{
diff --git a/static/css/04-interactive/_contact-form.css b/static/css/04-interactive/_contact-form.css
new file mode 100644
index 0000000..2353096
--- /dev/null
+++ b/static/css/04-interactive/_contact-form.css
@@ -0,0 +1,387 @@
+/* =============================================================================
+ CONTACT FORM - Modal form styling
+ ============================================================================= */
+
+/* Contact Modal Specific Overrides */
+#contact-modal {
+ max-width: 520px;
+}
+
+#contact-modal .info-modal-cv-title {
+ color: #3498db; /* Blue subtitle for contact */
+}
+
+#contact-modal .info-modal-cv-title iconify-icon {
+ color: #3498db;
+}
+
+/* Contact Modal Description */
+.contact-modal-description {
+ font-size: 0.95rem;
+ line-height: 1.6;
+ margin-bottom: 1.5rem;
+ color: #555;
+ text-align: center;
+}
+
+/* =============================================================================
+ FORM ELEMENTS
+ ============================================================================= */
+
+/* Form Group - Wrapper for label + input */
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-group:last-of-type {
+ margin-bottom: 1rem;
+}
+
+/* Form Labels */
+.form-label {
+ display: block;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 0.4rem;
+}
+
+.required-indicator {
+ color: #ef4444;
+ margin-left: 0.2rem;
+}
+
+/* Form Inputs and Textarea */
+.form-input,
+.form-textarea {
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 0.95rem;
+ font-family: inherit;
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #333;
+ transition: all 0.2s ease;
+ box-sizing: border-box;
+}
+
+.form-input:focus,
+.form-textarea:focus {
+ outline: none;
+ border-color: #3498db;
+ box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
+}
+
+.form-input::placeholder,
+.form-textarea::placeholder {
+ color: #999;
+ opacity: 1;
+}
+
+/* Textarea specific */
+.form-textarea {
+ resize: vertical;
+ min-height: 120px;
+ line-height: 1.5;
+}
+
+/* Invalid state (HTML5 validation) */
+.form-input:invalid:not(:placeholder-shown),
+.form-textarea:invalid:not(:placeholder-shown) {
+ border-color: #ef4444;
+}
+
+.form-input:invalid:focus:not(:placeholder-shown),
+.form-textarea:invalid:focus:not(:placeholder-shown) {
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
+}
+
+/* =============================================================================
+ CONTACT RESPONSE MESSAGES
+ ============================================================================= */
+
+.contact-response {
+ margin-bottom: 1rem;
+ min-height: 0;
+}
+
+.contact-message {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+ animation: messageSlideIn 0.3s ease;
+}
+
+@keyframes messageSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.contact-message iconify-icon {
+ flex-shrink: 0;
+ margin-top: 0.1rem;
+}
+
+.contact-message-content {
+ flex: 1;
+}
+
+.contact-message-content strong {
+ display: block;
+ font-size: 0.95rem;
+ margin-bottom: 0.25rem;
+}
+
+.contact-message-content p {
+ font-size: 0.875rem;
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* Success Message */
+.contact-success {
+ background: linear-gradient(135deg, rgba(40, 167, 69, 0.1) 0%, rgba(25, 135, 84, 0.05) 100%);
+ border: 2px solid rgba(40, 167, 69, 0.3);
+ color: #155724;
+}
+
+.contact-success iconify-icon {
+ color: #28a745;
+}
+
+/* Error Message */
+.contact-error {
+ background: linear-gradient(135deg, rgba(220, 53, 69, 0.1) 0%, rgba(200, 35, 51, 0.05) 100%);
+ border: 2px solid rgba(220, 53, 69, 0.3);
+ color: #721c24;
+}
+
+.contact-error iconify-icon {
+ color: #dc3545;
+}
+
+/* =============================================================================
+ FORM ACTIONS (Submit Button)
+ ============================================================================= */
+
+.form-actions {
+ margin-bottom: 0.75rem;
+}
+
+.contact-submit-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.875rem 1.5rem;
+ font-size: 1rem;
+ font-weight: 600;
+ font-family: inherit;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
+ color: white;
+ transition: all 0.2s ease;
+ box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
+}
+
+.contact-submit-btn:hover {
+ background: linear-gradient(135deg, #2980b9 0%, #3498db 100%);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(52, 152, 219, 0.4);
+}
+
+.contact-submit-btn:active {
+ transform: translateY(0);
+ box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
+}
+
+.contact-submit-btn:disabled {
+ background: #e0e0e0;
+ color: #999;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+/* Loading spinner in button */
+.contact-submit-btn .htmx-indicator {
+ display: none;
+}
+
+.contact-submit-btn.htmx-request .htmx-indicator {
+ display: inline-flex;
+}
+
+.contact-submit-btn.htmx-request > span {
+ opacity: 0.7;
+}
+
+/* Spinning animation */
+.spinning {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* =============================================================================
+ FORM NOTE
+ ============================================================================= */
+
+.form-note {
+ font-size: 0.8rem;
+ color: #666;
+ text-align: center;
+ margin: 0;
+ font-style: italic;
+}
+
+/* =============================================================================
+ CONTACT BUTTON (Fixed Position)
+ ============================================================================= */
+
+/* Contact Button - positioned above bottom buttons */
+.contact-btn {
+ position: fixed;
+ bottom: 14rem; /* Above zoom button */
+ left: 2rem;
+ width: 50px;
+ height: 50px;
+ background: var(--black-bar);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ transition: all 0.3s ease;
+ z-index: 999;
+ opacity: 0.6;
+}
+
+.contact-btn:hover {
+ opacity: 1;
+ transform: translateY(-3px);
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
+ background: #3498db; /* Blue hover */
+}
+
+.contact-btn.at-bottom {
+ opacity: 1;
+ background: #3498db; /* Blue when at bottom */
+}
+
+/* =============================================================================
+ RESPONSIVE DESIGN - CONTACT FORM
+ ============================================================================= */
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ #contact-modal {
+ max-width: calc(100vw - 2rem) !important;
+ width: calc(100vw - 2rem) !important;
+ max-height: calc(100vh - 2rem) !important;
+ }
+
+ #contact-modal .info-modal-content {
+ padding: 1.5rem 1rem;
+ max-height: calc(100vh - 2rem);
+ overflow-y: auto;
+ }
+
+ #contact-modal .info-modal-header h2 {
+ font-size: 1.25rem;
+ }
+
+ #contact-modal .info-modal-cv-title {
+ font-size: 1rem;
+ }
+
+ .contact-modal-description {
+ font-size: 0.875rem;
+ margin-bottom: 1.25rem;
+ }
+
+ .form-group {
+ margin-bottom: 1rem;
+ }
+
+ .form-label {
+ font-size: 0.85rem;
+ }
+
+ .form-input,
+ .form-textarea {
+ font-size: 0.9rem;
+ padding: 0.65rem;
+ }
+
+ .contact-submit-btn {
+ font-size: 0.95rem;
+ padding: 0.75rem 1.25rem;
+ }
+
+ .form-note {
+ font-size: 0.75rem;
+ }
+
+ /* Contact button mobile */
+ .contact-btn {
+ bottom: 13.5rem; /* Adjust for mobile button spacing */
+ left: 1.5rem;
+ width: 45px;
+ height: 45px;
+ }
+}
+
+/* =============================================================================
+ ACCESSIBILITY - REDUCED MOTION
+ ============================================================================= */
+
+@media (prefers-reduced-motion: reduce) {
+ .contact-message {
+ animation: none;
+ }
+
+ .spinning {
+ animation: none;
+ }
+
+ .contact-submit-btn {
+ transition: none;
+ }
+
+ .form-input,
+ .form-textarea {
+ transition: none;
+ }
+}
+
+/* =============================================================================
+ PRINT - HIDE CONTACT ELEMENTS
+ ============================================================================= */
+
+@media print {
+ .contact-btn,
+ #contact-modal {
+ display: none !important;
+ }
+}
diff --git a/templates/partials/contact/contact-error.html b/templates/partials/contact/contact-error.html
new file mode 100644
index 0000000..c30bbdd
--- /dev/null
+++ b/templates/partials/contact/contact-error.html
@@ -0,0 +1,9 @@
+{{define "contact-error"}}
+
+{{end}}
diff --git a/templates/partials/contact/contact-success.html b/templates/partials/contact/contact-success.html
new file mode 100644
index 0000000..21e1614
--- /dev/null
+++ b/templates/partials/contact/contact-success.html
@@ -0,0 +1,9 @@
+{{define "contact-success"}}
+
+{{end}}
diff --git a/templates/partials/contact_error.html b/templates/partials/contact_error.html
new file mode 100644
index 0000000..24c38a1
--- /dev/null
+++ b/templates/partials/contact_error.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Unable to Send Message
+
+
+ {{.Message}}
+
+
+
+
+
+
diff --git a/templates/partials/contact_success.html b/templates/partials/contact_success.html
new file mode 100644
index 0000000..4ee2b60
--- /dev/null
+++ b/templates/partials/contact_success.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Message Sent Successfully!
+
+
+ Thank you for reaching out. I've received your message and will get back to you as soon as possible.
+
+
+
+
+
+
diff --git a/templates/partials/modals/contact-modal.html b/templates/partials/modals/contact-modal.html
new file mode 100644
index 0000000..b1499ad
--- /dev/null
+++ b/templates/partials/modals/contact-modal.html
@@ -0,0 +1,162 @@
+{{define "contact-modal"}}
+
+
+
+
+
+{{end}}
diff --git a/templates/partials/navigation/hamburger-menu.html b/templates/partials/navigation/hamburger-menu.html
index 400bb9d..c5ac521 100644
--- a/templates/partials/navigation/hamburger-menu.html
+++ b/templates/partials/navigation/hamburger-menu.html
@@ -191,6 +191,17 @@
{{.UI.Widgets.ActionButtons.PrintFriendly}}
+
+
+
+
diff --git a/templates/partials/widgets/contact-button.html b/templates/partials/widgets/contact-button.html
new file mode 100644
index 0000000..7800178
--- /dev/null
+++ b/templates/partials/widgets/contact-button.html
@@ -0,0 +1,11 @@
+{{define "contact-button"}}
+
+
+{{end}}