feat: initialize nuntius project with full server implementation

This commit is contained in:
2026-06-20 20:48:09 +02:00
commit 1b700f7975
33 changed files with 4313 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
//go:build linux || freebsd
// Package contactform provides reusable types and validation
// for a JSON contact form. Import it into any Go project that
// needs a contact endpoint.
package contactform
// Request is the JSON body sent to the contact endpoint.
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
Honeypot string `json:"-"`
}
// Response is the success response.
type Response struct {
OK bool `json:"ok"`
}
// FieldError describes a single validation failure.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ErrorResponse is returned for any non-2xx response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
+111
View File
@@ -0,0 +1,111 @@
//go:build linux || freebsd
package contactform
import (
"net/mail"
"strings"
"unicode/utf8"
)
// validServices is the allow-list for contact-type forms.
var validServices = map[string]bool{
"": true,
"architecture": true,
"ai": true,
"infrastructure": true,
"software": true,
"unix": true,
"other": true,
}
// MinNameRunes is the minimum allowed length of a name.
const MinNameRunes = 2
// MaxNameRunes is the maximum allowed length of a name.
const MaxNameRunes = 100
// MinMessageRunes is the minimum allowed length of a message body.
const MinMessageRunes = 10
// MaxMessageRunes is the maximum allowed length of a message body.
const MaxMessageRunes = 5000
// Validate dispatches to the correct type-specific validator.
// Supported types: contact, feedback, newsletter, generic. Default is "contact".
func Validate(r *Request, formType string) []FieldError {
switch formType {
case "newsletter":
return validateNewsletter(r)
case "feedback", "generic":
return validateGeneric(r)
default:
return validateContact(r)
}
}
func validateContact(r *Request) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Service = strings.TrimSpace(r.Service)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
} else if n > MaxNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if !validServices[r.Service] {
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
}
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
} else if n > MaxMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
}
return errs
}
func validateNewsletter(r *Request) []FieldError {
r.Email = strings.TrimSpace(r.Email)
if _, err := mail.ParseAddress(r.Email); err != nil {
return []FieldError{{Field: "email", Message: "email is invalid"}}
}
return nil
}
func validateGeneric(r *Request) []FieldError {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
r.Message = strings.TrimSpace(r.Message)
var errs []FieldError
if n := utf8.RuneCountInString(r.Name); n < MinNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at least 2 characters"})
} else if n > MaxNameRunes {
errs = append(errs, FieldError{Field: "name", Message: "name must be at most 100 characters"})
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if n := utf8.RuneCountInString(r.Message); n < MinMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at least 10 characters"})
} else if n > MaxMessageRunes {
errs = append(errs, FieldError{Field: "message", Message: "message must be at most 5000 characters"})
}
return errs
}
+127
View File
@@ -0,0 +1,127 @@
//go:build linux || freebsd
package contactform
import (
"strings"
"testing"
)
func TestValidateContact_Happy(t *testing.T) {
r := &Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss a project.",
}
if errs := Validate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateContact_DefaultType(t *testing.T) {
// Unknown / empty type falls back to contact validation.
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "Hello there."}
if errs := Validate(r, ""); len(errs) > 0 {
t.Fatalf("expected no errors with empty type, got %v", errs)
}
}
func TestValidateContact_RejectsInvalidEmail(t *testing.T) {
r := &Request{Name: "Jane", Email: "not-an-email", Message: "A message long enough."}
errs := Validate(r, "contact")
if len(errs) == 0 {
t.Fatal("expected error for invalid email")
}
if errs[0].Field != "email" {
t.Errorf("expected field=email, got %q", errs[0].Field)
}
}
func TestValidateContact_RejectsBadService(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}
errs := Validate(r, "contact")
if len(errs) == 0 || errs[0].Field != "service" {
t.Fatalf("expected service error, got %v", errs)
}
}
func TestValidateFeedback_OmitsService(t *testing.T) {
// Feedback validates like generic: name, email, message; no service field.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}
if errs := Validate(r, "feedback"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateGeneric_RejectsShortMessage(t *testing.T) {
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "short"}
errs := Validate(r, "generic")
if len(errs) == 0 || errs[0].Field != "message" {
t.Fatalf("expected message error, got %v", errs)
}
}
func TestValidateNewsletter_Happy(t *testing.T) {
r := &Request{Email: "jane@example.com"}
if errs := Validate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
r := &Request{Email: "garbage"}
errs := Validate(r, "newsletter")
if len(errs) == 0 {
t.Fatal("expected error for missing/invalid email")
}
}
func TestValidateNewsletter_IgnoresNameAndMessage(t *testing.T) {
// Newsletter type only cares about the email field.
r := &Request{Email: "jane@example.com", Name: "", Message: ""}
if errs := Validate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("newsletter should ignore empty name/message, got %v", errs)
}
}
func TestValidateRejectsUnknownTypeOnlyWhenTypeExplicitlyInvalid(t *testing.T) {
// Sanity: an unsupported form type (e.g. "foo") falls back to contact
// validation, not to a hard error. The hard error is enforced at the
// config layer, not in Validate itself.
r := &Request{Name: "Jane", Email: "jane@example.com", Message: "Hello there."}
if errs := Validate(r, "foo"); len(errs) > 0 {
t.Fatalf("Validate with unknown type should fall back to contact, got %v", errs)
}
}
func TestValidateTrimsWhitespace(t *testing.T) {
r := &Request{
Name: " Jane ",
Email: " jane@example.com ",
Service: " architecture ",
Message: " hello there ",
}
if errs := Validate(r, "contact"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if r.Name != "Jane" || r.Email != "jane@example.com" || r.Message != "hello there" {
t.Errorf("expected whitespace to be trimmed, got %+v", r)
}
}
func TestErrorMessageHasMinLengthPlaceholder(t *testing.T) {
// Sanity: the minLength error message in the en.json UI mirror should
// remain a simple string. Here we just check that error messages are
// human-readable, not empty.
r := &Request{Name: "J", Email: "jane@example.com", Message: "hi"}
errs := Validate(r, "contact")
if len(errs) < 2 {
t.Fatalf("expected at least 2 errors, got %v", errs)
}
for _, e := range errs {
if strings.TrimSpace(e.Message) == "" {
t.Errorf("error message is empty for field %q", e.Field)
}
}
}