Last Week
Last week I finally pried open the Supabase email pipeline that had been stuck in limbo. I rewired the outbox drainer so it writes to a dedicated log table before and after each batch, captured the payload that Resend expects, and added defensive retries around the queue reader. Most of that effort went into proving the cron job could wake the Edge Function reliably; I ended up setting up a cron_debug_log
table, replaying scheduled jobs, and verifying Supabase’s pg_cron
extension was actually firing once a minute.
On the UI side I squashed the stale state issues in the guest list details pane and fixed the error banner that refused to render when network calls failed. With the queue processing in place and the interface no longer lying to operators, Week 38 ended with a functioning manual drain and a stubborn automation problem that I wanted to finish this week.
Feature Complete, Webhook Pending
With the cron job now firing on schedule, the Supabase Edge function finally drains the outbox without human babysitting. I spent the early part of the week replaying pending messages, watching the handoff from the Kotlin client to the Postgres queue, and confirming that Resend reports a 202 within the same invocation. The queue drains cleanly, throughput stays steady at my test dataset of 500 guests, and the internal notifications table now reflects successes within seconds instead of hours.
Supabase surprised me by allowing sub-minute cron frequencies. The scheduler will happily fire jobs every second; the limiter is really the Edge function billing. I ran a few experiments at 30-second intervals just to validate concurrency and idempotency, and the free tier handled it without blinking. For now I’ve dialed it back to 60 seconds so I can collect metrics without burning through invocation quotas, but it’s good to know the system can scale tighter if restaurant demand ever justifies it.
That milestone means Shokken is feature complete from an infrastructure standpoint. Every core capability that I promised for the alpha–queue management, guest notifications, email redundancies, and admin tooling–is now implemented. The roadmap from here shifts from invention to refinement: polishing UI flows, tightening copy around guest communication, and removing papercuts that surfaced during internal use.
The only unfinished backend task is the Resend webhook integration. Resend can call back with delivery confirmations, bounces, and spam complaints, and I want to persist those events. The Edge function that should receive those hooks keeps returning 401s even after I disabled JWT enforcement and rotated the signing secret. The webhook failure doesn’t block core functionality–the drainer call itself still tells me whether the send request succeeded–but it does limit what I can surface in the operator dashboard.
Rather than sink another week into a non-critical integration, I’m putting the webhook on hold and turning to UX polish. Empty states, onboarding hints, and the first five minutes of the product are currently sterile developer scaffolding. I’m hosting friends next week and then heading overseas soon after, so I’ve got a two-week window to make the app feel test-ready. Getting those first-run experiences to sing matters more for the upcoming pilot than a perfect delivery receipt story.
What does it mean in English?
Shokken now sends guest emails on its own. When I mark someone as ready, the app queues the email, Supabase wakes up a cloud function, and the message goes out. That cycle repeats every minute without me touching anything.
I still want a follow-up signal from the email provider that says whether the guest’s inbox accepted the message. That callback refuses to authenticate today, so I’m shipping without it and will circle back later.
Instead of chasing that bug, I’m investing the next couple of weeks in polish. The first-run experience will feel welcoming, guide restaurant owners through creating their first waitlist, and surface issues without forcing anyone to dig through raw tables.
Nerdy Details
Email Outbox Data Model
I cemented the queue around a dedicated Postgres table rather than relying on ephemeral Supabase job state. Each email is a row with enough context to rehydrate the message if I need to replay it. I also store the guest identifier, the waitlist event that triggered the mail, and the templated subject so the UI can render a full audit log.
CREATE TABLE IF NOT EXISTS email_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
guest_id uuid NOT NULL REFERENCES guests(id),
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending|sending|sent|failed
attempts integer NOT NULL DEFAULT 0,
last_error text,
scheduled_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);
CREATE INDEX CONCURRENTLY IF NOT EXISTS email_outbox_pending_idx
ON email_outbox (status, scheduled_at)
WHERE status IN ('pending','sending');
CREATE UNIQUE INDEX IF NOT EXISTS email_outbox_unique_event
ON email_outbox (guest_id, event_type)
WHERE status IN ('pending','sending');
The payload
column holds the final Resend request body so I can reconstruct the message without fetching additional tables. Keeping scheduled_at
separate from sent_at
gives me latency metrics with a single query. The partial index lets the drainer select work in order while ignoring rows that already finished, and the uniqueness constraint prevents the host from double-tapping “Notify guest” during busy periods.
Edge Function Drainer Flow
The Edge function works as a disciplined state machine: mark work in progress, attempt the Resend call, then record the outcome. Supabase’s environment supplies the service role key, so the function can update internal tables without exposing credentials to clients.
import { createClient } from '@supabase/supabase-js'
import { Resend } from 'resend'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
const resend = new Resend(process.env.RESEND_API_KEY!)
export default async function handler() {
const { data: pending } = await supabase
.from('email_outbox')
.select('*')
.eq('status', 'pending')
.order('scheduled_at', { ascending: true })
.limit(50)
for (const row of pending ?? []) {
const { error: lockError } = await supabase
.from('email_outbox')
.update({ status: 'sending', attempts: row.attempts + 1 })
.eq('id', row.id)
.eq('status', 'pending')
if (lockError) continue // another worker grabbed it
try {
const response = await resend.emails.send(row.payload as any)
await supabase
.from('email_outbox')
.update({ status: 'sent', sent_at: new Date(), last_error: null })
.eq('id', row.id)
await supabase
.from('notification_events')
.insert({
guest_id: row.guest_id,
event_type: row.event_type,
payload: response
})
} catch (error: any) {
await supabase
.from('email_outbox')
.update({ status: 'failed', last_error: error.message ?? 'unknown error' })
.eq('id', row.id)
}
}
}
I keep the batch size at 50 to respect Resend’s recommended rate limits and to make retries predictable. If a call throws, the row flips to failed
, which keeps it visible in dashboards and lets me craft retries manually.
Cron Cadence and Cost Modeling
Supabase’s pg_cron
extension allows sub-minute schedules, so I stress-tested a few cadence options. The job definition now runs through the SQL API in a migration so I can manage it like any other schema change.
SELECT cron.schedule(
'drain-email-outbox',
'* * * * *',
$$
SELECT net.http_post(
url := current_setting('app.settings.drainer_url'),
headers := jsonb_build_object(
'Authorization', 'Bearer ' || current_setting('app.settings.drainer_secret')
)
);
$$
);
The app.settings.*
values are custom configuration parameters that I load through alter system
. Switching to a 30-second cadence is as simple as updating the crontab to '*/0.5 * * * * *'
(yes, fractional minutes work) or cloning the job with a tighter interval. Based on Supabase’s current pricing, 60-second drains translate to roughly 43,200 Edge invocations per month, which is still below the free tier. That makes experimentation cheap while keeping the architecture future-proof if I need higher throughput.
Observability and Operational Guardrails
To avoid another silent failure, I instrumented both the cron job and the Edge function. Every invocation writes to cron_debug_log
, and the function itself logs structured events into notification_events
and Supabase’s native log stream. Pairing that with supabase functions logs --follow
gives me real-time insight when Resend hiccups.
CREATE TABLE IF NOT EXISTS cron_debug_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
executed_at timestamptz NOT NULL DEFAULT now(),
event text NOT NULL,
metadata jsonb
);
SELECT cron.schedule(
'heartbeat-email-drain',
'* * * * *',
$$
INSERT INTO cron_debug_log (event, metadata)
VALUES ('cron-fired', jsonb_build_object('job', 'drain-email-outbox'));
$$
);
I also added Prometheus-compatible metrics to the Edge function response by returning counts of sent, failed, and retried rows. That response feeds into a tiny Cloudflare Worker that emits StatsD metrics so I can graph queue depth over time. The new visibility made it easy to confirm that reducing the cadence would back up the queue long before I pushed those changes to production.
Resend Webhook Autopsy
Resend signs webhook payloads with an X-Resend-Signature
header, but it refuses to send JWTs, which conflicts with Supabase’s default authentication middleware. I disabled JWT enforcement for the function route and rotated the RESEND_WEBHOOK_SECRET
, yet every attempt still returns 401. My current suspicion is that Supabase expects the service role key even for anonymous Edge routes, so I’ll need to build a dedicated middleware that validates the HMAC first, then injects a server-side session.
To debug it, I replayed Resend payloads locally using ngrok
and the Supabase CLI:
curl -X POST "http://localhost:54321/functions/v1/resend-webhook" \
-H "Content-Type: application/json" \
-H "X-Resend-Signature: $SIGNATURE" \
-d '{"type":"email.delivered","data":{"messageId":"123","to":"[email protected]"}}'
The handler verifies the signature correctly in isolation, so the failure only appears once the call transits Supabase’s CDN. I captured the 401 response headers and confirmed the platform injects x-supabase-function-error: Invalid JWT
even though the route should be open. The workaround is to provision a dedicated service role endpoint with RLS exceptions for the webhook schema–a task I’ll revisit after the UI polish sprint.
Delivery Fallbacks
Even without webhook confirmations, the system still differentiates between “fired” and “guaranteed delivered.” The drainer stores the Resend response body, including the message ID, so I can query delivery status manually if a restaurant reports an issue. I also added a nightly job that reconciles local state by calling Resend’s REST API for messages sent over the previous 24 hours. Those reconciliations write into an email_deliverability
table that tracks bounces and complaints when available, ensuring there’s a paper trail once the webhook finally works.
On the client side I surface two badges: “Sent” means the API accepted the request, and “Confirmed” will light up once the webhook flow exists. That keeps expectations clear and lets me ship the functionality today without over-promising reliability.
UI Polishing Roadmap
The next milestone is making the empty states friendly. Right now a brand-new venue sees a blank table and a lonely “Add guest” button. I’m replacing that with an onboarding scaffold that encourages the first action and shows sample data when the list is empty.
@Composable
fun EmptyWaitlist(onAddGuest: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Rounded.MailOutline,
contentDescription = null,
tint = ShokkenPalette.primary
)
Spacer(Modifier.height(16.dp))
Text(
text = "Welcome to Shokken",
style = ShokkenTypography.h4,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Add your first party to see real-time waitlist updates and automatic emails.",
style = ShokkenTypography.body1,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddGuest) {
Text("Add guest")
}
}
}
That component slots into both Android and desktop layouts thanks to Compose Multiplatform. I’m pairing it with contextual tips (e.g., “Don’t worry, we only email when you tell us to”) and a quick-start checklist so new operators understand the system before the dinner rush.
Testing Checklist Before Travel
Before leaving for my international trip I want automated coverage around the scenarios that just burned me. The plan is to add integration tests that seed the outbox, run the Edge function locally, and assert that Resend calls receive the right payload. On the client side I’ll extend the compose UI tests to cover the new empty state and the dual badge system for email status.
./gradlew :app:connectedDebugAndroidTest
supabase functions serve drain-email-outbox --env-file supabase/.env.test
npm run test -- workspaces=edge-functions
Those commands already live in the project README, but I’m building a pre-flight script that executes them sequentially and fails fast when migrations drift. Combined with manual smoke tests on the tablet device I use for demos, that should keep the app stable while I’m traveling.
Next Week
I’m hosting guests next week, so the sprint will be intentionally light. I want to land the onboarding empty state, wire the two-level email badges into the operator dashboard, and audit copy across the waitlist flow. If time permits I’ll add the nightly Resend reconciliation job behind a feature flag so it can run in staging. The goal is to freeze those polish items before I hop on a plane, leaving only QA feedback to process during the following two weeks.