feat(mail): add site URL handling and notification headers

* include site URL in Mailer for absolute URLs
* add header method for email notifications
This commit is contained in:
2026-08-23 11:53:07 +02:00
parent f96465b798
commit 143b2361f6
4 changed files with 72 additions and 38 deletions
+26 -6
View File
@@ -14,11 +14,31 @@ import (
) )
type Mailer struct { type Mailer struct {
cfg config.EmailConfig cfg config.EmailConfig
siteURL string
} }
func New(cfg config.EmailConfig) *Mailer { func New(cfg config.EmailConfig, siteURL string) *Mailer {
return &Mailer{cfg: cfg} return &Mailer{cfg: cfg, siteURL: strings.TrimSuffix(siteURL, "/")}
}
// absoluteURL turns a site-relative path (e.g. "/uploads/1/foo.jpg") into a
// full URL so it's clickable straight from an email client.
func (m *Mailer) absoluteURL(path string) string {
if m.siteURL == "" {
return path
}
return m.siteURL + path
}
// header prefixes every notification with a one-line explainer of where it
// came from and what triggered it, so it's never a mystery in an inbox.
func (m *Mailer) header(event string) string {
site := m.siteURL
if site == "" {
site = "the wedding site"
}
return fmt.Sprintf("This is an automated email that gets triggered for %s when %s.\n\n", site, event)
} }
// newMessageID builds an RFC 5322 Message-ID, scoped to the sender's own // newMessageID builds an RFC 5322 Message-ID, scoped to the sender's own
@@ -93,7 +113,7 @@ func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren) fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
} }
return m.send(fmt.Sprintf("RSVP from %s", joined), b.String()) return m.send(fmt.Sprintf("RSVP from %s", joined), m.header("an RSVP is made")+b.String())
} }
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error { func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
@@ -110,7 +130,7 @@ func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) e
} }
b.WriteString("\nPhotos:\n") b.WriteString("\nPhotos:\n")
for _, f := range files { for _, f := range files {
fmt.Fprintf(&b, "- %s\n", f.URL) fmt.Fprintf(&b, "- %s\n", m.absoluteURL(f.URL))
} }
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), b.String()) return m.send(fmt.Sprintf("%s uploaded photos", u.Name), m.header("photos are uploaded")+b.String())
} }
+1 -1
View File
@@ -45,7 +45,7 @@ func main() {
} }
defer st.Close() defer st.Close()
mailer := mail.New(cfg.Email) mailer := mail.New(cfg.Email, cfg.Site.URL)
wh := webhook.New(cfg.Webhook) wh := webhook.New(cfg.Webhook)
if cfg.Origin.ServiceKey == "" { if cfg.Origin.ServiceKey == "" {
log.Printf("origin registration disabled: origin.service_key is not configured") log.Printf("origin registration disabled: origin.service_key is not configured")
+26 -19
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte"; import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
import { buildIcsDataUrl } from "$lib/calendar"; import { buildIcsDataUrl } from "$lib/calendar";
@@ -29,33 +30,39 @@
]; ];
</script> </script>
<section class="flex flex-1 items-center justify-center px-4 py-16"> <section
<div class="w-full max-w-md"> class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
<h1 class="font-serif text-4xl">Important Information</h1> >
<p class="text-secondary-600-400 mt-2 text-2xl"> <PhotoBackground />
Please <a href="/rsvp" class="underline hover:text-secondary-500">RSVP</a> by {formatFullDate(wedding.rsvpByDate)}.
<div
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
>
<h1 class="text-surface-50 font-serif text-4xl">Important Information</h1>
<p class="text-secondary-300 mt-2 text-2xl">
Please <a href="/rsvp" class="underline hover:text-secondary-400">RSVP</a> by {formatFullDate(wedding.rsvpByDate)}.
</p> </p>
<div class="mt-6 flex flex-col gap-1"> <div class="mt-6 flex flex-col gap-1">
<h2 class="font-serif text-3xl">Dress Code: Formal</h2> <h2 class="text-surface-50 font-serif text-3xl">Dress Code: Formal</h2>
<p class="text-surface-600-400 text-2xl"> <p class="text-surface-100 text-2xl">
We ask that our guests dress in formal attire. Think elegant dresses We ask that our guests dress in formal attire. Think elegant dresses
and sharp suits. and sharp suits.
</p> </p>
</div> </div>
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6"> <div class="border-surface-50/30 mt-10 border-t pt-6">
<h2 class="font-serif text-3xl">Food &amp; Drinks</h2> <h2 class="text-surface-50 font-serif text-3xl">Food &amp; Drinks</h2>
<p class="text-surface-600-400 mt-2 text-2xl"> <p class="text-surface-100 mt-2 text-2xl">
A delicious dinner will be served. Please note that a cash bar is A delicious dinner will be served. Please note that a cash bar is
available for the evening. Card facilities will be available, but we available for the evening. Card facilities will be available, but we
suggest bringing cash just in case. suggest bringing cash just in case.
</p> </p>
</div> </div>
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6"> <div class="border-surface-50/30 mt-10 border-t pt-6">
<h2 class="font-serif text-3xl">Honeymoon Fund</h2> <h2 class="text-surface-50 font-serif text-3xl">Honeymoon Fund</h2>
<p class="text-surface-600-400 mt-2 text-2xl"> <p class="text-surface-100 mt-2 text-2xl">
Your presence on our special day is the greatest gift we could ask Your presence on our special day is the greatest gift we could ask
for. However, if you would like to honour us with a gift, a for. However, if you would like to honour us with a gift, a
contribution toward our future home or honeymoon would be warmly contribution toward our future home or honeymoon would be warmly
@@ -63,9 +70,9 @@
</p> </p>
</div> </div>
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6"> <div class="border-surface-50/30 mt-10 border-t pt-6">
<h2 class="font-serif text-3xl">Order of Events</h2> <h2 class="text-surface-50 font-serif text-3xl">Order of Events</h2>
<p class="text-surface-600-400 mt-2 text-2xl"> <p class="text-surface-100 mt-2 text-2xl">
Zoé &amp; Shaldon — Saturday, 7 November 2026 Zoé &amp; Shaldon — Saturday, 7 November 2026
</p> </p>
<a <a
@@ -78,12 +85,12 @@
<ul class="mt-4 flex flex-col gap-2"> <ul class="mt-4 flex flex-col gap-2">
{#each events as event (event.time)} {#each events as event (event.time)}
<li <li
class="border-surface-300 dark:border-surface-700 flex items-baseline gap-4 border-b py-2" class="border-surface-50/30 flex items-baseline gap-4 border-b py-2"
> >
<span class="text-secondary-600-400 w-16 shrink-0 text-2xl" <span class="text-secondary-300 w-16 shrink-0 text-2xl"
>{event.time}</span >{event.time}</span
> >
<span class="text-2xl">{event.label}</span> <span class="text-surface-100 text-2xl">{event.label}</span>
</li> </li>
{/each} {/each}
</ul> </ul>
+19 -12
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
import { createWeddingInfo } from "$lib/wedding-info.svelte"; import { createWeddingInfo } from "$lib/wedding-info.svelte";
const wedding = createWeddingInfo(); const wedding = createWeddingInfo();
@@ -10,13 +11,19 @@
); );
</script> </script>
<section class="flex flex-1 items-center justify-center px-4 py-16"> <section
<div class="w-full max-w-md"> class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
<h1 class="font-serif text-4xl">The Venue</h1> >
<PhotoBackground />
<div
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
>
<h1 class="text-surface-50 font-serif text-4xl">The Venue</h1>
<div class="mt-6 flex flex-col gap-1"> <div class="mt-6 flex flex-col gap-1">
<p class="text-2xl">{wedding.venueName}</p> <p class="text-surface-100 text-2xl">{wedding.venueName}</p>
<p class="text-surface-600-400 text-2xl">{wedding.venueAddress}</p> <p class="text-surface-200 text-2xl">{wedding.venueAddress}</p>
</div> </div>
<a <a
@@ -28,17 +35,17 @@
Get directions Get directions
</a> </a>
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6"> <div class="border-surface-50/30 mt-10 border-t pt-6">
<h2 class="font-serif text-3xl">Parking</h2> <h2 class="text-surface-50 font-serif text-3xl">Parking</h2>
<p class="text-surface-600-400 mt-2 text-2xl"> <p class="text-surface-100 mt-2 text-2xl">
Secure parking is available on-site at the venue. Secure parking is available on-site at the venue.
</p> </p>
</div> </div>
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6"> <div class="border-surface-50/30 mt-10 border-t pt-6">
<h2 class="font-serif text-3xl">Accommodation</h2> <h2 class="text-surface-50 font-serif text-3xl">Accommodation</h2>
<p class="mt-2 text-2xl">Plaaskombuis Meyerton</p> <p class="text-surface-100 mt-2 text-2xl">Plaaskombuis Meyerton</p>
<div class="text-surface-600-400 mt-1 flex flex-col text-2xl"> <div class="text-surface-200 mt-1 flex flex-col text-2xl">
<a href="tel:0163644144" class="hover:text-secondary-400">016 364 4144</a> <a href="tel:0163644144" class="hover:text-secondary-400">016 364 4144</a>
<a href="mailto:marisel@plaaskombuis.co.za" class="hover:text-secondary-400" <a href="mailto:marisel@plaaskombuis.co.za" class="hover:text-secondary-400"
>marisel@plaaskombuis.co.za</a >marisel@plaaskombuis.co.za</a