contact

package
v1.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: GPL-3.0 Imports: 15 Imported by: 0

Documentation

Overview

Package contact turns a submitted form into a validated message and hands it to something that can deliver it.

Index

Examples

Constants

View Source
const (
	MaxNameLen    = 100
	MaxEmailLen   = 254 // RFC 5321 maximum for a forward path
	MaxMessageLen = 5000
	MinMessageLen = 10
)

Field limits. Generous for a human, mean for anything pasting a payload: the message cap is the one that matters, since an unbounded body is how a form becomes a way to post arbitrary volumes of text into someone's inbox.

View Source
const DefaultSubject = "Contact form: {{ .Name }}"

DefaultSubject is what a form that does not name a subject template gets.

Variables

View Source
var ErrSpam = errors.New("submission looks automated")

ErrSpam means the submission looked automated. Callers should answer as if it succeeded: telling a bot it was caught only teaches it what to change.

Functions

func Undeliverable added in v1.2.0

func Undeliverable(provider, op string, err error) error

Undeliverable wraps err as a delivery failure. Adapters use it so every failure that leaves a Mailer looks the same from the outside.

Types

type DeliveryError added in v1.2.0

type DeliveryError struct {
	// Provider is the adapter that failed: "smtp" or "mailgun".
	Provider string
	// Op is the step it failed at — "dial", "auth", "send". Named for what the
	// adapter was doing rather than for the function it was in, because this
	// ends up in a log somebody reads at three in the morning.
	Op  string
	Err error
}

DeliveryError is what a Mailer returns when it could not deliver.

It lives here rather than beside either adapter because it belongs to the port: the handler is what reads it, and the handler must not have to know whether the form behind it speaks SMTP or HTTP. Once two providers can fail, "could not send message" in a log line stops being enough to act on — which one, and at which step, is the difference between a wrong password and a mail server that is briefly down.

func (*DeliveryError) Error added in v1.2.0

func (e *DeliveryError) Error() string

func (*DeliveryError) Unwrap added in v1.2.0

func (e *DeliveryError) Unwrap() error

Unwrap keeps errors.Is and errors.As working through this, so an adapter wrapping a provider's own typed error does not hide it.

type Form

type Form struct {
	// ID is the last path segment of the endpoint, so it has to survive being
	// in a URL. Validated by whatever builds the Form.
	ID string
	// Origins are the sites allowed to post to this form. Per form rather than
	// global: one site being allowed to use its own form must not let it use
	// somebody else's.
	Origins []string
	// Subject is a text/template rendered with .Name, .Email and .Form. Empty
	// means DefaultSubject.
	Subject string
	// RatePerHour is submissions allowed per client address per hour. Zero
	// disables the limit.
	RatePerHour int
}

Form is one configured form, in the terms this package needs: who may post to it, how often, and what the resulting subject line says. Where the mail goes is not here — that belongs to the Mailer the form is wired to, so a form and its destination are chosen together at the composition root.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler serves one form's endpoint. One per configured form, each with its own origins, its own subject line, its own rate limit and its own Mailer — which is what keeps two forms on one service from leaking into each other.

func NewHandler

func NewHandler(f Form, m Mailer, log *slog.Logger) (*Handler, error)

NewHandler wires a handler for one form.

Origins are the sites allowed to post here; a browser will not send the form from anywhere else once this is set, and anything that is not a browser was never going to respect CORS anyway — so this is about keeping other people's pages from using our mailbox, not about authentication.

It returns an error rather than panicking on a bad subject template, because that template comes from a config file a person edits, and the useful moment to hear about a typo in it is at startup.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type Mailer

type Mailer interface {
	Send(ctx context.Context, m Message) error
}

Mailer is what this package needs from the outside world: somewhere to send a message. Declared here rather than beside the SMTP implementation, because the consumer is what knows the shape it wants.

type Message

type Message struct {
	Name    string
	Email   string
	Subject string
	Body    string
}

Message is what actually gets delivered. Separate from Submission on purpose: a submission is untrusted input, a message has been through Validate.

Subject is left empty by Validate and filled in by the Handler, because the subject line belongs to the form that was posted to rather than to the submission: two forms on the same service word it differently.

func Validate

func Validate(s Submission) (Message, error)

Validate checks a submission and returns the message to deliver.

Trims first, then measures. " " in a required field is absence with extra steps, and counting it as present lets an empty message through.

Example

The ordinary path: an untrusted Submission goes in, a Message that has been through validation comes out, trimmed and with the address parsed.

The subject is not filled in here. It belongs to the form that was posted to — two forms on one service word it differently — so the Handler renders it from that form's template.

package main

import (
	"fmt"

	"github.com/alrayyes/form-handler/internal/contact"
)

func main() {
	msg, err := contact.Validate(contact.Submission{
		Name:    "  Ada Lovelace  ",
		Email:   "ada@example.com",
		Message: "Please get in touch about an awkward system.",
	})
	if err != nil {
		fmt.Println("rejected:", err)
		return
	}

	fmt.Printf("%q\n", msg.Name)
	fmt.Println(msg.Email)
	fmt.Printf("subject: %q\n", msg.Subject)
}
Output:
"Ada Lovelace"
ada@example.com
subject: ""
Example (Honeypot)

A caught bot is an error, but not one to show anyone: answer it exactly as you would a real submission, because telling it which field gave it away only teaches whoever wrote it what to leave alone next time.

package main

import (
	"errors"
	"fmt"

	"github.com/alrayyes/form-handler/internal/contact"
)

func main() {
	_, err := contact.Validate(contact.Submission{
		Name:    "Bot",
		Email:   "bot@example.com",
		Message: "Cheap watches, buy now please.",
		Website: "http://spam.example",
	})

	fmt.Println(errors.Is(err, contact.ErrSpam))
}
Output:
true
Example (ValidationError)

A rejected field says which one and why, so the browser can point at it rather than showing a generic failure.

package main

import (
	"errors"
	"fmt"

	"github.com/alrayyes/form-handler/internal/contact"
)

func main() {
	_, err := contact.Validate(contact.Submission{
		Name:    "Ada Lovelace",
		Email:   "not-an-address",
		Message: "Please get in touch about an awkward system.",
	})

	var ve contact.ValidationError
	if errors.As(err, &ve) {
		fmt.Println(ve.Field, "->", ve.Reason)
	}
}
Output:
email -> not a valid address

type Submission

type Submission struct {
	Name    string `json:"name"`
	Email   string `json:"email"`
	Message string `json:"message"`
	// Website is a honeypot. It is hidden from people and left empty by them;
	// a bot that fills every field it finds will populate it. Named something
	// a bot would want to fill rather than "honeypot".
	Website string `json:"website"`
}

Submission is the raw form, exactly as posted.

type ValidationError

type ValidationError struct {
	Field  string `json:"field"`
	Reason string `json:"reason"`
}

ValidationError names the field that was wrong, so the browser can point at it instead of showing a generic failure.

func (ValidationError) Error

func (e ValidationError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL