This commit is contained in:
+6
-5
@@ -11,8 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/shared/mail"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/api"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/mail"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -21,9 +21,10 @@ func main() {
|
||||
|
||||
addr := ":" + getEnv("PORT", "8082")
|
||||
|
||||
mailCfg := mail.FromEnv()
|
||||
if mailCfg.Enabled() {
|
||||
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
|
||||
sender := mail.FromEnv()
|
||||
contactTo := getEnv("SMTP_TO", "support@hostxtra.co.uk")
|
||||
if sender.Enabled() {
|
||||
log.Printf("smtp enabled (%s) contact form delivers to %s", sender.Host, contactTo)
|
||||
} else {
|
||||
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact form will refuse submissions")
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func main() {
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: api.New(mailCfg).Routes(),
|
||||
Handler: api.New(sender, contactTo).Routes(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
|
||||
+16
-25
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/mail"
|
||||
"github.com/mrhid6/vantage/shared/mail"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,15 +21,18 @@ const (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
mail mail.Sender
|
||||
contact string // where enquiries go; the sender itself has no default recipient
|
||||
limiter *limiter
|
||||
|
||||
allowOrigin map[string]bool
|
||||
trustProxy bool
|
||||
}
|
||||
|
||||
func New(mailCfg mail.Config) *Server {
|
||||
func New(sender mail.Sender, contactTo string) *Server {
|
||||
return &Server{
|
||||
mail: mailCfg,
|
||||
mail: sender,
|
||||
contact: contactTo,
|
||||
limiter: newLimiter(perIPLimit, perIPWindow),
|
||||
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
|
||||
trustProxy: os.Getenv("TRUST_PROXY") == "true",
|
||||
@@ -168,7 +171,7 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mail.Enabled() {
|
||||
if !s.mail.Enabled() || s.contact == "" {
|
||||
log.Println("contact submission dropped: smtp is not configured")
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "The contact form is unavailable right now. Email support@hostxtra.co.uk directly.",
|
||||
@@ -176,7 +179,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
|
||||
if err := s.mail.SendEnquiry(s.contact, mail.Enquiry{
|
||||
Name: fields["name"],
|
||||
Email: addr,
|
||||
Servers: fields["servers"],
|
||||
Topic: fields["topic"],
|
||||
Message: fields["message"],
|
||||
}); err != nil {
|
||||
log.Printf("contact send: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send that. Try again, or email support@hostxtra.co.uk directly.",
|
||||
@@ -187,24 +196,6 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
|
||||
}
|
||||
|
||||
func subject(addr string, fields map[string]string) string {
|
||||
return fmt.Sprintf("[Vantage] %s %s", fields["topic"], addr)
|
||||
}
|
||||
|
||||
func plainBody(addr string, fields map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("New contact enquiry from the Vantage site.\n\n")
|
||||
fmt.Fprintf(&b, "Name: %s\n", fields["name"])
|
||||
fmt.Fprintf(&b, "Email: %s\n", addr)
|
||||
fmt.Fprintf(&b, "Servers: %s\n", fields["servers"])
|
||||
fmt.Fprintf(&b, "Topic: %s\n", fields["topic"])
|
||||
fmt.Fprintf(&b, "Received: %s\n\n", time.Now().UTC().Format(time.RFC1123))
|
||||
b.WriteString("Message:\n")
|
||||
b.WriteString(fields["message"])
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func FromEnv() Config {
|
||||
return Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: envOr("SMTP_PORT", "587"),
|
||||
Username: os.Getenv("SMTP_USERNAME"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
To: envOr("SMTP_TO", "support@hostxtra.co.uk"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) Enabled() bool {
|
||||
return c.Host != "" && c.From != "" && c.To != ""
|
||||
}
|
||||
|
||||
func (c Config) Send(subject, body, replyTo string) error {
|
||||
return c.sendTo(c.To, subject, body, replyTo)
|
||||
}
|
||||
|
||||
func (c Config) sendTo(to, subject, body, replyTo string) error {
|
||||
if !c.Enabled() {
|
||||
return fmt.Errorf("smtp: not configured")
|
||||
}
|
||||
if to == "" {
|
||||
return fmt.Errorf("smtp: no recipient")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(c.Host, c.Port)
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: dial %s: %w", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
if c.Port == "465" {
|
||||
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp: client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if c.Port != "465" {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
|
||||
return fmt.Errorf("smtp: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
|
||||
return fmt.Errorf("smtp: auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(c.From); err != nil {
|
||||
return fmt.Errorf("smtp: mail from: %w", err)
|
||||
}
|
||||
for _, rcpt := range recipients(to) {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(message(c.From, to, subject, body, replyTo)); err != nil {
|
||||
return fmt.Errorf("smtp: write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp: close data: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func recipients(to string) []string {
|
||||
parts := strings.Split(to, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func message(from, to, subject, body, replyTo string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
|
||||
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
|
||||
if replyTo != "" {
|
||||
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
|
||||
}
|
||||
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("Message-ID: " + messageID(from) + "\r\n")
|
||||
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func messageID(from string) string {
|
||||
domain := "vantage.local"
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
|
||||
domain = strings.Trim(from[at+1:], "<> ")
|
||||
}
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain)
|
||||
}
|
||||
return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain)
|
||||
}
|
||||
|
||||
func sanitizeHeader(v string) string {
|
||||
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
|
||||
}
|
||||
|
||||
func (c Config) SendVerification(to, instanceName, link string, ttl time.Duration) error {
|
||||
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
|
||||
|
||||
Open this link:
|
||||
|
||||
%s
|
||||
|
||||
The link works once and expires in %d hours. Until you use it, no account
|
||||
exists nothing has been created and the address is not registered.
|
||||
|
||||
If you did not request this, ignore this email and nothing will happen.
|
||||
`, instanceName, link, int(ttl.Hours()))
|
||||
|
||||
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user