diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..a193913
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "site",
+ "runtimeExecutable": "pnpm",
+ "runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"],
+ "port": 4321
+ }
+ ]
+}
diff --git a/relay/.dockerignore b/relay/.dockerignore
new file mode 100644
index 0000000..d5365a9
--- /dev/null
+++ b/relay/.dockerignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+**/appsettings.*.local.json
+Dockerfile
+.dockerignore
diff --git a/relay/ContactRelay.csproj b/relay/ContactRelay.csproj
new file mode 100644
index 0000000..c3f9ef0
--- /dev/null
+++ b/relay/ContactRelay.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net9.0
+ enable
+ enable
+ true
+ ContactRelay
+ ContactRelay
+
+
+
diff --git a/relay/Dockerfile b/relay/Dockerfile
new file mode 100644
index 0000000..b4d6be3
--- /dev/null
+++ b/relay/Dockerfile
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1
+
+# ---- build ----
+FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
+WORKDIR /src
+COPY ContactRelay.csproj .
+RUN dotnet restore
+COPY . .
+RUN dotnet publish -c Release -o /app --no-restore
+
+# ---- runtime ----
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
+WORKDIR /app
+RUN addgroup -S app && adduser -S app -G app
+COPY --from=build /app .
+USER app
+ENV ASPNETCORE_URLS=http://+:8081 \
+ DOTNET_EnableDiagnostics=0
+EXPOSE 8081
+HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+ CMD wget -qO- http://localhost:8081/healthz || exit 1
+ENTRYPOINT ["dotnet", "ContactRelay.dll"]
diff --git a/relay/Program.cs b/relay/Program.cs
new file mode 100644
index 0000000..b032109
--- /dev/null
+++ b/relay/Program.cs
@@ -0,0 +1,146 @@
+using System.Net;
+using System.Net.Mail;
+using System.Net.Mime;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Serialization;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.RateLimiting;
+
+// Stateless contact relay (TECH_SPEC §7): validate -> honeypot -> rate-limit ->
+// SMTP send -> forget. No persistence, no message bodies logged.
+
+var builder = WebApplication.CreateBuilder(args);
+
+var cfg = builder.Configuration;
+var allowedOrigin = cfg["Relay:AllowedOrigin"] ?? "https://cesnimda.co.uk";
+var rateLimit = cfg.GetValue("Relay:RateLimitPerWindow", 5);
+var windowSeconds = cfg.GetValue("Relay:WindowSeconds", 600);
+
+builder.Services.ConfigureHttpJsonOptions(o =>
+ o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
+
+// Real client IP from our own reverse proxy.
+builder.Services.Configure(o =>
+{
+ o.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
+ o.KnownNetworks.Clear();
+ o.KnownProxies.Clear();
+});
+
+builder.Services.AddCors(o => o.AddPolicy("site", p =>
+ p.WithOrigins(allowedOrigin).WithMethods("POST").WithHeaders("Content-Type")));
+
+builder.Services.AddRateLimiter(o =>
+{
+ o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ o.AddPolicy("contact", ctx => RateLimitPartition.GetFixedWindowLimiter(
+ ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = rateLimit,
+ Window = TimeSpan.FromSeconds(windowSeconds),
+ QueueLimit = 0,
+ }));
+});
+
+var app = builder.Build();
+app.UseForwardedHeaders();
+app.UseCors("site");
+app.UseRateLimiter();
+
+app.MapGet("/healthz", () => Results.Ok("ok"));
+
+app.MapPost("/api/contact", async (ContactRequest req, HttpContext http, ILogger log) =>
+{
+ var ipHash = HashIp(http.Connection.RemoteIpAddress?.ToString());
+
+ // Honeypot: a filled hidden field means a bot. Feign success, send nothing.
+ if (!string.IsNullOrWhiteSpace(req.Company))
+ {
+ log.LogInformation("contact rejected (honeypot) ip={Ip}", ipHash);
+ return Results.Ok(new ContactResponse(true));
+ }
+
+ var name = (req.Name ?? "").Trim();
+ var email = (req.Email ?? "").Trim();
+ var message = (req.Message ?? "").Trim();
+
+ if (name.Length is 0 or > 200 ||
+ email.Length is 0 or > 320 || !IsEmail(email) ||
+ message.Length is 0 or > 5000)
+ {
+ return Results.BadRequest(new ContactResponse(false, "invalid"));
+ }
+
+ try
+ {
+ await SendAsync(cfg, name, email, message);
+ log.LogInformation("contact sent ip={Ip}", ipHash);
+ return Results.Ok(new ContactResponse(true));
+ }
+ catch (Exception ex)
+ {
+ log.LogError(ex, "contact send failed ip={Ip}", ipHash);
+ return Results.StatusCode(StatusCodes.Status502BadGateway);
+ }
+}).RequireRateLimiting("contact");
+
+app.Run();
+
+static bool IsEmail(string s)
+{
+ var at = s.IndexOf('@');
+ var dot = s.LastIndexOf('.');
+ return at > 0 && dot > at + 1 && dot < s.Length - 1 && !s.Contains(' ');
+}
+
+static string HashIp(string? ip)
+{
+ if (string.IsNullOrEmpty(ip)) return "unknown";
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip));
+ return Convert.ToHexString(bytes, 0, 4); // short, non-reversible marker
+}
+
+static async Task SendAsync(IConfiguration cfg, string name, string email, string message)
+{
+ var host = cfg["Smtp:Host"] ?? throw new InvalidOperationException("Smtp:Host not configured");
+ var port = cfg.GetValue("Smtp:Port", 587);
+ var user = cfg["Smtp:User"];
+ var password = cfg["Smtp:Password"];
+ var to = cfg["Relay:ToAddress"] ?? throw new InvalidOperationException("Relay:ToAddress not configured");
+ var from = cfg["Relay:FromAddress"] ?? user ?? to;
+
+ using var msg = new MailMessage
+ {
+ From = new MailAddress(from, "Portfolio contact form"),
+ Subject = $"Portfolio contact from {name}",
+ Body =
+ $"From: {name} <{email}>\n\n{message}\n\n— sent via cesnimda.co.uk contact form",
+ BodyEncoding = Encoding.UTF8,
+ SubjectEncoding = Encoding.UTF8,
+ };
+ msg.To.Add(to);
+ msg.ReplyToList.Add(new MailAddress(email, name));
+
+ using var client = new SmtpClient(host, port)
+ {
+ EnableSsl = true,
+ DeliveryMethod = SmtpDeliveryMethod.Network,
+ Credentials = string.IsNullOrEmpty(user) ? null : new NetworkCredential(user, password),
+ };
+ await client.SendMailAsync(msg);
+}
+
+record ContactRequest(
+ [property: JsonPropertyName("name")] string? Name,
+ [property: JsonPropertyName("email")] string? Email,
+ [property: JsonPropertyName("message")] string? Message,
+ [property: JsonPropertyName("company")] string? Company);
+
+record ContactResponse(bool Ok, string? Error = null);
+
+[JsonSerializable(typeof(ContactRequest))]
+[JsonSerializable(typeof(ContactResponse))]
+partial class AppJsonContext : JsonSerializerContext;
diff --git a/relay/appsettings.json b/relay/appsettings.json
new file mode 100644
index 0000000..fb04949
--- /dev/null
+++ b/relay/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Relay": {
+ "AllowedOrigin": "https://cesnimda.co.uk",
+ "RateLimitPerWindow": 5,
+ "WindowSeconds": 600
+ },
+ "Smtp": {
+ "Port": 587
+ }
+}