{{ theme.skipToContentLabel || 'Skip to content' }}

Email Archive Feature Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add archive functionality to email schedules — auto-archive terminal-state one-time emails, add Archive tab, remove Sent tab, keep Logs page as-is.

Architecture: Database trigger auto-archives one-time emails when they reach terminal status (sent/failed/cancelled). New is_archived boolean column on email_schedules. Client-side filtering updated to replace "Sent" tab with "Archived" tab. "All" tab excludes archived.

Tech Stack: PostgreSQL (trigger + migration), TypeScript, React, Supabase client

Design doc: docs/plans/2026-02-09-email-archive-design.md


Task 1: Database Migration

Files:

  • Create: supabase/migrations/20260211000000_add_email_archive.sql

Step 1: Create the migration file

sql
-- Add is_archived column
ALTER TABLE email_schedules
ADD COLUMN is_archived boolean NOT NULL DEFAULT false;

-- Backfill: archive existing terminal one-time emails
UPDATE email_schedules
SET is_archived = true
WHERE mode IN ('manual', 'scheduled')
  AND status IN ('sent', 'failed', 'cancelled');

-- Auto-archive trigger function
CREATE OR REPLACE FUNCTION auto_archive_email_schedule()
RETURNS TRIGGER AS $$
BEGIN
  -- Only auto-archive one-time emails (manual/scheduled) when they reach terminal status
  IF NEW.mode IN ('manual', 'scheduled')
     AND NEW.status IN ('sent', 'failed', 'cancelled')
     AND (OLD.status IS DISTINCT FROM NEW.status)
  THEN
    NEW.is_archived := true;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Attach trigger
DROP TRIGGER IF EXISTS trg_auto_archive_email_schedule ON email_schedules;
CREATE TRIGGER trg_auto_archive_email_schedule
  BEFORE UPDATE ON email_schedules
  FOR EACH ROW
  EXECUTE FUNCTION auto_archive_email_schedule();

-- Index for efficient filtering
CREATE INDEX idx_email_schedules_is_archived ON email_schedules(is_archived);

Step 2: Push migration to remote database

Run: pnpm supabase:push Expected: Migration applied successfully

Step 3: Regenerate TypeScript types

Run: pnpm supabase:types Expected: src/lib/supabaseTypes.ts updated with is_archived column in email_schedules table type

Step 4: Commit

bash
git add supabase/migrations/20260211000000_add_email_archive.sql src/lib/supabaseTypes.ts
git commit -m "feat(email): add is_archived column with auto-archive trigger"

Task 2: Update TypeScript Types

Files:

  • Modify: src/features/email-schedules/types.ts

Step 1: Add isArchived to EmailSchedule interface

In src/features/email-schedules/types.ts, find the EmailSchedule interface (line 40–66). Add isArchived after nextSendAt:

typescript
export interface EmailSchedule {
  // ... existing fields ...
  nextSendAt: string | null;
  isArchived: boolean;  // <-- ADD THIS
}

Step 2: Verify build compiles

Run: pnpm build:check Expected: Build errors in EmailScheduleService.ts mapRow() (missing isArchived mapping) — this is expected and will be fixed in Task 3.

Step 3: Commit

bash
git add src/features/email-schedules/types.ts
git commit -m "feat(email): add isArchived to EmailSchedule type"

Task 3: Update Service Layer

Files:

  • Modify: src/features/email-schedules/services/EmailScheduleService.ts

Step 1: Add isArchived mapping in mapRow()

In EmailScheduleService.ts, find the mapRow() function (line 37–64). Add isArchived mapping after nextSendAt (line 62):

typescript
function mapRow(row: EmailScheduleRow): EmailSchedule {
  return {
    // ... existing fields ...
    nextSendAt: row.next_send_at,
    isArchived: row.is_archived ?? false,  // <-- ADD THIS
  };
}

Step 2: Add setArchived() method to EmailScheduleService

Add a new method to the EmailScheduleService object. Place it after the remove() method (after line 363), before sendNow():

typescript
  async setArchived(id: string, isArchived: boolean): Promise<EmailSchedule> {
    const { data, error } = await supabase
      .from("email_schedules")
      .update({ is_archived: isArchived })
      .eq("id", id)
      .select("*")
      .single()
      .returns<EmailScheduleRow>();

    if (error) {
      console.error("Failed to update archive status", error);
      throw new Error(error.message || "Failed to update archive status");
    }

    return mapRow(data);
  },

Step 3: Verify build compiles

Run: pnpm build:check Expected: PASS (no type errors in service layer)

Step 4: Commit

bash
git add src/features/email-schedules/services/EmailScheduleService.ts
git commit -m "feat(email): add isArchived mapping and setArchived service method"

Task 4: Update Hook

Files:

  • Modify: src/features/email-schedules/hooks/useEmailSchedules.ts

Step 1: Add archiveSchedule and unarchiveSchedule callbacks

In useEmailSchedules.ts, add two new callbacks after deleteSchedule (after line 56):

typescript
  const archiveSchedule = useCallback(async (id: string) => {
    const updated = await EmailScheduleService.setArchived(id, true);
    setSchedules((prev) =>
      prev.map((item) => (item.id === id ? updated : item))
    );
  }, []);

  const unarchiveSchedule = useCallback(async (id: string) => {
    const updated = await EmailScheduleService.setArchived(id, false);
    setSchedules((prev) =>
      prev.map((item) => (item.id === id ? updated : item))
    );
  }, []);

Step 2: Expose new callbacks in return object

Update the return object (line 94–103) to include the new callbacks:

typescript
  return {
    schedules,
    loading,
    error,
    refresh,
    createSchedule,
    updateSchedule,
    deleteSchedule,
    sendNow,
    archiveSchedule,      // <-- ADD
    unarchiveSchedule,    // <-- ADD
  };

Step 3: Verify build compiles

Run: pnpm build:check Expected: PASS

Step 4: Commit

bash
git add src/features/email-schedules/hooks/useEmailSchedules.ts
git commit -m "feat(email): add archive/unarchive callbacks to useEmailSchedules hook"

Task 5: Update EmailSchedulesTable Component — Filters & Tabs

Files:

  • Modify: src/features/email-schedules/components/EmailSchedulesTable.tsx

Step 1: Add onArchive and onUnarchive to Props type

In EmailSchedulesTable.tsx, update the Props type (line 5–18):

typescript
type Props = {
  schedules: EmailSchedule[];
  senderMap: Map<string, string>;
  loading: boolean;
  error: string | null;
  sendingId: string | null;
  deletingId: string | null;
  onEdit: (schedule: EmailSchedule) => void;
  onView: (schedule: EmailSchedule) => void;
  onSendNow: (id: string) => void;
  onDelete: (id: string) => void;
  onDuplicate: (schedule: EmailSchedule) => void;
  onRefresh: () => void;
  onArchive: (id: string) => void;       // <-- ADD
  onUnarchive: (id: string) => void;     // <-- ADD
};

Step 2: Update FilterStatus type

Replace line 87:

Old:

typescript
type FilterStatus = "all" | "pending" | "sent" | "failed" | "recurring";

New:

typescript
type FilterStatus = "all" | "pending" | "failed" | "recurring" | "archived";

Step 3: Destructure new props in component

Update the component destructuring (line 112–125) to include onArchive and onUnarchive:

typescript
export function EmailSchedulesTable({
  schedules,
  senderMap,
  loading,
  error,
  sendingId,
  deletingId,
  onEdit,
  onView,
  onSendNow,
  onDelete,
  onDuplicate,
  onRefresh,
  onArchive,        // <-- ADD
  onUnarchive,      // <-- ADD
}: Props) {

Step 4: Update filteredSchedules logic

Replace the entire filteredSchedules useMemo (lines 128–147):

typescript
  const filteredSchedules = useMemo(() => {
    if (filterStatus === "all")
      return schedules.filter((s) => !s.isArchived);
    if (filterStatus === "pending") {
      return schedules.filter(
        (s) =>
          !s.isArchived &&
          ["draft", "scheduled", "processing"].includes(s.status)
      );
    }
    if (filterStatus === "failed") {
      return schedules.filter(
        (s) =>
          !s.isArchived && ["failed", "cancelled"].includes(s.status)
      );
    }
    if (filterStatus === "recurring") {
      return schedules.filter((s) => s.mode === "recurring");
    }
    if (filterStatus === "archived") {
      return schedules.filter((s) => s.isArchived);
    }
    return schedules;
  }, [schedules, filterStatus]);

Step 5: Update counts logic

Replace the entire counts useMemo (lines 149–161):

typescript
  const counts = useMemo(() => {
    return {
      all: schedules.filter((s) => !s.isArchived).length,
      pending: schedules.filter(
        (s) =>
          !s.isArchived &&
          ["draft", "scheduled", "processing"].includes(s.status)
      ).length,
      failed: schedules.filter(
        (s) =>
          !s.isArchived && ["failed", "cancelled"].includes(s.status)
      ).length,
      recurring: schedules.filter((s) => s.mode === "recurring").length,
      archived: schedules.filter((s) => s.isArchived).length,
    };
  }, [schedules]);

Step 6: Update filter buttons

Replace the filter buttons block (lines 180–219). Remove the "Sent" button, add "Archived" button:

typescript
              <FilterButton
                active={filterStatus === "all"}
                count={counts.all}
                label="All"
                onClick={() => {
                  setFilterStatus("all");
                }}
              />
              <FilterButton
                active={filterStatus === "pending"}
                count={counts.pending}
                label="Pending"
                onClick={() => {
                  setFilterStatus("pending");
                }}
              />
              <FilterButton
                active={filterStatus === "failed"}
                count={counts.failed}
                label="Failed"
                onClick={() => {
                  setFilterStatus("failed");
                }}
              />
              <FilterButton
                active={filterStatus === "recurring"}
                count={counts.recurring}
                label="Recurring"
                onClick={() => {
                  setFilterStatus("recurring");
                }}
              />
              <FilterButton
                active={filterStatus === "archived"}
                count={counts.archived}
                label="Archived"
                onClick={() => {
                  setFilterStatus("archived");
                }}
              />

Step 7: Verify build compiles

Run: pnpm build:check Expected: Build error in index.tsx (missing onArchive/onUnarchive props) — expected, fixed in Task 7.

Step 8: Commit

bash
git add src/features/email-schedules/components/EmailSchedulesTable.tsx
git commit -m "feat(email): update table filters — replace Sent with Archived tab"

Task 6: Update EmailSchedulesTable Component — Row Actions

Files:

  • Modify: src/features/email-schedules/components/EmailSchedulesTable.tsx

Step 1: Update row actions for non-pending emails

Find the row actions section for non-pending emails (lines 400–421). This currently shows "Resend" and "Duplicate". Replace with logic that shows Archive/Unarchive based on isArchived state:

Replace lines 400–421:

typescript
                          ) : (
                            <>
                              {schedule.isArchived ? (
                                <button
                                  className="btn btn-sm bg-primary text-white"
                                  type="button"
                                  onClick={() => {
                                    onUnarchive(schedule.id);
                                  }}
                                >
                                  Unarchive
                                </button>
                              ) : (
                                <>
                                  <button
                                    className="btn btn-sm bg-primary text-white"
                                    type="button"
                                    onClick={() => {
                                      onEdit(schedule);
                                    }}
                                  >
                                    Resend
                                  </button>
                                  <button
                                    className="btn btn-sm border border-default-200 text-default-700"
                                    type="button"
                                    onClick={() => {
                                      onDuplicate(schedule);
                                    }}
                                  >
                                    Duplicate
                                  </button>
                                  <button
                                    className="btn btn-sm border border-default-200 text-amber-700"
                                    type="button"
                                    onClick={() => {
                                      onArchive(schedule.id);
                                    }}
                                  >
                                    Archive
                                  </button>
                                </>
                              )}
                            </>
                          )}

Step 2: Verify build compiles

Run: pnpm build:check Expected: Build error in index.tsx (missing props) — expected, fixed in Task 7.

Step 3: Commit

bash
git add src/features/email-schedules/components/EmailSchedulesTable.tsx
git commit -m "feat(email): add archive/unarchive row actions"

Task 7: Update Page Component — Wire Everything Together

Files:

  • Modify: src/app/(admin)/(pages)/email-schedules/index.tsx

Step 1: Destructure new hook callbacks

In index.tsx, update the useEmailSchedules destructuring (lines 179–188):

typescript
  const {
    schedules,
    loading,
    error,
    refresh,
    createSchedule,
    updateSchedule,
    deleteSchedule,
    sendNow,
    archiveSchedule,      // <-- ADD
    unarchiveSchedule,    // <-- ADD
  } = useEmailSchedules(Boolean(user));

Step 2: Create handler functions

Add handleArchive and handleUnarchive handlers after handleDuplicate (after line 413):

typescript
  const handleArchive = useCallback(
    async (id: string) => {
      try {
        await archiveSchedule(id);
        toast.success("Email archived");
      } catch (err) {
        toast.error(
          err instanceof Error ? err.message : "Failed to archive email"
        );
      }
    },
    [archiveSchedule]
  );

  const handleUnarchive = useCallback(
    async (id: string) => {
      try {
        await unarchiveSchedule(id);
        toast.success("Email unarchived");
      } catch (err) {
        toast.error(
          err instanceof Error ? err.message : "Failed to unarchive email"
        );
      }
    },
    [unarchiveSchedule]
  );

Step 3: Update stats — replace sentCount with archivedCount

Replace the sentCount useMemo (lines 225–228):

Old:

typescript
  const sentCount = useMemo(
    () => schedules.filter((s) => s.status === "sent").length,
    [schedules]
  );

New:

typescript
  const archivedCount = useMemo(
    () => schedules.filter((s) => s.isArchived).length,
    [schedules]
  );

Step 4: Update stats card in JSX

Replace the "Sent" stats card (lines 582–598):

Old:

tsx
              <div className="card group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/20">
                <div className="card-body py-4">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm font-medium text-default-500">
                        Sent
                      </p>
                      <p className="text-2xl font-bold text-default-800">
                        {sentCount}
                      </p>
                    </div>
                    <div className="rounded-lg bg-emerald-100 p-2.5 text-emerald-600">
                      <PaperAirplaneIcon />
                    </div>
                  </div>
                </div>
              </div>

New:

tsx
              <div className="card group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/20">
                <div className="card-body py-4">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm font-medium text-default-500">
                        Archived
                      </p>
                      <p className="text-2xl font-bold text-default-800">
                        {archivedCount}
                      </p>
                    </div>
                    <div className="rounded-lg bg-default-100 p-2.5 text-default-500">
                      <EnvelopeIcon />
                    </div>
                  </div>
                </div>
              </div>

Step 5: Pass new props to EmailSchedulesTable

Update the <EmailSchedulesTable> rendering (lines 667–680) to include the new props:

tsx
            <EmailSchedulesTable
              deletingId={deletingId}
              error={error}
              loading={loading}
              schedules={schedules}
              senderMap={senderMap}
              sendingId={sendingId}
              onArchive={handleArchive}
              onDelete={handleDelete}
              onDuplicate={handleDuplicate}
              onEdit={handleEdit}
              onRefresh={() => void refresh()}
              onSendNow={handleSendNow}
              onUnarchive={handleUnarchive}
              onView={setViewingSchedule}
            />

Step 6: Update scheduledCount to exclude archived

Update the scheduledCount useMemo (lines 217–223) to exclude archived emails:

typescript
  const scheduledCount = useMemo(
    () =>
      schedules.filter(
        (s) =>
          !s.isArchived &&
          ["draft", "scheduled", "processing"].includes(s.status)
      ).length,
    [schedules]
  );

Step 7: Verify full build

Run: pnpm build:check Expected: PASS — no type errors

Step 8: Commit

bash
git add src/app/\(admin\)/\(pages\)/email-schedules/index.tsx
git commit -m "feat(email): wire archive/unarchive to page component, update stats"

Task 8: Lint & Final Verification

Step 1: Run linter

Run: pnpm lint Expected: PASS (or only pre-existing warnings, no new errors)

Step 2: Fix any lint issues

Run: pnpm lint:fix if needed

Step 3: Run full build

Run: pnpm build:check Expected: PASS

Step 4: Run unit tests

Run: pnpm test:unit Expected: PASS (existing tests should still pass; isArchived defaults to false so existing mock data should be compatible)

Step 5: Final commit if any lint fixes

bash
git add -A
git commit -m "chore: lint fixes for email archive feature"

Summary of All Changes

TaskFilesDescription
1supabase/migrations/20260211000000_add_email_archive.sql, src/lib/supabaseTypes.tsDB migration + type regen
2src/features/email-schedules/types.tsAdd isArchived to interface
3src/features/email-schedules/services/EmailScheduleService.tsAdd mapping + setArchived() method
4src/features/email-schedules/hooks/useEmailSchedules.tsAdd archive/unarchive callbacks
5src/features/email-schedules/components/EmailSchedulesTable.tsxUpdate filters, counts, tabs
6src/features/email-schedules/components/EmailSchedulesTable.tsxUpdate row actions
7src/app/(admin)/(pages)/email-schedules/index.tsxWire everything, update stats
8All filesLint + final verification