-

-
-
- Hackathon
-
-
- Application
-
-
- Review
-
-
- Platform
-
-
+
+
+
+
+ Hacker access // 01
+
+
+
+
+
-
-
- Login or create an account
+
+
+
+
+ Authentication required
+
+
+ Enter Zero Day.
+
+
+ Sign in or create your hacker account with a secure magic link.
-
+
{state === "error" && error && (
-
- {error}
+
+
+ {error}
+
)}
{/* Magic Link Form */}
-
-
+
+
);
}
diff --git a/client/portal/src/pages/public/components/MascotField.tsx b/client/portal/src/pages/public/components/MascotField.tsx
new file mode 100644
index 000000000..f8b0b173a
--- /dev/null
+++ b/client/portal/src/pages/public/components/MascotField.tsx
@@ -0,0 +1,69 @@
+import dragonfly from "@/assets/mascots/dragonfly.webp";
+import jaguar from "@/assets/mascots/jaguar.webp";
+import octopus from "@/assets/mascots/octopus.webp";
+import raccoon from "@/assets/mascots/raccoon_walk.webp";
+
+/**
+ * Decorative mascots that wander around behind the sign-in form.
+ *
+ * The webp files are already animated in place (the jaguar's run cycle, the
+ * dragonfly's wings, the raccoon's walk), so all this layer does is move them
+ * around the page: the jaguar sprints the full width and turns around
+ * off-screen, the dragonfly flits about overhead, and the octopus and raccoon
+ * wander their own stretch of floor. Each one covers real ground rather than
+ * rocking in place, on periods long enough that they never fall into step with
+ * each other. Keyframes live in `src/index.css` under "Login mascots".
+ *
+ * They are kept small — pet-sized rather than poster-sized. On phones the form
+ * takes up nearly the whole width, so they stay in the bands above and below it
+ * and roam mostly sideways; from `md` up, where there is room either side of
+ * the form, they spread out and the roaming opens up. The layer is inert
+ * (`pointer-events-none`), hidden from assistive tech, and dropped entirely
+ * under `prefers-reduced-motion` or on a viewport too short to spare the room.
+ */
+export default function MascotField() {
+ return (
+
+ {/* Jaguar — sprints across the bottom, waits off-screen, sprints back */}
+

+
+ {/* Dragonfly — flits across the top, never quite settling */}
+
+

+
+
+ {/* Octopus — waddles the long way around its own patch of floor */}
+
+

+
+
+ {/* Raccoon — potters up and down the floor, turning at each end */}
+
+

+
+
+ );
+}
diff --git a/client/portal/src/routes.tsx b/client/portal/src/routes.tsx
index afaa884ca..299072d50 100644
--- a/client/portal/src/routes.tsx
+++ b/client/portal/src/routes.tsx
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import { createBrowserRouter, Navigate, Outlet } from "react-router";
import { ErrorPage } from "@/components/ErrorPage";
+import { HackerPageLoader } from "@/components/HackerPageLoader";
import { PageLoader } from "@/components/PageLoader";
// Auth pages stay eager (critical path)
import {
@@ -105,7 +106,7 @@ export const router = createBrowserRouter([
path: "/app",
element: (
- }>
+ }>
@@ -114,7 +115,7 @@ export const router = createBrowserRouter([
{
index: true,
element: (
-
}>
+
}>
),
@@ -122,7 +123,7 @@ export const router = createBrowserRouter([
{
path: "apply",
element: (
-
}>
+
}>
),
@@ -140,7 +141,7 @@ export const router = createBrowserRouter([
{
path: "application",
element: (
-
}>
+
}>
),
@@ -148,7 +149,7 @@ export const router = createBrowserRouter([
{
path: "rsvp",
element: (
-
}>
+
}>
),
@@ -156,7 +157,7 @@ export const router = createBrowserRouter([
{
path: "travel-rsvp",
element: (
-
}>
+
}>
),
@@ -164,7 +165,7 @@ export const router = createBrowserRouter([
{
path: "scan",
element: (
-
}>
+
}>
),
@@ -172,7 +173,7 @@ export const router = createBrowserRouter([
{
path: "schedule",
element: (
-
}>
+
}>
),
@@ -180,7 +181,7 @@ export const router = createBrowserRouter([
{
path: "profile",
element: (
-
}>
+
}>
),
@@ -188,7 +189,7 @@ export const router = createBrowserRouter([
{
path: "notifications",
element: (
-
}>
+
}>
),
@@ -196,7 +197,7 @@ export const router = createBrowserRouter([
{
path: "faq",
element: (
-
}>
+
}>
),
@@ -204,7 +205,7 @@ export const router = createBrowserRouter([
{
path: "hacker-pack",
element: (
-
}>
+
}>
),
diff --git a/client/portal/src/shared/auth/guards/RequireAuth.tsx b/client/portal/src/shared/auth/guards/RequireAuth.tsx
index d3e0265ad..f783349d3 100644
--- a/client/portal/src/shared/auth/guards/RequireAuth.tsx
+++ b/client/portal/src/shared/auth/guards/RequireAuth.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { Navigate } from "react-router";
import { useSessionContext } from "supertokens-auth-react/recipe/session";
-import { Skeleton } from "@/components/ui/skeleton";
+import { HackerPageLoader } from "@/components/HackerPageLoader";
import { useUserStore } from "@/shared/stores";
interface RequireAuthProps {
@@ -30,15 +30,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
// Show loading if session is loading or actively fetching user data
if (session.loading || loading) {
- return (
-
- );
+ return
;
}
// No session means not authenticated
@@ -48,15 +40,7 @@ const RequireAuth = ({ children }: RequireAuthProps) => {
// Session exists but no user data - show loading while fetch happens
if (!user) {
- return (
-
- );
+ return
;
}
return <>{children}>;
diff --git a/client/portal/src/shared/hooks/index.ts b/client/portal/src/shared/hooks/index.ts
index 4df71c142..cd4b80a32 100644
--- a/client/portal/src/shared/hooks/index.ts
+++ b/client/portal/src/shared/hooks/index.ts
@@ -1,3 +1,3 @@
-export { useIsMobile } from "./use-mobile";
+export { isMobileViewport, useIsMobile } from "./use-mobile";
export { useQrScanner } from "./use-qr-scanner";
export { useRedactApplicants } from "./use-redaction";
diff --git a/client/portal/src/shared/hooks/use-mobile.ts b/client/portal/src/shared/hooks/use-mobile.ts
index a93d58393..be979eaa7 100644
--- a/client/portal/src/shared/hooks/use-mobile.ts
+++ b/client/portal/src/shared/hooks/use-mobile.ts
@@ -2,6 +2,14 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768;
+/**
+ * One-shot viewport check for code that runs outside of render (effects,
+ * event handlers), where `useIsMobile` would still hold its initial value.
+ */
+export function isMobileViewport() {
+ return window.innerWidth < MOBILE_BREAKPOINT;
+}
+
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState
(
undefined,
diff --git a/client/portal/vite.config.ts b/client/portal/vite.config.ts
index 865b3440b..328574304 100644
--- a/client/portal/vite.config.ts
+++ b/client/portal/vite.config.ts
@@ -1,3 +1,5 @@
+import { createReadStream, readFileSync } from "node:fs";
+
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
@@ -16,6 +18,8 @@ const apiTarget = process.env.API_PROXY_TARGET || "http://localhost:8080";
function brandingHtml(): Plugin {
const tokens: Record = {
"%HARP_TITLE%": branding.appName,
+ "%HARP_SHORT_NAME%": branding.shortName,
+ "%HARP_DESCRIPTION%": branding.description,
"%HARP_THEME_COLOR%": branding.themeColor,
};
@@ -30,11 +34,42 @@ function brandingHtml(): Plugin {
};
}
+// Email clients cannot use Vite's hashed asset URLs because the API renders
+// the message independently of the frontend bundle. Emit the optimized title
+// artwork at a stable public path in production and serve the same path in dev.
+function zeroDayEmailAsset(): Plugin {
+ const publicPath = "/email-assets/zero-day-title.webp";
+ const sourcePath = path.resolve(__dirname, "./src/assets/title-login.webp");
+
+ return {
+ name: "zero-day-email-asset",
+ configureServer(server) {
+ server.middlewares.use((request, response, next) => {
+ if (request.url?.split("?", 1)[0] !== publicPath) {
+ next();
+ return;
+ }
+ response.setHeader("Content-Type", "image/webp");
+ response.setHeader("Cache-Control", "public, max-age=3600");
+ createReadStream(sourcePath).pipe(response);
+ });
+ },
+ generateBundle() {
+ this.emitFile({
+ type: "asset",
+ fileName: publicPath.slice(1),
+ source: readFileSync(sourcePath),
+ });
+ },
+ };
+}
+
export default defineConfig({
plugins: [
react(),
tailwindcss(),
brandingHtml(),
+ zeroDayEmailAsset(),
VitePWA({
registerType: "autoUpdate",
strategies: "injectManifest",
@@ -56,7 +91,10 @@ export default defineConfig({
theme_color: branding.themeColor,
background_color: branding.backgroundColor,
display: "standalone",
+ lang: "en-US",
+ categories: ["education", "productivity", "social"],
id: "/",
+ scope: "/",
start_url: "/",
icons: [
{
diff --git a/cmd/api/main.go b/cmd/api/main.go
index c78cdfcda..f758572ad 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -150,22 +150,6 @@ func main() {
store := store.NewStorage(db)
- // Initialize SuperTokens
- authCfg := auth.Config{
- AppName: cfg.supertokens.appName,
- ConnectionURI: cfg.supertokens.connectionURI,
- APIKey: cfg.supertokens.apiKey,
- APIBasePath: "/auth",
- APIURL: cfg.appURL,
- FrontendURL: cfg.frontendURL,
- GoogleClientID: cfg.supertokens.googleClientID,
- GoogleClientSecret: cfg.supertokens.googleClientSecret,
- }
- if err := auth.InitSuperTokens(authCfg, store); err != nil {
- logger.Fatal("failed to initialize supertokens", zap.Error(err))
- }
- logger.Info("supertokens initialized")
-
// Init mailer — picks provider from .env SMTP or SendGrid, at least one is required
mailClient, err := mailer.New(cfg.mail)
if err != nil {
@@ -194,6 +178,23 @@ func main() {
return mailer.Identity{HackathonName: name, FromEmail: fromEmail, FromName: fromName}
})
+ // Initialize SuperTokens after the mailer so passwordless sign-in uses the
+ // themed application email rather than SuperTokens' stock delivery service.
+ authCfg := auth.Config{
+ AppName: cfg.supertokens.appName,
+ ConnectionURI: cfg.supertokens.connectionURI,
+ APIKey: cfg.supertokens.apiKey,
+ APIBasePath: "/auth",
+ APIURL: cfg.appURL,
+ FrontendURL: cfg.frontendURL,
+ GoogleClientID: cfg.supertokens.googleClientID,
+ GoogleClientSecret: cfg.supertokens.googleClientSecret,
+ }
+ if err := auth.InitSuperTokens(authCfg, store, mailClient); err != nil {
+ logger.Fatal("failed to initialize supertokens", zap.Error(err))
+ }
+ logger.Info("supertokens initialized")
+
// Init GCS (optional in local/dev)
var gcsClient gcs.Client
if cfg.gcs.bucketName != "" {
diff --git a/internal/auth/supertokens.go b/internal/auth/supertokens.go
index 2720074d2..bb4c7f909 100644
--- a/internal/auth/supertokens.go
+++ b/internal/auth/supertokens.go
@@ -2,9 +2,11 @@ package auth
import (
"context"
+ "errors"
"time"
"github.com/hackutd/harp/internal/store"
+ "github.com/supertokens/supertokens-golang/ingredients/emaildelivery"
"github.com/supertokens/supertokens-golang/recipe/passwordless"
"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
"github.com/supertokens/supertokens-golang/recipe/session"
@@ -16,6 +18,10 @@ import (
const DefaultSessionRole = store.RoleHacker
+type MagicLinkEmailSender interface {
+ SendMagicLinkEmail(toEmail, magicLink string, codeLifetime time.Duration) error
+}
+
// Config holds the configuration needed for SuperTokens initialization.
type Config struct {
AppName string
@@ -29,11 +35,11 @@ type Config struct {
}
// InitSuperTokens initializes the SuperTokens SDK with the given configuration.
-func InitSuperTokens(cfg Config, appStore store.Storage) error {
+func InitSuperTokens(cfg Config, appStore store.Storage, emailSender MagicLinkEmailSender) error {
apiBasePath := cfg.APIBasePath
recipes := []supertokens.Recipe{
- passwordlessRecipe(appStore),
+ passwordlessRecipe(appStore, emailSender),
sessionRecipe(),
}
@@ -65,14 +71,33 @@ func googleEnabled(cfg Config) bool {
return cfg.GoogleClientID != "" && cfg.GoogleClientSecret != ""
}
-func passwordlessRecipe(appStore store.Storage) supertokens.Recipe {
+func passwordlessRecipe(appStore store.Storage, emailSender MagicLinkEmailSender) supertokens.Recipe {
return passwordless.Init(plessmodels.TypeInput{
ContactMethodEmail: plessmodels.ContactMethodEmailConfig{Enabled: true},
FlowType: "MAGIC_LINK",
Override: passwordlessOverrides(appStore),
+ EmailDelivery: magicLinkEmailDelivery(emailSender),
})
}
+func magicLinkEmailDelivery(emailSender MagicLinkEmailSender) *emaildelivery.TypeInput {
+ sendEmail := func(input emaildelivery.EmailType, _ supertokens.UserContext) error {
+ login := input.PasswordlessLogin
+ if login == nil || login.UrlWithLinkCode == nil {
+ return errors.New("passwordless email is missing its magic link")
+ }
+ return emailSender.SendMagicLinkEmail(
+ login.Email,
+ *login.UrlWithLinkCode,
+ time.Duration(login.CodeLifetime)*time.Millisecond,
+ )
+ }
+
+ return &emaildelivery.TypeInput{
+ Service: &emaildelivery.EmailDeliveryInterface{SendEmail: &sendEmail},
+ }
+}
+
func sessionRecipe() supertokens.Recipe {
return session.Init(&sessmodels.TypeInput{
Override: sessionOverrides(),
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
index 1def166db..6302538c4 100644
--- a/internal/mailer/mailer.go
+++ b/internal/mailer/mailer.go
@@ -5,6 +5,10 @@ import (
"embed"
"fmt"
"html/template"
+ "os"
+ "time"
+
+ qrcode "github.com/skip2/go-qrcode"
"github.com/hackutd/harp/internal/slug"
)
@@ -17,8 +21,40 @@ const (
// defaultQRAttachmentFilename is used when the configured event name has no
// ASCII characters to build a filename from.
defaultQRAttachmentFilename = "qr-code.png"
+
+ // brandImageContentID is the Content-ID every template references as
+ // cid:zero-day-title.webp. The image is embedded inline on every send so
+ // the header renders without the client having to fetch a remote asset.
+ brandImageContentID = "zero-day-title.webp"
+ brandImageContentType = "image/webp"
)
+// brandImagePaths are tried in order: the production container ships the
+// asset under static/, while local dev and tests read it from the portal
+// source tree (from the repo root or from within this package).
+var brandImagePaths = []string{
+ "static/email-assets/zero-day-title.webp",
+ "client/portal/src/assets/title-login.webp",
+ "../../client/portal/src/assets/title-login.webp",
+}
+
+// attachment is a provider-agnostic file to attach to an outgoing email.
+type attachment struct {
+ Filename string
+ ContentType string
+ Content []byte
+}
+
+// qrAttachment renders the hacker's check-in QR code as a PNG attachment
+// named after the configured event.
+func qrAttachment(hackathonName, userID string) (attachment, error) {
+ png, err := qrcode.Encode(userID, qrcode.Medium, 256)
+ if err != nil {
+ return attachment{}, fmt.Errorf("generating QR code: %w", err)
+ }
+ return attachment{Filename: qrAttachmentFilename(hackathonName), ContentType: "image/png", Content: png}, nil
+}
+
// qrAttachmentFilename names the attached QR image after the configured event,
// so a hacker saves smu-hacks-2027-qr-code.png rather than a file named after
// whoever happens to run the upstream project.
@@ -47,6 +83,7 @@ const (
)
type Client interface {
+ SendMagicLinkEmail(toEmail, magicLink string, codeLifetime time.Duration) error
SendQREmail(toEmail, toName, userID string) error
SendWalkInQueuedEmail(toEmail string, position int) error
SendWalkInAcceptedEmail(toEmail, userID string) error
@@ -58,6 +95,44 @@ type Client interface {
SetIdentityResolver(fn IdentityFunc)
}
+type magicLinkEmailData struct {
+ Email string
+ MagicLink string
+ Expires string
+ HackathonName string
+ From string
+}
+
+func loadBrandImage() ([]byte, error) {
+ var lastErr error
+ for _, path := range brandImagePaths {
+ image, err := os.ReadFile(path)
+ if err == nil {
+ return image, nil
+ }
+ lastErr = err
+ }
+ return nil, fmt.Errorf("reading Zero Day email title image: %w", lastErr)
+}
+
+func magicLinkLifetime(duration time.Duration) string {
+ minutes := int(duration.Round(time.Minute) / time.Minute)
+ if minutes <= 0 {
+ return "a short time"
+ }
+ if minutes == 1 {
+ return "1 minute"
+ }
+ if minutes%60 == 0 {
+ hours := minutes / 60
+ if hours == 1 {
+ return "1 hour"
+ }
+ return fmt.Sprintf("%d hours", hours)
+ }
+ return fmt.Sprintf("%d minutes", minutes)
+}
+
// Identity is the sender identity and event name used in outgoing email.
type Identity struct {
FromEmail string
diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go
index 4ff9ec485..8608007fc 100644
--- a/internal/mailer/mailer_test.go
+++ b/internal/mailer/mailer_test.go
@@ -3,8 +3,150 @@ package mailer
import (
"strings"
"testing"
+ "time"
)
+func TestMagicLinkTemplateRenders(t *testing.T) {
+ data := magicLinkEmailData{
+ Email: "hacker@example.com",
+ MagicLink: "https://portal.test/auth/verify?token=abc123",
+ Expires: "15 minutes",
+ HackathonName: "HackUTD 2026",
+ From: "HackUTD",
+ }
+ out, err := renderTemplate("magic_link", data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "Zero Day",
+ "hacker@example.com",
+ "https://portal.test/auth/verify?token=abc123",
+ "cid:zero-day-title.webp",
+ `bgcolor="#0B0C15"`,
+ "15 minutes",
+ "HackUTD 2026",
+ "Powered by Harp",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("magic_link: missing %q", want)
+ }
+ }
+ if strings.Contains(out, "") || strings.Contains(out, "{{") {
+ t.Error("magic_link: unresolved placeholder")
+ }
+}
+
+func TestBrandImageLoads(t *testing.T) {
+ image, err := loadBrandImage()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(image) == 0 {
+ t.Fatal("Zero Day email title image is empty")
+ }
+}
+
+// Every template shares the Zero Day frame from magic_link: an unpainted
+// canvas the client colours, the dark card, the inline title image, and the
+// Harp footer. The image is attached by Content-ID on every send, so a
+// template that forgets it renders a dangling attachment instead of the
+// header. The color-scheme declaration is what keeps inversion-aware clients
+// from repainting the card, so it is asserted alongside the frame.
+func TestAllTemplatesShareZeroDayTheme(t *testing.T) {
+ decision := decisionEmailData{Name: "Ada", HackathonName: "HackUTD 2026", PortalURL: "https://portal.test", From: "HackUTD"}
+ templates := map[string]any{
+ "magic_link": magicLinkEmailData{
+ Email: "hacker@example.com", MagicLink: "https://portal.test/auth/verify?token=abc123",
+ Expires: "15 minutes", HackathonName: "HackUTD 2026", From: "HackUTD",
+ },
+ "decision_accepted": decision,
+ "decision_waitlisted": decision,
+ "decision_rejected": decision,
+ "decisions_released": decision,
+ "qr_email": qrEmailData{Name: "Ada", HackathonName: "HackUTD 2026", From: "HackUTD"},
+ "walk_in_queued": walkInQueuedData{Email: "hacker@example.com", Position: 7, HackathonName: "HackUTD 2026", From: "HackUTD"},
+ "walk_in_accepted": walkInAcceptedData{Email: "hacker@example.com", HackathonName: "HackUTD 2026", From: "HackUTD"},
+ }
+
+ for name, data := range templates {
+ t.Run(name, func(t *testing.T) {
+ out, err := renderTemplate(name, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "cid:" + brandImageContentID,
+ `bgcolor="#0B0C15"`,
+ ``,
+ ``,
+ "HackUTD 2026",
+ "Powered by Harp",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("%s: missing %q", name, want)
+ }
+ }
+ if strings.Contains(out, "") || strings.Contains(out, "{{") {
+ t.Errorf("%s: unresolved placeholder", name)
+ }
+ })
+ }
+}
+
+func TestEventTemplatesRender(t *testing.T) {
+ t.Run("qr_email", func(t *testing.T) {
+ out, err := renderTemplate("qr_email", qrEmailData{Name: "Ada", HackathonName: "HackUTD", From: "HackUTD"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"Ada", "attached"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("qr_email: missing %q", want)
+ }
+ }
+ })
+
+ t.Run("walk_in_queued", func(t *testing.T) {
+ out, err := renderTemplate("walk_in_queued", walkInQueuedData{Email: "hacker@example.com", Position: 7, HackathonName: "HackUTD", From: "HackUTD"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"hacker@example.com", ">7<", "10 minutes"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("walk_in_queued: missing %q", want)
+ }
+ }
+ })
+
+ t.Run("walk_in_accepted", func(t *testing.T) {
+ out, err := renderTemplate("walk_in_accepted", walkInAcceptedData{Email: "hacker@example.com", HackathonName: "HackUTD", From: "HackUTD"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"hacker@example.com", "10 minutes", "attached"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("walk_in_accepted: missing %q", want)
+ }
+ }
+ })
+}
+
+func TestMagicLinkLifetime(t *testing.T) {
+ tests := map[time.Duration]string{
+ 0: "a short time",
+ time.Minute: "1 minute",
+ 15 * time.Minute: "15 minutes",
+ time.Hour: "1 hour",
+ 2 * time.Hour: "2 hours",
+ }
+ for duration, want := range tests {
+ if got := magicLinkLifetime(duration); got != want {
+ t.Errorf("magicLinkLifetime(%s) = %q, want %q", duration, got, want)
+ }
+ }
+}
+
func TestDecisionTemplatesRender(t *testing.T) {
data := decisionEmailData{Name: "Ada", HackathonName: "HackUTD", PortalURL: "https://portal.test", From: "HackUTD"}
for _, name := range []string{"decision_accepted", "decision_waitlisted", "decision_rejected", "decisions_released"} {
diff --git a/internal/mailer/mock_mailer.go b/internal/mailer/mock_mailer.go
index d4e49b84d..debe33a39 100644
--- a/internal/mailer/mock_mailer.go
+++ b/internal/mailer/mock_mailer.go
@@ -1,11 +1,20 @@
package mailer
-import "github.com/stretchr/testify/mock"
+import (
+ "time"
+
+ "github.com/stretchr/testify/mock"
+)
type MockClient struct {
mock.Mock
}
+func (m *MockClient) SendMagicLinkEmail(toEmail, magicLink string, codeLifetime time.Duration) error {
+ args := m.Called(toEmail, magicLink, codeLifetime)
+ return args.Error(0)
+}
+
func (m *MockClient) SendQREmail(toEmail, toName, userID string) error {
args := m.Called(toEmail, toName, userID)
return args.Error(0)
diff --git a/internal/mailer/render_preview_test.go b/internal/mailer/render_preview_test.go
new file mode 100644
index 000000000..6697591b6
--- /dev/null
+++ b/internal/mailer/render_preview_test.go
@@ -0,0 +1,46 @@
+package mailer
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestRenderPreviews writes each template to $EMAIL_PREVIEW_DIR for manual
+// inspection. Skipped unless the env var is set.
+func TestRenderPreviews(t *testing.T) {
+ dir := os.Getenv("EMAIL_PREVIEW_DIR")
+ if dir == "" {
+ t.Skip("EMAIL_PREVIEW_DIR not set")
+ }
+ image, err := loadBrandImage()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, brandImageContentID), image, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ decision := decisionEmailData{Name: "Ada", HackathonName: "HackUTD 2026", PortalURL: "https://portal.test", From: "HackUTD"}
+ templates := map[string]any{
+ "magic_link": magicLinkEmailData{Email: "hacker@example.com", MagicLink: "https://portal.test/auth/verify?token=abc123", Expires: "15 minutes", HackathonName: "HackUTD 2026", From: "HackUTD"},
+ "decision_accepted": decision,
+ "decision_waitlisted": decision,
+ "decision_rejected": decision,
+ "decisions_released": decision,
+ "qr_email": qrEmailData{Name: "Ada", HackathonName: "HackUTD 2026", From: "HackUTD"},
+ "walk_in_queued": walkInQueuedData{Email: "hacker@example.com", Position: 7, HackathonName: "HackUTD 2026", From: "HackUTD"},
+ "walk_in_accepted": walkInAcceptedData{Email: "hacker@example.com", HackathonName: "HackUTD 2026", From: "HackUTD"},
+ }
+ for name, data := range templates {
+ out, err := renderTemplate(name, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out = strings.ReplaceAll(out, "cid:"+brandImageContentID, brandImageContentID)
+ if err := os.WriteFile(filepath.Join(dir, name+".html"), []byte(out), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/mailer/sendgrid.go b/internal/mailer/sendgrid.go
index e2cb29787..4d0d49075 100644
--- a/internal/mailer/sendgrid.go
+++ b/internal/mailer/sendgrid.go
@@ -1,14 +1,12 @@
package mailer
import (
- "bytes"
"encoding/base64"
"fmt"
- "html/template"
+ "time"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
- qrcode "github.com/skip2/go-qrcode"
)
type walkInQueuedData struct {
@@ -44,8 +42,15 @@ func NewSendGrid(apiKey, fromEmail, fromName, hackathonName, portalURL string) *
}
}
-// send delivers a rendered HTML email to a single recipient.
-func (m *SendGridMailer) send(id Identity, toEmail, toName, subject, htmlBody string) error {
+// send delivers a rendered HTML email to a single recipient. The Zero Day
+// title image every template references by Content-ID is embedded inline;
+// any further attachments are delivered as regular downloads.
+func (m *SendGridMailer) send(id Identity, toEmail, toName, subject, htmlBody string, attachments ...attachment) error {
+ brandImage, err := loadBrandImage()
+ if err != nil {
+ return err
+ }
+
message := mail.NewV3Mail()
message.SetFrom(mail.NewEmail(id.FromName, id.FromEmail))
message.Subject = subject
@@ -55,6 +60,23 @@ func (m *SendGridMailer) send(id Identity, toEmail, toName, subject, htmlBody st
message.AddPersonalizations(p)
message.AddContent(mail.NewContent("text/html", htmlBody))
+ brand := mail.NewAttachment()
+ brand.SetContent(base64.StdEncoding.EncodeToString(brandImage))
+ brand.SetType(brandImageContentType)
+ brand.SetFilename(brandImageContentID)
+ brand.SetDisposition("inline")
+ brand.SetContentID(brandImageContentID)
+ message.AddAttachment(brand)
+
+ for _, a := range attachments {
+ file := mail.NewAttachment()
+ file.SetContent(base64.StdEncoding.EncodeToString(a.Content))
+ file.SetType(a.ContentType)
+ file.SetFilename(a.Filename)
+ file.SetDisposition("attachment")
+ message.AddAttachment(file)
+ }
+
response, err := m.client.Send(message)
if err != nil {
return fmt.Errorf("sending email: %w", err)
@@ -66,6 +88,22 @@ func (m *SendGridMailer) send(id Identity, toEmail, toName, subject, htmlBody st
return nil
}
+func (m *SendGridMailer) SendMagicLinkEmail(toEmail, magicLink string, codeLifetime time.Duration) error {
+ id := m.resolve()
+ htmlBody, err := renderTemplate("magic_link", magicLinkEmailData{
+ Email: toEmail,
+ MagicLink: magicLink,
+ Expires: magicLinkLifetime(codeLifetime),
+ HackathonName: id.HackathonName,
+ From: id.FromName,
+ })
+ if err != nil {
+ return err
+ }
+
+ return m.send(id, toEmail, toEmail, "Your Zero Day access link", htmlBody)
+}
+
func (m *SendGridMailer) SendDecisionEmail(toEmail, toName string, decision Decision) error {
tmplName, subjectFormat, err := decisionTemplate(decision)
if err != nil {
@@ -102,151 +140,48 @@ func (m *SendGridMailer) SendDecisionsReleasedEmail(toEmail, toName string) erro
}
func (m *SendGridMailer) SendQREmail(toEmail, toName, userID string) error {
- qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256)
- if err != nil {
- return fmt.Errorf("generating QR code: %w", err)
- }
-
- qrBase64 := base64.StdEncoding.EncodeToString(qrPNG)
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/qr_email.html")
+ qr, err := qrAttachment(id.HackathonName, userID)
if err != nil {
- return fmt.Errorf("reading email template: %w", err)
- }
-
- tmpl, err := template.New("qr_email").Parse(string(tmplData))
- if err != nil {
- return fmt.Errorf("parsing email template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- err = tmpl.Execute(&htmlBody, qrEmailData{Name: toName, HackathonName: id.HackathonName, From: id.FromName})
- if err != nil {
- return fmt.Errorf("executing email template: %w", err)
+ return err
}
- from := mail.NewEmail(id.FromName, id.FromEmail)
- to := mail.NewEmail(toName, toEmail)
-
- message := mail.NewV3Mail()
- message.SetFrom(from)
- message.Subject = fmt.Sprintf("Your %s QR code", id.HackathonName)
-
- p := mail.NewPersonalization()
- p.AddTos(to)
- message.AddPersonalizations(p)
-
- message.AddContent(mail.NewContent("text/html", htmlBody.String()))
-
- attachment := mail.NewAttachment()
- attachment.SetContent(qrBase64)
- attachment.SetType("image/png")
- attachment.SetFilename(qrAttachmentFilename(id.HackathonName))
- attachment.SetDisposition("attachment")
- message.AddAttachment(attachment)
-
- response, err := m.client.Send(message)
+ htmlBody, err := renderTemplate("qr_email", qrEmailData{Name: toName, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("sending email: %w", err)
- }
- if response.StatusCode >= 400 {
- return fmt.Errorf("sendgrid returned status %d: %s", response.StatusCode, response.Body)
+ return err
}
- return nil
+ return m.send(id, toEmail, toName, fmt.Sprintf("Your %s QR code", id.HackathonName), htmlBody, qr)
}
func (m *SendGridMailer) SendWalkInQueuedEmail(toEmail string, position int) error {
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/walk_in_queued.html")
- if err != nil {
- return fmt.Errorf("reading walk_in_queued template: %w", err)
- }
-
- tmpl, err := template.New("walk_in_queued").Parse(string(tmplData))
+ htmlBody, err := renderTemplate("walk_in_queued", walkInQueuedData{Email: toEmail, Position: position, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("parsing walk_in_queued template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- if err := tmpl.Execute(&htmlBody, walkInQueuedData{Email: toEmail, Position: position, HackathonName: id.HackathonName, From: id.FromName}); err != nil {
- return fmt.Errorf("executing walk_in_queued template: %w", err)
+ return err
}
- from := mail.NewEmail(id.FromName, id.FromEmail)
- to := mail.NewEmail(toEmail, toEmail)
-
- message := mail.NewV3Mail()
- message.SetFrom(from)
- message.Subject = fmt.Sprintf("You're #%d in the %s walk-in queue", position, id.HackathonName)
-
- p := mail.NewPersonalization()
- p.AddTos(to)
- message.AddPersonalizations(p)
- message.AddContent(mail.NewContent("text/html", htmlBody.String()))
-
- response, err := m.client.Send(message)
- if err != nil {
+ subject := fmt.Sprintf("You're #%d in the %s walk-in queue", position, id.HackathonName)
+ if err := m.send(id, toEmail, toEmail, subject, htmlBody); err != nil {
return fmt.Errorf("sending walk-in queued email: %w", err)
}
- if response.StatusCode >= 400 {
- return fmt.Errorf("sendgrid returned status %d: %s", response.StatusCode, response.Body)
- }
-
return nil
}
func (m *SendGridMailer) SendWalkInAcceptedEmail(toEmail, userID string) error {
- qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256)
- if err != nil {
- return fmt.Errorf("generating QR code: %w", err)
- }
- qrBase64 := base64.StdEncoding.EncodeToString(qrPNG)
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/walk_in_accepted.html")
+ qr, err := qrAttachment(id.HackathonName, userID)
if err != nil {
- return fmt.Errorf("reading walk_in_accepted template: %w", err)
+ return err
}
- tmpl, err := template.New("walk_in_accepted").Parse(string(tmplData))
+ htmlBody, err := renderTemplate("walk_in_accepted", walkInAcceptedData{Email: toEmail, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("parsing walk_in_accepted template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- if err := tmpl.Execute(&htmlBody, walkInAcceptedData{Email: toEmail, HackathonName: id.HackathonName, From: id.FromName}); err != nil {
- return fmt.Errorf("executing walk_in_accepted template: %w", err)
+ return err
}
- from := mail.NewEmail(id.FromName, id.FromEmail)
- to := mail.NewEmail(toEmail, toEmail)
-
- message := mail.NewV3Mail()
- message.SetFrom(from)
- message.Subject = fmt.Sprintf("You're in for %s", id.HackathonName)
-
- p := mail.NewPersonalization()
- p.AddTos(to)
- message.AddPersonalizations(p)
- message.AddContent(mail.NewContent("text/html", htmlBody.String()))
-
- attachment := mail.NewAttachment()
- attachment.SetContent(qrBase64)
- attachment.SetType("image/png")
- attachment.SetFilename(qrAttachmentFilename(id.HackathonName))
- attachment.SetDisposition("attachment")
- message.AddAttachment(attachment)
-
- response, err := m.client.Send(message)
- if err != nil {
+ if err := m.send(id, toEmail, toEmail, fmt.Sprintf("You're in for %s", id.HackathonName), htmlBody, qr); err != nil {
return fmt.Errorf("sending walk-in accepted email: %w", err)
}
- if response.StatusCode >= 400 {
- return fmt.Errorf("sendgrid returned status %d: %s", response.StatusCode, response.Body)
- }
-
return nil
}
diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go
index ebac2fb9d..c8291862c 100644
--- a/internal/mailer/smtp.go
+++ b/internal/mailer/smtp.go
@@ -3,9 +3,8 @@ package mailer
import (
"bytes"
"fmt"
- "html/template"
+ "time"
- qrcode "github.com/skip2/go-qrcode"
mail "github.com/wneessen/go-mail"
)
@@ -43,8 +42,15 @@ func NewSMTP(host string, port int, username, password, fromEmail, fromName, hac
}, nil
}
-// send delivers a rendered HTML email to a single recipient.
-func (m *SMTPMailer) send(id Identity, toEmail, toName, subject, htmlBody string) error {
+// send delivers a rendered HTML email to a single recipient. The Zero Day
+// title image every template references by Content-ID is embedded inline;
+// any further attachments are delivered as regular downloads.
+func (m *SMTPMailer) send(id Identity, toEmail, toName, subject, htmlBody string, attachments ...attachment) error {
+ brandImage, err := loadBrandImage()
+ if err != nil {
+ return err
+ }
+
msg := mail.NewMsg()
if err := msg.FromFormat(id.FromName, id.FromEmail); err != nil {
return fmt.Errorf("setting from address: %w", err)
@@ -55,6 +61,15 @@ func (m *SMTPMailer) send(id Identity, toEmail, toName, subject, htmlBody string
msg.Subject(subject)
msg.SetBodyString(mail.TypeTextHTML, htmlBody)
+ if err := msg.EmbedReader(brandImageContentID, bytes.NewReader(brandImage), mail.WithFileContentType(brandImageContentType)); err != nil {
+ return fmt.Errorf("embedding Zero Day email title image: %w", err)
+ }
+ for _, a := range attachments {
+ if err := msg.AttachReader(a.Filename, bytes.NewReader(a.Content), mail.WithFileContentType(mail.ContentType(a.ContentType))); err != nil {
+ return fmt.Errorf("attaching %s: %w", a.Filename, err)
+ }
+ }
+
if err := m.client.DialAndSend(msg); err != nil {
return fmt.Errorf("sending email: %w", err)
}
@@ -62,6 +77,22 @@ func (m *SMTPMailer) send(id Identity, toEmail, toName, subject, htmlBody string
return nil
}
+func (m *SMTPMailer) SendMagicLinkEmail(toEmail, magicLink string, codeLifetime time.Duration) error {
+ id := m.resolve()
+ htmlBody, err := renderTemplate("magic_link", magicLinkEmailData{
+ Email: toEmail,
+ MagicLink: magicLink,
+ Expires: magicLinkLifetime(codeLifetime),
+ HackathonName: id.HackathonName,
+ From: id.FromName,
+ })
+ if err != nil {
+ return err
+ }
+
+ return m.send(id, toEmail, toEmail, "Your Zero Day access link", htmlBody)
+}
+
func (m *SMTPMailer) SendDecisionEmail(toEmail, toName string, decision Decision) error {
tmplName, subjectFormat, err := decisionTemplate(decision)
if err != nil {
@@ -98,122 +129,48 @@ func (m *SMTPMailer) SendDecisionsReleasedEmail(toEmail, toName string) error {
}
func (m *SMTPMailer) SendQREmail(toEmail, toName, userID string) error {
- qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256)
- if err != nil {
- return fmt.Errorf("generating QR code: %w", err)
- }
-
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/qr_email.html")
+ qr, err := qrAttachment(id.HackathonName, userID)
if err != nil {
- return fmt.Errorf("reading email template: %w", err)
+ return err
}
- tmpl, err := template.New("qr_email").Parse(string(tmplData))
+ htmlBody, err := renderTemplate("qr_email", qrEmailData{Name: toName, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("parsing email template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- if err := tmpl.Execute(&htmlBody, qrEmailData{Name: toName, HackathonName: id.HackathonName, From: id.FromName}); err != nil {
- return fmt.Errorf("executing email template: %w", err)
- }
-
- msg := mail.NewMsg()
- if err := msg.FromFormat(id.FromName, id.FromEmail); err != nil {
- return fmt.Errorf("setting from address: %w", err)
- }
- if err := msg.AddToFormat(toName, toEmail); err != nil {
- return fmt.Errorf("setting to address: %w", err)
- }
- msg.Subject(fmt.Sprintf("Your %s QR code", id.HackathonName))
- msg.SetBodyString(mail.TypeTextHTML, htmlBody.String())
- if err := msg.AttachReader(qrAttachmentFilename(id.HackathonName), bytes.NewReader(qrPNG), mail.WithFileContentType("image/png")); err != nil {
- return fmt.Errorf("attaching QR code: %w", err)
- }
-
- if err := m.client.DialAndSend(msg); err != nil {
- return fmt.Errorf("sending email: %w", err)
+ return err
}
- return nil
+ return m.send(id, toEmail, toName, fmt.Sprintf("Your %s QR code", id.HackathonName), htmlBody, qr)
}
func (m *SMTPMailer) SendWalkInQueuedEmail(toEmail string, position int) error {
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/walk_in_queued.html")
+ htmlBody, err := renderTemplate("walk_in_queued", walkInQueuedData{Email: toEmail, Position: position, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("reading walk_in_queued template: %w", err)
- }
-
- tmpl, err := template.New("walk_in_queued").Parse(string(tmplData))
- if err != nil {
- return fmt.Errorf("parsing walk_in_queued template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- if err := tmpl.Execute(&htmlBody, walkInQueuedData{Email: toEmail, Position: position, HackathonName: id.HackathonName, From: id.FromName}); err != nil {
- return fmt.Errorf("executing walk_in_queued template: %w", err)
- }
-
- msg := mail.NewMsg()
- if err := msg.FromFormat(id.FromName, id.FromEmail); err != nil {
- return fmt.Errorf("setting from address: %w", err)
- }
- if err := msg.AddToFormat(toEmail, toEmail); err != nil {
- return fmt.Errorf("setting to address: %w", err)
+ return err
}
- msg.Subject(fmt.Sprintf("You're #%d in the %s walk-in queue", position, id.HackathonName))
- msg.SetBodyString(mail.TypeTextHTML, htmlBody.String())
- if err := m.client.DialAndSend(msg); err != nil {
+ subject := fmt.Sprintf("You're #%d in the %s walk-in queue", position, id.HackathonName)
+ if err := m.send(id, toEmail, toEmail, subject, htmlBody); err != nil {
return fmt.Errorf("sending walk-in queued email: %w", err)
}
-
return nil
}
func (m *SMTPMailer) SendWalkInAcceptedEmail(toEmail, userID string) error {
- qrPNG, err := qrcode.Encode(userID, qrcode.Medium, 256)
- if err != nil {
- return fmt.Errorf("generating QR code: %w", err)
- }
-
id := m.resolve()
-
- tmplData, err := FS.ReadFile("template/walk_in_accepted.html")
+ qr, err := qrAttachment(id.HackathonName, userID)
if err != nil {
- return fmt.Errorf("reading walk_in_accepted template: %w", err)
+ return err
}
- tmpl, err := template.New("walk_in_accepted").Parse(string(tmplData))
+ htmlBody, err := renderTemplate("walk_in_accepted", walkInAcceptedData{Email: toEmail, HackathonName: id.HackathonName, From: id.FromName})
if err != nil {
- return fmt.Errorf("parsing walk_in_accepted template: %w", err)
- }
-
- var htmlBody bytes.Buffer
- if err := tmpl.Execute(&htmlBody, walkInAcceptedData{Email: toEmail, HackathonName: id.HackathonName, From: id.FromName}); err != nil {
- return fmt.Errorf("executing walk_in_accepted template: %w", err)
- }
-
- msg := mail.NewMsg()
- if err := msg.FromFormat(id.FromName, id.FromEmail); err != nil {
- return fmt.Errorf("setting from address: %w", err)
- }
- if err := msg.AddToFormat(toEmail, toEmail); err != nil {
- return fmt.Errorf("setting to address: %w", err)
- }
- msg.Subject(fmt.Sprintf("You're in for %s", id.HackathonName))
- msg.SetBodyString(mail.TypeTextHTML, htmlBody.String())
- if err := msg.AttachReader(qrAttachmentFilename(id.HackathonName), bytes.NewReader(qrPNG), mail.WithFileContentType("image/png")); err != nil {
- return fmt.Errorf("attaching QR code: %w", err)
+ return err
}
- if err := m.client.DialAndSend(msg); err != nil {
+ if err := m.send(id, toEmail, toEmail, fmt.Sprintf("You're in for %s", id.HackathonName), htmlBody, qr); err != nil {
return fmt.Errorf("sending walk-in accepted email: %w", err)
}
-
return nil
}
diff --git a/internal/mailer/template/decision_accepted.html b/internal/mailer/template/decision_accepted.html
index 4814790eb..797da69cf 100644
--- a/internal/mailer/template/decision_accepted.html
+++ b/internal/mailer/template/decision_accepted.html
@@ -1,94 +1,278 @@
-
+
-
+
Welcome to {{.HackathonName}}
+
+
+
+
+
+ You're in. Confirm your spot at {{.HackathonName}}.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
-
- Congratulations, {{.Name}} - you're in!
-
-
- We read your application and we want you at
- {{.HackathonName}}.
-
-
- Log in to the portal to confirm your spot.
-
-
- Confirm your spot
+ |
+
+ Access granted // 2026
-
- We'll send your check-in QR code closer to the event.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+ |
+
+ Status // Accepted
+
+
+ Congratulations, {{.Name}} — you're in!
+
+
+ We read your application and we want you at
+ {{.HackathonName}}.
+
+
+ Log in to the portal to confirm your spot.
+
+
+
+
+
+ We'll send your check-in QR code closer to the event.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/decision_rejected.html b/internal/mailer/template/decision_rejected.html
index babf84a9a..47c026fdb 100644
--- a/internal/mailer/template/decision_rejected.html
+++ b/internal/mailer/template/decision_rejected.html
@@ -1,78 +1,234 @@
-
+
-
+
Your {{.HackathonName}} application
+
+
+
+
+
+ An update on your {{.HackathonName}} application.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
- Hi {{.Name}}
-
- Thanks for applying to {{.HackathonName}}. We read every
- application, and this time we can't offer you a spot.
-
-
- We had far more applications than space. This isn't a
- reflection of you or your work.
+ |
+
+ Application update // 2026
-
- We hope you apply again next year.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+
+
+ Hi {{.Name}}
+
+
+ Thanks for applying to
+ {{.HackathonName}}.
+ We read every application, and this time we can't offer
+ you a spot.
+
+
+ We had far more applications than space. This isn't a
+ reflection of you or your work.
+
+
+ We hope you apply again next year.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/decision_waitlisted.html b/internal/mailer/template/decision_waitlisted.html
index 918812140..baf0d1ae1 100644
--- a/internal/mailer/template/decision_waitlisted.html
+++ b/internal/mailer/template/decision_waitlisted.html
@@ -1,94 +1,281 @@
-
+
-
+
You're on the {{.HackathonName}} waitlist
+
+
+
+
+
+ You're on the {{.HackathonName}} waitlist. This is not a no.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
- Hi {{.Name}}
-
- You're on the {{.HackathonName}} waitlist. We had more
- strong applications than spots.
-
-
- This is not a no. Spots open up, and we email people on the
- waitlist as they do.
-
-
- View your status
+ |
+
+ Standby // 2026
-
- Keep the event dates open if you can. Waitlist spots often
- open up in the final days.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+ |
+
+ Status // Waitlisted
+
+
+ Hi {{.Name}}
+
+
+ You're on the
+ {{.HackathonName}}
+ waitlist. We had more strong applications than spots.
+
+
+ This is not a no. Spots open up, and we email people on
+ the waitlist as they do.
+
+
+
+
+
+ Keep the event dates open if you can. Waitlist spots
+ often open up in the final days.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/decisions_released.html b/internal/mailer/template/decisions_released.html
index e00b28ace..2faaf98c0 100644
--- a/internal/mailer/template/decisions_released.html
+++ b/internal/mailer/template/decisions_released.html
@@ -1,93 +1,268 @@
-
+
-
+
{{.HackathonName}} decisions are out
+
+
+
+
+
+ {{.HackathonName}} decisions are out. Log in to see your result.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
- Hi {{.Name}}
-
- {{.HackathonName}} decisions are out.
-
-
- Log in to the portal to see your result. Your status page
- is always the place to check.
-
-
- View your decision
+ |
+
+ Decisions live // 2026
-
- Thanks for applying, and thanks for your patience while we
- read every application.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+
+
+ Hi {{.Name}}
+
+
+ {{.HackathonName}}
+ decisions are out.
+
+
+ Log in to the portal to see your result. Your status
+ page is always the place to check.
+
+
+
+
+
+ Thanks for applying, and thanks for your patience while
+ we read every application.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/magic_link.html b/internal/mailer/template/magic_link.html
new file mode 100644
index 000000000..2c6290880
--- /dev/null
+++ b/internal/mailer/template/magic_link.html
@@ -0,0 +1,288 @@
+
+
+
+
+
+ Your Zero Day access link
+
+
+
+
+
+
+
+ Your secure link to enter the Zero Day Portal.
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+ System entry // 2026
+
+
+
+ {{.HackathonName}} portal
+
+ |
+
+
+
+
+
+
+
+ Authenticate your session
+
+
+ A sign-in request was received for
+ {{.Email}}. Use
+ the secure link below to enter the Zero Day Portal.
+
+
+
+
+
+ Link expires in {{.Expires}} and can be used once.
+
+ |
+
+
+
+
+ Button not responding? Copy this secure link into your
+ browser:
+
+
+ {{.MagicLink}}
+
+
+
+ If you did not request this link, you can safely ignore this
+ message. Never forward this email or share its access link.
+
+ |
+
+
+ |
+
+ Powered by Harp // {{.From}}
+
+ |
+
+
+ |
+
+
+
+
diff --git a/internal/mailer/template/qr_email.html b/internal/mailer/template/qr_email.html
index 4449b0070..e41483d25 100644
--- a/internal/mailer/template/qr_email.html
+++ b/internal/mailer/template/qr_email.html
@@ -1,74 +1,269 @@
-
+
-
+
Your {{.HackathonName}} QR code
+
+
+
+
+
+ Your {{.HackathonName}} check-in QR code is attached.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
- Hi {{.Name}}
-
- Your QR code for {{.HackathonName}} check-in is attached.
- Have it ready when you arrive.
+ |
+
+ Check-in credential // 2026
-
- This code is yours. Please don't share it with anyone else.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+
+
+ Hi {{.Name}}
+
+
+ Your QR code for
+ {{.HackathonName}}
+ check-in is attached. Have it ready when you arrive.
+
+
+
+
+ |
+
+ Attachment // QR code
+
+
+ Save the PNG to your phone so it scans without a
+ signal. Show it at the check-in desk.
+
+ |
+
+
+
+
+ This code is yours. Please don't share it with anyone
+ else.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/walk_in_accepted.html b/internal/mailer/template/walk_in_accepted.html
index f8a77d8ca..80fb6fc4b 100644
--- a/internal/mailer/template/walk_in_accepted.html
+++ b/internal/mailer/template/walk_in_accepted.html
@@ -1,79 +1,283 @@
-
+
-
+
You're in for {{.HackathonName}}
+
+
+
+
+
+ You have a walk-in spot at {{.HackathonName}}. Get in line within 10
+ minutes.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
- You're in
-
- Hi {{.Email}}, you have a walk-in spot at
- {{.HackathonName}}.
-
-
- Your QR code is attached. Please get in line within
- 10 minutes, or your spot may go to the
- next person.
+ |
+
+ Access granted // 2026
-
- Show your QR code at the check-in desk to get in.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+ |
+
+ Status // Walk-in confirmed
+
+
+ You're in
+
+
+ Hi {{.Email}},
+ you have a walk-in spot at
+ {{.HackathonName}}.
+
+
+
+
+ |
+
+ Time-sensitive // Get in line
+
+
+ Your QR code is attached. Please get in line
+ within
+ 10 minutes,
+ or your spot may go to the next person.
+
+ |
+
+
+
+
+ Show your QR code at the check-in desk to get in.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
diff --git a/internal/mailer/template/walk_in_queued.html b/internal/mailer/template/walk_in_queued.html
index 9dac87772..0310c1872 100644
--- a/internal/mailer/template/walk_in_queued.html
+++ b/internal/mailer/template/walk_in_queued.html
@@ -1,82 +1,294 @@
-
+
-
+
You're on the {{.HackathonName}} walk-in list
+
+
+
+
+
+ You're #{{.Position}} in the {{.HackathonName}} walk-in queue. Stay
+ close to check-in.
+
+
- |
+ |
-
- {{.HackathonName}}
-
+
|
-
-
- You're on the list
-
-
- Hi {{.Email}}, you're number
- {{.Position}} in the {{.HackathonName}}
- walk-in queue.
-
-
- Your application status is now waitlisted. We'll email you
- a QR code if a spot opens up.
+ |
+
+ Walk-in queue // 2026
-
- Please stay within 10 minutes of the
- check-in area so you can make it in time when called.
+
+
+ {{.HackathonName}} portal
-
- {{.From}}
+ |
+
+
+
+
+
+ |
+
+ Status // Waitlisted
+
+
+ You're on the list
+
+
+ Hi {{.Email}},
+ you're in the {{.HackathonName}} walk-in queue.
+
+
+
+
+ |
+
+ Queue position
+
+
+ #{{.Position}}
+
+ |
+
+
+
+
+ Your application status is now waitlisted. We'll email
+ you a QR code if a spot opens up.
+
+
+ Please stay within
+ 10 minutes of
+ the check-in area so you can make it in time when
+ called.
+
+ |
+
+
+
+
+ — {{.From}}
|
|
-
- © {{.HackathonName}}. All rights reserved.
+
+ Powered by Harp // {{.From}}
|
| | | | | | |