UNPKG

oneie

Version:

Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.

1,362 lines (1,161 loc) 37.6 kB
--- title: Mail Backend dimension: things category: plans tags: ai, architecture, artificial-intelligence, backend, convex, frontend related_dimensions: events, knowledge scope: global created: 2025-11-03 updated: 2025-11-03 version: 1.0.0 ai_context: | This document is part of the things dimension in the plans category. Location: one/things/plans/mail-backend.md Purpose: Documents mail backend implementation plan Related dimensions: events, knowledge For AI agents: Read this to understand mail backend. --- # Mail Backend Implementation Plan **Goal:** Add complete email functionality to `mail.astro` using Resend, Convex, and Effect.ts to create a fully functional email client. **Current State:** `mail.astro` exists with shadcn/ui Mail interface using mock data from `src/data/mail-data.ts` **Target State:** Production email client with real Resend integration, database storage, and full CRUD operations. --- ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────┐ FRONTEND (mail.astro) - MailLayout component (client:load) - Jotai state: selected, folder, search, tab - Convex hooks: useQuery, useMutation └──────────────────┬──────────────────────────────────────┘ (Real-time queries & mutations) ┌─────────────────────────────────────────────────────────┐ MIDDLEWARE (Convex + Effect.ts) - Queries: list, get, search (read operations) - Mutations: send, archive, delete (write operations) - Services: EmailService (Effect.ts business logic) └──────────────────┬──────────────────────────────────────┘ (Async actions via scheduler) ┌─────────────────────────────────────────────────────────┐ ACTIONS (Resend API) - sendEmailAction: Actually sends via Resend - processWebhookAction: Handles delivery events └──────────────────┬──────────────────────────────────────┘ (HTTP webhooks) ┌─────────────────────────────────────────────────────────┐ RESEND SERVICE - Email delivery - Tracking (opens, clicks, bounces) - Webhooks (status updates) └─────────────────────────────────────────────────────────┘ ``` --- ## Phase 1: Database Schema & Ontology Mapping ### 1.1 Things (Entities) **Email Entity:** ```typescript // In things table { _id: Id<"things">, type: "email", name: string, // Subject line status: "draft" | "sent" | "delivered" | "failed", createdAt: number, updatedAt: number, properties: { // Email-specific fields from: string, // Sender email fromName: string, // Sender display name to: string[], // Recipients cc?: string[], bcc?: string[], subject: string, bodyHtml: string, bodyText: string, folder: "inbox" | "sent" | "drafts" | "trash" | "archive" | "junk", read: boolean, starred: boolean, labels: string[], // ["work", "important", "meeting"] threadId?: string, // For conversation threading inReplyTo?: string, // Message ID of parent references?: string[], // Thread references attachments?: Array<{ name: string, size: number, type: string, url: string }>, // Resend tracking resendId?: string, // Resend message ID deliveryStatus?: "sent" | "delivered" | "bounced" | "complained", openedAt?: number, clickedAt?: number, bouncedAt?: number, } } ``` **Email Template Entity:** ```typescript { _id: Id<"things">, type: "email_template", name: string, // Template name status: "active" | "draft" | "archived", properties: { subject: string, // Can include {{variables}} bodyHtml: string, bodyText: string, variables: string[], // ["user.name", "resetLink"] category: "transactional" | "notification" | "marketing", version: number, previewText?: string, } } ``` ### 1.2 Connections **Email Relationships:** ```typescript // Sender Email (who sent it) { fromThingId: userId, toThingId: emailId, relationshipType: "sent_email", createdAt: number } // Recipient Email (who received it) { fromThingId: userId, toThingId: emailId, relationshipType: "received_email", createdAt: number } // Email Email (reply threading) { fromThingId: replyEmailId, toThingId: originalEmailId, relationshipType: "reply_to", createdAt: number } ``` ### 1.3 Events **Email Lifecycle Events:** ```typescript // Email drafted { thingId: emailId, eventType: "email_drafted", timestamp: Date.now(), actorType: "user", actorId: userId, metadata: { folder: "drafts" } } // Email sent { thingId: emailId, eventType: "email_sent", timestamp: Date.now(), actorType: "user", actorId: userId, metadata: { resendId: string, recipients: string[], subject: string } } // Email delivered { thingId: emailId, eventType: "email_delivered", timestamp: Date.now(), actorType: "system", metadata: { resendId: string } } // Email opened { thingId: emailId, eventType: "email_opened", timestamp: Date.now(), actorType: "user", actorId: recipientId, metadata: { openedAt: number } } // Email clicked { thingId: emailId, eventType: "email_clicked", timestamp: Date.now(), actorType: "user", actorId: recipientId, metadata: { clickedLink: string } } // Email failed { thingId: emailId, eventType: "email_failed", timestamp: Date.now(), actorType: "system", metadata: { error: string, reason: "bounce" | "complaint" | "rejected" } } ``` ### 1.4 Tags **Email Categories:** ```typescript // Tag for categorization { category: "folder", value: "inbox" | "sent" | "drafts" | "trash" } { category: "label", value: "work" | "personal" | "important" | "meeting" } { category: "status", value: "read" | "unread" | "starred" | "archived" } ``` --- ## Phase 2: Backend Services (Effect.ts) ### 2.1 Email Service **File:** `convex/services/email/email.ts` ```typescript import { Effect } from "effect"; import { ConvexDatabase } from "../providers/convex"; import { ResendProvider } from "../providers/resend"; export class EmailService extends Effect.Service<EmailService>()( "EmailService", { effect: Effect.gen(function* () { const db = yield* ConvexDatabase; const resend = yield* ResendProvider; return { // Create draft email createDraft: (args: CreateDraftArgs) => Effect.gen(function* () { // 1. Validate user const user = yield* Effect.tryPromise(() => db.get(args.userId)); if (!user) { return yield* Effect.fail({ _tag: "UserNotFound", message: "User not found", }); } // 2. Create email entity const emailId = yield* Effect.tryPromise(() => db.insert("things", { type: "email", name: args.subject, status: "draft", createdAt: Date.now(), updatedAt: Date.now(), properties: { from: user.properties.email, fromName: user.properties.name, to: args.to, cc: args.cc, bcc: args.bcc, subject: args.subject, bodyHtml: args.bodyHtml, bodyText: args.bodyText, folder: "drafts", read: true, starred: false, labels: args.labels || [], }, }), ); // 3. Create connection (user authored email) yield* Effect.tryPromise(() => db.insert("connections", { fromThingId: args.userId, toThingId: emailId, relationshipType: "sent_email", createdAt: Date.now(), }), ); // 4. Log event yield* Effect.tryPromise(() => db.insert("events", { thingId: emailId, eventType: "email_drafted", timestamp: Date.now(), actorType: "user", actorId: args.userId, metadata: { folder: "drafts" }, }), ); return emailId; }), // Send email send: (args: SendEmailArgs) => Effect.gen(function* () { // 1. Get email const email = yield* Effect.tryPromise(() => db.get(args.emailId)); if (!email) { return yield* Effect.fail({ _tag: "EmailNotFound", message: "Email not found", }); } // 2. Update status to sending yield* Effect.tryPromise(() => db.patch(args.emailId, { status: "sent", updatedAt: Date.now(), properties: { ...email.properties, folder: "sent", }, }), ); // 3. Schedule actual send via action (async) yield* Effect.promise(() => db.scheduler.runAfter(0, internal.email.sendEmailAction, { emailId: args.emailId, }), ); // 4. Log event yield* Effect.tryPromise(() => db.insert("events", { thingId: args.emailId, eventType: "email_sent", timestamp: Date.now(), actorType: "user", actorId: args.userId, metadata: { recipients: email.properties.to, subject: email.properties.subject, }, }), ); return { success: true, emailId: args.emailId }; }), // List emails by folder listByFolder: (args: ListByFolderArgs) => Effect.gen(function* () { const emails = yield* Effect.tryPromise(() => db .query("things") .withIndex("type", (q) => q.eq("type", "email")) .filter((q) => q.eq(q.field("properties.folder"), args.folder)) .order("desc") .collect(), ); return emails; }), // Search emails search: (args: SearchEmailArgs) => Effect.gen(function* () { const allEmails = yield* Effect.tryPromise(() => db .query("things") .withIndex("type", (q) => q.eq("type", "email")) .collect(), ); // Filter by search query const query = args.query.toLowerCase(); const filtered = allEmails.filter( (email) => email.properties.subject.toLowerCase().includes(query) || email.properties.bodyText.toLowerCase().includes(query) || email.properties.fromName.toLowerCase().includes(query) || email.properties.from.toLowerCase().includes(query), ); return filtered; }), // Archive email archive: (args: ArchiveEmailArgs) => Effect.gen(function* () { const email = yield* Effect.tryPromise(() => db.get(args.emailId)); if (!email) { return yield* Effect.fail({ _tag: "EmailNotFound", message: "Email not found", }); } yield* Effect.tryPromise(() => db.patch(args.emailId, { updatedAt: Date.now(), properties: { ...email.properties, folder: "archive", }, }), ); // Log event yield* Effect.tryPromise(() => db.insert("events", { thingId: args.emailId, eventType: "email_archived", timestamp: Date.now(), actorType: "user", actorId: args.userId, metadata: {}, }), ); return { success: true }; }), // Delete email (move to trash) delete: (args: DeleteEmailArgs) => Effect.gen(function* () { const email = yield* Effect.tryPromise(() => db.get(args.emailId)); if (!email) { return yield* Effect.fail({ _tag: "EmailNotFound", message: "Email not found", }); } yield* Effect.tryPromise(() => db.patch(args.emailId, { updatedAt: Date.now(), properties: { ...email.properties, folder: "trash", }, }), ); return { success: true }; }), // Mark as read/unread markRead: (args: MarkReadArgs) => Effect.gen(function* () { const email = yield* Effect.tryPromise(() => db.get(args.emailId)); if (!email) { return yield* Effect.fail({ _tag: "EmailNotFound", message: "Email not found", }); } yield* Effect.tryPromise(() => db.patch(args.emailId, { updatedAt: Date.now(), properties: { ...email.properties, read: args.read, }, }), ); return { success: true }; }), // Get folder counts getFolderCounts: (args: GetFolderCountsArgs) => Effect.gen(function* () { const allEmails = yield* Effect.tryPromise(() => db .query("things") .withIndex("type", (q) => q.eq("type", "email")) .collect(), ); const counts = { inbox: allEmails.filter((e) => e.properties.folder === "inbox") .length, sent: allEmails.filter((e) => e.properties.folder === "sent") .length, drafts: allEmails.filter((e) => e.properties.folder === "drafts") .length, trash: allEmails.filter((e) => e.properties.folder === "trash") .length, archive: allEmails.filter( (e) => e.properties.folder === "archive", ).length, junk: allEmails.filter((e) => e.properties.folder === "junk") .length, unread: allEmails.filter((e) => !e.properties.read).length, }; return counts; }), }; }), dependencies: [ConvexDatabase.Default, ResendProvider.Default], }, ) {} // Type definitions interface CreateDraftArgs { userId: Id<"things">; to: string[]; cc?: string[]; bcc?: string[]; subject: string; bodyHtml: string; bodyText: string; labels?: string[]; } interface SendEmailArgs { emailId: Id<"things">; userId: Id<"things">; } interface ListByFolderArgs { folder: "inbox" | "sent" | "drafts" | "trash" | "archive" | "junk"; userId: Id<"things">; } interface SearchEmailArgs { query: string; userId: Id<"things">; } interface ArchiveEmailArgs { emailId: Id<"things">; userId: Id<"things">; } interface DeleteEmailArgs { emailId: Id<"things">; userId: Id<"things">; } interface MarkReadArgs { emailId: Id<"things">; read: boolean; } interface GetFolderCountsArgs { userId: Id<"things">; } ``` ### 2.2 Resend Provider **File:** `convex/services/providers/resend.ts` ```typescript import { Effect, Layer } from "effect"; import { Resend } from "@convex-dev/resend"; import { components } from "../../_generated/api"; export class ResendProvider extends Effect.Service<ResendProvider>()( "ResendProvider", { effect: Effect.gen(function* () { const resend = new Resend(components.resend, { testMode: false }); return { send: (args: ResendSendArgs) => Effect.tryPromise({ try: async () => { const result = await resend.sendEmail(ctx, { from: args.from, to: args.to, cc: args.cc, bcc: args.bcc, subject: args.subject, html: args.html, text: args.text, }); return result; }, catch: (error) => ({ _tag: "ResendError" as const, message: error instanceof Error ? error.message : "Unknown error", }), }), }; }), }, ) {} interface ResendSendArgs { from: string; to: string[]; cc?: string[]; bcc?: string[]; subject: string; html: string; text: string; } export const ResendProviderLive = Layer.succeed(ResendProvider, ResendProvider); ``` --- ## Phase 3: Convex Layer (Queries, Mutations, Actions) ### 3.1 Queries **File:** `convex/queries/email.ts` ```typescript import { query } from "./_generated/server"; import { v } from "convex/values"; import { confect } from "convex-helpers/server/confect"; import { Effect } from "effect"; import { EmailService } from "./services/email/email"; import { MainLayer } from "./services/layers"; // List emails by folder export const list = confect.query({ args: { folder: v.union( v.literal("inbox"), v.literal("sent"), v.literal("drafts"), v.literal("trash"), v.literal("archive"), v.literal("junk"), ), userId: v.id("things"), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.listByFolder(args); }).pipe(Effect.provide(MainLayer)), }); // Get single email export const get = confect.query({ args: { id: v.id("things") }, handler: (ctx, args) => Effect.gen(function* () { const email = yield* Effect.tryPromise(() => ctx.db.get(args.id)); return email; }).pipe(Effect.provide(MainLayer)), }); // Search emails export const search = confect.query({ args: { query: v.string(), userId: v.id("things"), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.search(args); }).pipe(Effect.provide(MainLayer)), }); // Get folder counts export const folderCounts = confect.query({ args: { userId: v.id("things") }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.getFolderCounts(args); }).pipe(Effect.provide(MainLayer)), }); ``` ### 3.2 Mutations **File:** `convex/mutations/email.ts` ```typescript import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { confect } from "convex-helpers/server/confect"; import { Effect } from "effect"; import { EmailService } from "./services/email/email"; import { MainLayer } from "./services/layers"; // Create draft export const createDraft = confect.mutation({ args: { userId: v.id("things"), to: v.array(v.string()), cc: v.optional(v.array(v.string())), bcc: v.optional(v.array(v.string())), subject: v.string(), bodyHtml: v.string(), bodyText: v.string(), labels: v.optional(v.array(v.string())), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.createDraft(args); }).pipe(Effect.provide(MainLayer)), }); // Send email export const send = confect.mutation({ args: { emailId: v.id("things"), userId: v.id("things"), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.send(args); }).pipe(Effect.provide(MainLayer)), }); // Archive email export const archive = confect.mutation({ args: { emailId: v.id("things"), userId: v.id("things"), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.archive(args); }).pipe(Effect.provide(MainLayer)), }); // Delete email export const deleteEmail = confect.mutation({ args: { emailId: v.id("things"), userId: v.id("things"), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.delete(args); }).pipe(Effect.provide(MainLayer)), }); // Mark as read/unread export const markRead = confect.mutation({ args: { emailId: v.id("things"), read: v.boolean(), }, handler: (ctx, args) => Effect.gen(function* () { const emailService = yield* EmailService; return yield* emailService.markRead(args); }).pipe(Effect.provide(MainLayer)), }); ``` ### 3.3 Actions **File:** `convex/actions/email.ts` ```typescript import { internalAction } from "./_generated/server"; import { v } from "convex/values"; import { Resend } from "@convex-dev/resend"; import { components, internal } from "./_generated/api"; // Actually send email via Resend export const sendEmailAction = internalAction({ args: { emailId: v.id("things") }, handler: async (ctx, args) => { // 1. Get email const email = await ctx.runQuery(internal.email.getEmail, { id: args.emailId, }); if (!email) { throw new Error("Email not found"); } // 2. Send via Resend const resend = new Resend(components.resend, { testMode: false }); try { const result = await resend.sendEmail(ctx, { from: email.properties.from, to: email.properties.to, cc: email.properties.cc, bcc: email.properties.bcc, subject: email.properties.subject, html: email.properties.bodyHtml, text: email.properties.bodyText, }); // 3. Update email with Resend ID await ctx.runMutation(internal.email.updateResendId, { emailId: args.emailId, resendId: result.id, }); // 4. Log delivery event await ctx.runMutation(internal.email.logEvent, { emailId: args.emailId, eventType: "email_delivered", metadata: { resendId: result.id }, }); return { success: true, resendId: result.id }; } catch (error) { // Log failure event await ctx.runMutation(internal.email.logEvent, { emailId: args.emailId, eventType: "email_failed", metadata: { error: error instanceof Error ? error.message : "Unknown error", }, }); throw error; } }, }); ``` --- ## Phase 4: Frontend Integration ### 4.1 Update use-mail.ts with Convex **File:** `src/components/mail/use-mail.ts` ```typescript import { atom, useAtom } from "jotai"; import { Id } from "@/convex/_generated/dataModel"; export type MailFolder = | "inbox" | "drafts" | "sent" | "junk" | "trash" | "archive"; type Config = { selected: Id<"things"> | null; activeFolder: MailFolder; searchQuery: string; activeTab: "all" | "unread"; }; const configAtom = atom<Config>({ selected: null, activeFolder: "inbox", searchQuery: "", activeTab: "all", }); export function useMail() { return useAtom(configAtom); } ``` ### 4.2 Update MailList with Real Data **File:** `src/components/mail/MailList.tsx` ```tsx import { useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useMail } from "./use-mail"; export function MailList({ userId }: { userId: string }) { const [mail, setMail] = useMail(); // Real-time query from Convex const emails = useQuery(api.email.list, { folder: mail.activeFolder, userId: userId as any, // Type assertion for now }); // Filter by search query (client-side for now) const filteredEmails = emails?.filter((email) => { if (!mail.searchQuery) return true; const query = mail.searchQuery.toLowerCase(); return ( email.properties.subject.toLowerCase().includes(query) || email.properties.bodyText.toLowerCase().includes(query) || email.properties.fromName.toLowerCase().includes(query) ); }) || []; // Filter by tab const displayEmails = mail.activeTab === "unread" ? filteredEmails.filter((e) => !e.properties.read) : filteredEmails; if (!emails) { return <div className="p-8 text-center">Loading...</div>; } if (displayEmails.length === 0) { return ( <div className="flex items-center justify-center p-8 text-center text-sm text-muted-foreground"> No emails found </div> ); } return ( <ScrollArea className="h-screen"> <div className="flex flex-col gap-2 p-4 pt-0"> {displayEmails.map((email) => ( <button key={email._id} className={cn( "flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent active:scale-[0.99]", mail.selected === email._id && "bg-muted ring-2 ring-primary/20", )} onClick={() => setMail({ ...mail, selected: email._id, }) } > <div className="flex w-full flex-col gap-1"> <div className="flex items-center"> <div className="flex items-center gap-2"> <div className={cn( "font-semibold", !email.properties.read && "text-primary", )} > {email.properties.fromName} </div> {!email.properties.read && ( <span className="flex size-2 rounded-full bg-blue-600 animate-pulse" /> )} </div> <div className={cn( "ml-auto text-xs", mail.selected === email._id ? "text-foreground" : "text-muted-foreground", )} > {formatDistanceToNow(new Date(email.createdAt), { addSuffix: true, })} </div> </div> <div className={cn( "text-xs font-medium", !email.properties.read && "text-primary", )} > {email.properties.subject} </div> </div> <div className="line-clamp-2 text-xs text-muted-foreground"> {email.properties.bodyText.substring(0, 300)} </div> {email.properties.labels.length ? ( <div className="flex items-center gap-2"> {email.properties.labels.map((label) => ( <Badge key={label} variant="secondary" className="text-xs"> {label} </Badge> ))} </div> ) : null} </button> ))} </div> </ScrollArea> ); } ``` ### 4.3 Update MailDisplay with Actions **File:** `src/components/mail/MailDisplay.tsx` ```tsx import { useQuery, useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import { toast } from "sonner"; import { Archive, ArchiveX, Trash2, Reply } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { useMail } from "./use-mail"; import { useState } from "react"; export function MailDisplay({ userId }: { userId: string }) { const [mail] = useMail(); const [replyText, setReplyText] = useState(""); // Get selected email const currentMail = useQuery( api.email.get, mail.selected ? { id: mail.selected } : "skip", ); // Mutations const archive = useMutation(api.email.archive); const deleteEmail = useMutation(api.email.deleteEmail); const markRead = useMutation(api.email.markRead); const handleArchive = async () => { if (!mail.selected) return; await archive({ emailId: mail.selected, userId: userId as any }); toast.success("Email archived"); }; const handleDelete = async () => { if (!mail.selected) return; await deleteEmail({ emailId: mail.selected, userId: userId as any }); toast.success("Email deleted"); }; const handleReply = async (e: React.FormEvent) => { e.preventDefault(); if (!replyText.trim() || !currentMail) return; // Create draft reply // TODO: Implement reply functionality toast.success("Reply sent"); setReplyText(""); }; if (!currentMail) { return ( <div className="flex h-full items-center justify-center p-8 text-center text-muted-foreground"> No message selected </div> ); } return ( <div className="flex h-full flex-col"> <div className="flex items-center p-2 gap-2"> <Button variant="ghost" size="icon" onClick={handleArchive}> <Archive className="size-4" /> </Button> <Button variant="ghost" size="icon" onClick={handleDelete}> <Trash2 className="size-4" /> </Button> </div> <div className="flex-1 p-4"> <div className="font-semibold text-lg mb-2"> {currentMail.properties.subject} </div> <div className="text-sm text-muted-foreground mb-4"> From: {currentMail.properties.fromName} ({currentMail.properties.from} ) </div> <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: currentMail.properties.bodyHtml }} /> </div> <div className="border-t p-4"> <form onSubmit={handleReply}> <Textarea placeholder="Reply..." value={replyText} onChange={(e) => setReplyText(e.target.value)} className="mb-2" /> <Button type="submit" size="sm"> <Reply className="size-4 mr-2" /> Send Reply </Button> </form> </div> </div> ); } ``` ### 4.4 Update mail.astro **File:** `src/pages/mail.astro` ```astro --- import BaseLayout from '@/layouts/BaseLayout.astro' import { MailLayout } from '@/components/mail/MailLayout' import { auth } from '@/lib/auth' // Get current user const session = await auth.api.getSession({ headers: Astro.request.headers }) if (!session?.user) { return Astro.redirect('/login') } const userId = session.user.id --- <BaseLayout title="Mail"> <div class="mail-container"> <MailLayout client:load userId={userId} /> </div> </BaseLayout> <style> .mail-container { height: 100vh; width: 100vw; overflow: hidden; } </style> ``` --- ## Phase 5: Webhooks (Resend Events) ### 5.1 Webhook Handler **File:** `convex/http/webhooks/resend.ts` ```typescript import { Hono } from "hono"; import { HonoWithConvex, HttpRouterWithHono } from "convex-helpers/server/hono"; import { internal } from "../../_generated/api"; const app: HonoWithConvex = new Hono(); app.post("/resend", async (c) => { const body = await c.req.json(); // Verify webhook signature (Resend provides this) // const signature = c.req.header("resend-signature") // if (!verifySignature(signature, body)) { // return c.json({ error: "Invalid signature" }, 401) // } const { type, data } = body; switch (type) { case "email.sent": await c.env.runMutation(internal.email.handleEmailSent, { resendId: data.email_id, timestamp: Date.now(), }); break; case "email.delivered": await c.env.runMutation(internal.email.handleEmailDelivered, { resendId: data.email_id, timestamp: Date.now(), }); break; case "email.opened": await c.env.runMutation(internal.email.handleEmailOpened, { resendId: data.email_id, timestamp: Date.now(), }); break; case "email.clicked": await c.env.runMutation(internal.email.handleEmailClicked, { resendId: data.email_id, clickedLink: data.link, timestamp: Date.now(), }); break; case "email.bounced": await c.env.runMutation(internal.email.handleEmailBounced, { resendId: data.email_id, reason: data.bounce_type, timestamp: Date.now(), }); break; case "email.complained": await c.env.runMutation(internal.email.handleEmailComplained, { resendId: data.email_id, timestamp: Date.now(), }); break; } return c.json({ success: true }); }); export default new HttpRouterWithHono(app); ``` --- ## Implementation Checklist ### Phase 1: Database Setup - [ ] Add email entity type to things table - [ ] Add email_template entity type to things table - [ ] Add connection types: sent_email, received_email, reply_to - [ ] Add event types: email_drafted, email_sent, email_delivered, email_opened, etc. - [ ] Add tag categories: folder, label, status ### Phase 2: Backend Services - [ ] Create `convex/services/email/email.ts` (EmailService) - [ ] Create `convex/services/providers/resend.ts` (ResendProvider) - [ ] Implement createDraft, send, listByFolder, search, archive, delete, markRead - [ ] Add to MainLayer in `convex/services/layers.ts` ### Phase 3: Convex Layer - [ ] Create `convex/queries/email.ts` (list, get, search, folderCounts) - [ ] Create `convex/mutations/email.ts` (createDraft, send, archive, delete, markRead) - [ ] Create `convex/actions/email.ts` (sendEmailAction) - [ ] Create internal mutations for webhook handlers ### Phase 4: Frontend Integration - [ ] Update `src/components/mail/use-mail.ts` with proper types - [ ] Update `src/components/mail/MailList.tsx` with useQuery - [ ] Update `src/components/mail/MailDisplay.tsx` with useMutation - [ ] Update `src/components/mail/Nav.tsx` with dynamic counts - [ ] Update `src/pages/mail.astro` with auth check - [ ] Add Toaster component for notifications ### Phase 5: Webhooks - [ ] Create `convex/http/webhooks/resend.ts` - [ ] Configure Resend webhook URL in dashboard - [ ] Implement webhook signature verification - [ ] Test email tracking events ### Phase 6: Testing - [ ] Test draft creation - [ ] Test email sending (receives in real inbox) - [ ] Test folder navigation - [ ] Test search functionality - [ ] Test archive/delete operations - [ ] Test webhook delivery - [ ] Test real-time updates (open in 2 browsers) --- ## Dependencies to Install ```bash # Already installed @convex-dev/resend jotai sonner lucide-react date-fns # Need to install bun add @react-email/components bun add @react-email/render ``` --- ## Environment Variables ```bash # Already set RESEND_API_KEY=re_... RESEND_FROM_EMAIL=noreply@yourdomain.com # Add for webhooks RESEND_WEBHOOK_SECRET=whsec_... ``` --- ## Success Criteria ### MVP (Minimum Viable Product) - Users can view emails in inbox - Users can compose and send emails - Emails deliver to real inboxes - Folder navigation works (Inbox, Sent, Drafts, Trash) - Search functionality works - Archive/Delete actions work - Real-time updates when emails arrive ### Full Features - Reply/Forward functionality - Email threading (conversations) - Attachments support - Rich text editor - Webhook tracking (opens, clicks) - Templates system - Scheduled sending - Email signatures --- **Result:** A fully functional email client in `mail.astro` powered by Resend, Convex, and Effect.ts with real-time updates and production-ready email delivery.