- Milestone 1: The IMAP spike
- Goal: Prove the core technology works.
- Tasks: Just a Go CLI app. Log in, THREAD, SEARCH, FETCH. No UI.
- Milestone 2: Read-only V-Mail (MVP)
- Goal: A read-only, online-only client.
- Tasks:
- Set up auth.
- Build the Go API for reading (threads, messages).
- Build the React UI (layout, sidebar, list, thread view).
- Implement j/k/o/u navigation.
- No sending, no offline.
- Create Settings page with reading/writing fields.
- Build onboarding flow.
- Milestone 3: Actions
- Goal: Be able to manage email.
- Tasks: Implement Archive, Star, Trash (both frontend and backend). Implement the search bar UI to call the search API.
- Milestone 4: Composing
- Goal: Be able to send email.
- Tasks: Build composer UI. Implement SMTP logic on the backend. Implement Reply/Forward. Implement "Undo Send."
- Milestone 5: Quality of life
- Goal: Polish the MVP.
- Tasks: Auto-save drafts.
- Add keyboard shortcuts.
- Add pagination.
- Add IDLE and WebSocket connection.
- Milestone 6: Offline
- Goal: Basic offline support.
- Tasks: Implement IndexedDB caching for recently viewed emails. Build the sync logic.
- Add
VMAIL_AUTH_MODEconfig option: "dev" (stub auth) or "header" (trusts reverse proxy headers). In header mode, backend readsRemote-Email/Remote-Userheaders from Caddy's forward_auth. - Update frontend to redirect to Authelia on 401 and remove hardcoded tokens.
- Update WebSocket handler to support header-based auth.
- Attachments are not always displayed. Make sure they are displayed correctly.
- Sent emails are not part of threads in Inbox. Make sure they are included. I guess same for vice versa. Add backend test to cover this.
- Rewrite /scripts/check.sh in Go because the logic is too complex now. Also, make it run E2E tests just once, and log it when failed, AND run gofmt and pnpm lint:fix automatically.
- Write docs for how to run the app in dev mode, and in general to run it with a single command, forking to Go and the frontend, with Go having live reload. These things would be nice.
- Ensure line-to-line debugging is possible for Go
- Add a "Test IMAP and SMTP connections" button to the Settings page.
- Goal: Be able to manage email.
- Tasks: Implement Archive, Star, Trash (both frontend and backend). Implement the search bar UI to call the search API.
This milestone is all about adding actions.
We'll build them one by one.
The "Star" action is the simplest, so we'll start there.
We'll use the action_queue for all of them to make the UI feel instant.
Before we can queue any actions, we need to build the worker that processes them.
- Create the worker service:
- In
/backend/internal/sync, create a newworker.gofile. - Create a
structfor your worker, e.g.,type ActionWorker struct { db *pgxpool.Pool, imapService *imap.Service }. (You'll need to create animap.Servicestruct in your/internal/imapfolder to hold your IMAP logic).
- In
- Create the worker loop:
- In
worker.go, create aStart()method. This method should be run as a goroutine from yourmain.go. - Inside
Start(), use atime.NewTicker(e.g., every 5 seconds) to wake up and poll for jobs. - In the loop, call a
processJobs()function.
- In
- Create the job processor:
- In
worker.go, createprocessJobs(). - It should query the DB:
SELECT id, action_type, payload FROM action_queue WHERE process_at <= NOW() FOR UPDATE SKIP LOCKED. - It should loop over the returned rows.
- Use a
switch item.action_typeto handle different jobs. For now, it will be empty. - After a job is processed successfully, it must
DELETE FROM action_queue WHERE id = $1.
- In
- Add unit tests:
- Test the
processJobsfunction in isolation. - Mock the database: Make your
SELECT ... FOR UPDATEmock return a list of 2β3 sample jobs (e.g., one "star", one "move"). - Mock the IMAP service.
- Assert that the correct IMAP service methods (e.g.,
StarThread,MoveThread) are called with the exact payloads from the mock jobs. - Assert that the
DELETE FROM action_queuecommand is called for each successfully processed job.
- Test the
- Add integration tests (Go + Testcontainers):
- Start a real Postgres DB using
testcontainers-go. - Mock only the IMAP service.
- Manually
INSERT2-3 jobs into theaction_queuetable. - Start your
ActionWorker. - Wait ~100ms.
- Assert your mock IMAP service methods were called.
- Query the database: Assert the processed jobs have been deleted from the
action_queuetable.
- Start a real Postgres DB using
- Create the "Star" IMAP logic:
- In
/backend/internal/imap, create a newactions.gofile. - Create a function:
func (s *Service) StarThread(ctx context.Context, userID string, threadStableID string, starStatus bool) error. - This function needs to:
- Get the user's settings (for credentials) from the DB.
- Get an IMAP connection from your pool.
- Find all messages in our DB for that thread:
SELECT imap_uid, imap_folder_name FROM messages WHERE thread_id = (SELECT id FROM threads WHERE stable_thread_id = $1 AND user_id = $2). - Loop through each message,
c.Select(folderName, ...) - Build a
seqSetfor theimap_uid. - Run the IMAP command:
c.UidStore(seqSet, flagOp, []interface{}{imap.FlaggedFlag})(whereflagOpis"+FLAGS.SILENT"or"-FLAGS.SILENT"). - Update our own DB to match:
UPDATE messages SET is_starred = $1 WHERE ....
- In
- Create the
actionsAPI endpoint:- In
/backend/internal/api/routes.go, add the route:router.Post("/api/v1/actions", app.actionsHandler). - Create a new
/backend/internal/api/actions_handler.gofile. - The
actionsHandlershould:- Read the JSON body, e.g.,
{"action": "star_thread", "payload": {"thread_stable_id": "...", "star_status": true}}. - Check the
actiontype. - Call a new function
db.QueueAction(...)to save the job to theaction_queuetable withprocess_at = NOW(). - Return
202 Acceptedto the frontend immediately.
- Read the JSON body, e.g.,
- In
- Hook up the worker:
- In
worker.go, add acase "star_thread":to yourswitchstatement. - It should parse the JSON payload, then call your new
imapService.StarThread(...).
- In
- Create the
starThreadmutation:- In
/frontend/src/api/(or a newapi/email.api.ts), create auseStarThreadMutationhook usingTanStack Query'suseMutation. - This mutation will
POSTto/api/v1/actionswith thestar_threadpayload.
- In
- Add optimistic updates:
- This is key for a fast UI. In your
useStarThreadMutation, use theonMutateoption to optimistically update the query cache before the API call runs. - You'll use
queryClient.setQueryData(...)to find the thread in the'threads'query cache and manually set itsis_starredstatus. - Add
onErrorto roll back the change if the mutation fails. - Add
onSettledtoqueryClient.invalidateQueries(['threads'])to refetch the real data.
- This is key for a fast UI. In your
- Add the UI button:
- In
EmailListItem.tsx, add a star icon button. onClickshould callstarThreadMutation.mutate({ thread_stable_id: ..., star_status: !thread.is_starred }).- The star's appearance (filled vs. outline) should be based on the
thread.is_starredprop.
- In
- Backend Unit (Go):
actions_handler: Test that aPOST /api/v1/actionsrequest with a "star_thread" body results in a new row being inserted intoaction_queuewith the correctaction_typeandpayload.imapService: Test theStarThreadfunction. Mock the DB and the IMAP client.- Assert it correctly fetches messages from the mock DB.
- Assert the mock IMAP client's
UidStoremethod is called with+FLAGS.SILENTforstarStatus: true. - Assert the mock IMAP client's
UidStoremethod is called with-FLAGS.SILENTforstarStatus: false. - Assert it updates the
messagestable in the mock DB.
- Frontend Unit (RTL):
- Test the
EmailListItemcomponent. - Mock the
useStarThreadMutationhook. - Assert the star icon is "outline" when
is_starredis false. - Click the star button.
- Assert the
mutatefunction from the mock hook was called with{ ..., star_status: true }.
- Test the
- Frontend Integration (RTL +
msw):- Test Optimistic Update:
- Mock the
POST /api/v1/actionsAPI to have a 1-second delay. - Render the
EmailListItem(withis_starred: false) inside aQueryClientProvider. - Click the star button.
- Immediately assert that the star icon changes to "filled" (even before the API mock resolves).
- Wait for the API to resolve.
- Assert the star icon remains "filled".
- Mock the
- Test Optimistic Update:
These are identical, just with a different destination. We'll build them as a generic "move" action.
- Create the "Move" IMAP logic:
- In
/backend/internal/imap/actions.go, create:func (s *Service) MoveThread(ctx context.Context, userID string, threadStableID string, destinationFolder string) error. - Logic:
- Get user settings and IMAP connection.
- Get all messages for the thread from our DB (
SELECT imap_uid, imap_folder_name FROM messages WHERE ...). - Loop through each message,
c.Select(folderName, ...). - Run the IMAP command:
c.UidMove(seqSet, destinationFolder). - Update our DB:
UPDATE messages SET imap_folder_name = $1 WHERE ....
- In
- Update the
actionsHandler:- Add logic to handle a new
action_type: "move_thread". - The payload should be
{"thread_stable_id": "...", "destination_folder": "Archive"}(or "Trash"). - It should queue this in the
action_queuetable.
- Add logic to handle a new
- Hook up the worker:
- In
worker.go, addcase "move_thread":to yourswitch. - It should parse the payload and call
imapService.MoveThread(...).
- In
- Create the
moveThreadmutation:- In
email.api.ts, create auseMoveThreadMutationhook. - It
POSTs to/api/v1/actionswith themove_threadpayload.
- In
- Add UI buttons:
- In
EmailListItem.tsx(e.g., on hover) andThreadView.tsx, add "Archive" and "Trash" icon buttons. onClickon "Archive" callsmoveThreadMutation.mutate({ ..., destination_folder: 'Archive' }).onClickon "Trash" callsmoveThreadMutation.mutate({ ..., destination_folder: 'Trash' }).
- In
- Update cache on success:
- This is not an optimistic update.
- In the
onSuccesscallback of youruseMoveThreadMutation, you must invalidate the cache for the list you're looking at. queryClient.invalidateQueries({ queryKey: ['threads', currentFolder] }). This will trigger a refetch, and the item will disappear from the list.
- Backend Unit (Go):
actions_handler: Test that aPOSTrequest with a "move_thread" body (payloaddestination_folder: 'Archive') queues the correct job.imapService: Test theMoveThreadfunction. Mock the DB and IMAP client.- Assert it correctly fetches messages.
- Assert the mock IMAP client's
UidMovemethod is called with the correctseqSetand destination folder. - Assert it updates the
messagestable in the mock DB with the newimap_folder_name.
- Frontend Integration (RTL +
msw):- Test Cache Invalidation:
- Mock the
GET /api/v1/threads?folder=INBOXto return three items. - Render the
Inbox.page.tsx. Assert three items are visible. - Mock the
POST /api/v1/actionsAPI to succeed. - Mock
queryClient.invalidateQueriesto track calls. - Click the "Archive" button on the first item.
- Assert
invalidateQuerieswas called with the['threads', 'INBOX']query key.
- Mock the
- Test Cache Invalidation:
This Playwright plan tests the full "action" loop. It assumes a pre-populated mock IMAP server.
- Test 1: Star a Thread (Full Loop)
- Log in and go to the Inbox.
- Find a thread and assert its star is "outline".
- Click the star.
- Assert the star immediately turns "filled" (optimistic UI).
- Wait 6 seconds (for the worker to run).
- Reload the entire page.
- Log in again (if needed).
- Assert the same thread still shows a "filled" star (proves the backend IMAP & DB update worked).
- Test 2: Archive a Thread (Full Loop)
- Log in and go to the Inbox.
- Find a thread and note its subject (e.g., "Project Budget").
- Click the "Archive" button.
- Assert the "Project Budget" thread disappears from the Inbox (tests cache invalidation).
- Click the "Archive" folder in the sidebar.
- Assert the "Project Budget" thread appears in the Archive folder list.
- Test 3: Search (Full Loop)
- Log in. (Assume a message with the subject "Special Report Q3" exists on the mock IMAP server).
- Type "Special Report" into the search bar and press Enter.
- Assert the URL changes to
/search?q=Special%20Report. - Assert the thread "Special Report Q3" is visible in the search results.
- Goal: Be able to send email.
- Tasks: Build composer UI. Implement SMTP logic on the backend. Implement Reply/Forward. Implement "Undo Send."
This milestone is built around the action_queue you created in M3.
The "Send" button doesn't send an email; it queues a "send" job.
This is what makes "Undo Send" possible.
Breakdown:
We'll start by creating the two API endpoints the frontend needs: one to queue a send action, and one to cancel it.
- Create the "Send" API endpoint:
- Add the route:
router.Post("/api/v1/send", app.sendHandler). - Create a new
/backend/internal/api/send_handler.go. - Handler Logic:
- Read the JSON body (to, subject, body, etc.).
- Get the user's
undo_send_delay_secondsfrom theiruser_settingsin the DB. - Calculate
process_at = NOW() + (delay * time.Second). - Create the
payload(a JSONB object of the full email). INSERTthe job intoaction_queue(action_type = 'send_email',payload,process_at).- Crucially: Use
RETURNING idon yourINSERTto get the new job's UUID. - Return
202 Acceptedwith a JSON body:{"job_id": "..."}. The frontend needs this ID for the "Undo" button.
- Add the route:
- Create the "Undo" API endpoint:
- Add the route:
router.Delete("/api/v1/send/undo/:jobID", app.undoHandler). - Create a new
/backend/internal/api/undo_handler.go. - Handler Logic:
- Get
jobIDfrom the URL parameters. - Get
userIDfrom the auth middleware. - Run
DELETE FROM action_queue WHERE id = $1 AND user_id = $2. (Checkinguser_idis a critical security step). - Return
200 OK.
- Get
- Add the route:
- Backend Unit Tests:
send_handler:- Test that
POST /api/v1/sendwith a valid body inserts a row intoaction_queue. - Assert the
action_typeissend_email. - Assert the
process_attime is correct (e.g.,now + 20s). - Assert the handler returns a
202status and thejob_id.
- Test that
undo_handler:INSERTa test job foruser_a.- Test that
DELETE /api/v1/send/undo/:jobID(asuser_a) deletes the job. - Test that
DELETE /api/v1/send/undo/:jobID(asuser_b) does not delete the job (and returns an error or404).
Now, we'll teach our M3 ActionWorker how to process the send_email jobs.
- Create SMTP logic:
- Create a new folder
/backend/internal/smtp. - In
smtp/client.go, create aSendEmailfunction:func SendEmail(settings *UserSettings, payload *EmailPayload) error. - This function should:
- Use
github.com/go-mail/mailto build the email message (set "From", "To", "Subject", "HTMLBody"). - Use
net/smtpto connect:smtp.Dial(settings.smtp_server_hostname + ":587"). - Authenticate:
smtp.PlainAuth(...). - Send:
mail.Send(client, msg).
- Use
- Create a new folder
- Create IMAP "Append" logic:
- In
/backend/internal/imap/actions.go, create a new function:func (s *Service) AppendToSent(ctx context.Context, userID string, rawEmailBytes []byte) error. - This function should:
- Get user settings (for credentials and
sent_folder_name). - Get an IMAP connection.
c.Append(settings.sent_folder_name, ...)to upload the raw email bytes.
- Get user settings (for credentials and
- In
- Hook up the worker:
- In
/backend/internal/sync/worker.go, addcase "send_email":to yourswitch. - Logic:
- Parse the
payload. - Call
smtp.SendEmail(...). - If SMTP fails, log the error and don't delete the job (it will retry).
- If SMTP succeeds, call
imap.AppendToSent(...)to save a copy. - If both succeed,
DELETEthe job fromaction_queue.
- Parse the
- In
- Backend Unit Tests:
smtp.SendEmail: Mocksmtp.Dialandsmtp.Auth. Test that the function attempts to connect and send.imap.AppendToSent: Mock the IMAP client. Test thatc.Appendis called with the correctsent_folder_name.worker: Test thecase "send_email"logic.- Mock the SMTP and IMAP services.
- Assert that on SMTP success, both SMTP and IMAP methods are called.
- Assert that on SMTP failure, the IMAP method is not called.
Now, the frontend to create and cancel jobs.
- Create
composerstore:- In
/frontend/src/store, createcomposer.store.ts(using Zustand). - It should hold state:
isOpen(boolean),to(string[]),subject(string),body(string),inReplyTo(Message, optional). - Add actions:
openCompose(),openReply(msg),openForward(msg),close().
- In
- Create
Composercomponent:- Create
/frontend/src/components/composer/Composer.tsx. - It renders as a modal or pop-up (fixed to the bottom corner) only if
composer.store.isOpenis true. - It should have
inputfields for "To" and "Subject," and a rich text editor (ortextarea) for "Body," all bound to the Zustand store.
- Create
- Create "Send" mutation:
- In
api/email.api.ts, createuseSendEmailMutation. - It
POSTs to/api/v1/sendwith the data from thecomposer.store. onClickfor the "Send" button callssendEmailMutation.mutate().
- In
- Create "Undo" snackbar/toast:
- Create a
store/ui.store.ts(Zustand) to holdundoJobId(string | null) andshowUndo(boolean). - In the
onSuccesscallback ofuseSendEmailMutation:- Call
composer.store.close(). - Set
ui.store.showUndo = trueandui.store.undoJobId = response.job_id. - Start a
setTimeoutforundo_send_delay_seconds. When it fires, setshowUndo = false.
- Call
- Create
components/UndoSnackbar.tsxthat renders ifshowUndois true. - Create
useUndoSendMutationthatDELETEs/api/v1/send/undo/:jobID. - The "Undo" button in the snackbar calls this mutation and hides the snackbar.
- Create a
- Frontend Integration Tests (RTL +
msw):- Mock
POST /api/v1/sendto return{"job_id": "123"}. - Click the "Compose" button. Assert composer is visible.
- Fill inputs, click "Send". Assert the API was called with the correct data.
- Assert the composer closes.
- Assert the "Undo Snackbar" is now visible.
- Mock
DELETE /api/v1/send/undo/123. - Click the "Undo" button. Assert the
DELETEAPI was called and the snackbar hides.
- Mock
This part just pre-fills the composer you just built.
- Add "Reply/Forward" buttons:
- In
Message.tsx(insideThreadView.tsx), add "Reply," "Reply All," and "Forward" buttons.
- In
- Implement store actions:
composer.store.openReply(msg):- Sets
isOpen = true. - Sets
to = [msg.from_address]. - Sets
subject = "Re: " + msg.subject. - Sets
body = "On [date], [sender] wrote:\n\n> [quoted body]". - Sets
inReplyTo = msg.
- Sets
composer.store.openReplyAll(msg):- Same, but
to = [msg.from_address, ...msg.to_addresses, ...msg.cc_addresses](filtering out the user's own email).
- Same, but
composer.store.openForward(msg):- Same, but
to = [],subject = "Fwd: " + msg.subject, andbody = "Forwarded message:\n\n...".
- Same, but
- Frontend Integration Tests (RTL):
- Render
ThreadView.tsxwith a mock message. - Click "Reply". Assert the
Composercomponent opens. - Assert the "To" and "Subject" fields are pre-filled correctly with "Re: ...".
- Render
This Playwright plan tests the full send-and-undo loop.
- Test 1: Compose and Send (Full Loop)
- Log in.
- Click "Compose."
- Fill in "To" (with an external email you can check), "Subject" ("E2E Test Send"), and "Body".
- Click "Send".
- Assert the composer closes.
- Assert the "Undo" snackbar appears.
- Do not click Undo. Wait for the
undo_send_delay_seconds(default: 20 sec) plus the worker poll time (default: 5 sec). - Check external email: Assert the email ("E2E Test Send") was received.
- Check V-Mail UI: Click the "Sent" folder. Assert the "E2E Test Send" email now appears in the "Sent" list (proves the IMAP
APPENDworked).
- Test 2: Compose and Undo (Full Loop)
- Log in.
- Click "Compose."
- Fill in "To" (with an external email), "Subject" ("E2E Test Undo"), and "Body".
- Click "Send".
- Assert the "Undo" snackbar appears.
- Immediately click "Undo".
- Assert the snackbar disappears.
- Wait 30 seconds (longer than your undo delay).
- Check external email: Assert the email ("E2E Test Undo") was NOT received.
- Check V-Mail UI: Click the "Sent" folder. Assert the email does NOT appear in the "Sent" list.
- Goal: Polish the MVP.
- Tasks: Auto-save drafts. Add keyboard shortcuts. Add pagination. Add IDLE and WebSocket connection.
Breakdown:
This plan implements a fast, reliable auto-save that saves to your Postgres DB first, and then (as a bonus) syncs to your IMAP server in the background.
- Update
draftstable:- Add a new column:
imap_uid BIGINT NOT NULL DEFAULT 0. This will store theUIDof the draft on the IMAP server once it's synced.
- Add a new column:
- Create
POST /api/v1/draftsendpoint:- This will be your auto-save endpoint. It needs to be fast.
- Create a
SaveDraft(ctx, draft)function in/backend/internal/db/drafts.go. - This function should use
INSERT ... ON CONFLICT (id) DO UPDATE ...to create or update the draft in your Postgresdraftstable. - The
actions_handlerfor this route should:- Read the draft payload (to, subject, body, etc.) from the JSON body.
- Call
db.SaveDraft(...). - Return the
draft.idto the frontend:{"draft_id": "..."}.
- (Optional/Bonus) Create background IMAP sync:
- After saving to the DB, have the
POST /api/v1/draftshandler queue a new job:db.QueueAction(..., "sync_draft", payload{"draft_id": "..."}) - In your
/backend/internal/sync/worker.go, add acase "sync_draft": - In
/backend/internal/imap/actions.go, createfunc (s *Service) SyncDraft(ctx, draftID string) error. - This function should:
- Fetch the draft from your Postgres
draftstable. - Get the
imap_uidanddrafts_folder_name(fromuser_settings). - Get an IMAP connection.
- If
imap_uid == 0:APPENDthe draft to thedrafts_folder_nameand save the newimap_uid(from theAPPENDresponse) back to your Postgresdraftstable. - (Harder/v2) If
imap_uid > 0:DELETEthe old message (STORE +FLAGS.SILENT \Deleted) andAPPENDthe new one (and update theimap_uid). For now, justAPPENDing is fine.
- Fetch the draft from your Postgres
- After saving to the DB, have the
- Create
GET /api/v1/draftsendpoint:- This route should query your Postgres
draftstable (not IMAP) and return all saved drafts for the user.
- This route should query your Postgres
- Update
send_emailworker:- The payload for
send_emailnow needs an optionaldraft_id_to_delete. - After the
send_emailjob successfully sends (SMTP) and appends to "Sent" (IMAP), it must also:DELETE FROM drafts WHERE id = $1(the Postgres draft).- (If you did the bonus sync) Queue a new job to delete the draft from the IMAP
Draftsfolder.
- The payload for
- Create
useAutoSavehook:- Create
hooks/useAutoSave.ts. - This hook takes the current composer state (to, subject, body) as an argument.
- It uses
useEffectandsetTimeoutto create a "debounce" (e.g., trigger 2 seconds after the user stops typing). - When the timeout fires, it calls
saveDraftMutation.mutate(...).
- Create
- Create
useSaveDraftMutation:- In
api/email.api.ts, create this mutation. ItPOSTs to/api/v1/drafts. - In its
onSuccesscallback, it must save the returneddraft_idinto yourcomposer.store(which needs a newdraft_idfield).
- In
- Integrate with
Composer:- Call
useAutoSave(composerState)from yourComposer.tsxcomponent.
- Call
- Update "Send" button:
- When the "Send" button is clicked, the
useSendEmailMutationmust now include thedraft_idfrom thecomposer.storein its payload, so the backend knows which draft to delete.
- When the "Send" button is clicked, the
- Create "Drafts" page:
- Add a "Drafts" link to your
Sidebar.tsx(it should point to/drafts). - Create
pages/Drafts.page.tsx. - This page uses
useQuerytoGET /api/v1/drafts. - It renders a list of drafts (you can reuse
EmailListItem.tsx). - When a draft is clicked, it calls
composer.store.openDraft(draftData)(a new action you'll create) to open the composer and pre-fill it.
- Add a "Drafts" link to your
- Backend Unit: Test the
POST /api/v1/draftshandler (asserts DBINSERT/UPDATEand returns anid). Test theGET /api/v1/draftshandler. - Frontend Unit: Test
useAutoSavehook. MocksetTimeoutand the mutation. Assertmutateis (or is not) called based on typing/pausing. - Frontend Integration: Mock
POST /api/v1/drafts. Type in the composer, pause for 3 seconds. Assert thePOSTAPI was called. - E2E:
- Click "Compose," type "test subject."
- Reload the page.
- Go to the "Drafts" page. Assert "test subject" is in the list.
- Click it. Assert the composer opens with "test subject".
- Add a recipient and click "Send."
- Wait 30 seconds. Assert the draft is now gone from the "Drafts" page.
This is a frontend-only task that expands on the hook you built in M2.
- Expand
useKeyboardShortcuts.ts:- Add logic to listen for key presses.
c: Callcomposer.store.openCompose()./: Find the search barrefand callref.current.focus().gtheni:Maps('/?folder=INBOX')(this needs a simple state machine in the hook).gthens:Maps('/?folder=Sent')(or your starred view).gthend:Maps('/drafts').
- Add shortcuts for selected items:
- This requires a "selected item" state (e.g.,
ui.store.selectedThreadId). Yourj/kkeys should update this ID. e: IfselectedThreadIdexists, callmoveThreadMutation.mutate({ ..., destination_folder: 'Archive' }).s: IfselectedThreadIdexists, callstarThreadMutation.mutate(...).#(Shift+3): IfselectedThreadIdexists, callmoveThreadMutation.mutate({ ..., destination_folder: 'Trash' }).
- This requires a "selected item" state (e.g.,
- Add shortcuts for the thread view:
- When on a
/thread/:threadIdpage: r: Callcomposer.store.openReply(currentThreadData).a: Callcomposer.store.openReplyAll(currentThreadData).f: Callcomposer.store.openForward(currentThreadData).
- When on a
- Add "Undo" shortcut:
z: Ifui.store.showUndois true, callundoSendMutation.mutate().
- Frontend Unit:
- Write extensive tests for
useKeyboardShortcuts.ts. - Simulate
keydownevents (c,/,g+i,e,r, etc.). - Mock all the store actions and mutations.
- Assert that the correct mock function is called for each key press.
- Write extensive tests for
This is the most complex but most rewarding "quality of life" feature.
- Add WebSocket library:
go get github.com/gorilla/websocket
- Create WebSocket Hub:
- Created
/backend/internal/websocket/hub.go. - The
Hubstruct manages active connections:userID -> set of *websocket.Conn(via aClientwrapper). - It exposes methods:
Register(userID, conn),Unregister(userID, client), andSend(userID, message []byte). - It supports multiple connections per user with a per-user limit (currently 10).
- Created
- Create
GET /api/v1/wsendpoint:- Added the route in
cmd/server/main.go. - The handler (
WebSocketHandler) upgrades the HTTP connection to a WebSocket. - It gets the
userIDfrom the auth context usingGetUserIDFromContext. - It calls
hub.Register(userID, conn)and starts a read loop to detect disconnects. - On disconnect, it calls
hub.Unregister(userID, client)and stops the IMAP IDLE listener when there are no more active connections for that user.
- Added the route in
- Create IMAP IDLE listener:
- Implemented in
/backend/internal/imap/idle.goasfunc (s *Service) StartIdleListener(ctx context.Context, userID string, hub *websocket.Hub). - Launch: When a user successfully connects to the WebSocket (
hub.Register),WebSocketHandlerstarts this function in a new goroutine for thatuserID(if not already running). - Logic:
- Get a dedicated IMAP listener connection from the pool.
- Run
SELECT INBOX. - Start an IDLE loop (with fallback) using
go-imap-idle. - Listen for updates via the client's
Updateschannel. When an update indicates new messages inINBOX, callSyncThreadsForFolderforINBOXimmediately. - After syncing, call
hub.Send(userID, []byte('{"type":"new_email","folder":"INBOX"}')). - On errors (e.g., timeout), log, remove the listener connection, and retry after a short sleep.
- Implemented in
- Create
useWebSockethook:- Created
hooks/useWebSocket.ts. - It is called once from the main
Layout.tsx. useEffecton mount:- Opens
new WebSocket(VITE_WS_URL || '<origin>/api/v1/ws'). - Sets status in a
connection.store.ts(Zustand) toconnecting/connected/disconnected. - Handles
onmessageandoncloseto update connection state.
- Opens
- The
onmessagehandler parsesevent.dataand, whenmessage.type === 'new_email', invalidates queries for that folder.
- Created
- Invalidate cache on message:
- Inside the
onmessagehandler: - Gets the
queryClientusinguseQueryClient(). - Calls
queryClient.invalidateQueries({ queryKey: ['threads', message.folder] }). - This automatically makes
TanStack Queryrefetch the thread list, and the new email appears.
- Inside the
- Connection status banner and manual reconnect:
- Added a
ConnectionStatusBannercomponent, shown when the WebSocket status isdisconnected. - The banner displays a Gmail-style "Connection lost. New emails may be delayed." message with a "Try now" link that triggers a reconnect of the WebSocket.
- Added a
- Frontend Integration (RTL + WebSocket mocking via MSW):
- Uses
msw's WebSocket support instead ofmock-socket. - Renders a component that uses
useWebSocketunder aQueryClientProvider. - Simulates a message from the mock socket:
server.send('{"type": "new_email", "folder": "INBOX"}'). - Asserts that
queryClient.invalidateQuerieswas called with{ queryKey: ['threads', 'INBOX'] }.
- Uses
- E2E:
- Adds a new E2E test in
e2e/tests/inbox.spec.ts. - With the Inbox page open, the test calls
/test/add-imap-message(a test-only backend endpoint) to append a message toINBOXon the IMAP server. - Asserts the new email appears in the V-Mail inbox without a page reload.
- Adds a new E2E test in
- Goal: Basic offline support.
- Tasks: Implement IndexedDB caching for recently viewed emails. Build the sync logic.
Breakdown:
First, we need a place to store the emails in the browser. We'll use dexie.js, which is a powerful and easy-to-use wrapper for IndexedDB.
- **Install Dexie:
pnpm install dexie dexie-react-hooks - Define the local DB schema:
- Create a new file:
/frontend/src/lib/db.ts. - In this file, define your Dexie database. The schema should mirror your Postgres tables, as this will make syncing much easier.
- Create a new file:
import Dexie, { Table } from 'dexie'
// Define interfaces for your tables
// (You can move these to a /types file)
export interface IThread {
id: string // This is your stable_thread_id
subject?: string
// ... other thread properties
}
export interface IMessage {
id: string // This is your message_id_header
thread_id: string
imap_folder_name: string
from_address?: string
subject?: string
unsafe_body_html?: string
body_text?: string
is_read: boolean
is_starred: boolean
// ... other message properties
}
export class VMailDB extends Dexie {
threads!: Table<IThread>
messages!: Table<IMessage>
constructor() {
super('vmailDB')
this.version(1).stores({
// 'id' is the primary key
// 'thread_id' and 'imap_folder_name' are indexes
threads: 'id',
messages: 'id, thread_id, imap_folder_name',
})
}
}
export const db = new VMailDB()This part implements "caching for recently viewed emails." The logic is simple: anything you successfully fetch from the API, you save a copy of in IndexedDB.
- Cache thread list:
- In your
Inbox.page.tsx(or wherever you fetch threads), find youruseQueryforGET /api/v1/threads. - Add an
onSuccesscallback to theuseQueryoptions. - In
onSuccess(data), calldb.threads.bulkPut(data.threads). This will "upsert" (insert or update) all the threads you just fetched.
- In your
- Cache full thread:
- In your
Thread.page.tsx, find youruseQueryforGET /api/v1/thread/:threadId. - Add an
onSuccesscallback. - In
onSuccess(data), save both the thread and its messages:
TypeScript
db.threads.put(data.thread) - db.messages.bulkPut(data.thread.messages)
- In your
Now, we'll change your queries to always read from the local cache first. This gives an "offline-first" feel.
- Modify
GET /api/v1/threadsquery:- In your
useQueryforGET /api/v1/threads, change thequeryFn. - The
queryFnshould first try to get data from Dexie:
- In your
queryFn: async () => {
// 1. Try to get data from the local cache
const cachedThreads = await db.threads
.where('imap_folder_name') // Assuming you add this to the threads table
.equals(folder)
.toArray()
// 2. If online, fetch from API in the background
if (navigator.onLine) {
try {
const freshData = await api.getThreads(folder)
// The onSuccess (from 6/2) will auto-cache this
return freshData.threads
} catch (error) {
// If API fails, return cached data so app still works
return cachedThreads
}
}
// 3. If offline, just return cached data
return cachedThreads
}- Modify
GET /api/v1/thread/:threadIdquery:- Apply the same logic. The
queryFnshould firstawait db.messages.where('thread_id').equals(threadId).toArray()and return that if the user is offline.
- Apply the same logic. The
Your action_queue already handles syncing actions (writes). This is for syncing reads (changes from other clients, like your phone).
To do this efficiently, we need a "give me what's changed" endpoint.
- Create a new migration:
migrate create -ext sql -dir backend/migrations -seq add_timestamps
- Modify schema (
.up.sql):- We need
updated_aton our main tables.
- We need
-- Create a trigger function to auto-update timestamps
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Add the column and trigger to 'threads'
ALTER TABLE "threads" ADD COLUMN "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON "threads"
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
-- Add the column and trigger to 'messages'
ALTER TABLE "messages" ADD COLUMN "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE TRIGGER set_timestamp
BEFORE UPDATE ON "messages"
FOR EACH ROW
EXECUTE PROCEDURE trigger_set_timestamp();
- Create new "sync" API endpoints:
GET /api/v1/sync/threads?since=<timestamp>GET /api/v1/sync/messages?since=<timestamp>- These handlers read the
sincequery param. - They query Postgres:
SELECT * FROM threads WHERE user_id = $1 AND updated_at > $2. - They return a list of updated/new threads and messages.
This logic will run in the background to keep the local database fresh.
- Create
useSynchook:- Create
hooks/useSync.ts. - This hook will be called once from your main
Layout.tsx.
- Create
- Implement the
syncfunction:- Inside the hook, create a
sync()function. - It should:
- Get
lastSyncTimestampfromlocalStorage. fetch('/api/v1/sync/threads?since=' + lastSyncTimestamp).fetch('/api/v1/sync/messages?since=' + lastSyncTimestamp).- Take the results and
bulkPutthem into your Dexiedb.threadsanddb.messagestables. - Save
new Date().toISOString()intolocalStorageas the newlastSyncTimestamp.
- Get
- Inside the hook, create a
- Trigger the sync:
- Use
useEffectto callsync()once on app load. - Use
setIntervalto callsync()every 5 minutes. - Add a
window.addEventListener('online', sync)to trigger an immediate sync when the user's connection returns.
- Use
Offline mode is notoriously hard to test. Use Playwright for this.
- Test 1: Offline read (cache population)
- Log in while online.
- Open the "Inbox".
- Click the first thread (Subject: "Meeting Notes").
- Click back to the "Inbox".
- Turn network offline using Playwright's
context.setOffline(true). - Reload the page.
- Assert the "Inbox" list still loads (from Dexie).
- Assert you can click "Meeting Notes" and read the full email (from Dexie).
- Assert that clicking the second thread (which you never opened) shows a loading spinner or an "Offline" message (because its body isn't cached).
- Test 2: Offline action (action_queue)
- Log in while online.
- Open the "Inbox".
- Turn network offline.
- Find a thread ("Meeting Notes") and click the "Star" button.
- Assert the star optimistically turns "filled".
- Reload the page (still offline).
- Assert the star is still "filled" (this tests that your optimistic UI state is also saved, or that you're reading the
action_queue). - Turn network online (
context.setOffline(false)). - Wait 10β15 seconds (for the worker and sync logic to run).
- Reload the page (now online).
- Assert the "Meeting Notes" email is still starred (proving the action was synced to the server).
- Test 3: Background sync (delta sync)
- Log in to V-Mail in Playwright. Have the "Inbox" open.
- Use your
spikescript (or another email client) to send a new email to your account. - Do not reload the Playwright browser.
- Wait for your sync interval (or manually trigger sync via a debug button).
- Assert the new email appears at the top of the list in V-Mail without a page reload.
- Write a doc for how to create a daily DB backup, e.g., via a
pg_dumpcron job.
Goal: Prove the core technology works. A simple Go CLI app. No UI.
- Set up a new Go module (
go mod init backend). - Add
github.com/emersion/go-imapas a dependency. - Create a
main.gofile. - Implement logic to connect to the mailcow IMAP server (using
imap.DialTLS). - Implement logic to log in using a username and password (from env vars for now).
- Implement a function to run the
CAPABILITYcommand and print the results (to verifyTHREADsupport). - Implement a function to
SELECTthe "Inbox". - Implement a function to run a
THREADcommand (THREAD=REFERENCES UTF-8 ALL) and print the raw response. - Implement a function to run a
SEARCHcommand (e.g.,SEARCH FROM "test") and print the resulting UIDs. - Implement a function to
FETCHa single message (using a UID from the search) and print its body structure and headers.
Done! π It works nicely. It's in /backend/cmd/spike. See /backend/README.md for details on milestone 1.
- Create the main server: In
/backend/cmd/server, create a newmain.go. This will be your actual server (unlike thespike).- It should start a
net/httpserver usinghttp.ListenAndServe. - It should use
http.ServeMux(as specified in your spec) for routing. - Add a simple
http.HandlerFuncfor/that responds with "V-Mail API is running".
- It should start a
- Set up config loading: In
/backend/internal/config, create aconfig.go.- Create a
structthat holds all env vars (DB host, master key, etc.). - Create a
NewConfig()function that reads from the.envfile (usinggodotenvfor local dev) andos.Getenv(for production). - Pass this
Configstruct to your server inmain.go.
- Create a
- Set up database connection: In
/backend/internal/db, create adb.go.- Create a
NewConnection()function that takes the DB config and returns a*pgxpool.Pool. - Add this
*pgxpool.Poolto your server's dependencies.
- Create a
- Set up DB migrations:
- Install
golang-migrate(e.g.,brew install golang-migrate). - Create a
/backend/migrationsdirectory. - Create a new migration file (e.g.,
migrate create -ext sql -dir backend/migrations -seq init_schema). - Copy-paste the entire SQL schema from
SPEC.mdinto the.up.sqlfile. - Run the migration (
migrate -database "..." -path backend/migrations up) to create your tables.
- Install
- Create Authelia middleware: In
/backend/internal/api(or/internal/auth), create amiddleware.go.- Create a
RequireAuthmiddleware. - This middleware should:
- Get the
Authorization: Bearer ...token from the request header. - (For now) Log the token. In a later step, you'll validate it.
- Pass the request to the next handler.
- Get the
- Create a
- Create API:
auth/statusendpoint:- Add the
GET /api/v1/auth/statusroute. - Create its handler function. This function should:
- (For now) Assume auth is okay.
- Check if a row exists in
user_settingsfor this user. - Return
{"isSetupComplete": [true/false]}.
- Add the
- Create API:
settingsendpoints:- In
/backend/internal/db, createuser_settings.go. AddGetUserSettings(userID string)andSaveUserSettings(settings UserSettings)functions. - Add the
GET /api/v1/settingsroute and handler. It should callGetUserSettingsand return the data (without passwords). - Add the
POST /api/v1/settingsroute and handler.- It must read the JSON body.
- It must encrypt the
imap_passwordandsmtp_passwordfields (using your standardcrypto/aeslogic). - It should call
SaveUserSettingsto save the data to the DB.
- In
- Set up the React project:
- In the root
/frontendfolder, runpnpm create vite . --template react-ts. - Install all your core dependencies:
pnpm install react-router-dom @tanstack/react-query zustand dompurify pnpm install -D tailwindcss postcss autoprefixer
- Initialize Tailwind (
pnpm tailwindcss init -p).
- In the root
- Create the basic layout:
- In
/frontend/src, create acomponents/Layout.tsx. Layout.tsxshould have a staticSidebar.tsx(left),Header.tsx(top, for search), and a main content area that renders{children}.
- In
- Set up routing:
- In
main.tsx, wrap your app in<BrowserRouter>. - Create
App.tsxto define your routes:/(goes toInbox.page.tsx)/thread/:threadId(goes toThread.page.tsx)/settings(goes toSettings.page.tsx)
- In
- Create Auth/Onboarding flow:
- Create an "auth" store in
store/auth.store.ts(using Zustand). It should holdisSetupComplete(boolean, defaultfalse). - Create a
components/AuthWrapper.tsxcomponent.- This component uses
useEffecton mount tofetchyourGET /api/v1/auth/statusendpoint. - When it gets the response, it sets the
isSetupCompletestate in your Zustand store. - It should render
{children}only ifisSetupCompleteis true. - If
isSetupCompleteisfalse, it should render<Navigate to="/settings" />(fromreact-router-dom).
- This component uses
- Wrap your main
<Layout />inAuthWrapper.tsx.
- Create an "auth" store in
- Build Settings Page:
- Create
pages/Settings.page.tsx. - This page should be a simple form with fields for all the
user_settings(IMAP server, username, password, folder names, etc.). - Use
TanStack Queryto fetch data fromGET /api/v1/settingsto populate the form. - Use
TanStack Query'suseMutationhook toPOSTthe form data toPOST /api/v1/settingson submit.
- Create
- Refactor
spikecode: Move yourconnectToIMAP,login,runThreadCommand, etc. from thespikeinto reusable functions in/backend/internal/imap.- Create an
imap/client.gothat manages a connection pool (as discussed). This is complex, so start simple: just amap[string]*client.Clientto hold one connection per user.
- Create an
- Create API:
foldersendpoint:- Add the
GET /api/v1/foldersroute and handler. - The handler should:
- Get the user's IMAP credentials from the DB.
- Get an IMAP connection from your pool.
- Run the IMAP
LISTcommand to get all folders. - Return them as a JSON array:
[{"name": "INBOX"}, {"name": "Sent"}].
- Add the
- Create API:
threadsendpoint:- Add the
GET /api/v1/threadsroute (it needs a query param, e.g.,?folder=INBOX). - This is the most complex handler:
- Get user credentials.
- Check the DB cache (as discussed, based on a TTL).
- If cache is stale: Get IMAP connection, run
THREAD(like in your spike), thenFETCHheaders for all messages in those threads. - Parse the messages (using
enmimefor headers). - Save the data to your
threadsandmessagestables. - Return the list of threads from your database.
- Add the
- Create API:
thread/:thread_idendpoint:- Add the
GET /api/v1/thread/:thread_idroute. - This handler should:
- Query your database for the thread (using
stable_thread_id). - Fetch all messages for that thread from your
messagestable. - (If messages are missing bodies) Fetch the full message bodies from IMAP.
- Parse with
enmime, savingunsafe_body_htmlandattachmentsto the DB. - Return the full thread with all messages and attachments as JSON.
- Query your database for the thread (using
- Add the
- Render folders:
- In
Sidebar.tsx, useTanStack Query(useQuery) to fetch fromGET /api/v1/folders. - Render the list of folders as links (
<Link to="/?folder=INBOX">Inbox</Link>).
- In
- Render thread list:
- Create
pages/Inbox.page.tsx. - It should read the
?folder=query param from the URL (usingreact-router'suseSearchParamshook). - Use
useQueryto fetch fromGET /api/v1/threads?folder=.... - Create an
EmailListItem.tsxcomponent. - Render the list of threads using this component, showing sender, subject, date, etc.
- Create
- Render thread view:
- Create
pages/Thread.page.tsx. - It should read the
:threadIdfrom the URL (usinguseParams). - Use
useQueryto fetch fromGET /api/v1/thread/:threadId. - Create a
Message.tsxcomponent. - Render each message in the thread.
- Crucially: In
Message.tsx, useDOMPurify.sanitize()on theunsafe_body_htmlbefore rendering it withdangerouslySetInnerHTML. - Render the list of attachments.
- Create
- Implement basic keyboard navigation:
- Create a
hooks/useKeyboardShortcuts.ts. - This hook should
useEffectto add akeydownevent listener. - (For now) Just implement
j(next item) andk(previous item) to move a "selected" index, which you'll store in a new Zustand store (ui.store.ts). - Implement
o(open) orEnterto navigate to the selected thread (usingreact-router'suseNavigatehook). - Implement
u(up) to navigate from a thread view back to the inbox (Maps('/')).
- Create a
-
Message.tsx(Security):- Test that the component always calls
DOMPurify.sanitize()with theunsafe_body_htmlprop. - Test that the output of
DOMPurify.sanitizeis what's actually rendered viadangerouslySetInnerHTML.
- Test that the component always calls
-
hooks/useKeyboardShortcuts.ts:- Mock
window.addEventListenerandwindow.removeEventListenerto test that they are called on mount/unmount. - Test that pressing "j" calls the function to increment the selected index.
- Test that pressing "k" calls the function to decrement the selected index.
- Test that pressing "o" or "Enter" calls the
react-routerMapsfunction. - Test that pressing "u" calls the
Mapsfunction to go back.
- Mock
- Mock API: Set up
msw(Mock Service Worker) to intercept and mock all API calls (GET /api/v1/folders,GET /api/v1/threads,GET /api/v1/thread/:threadId). -
Sidebar.tsx:- Test that it renders a "Loading..." state.
- Test that it calls
GET /api/v1/folders. - Test that it renders a list of links (e.g., "Inbox", "Sent") based on the mock API response.
- Test that clicking the "Sent" link navigates the user to
/?folder=Sent.
-
Inbox.page.tsx(Thread List):- Test that it renders a "Loading..." state.
- Test that it reads the
?folder=INBOXURL parameter and calls the correct API:GET /api/v1/threads?folder=INBOX. - Test that it renders the list of
EmailListItemcomponents based on the mock response. - Test that clicking an
EmailListItemnavigates the user to the correct thread (e.g.,/thread/thread-id-123).
-
Thread.page.tsx(Thread View):- Test that it renders a "Loading..." state.
- Test that it reads the
:threadIdURL parameter and calls the correct API:GET /api/v1/thread/thread-id-123. - Test that it correctly renders all messages, sender names, subjects, and attachment filenames from the mock response.
This plan uses Playwright to test the entire read-only flow, assuming the backend and frontend are running.
- Test 1: New User Onboarding Flow
- Mock your Authelia login to succeed for a new user.
- Start at the app's root URL.
- Assert the app redirects to the
/settingspage. - Fill in all the form fields (IMAP server, user, pass, etc.).
- Click "Save".
- Assert the app redirects to the Inbox (
/). - (Optional DB check):
SELECTfromuser_settingsandusersto verify the user was created and the passwords are encrypted.
- Test 2: Existing User Read-Only Flow
- Log in as an existing (already set-up) user.
- Assert the app lands on the Inbox (
/). - Assert the sidebar populates with folders (e.g., "Inbox", "Sent").
- Assert the main view populates with a list of email threads.
- Note the subject of the first email, then click it.
- Assert the URL changes to
/thread/some-id. - Assert the full email body and any attachment names are visible on the screen.
- Test 3: Navigation
- From the thread view, press the "u" key.
- Assert the app navigates back to the Inbox (
/). - Press the "j" key.
- Assert the visual focus/selection moves to the second email in the list.
- Press the "o" key.
- Assert the app navigates to the second thread's page.
- Do the stuff from frontend/README.md and get rid of that file.
This is a read-only feature, so it doesn't use the action queue. We'll implement it in two phases:
- Phase 1: Basic text search (plain text queries)
- Phase 2: Gmail-like syntax parsing (
from:,to:,subject:, etc.)
- Create the "Search" IMAP logic:
- Create a new file
/backend/internal/imap/search.go. - Create:
func (s *Service) Search(ctx context.Context, userID string, query string, page, limit int) ([]*models.Thread, int, error). - This function needs to:
- Get user settings and IMAP connection using
s.getClientAndSelectFolder(). - Folder selection: If the query contains
folder:(parse it first), use that folder. Otherwise, default to"INBOX". - Build IMAP criteria:
criteria := imap.NewSearchCriteria()->criteria.Text = []string{query}(for Phase 1, treat the entire query as plain text). - Run
uids, err := c.UidSearch(criteria). - If no UIDs are found, return an empty slice and count 0.
- Threading logic:
- Fetch headers for matching UIDs using
FetchMessageHeaders(client, uids). - For each message:
- Extract
Message-IDfromEnvelope.MessageId. - Look up the message in DB:
db.GetMessageByMessageID(ctx, pool, userID, messageID). - If found, get its
thread_id, then get the thread:db.GetThreadByID(ctx, pool, threadID). - If not found in DB, this is a new message β we'll need to create/sync it. For now, skip it (or trigger a sync β see note below).
- Extract
- Collect all unique threads (deduplicate by
stable_thread_id). - Note: Messages not in DB should ideally trigger a sync, but for MVP we can skip them or do a lightweight sync. Consider calling
s.SyncThreadsForFolder()for the search folder if many messages are missing.
- Fetch headers for matching UIDs using
- Pagination: Apply pagination to the deduplicated thread list (sort by most recent message
sent_at, then applyLIMITandOFFSET). - Get the total count of unique threads (before pagination).
- Return threads and total count.
- Get user settings and IMAP connection using
- Create a new file
- Create the
searchAPI endpoint:- In
routes.go(or wherever routes are defined), add:router.Get("/api/v1/search", app.searchHandler). - Create
/backend/internal/api/search_handler.go. - The
searchHandlershould:- Get the query:
q := r.URL.Query().Get("q"). If empty, treat as "return all emails". - Get pagination params: Use
parsePaginationParams(r, 100)(reuse fromthreads_handler.go). - Get user ID from context (reuse
getUserIDFromContextpattern from other handlers). - Call
imapService.Search(ctx, userID, q, page, limit). - Return the same JSON format as
GET /api/v1/threads: on {"threads": [...], "pagination": { "total_count": 123, "page": 1, "per_page": 100 } } - Error handling:
- Empty query (
q == ""): Return all emails (paginated). - Invalid query: Return
400 Bad Requestwith an error message. - IMAP errors: Return
500 Internal Server Errorwith a generic message (log details server-side). - No results: Return
200 OKwith emptythreadsarray.
- Empty query (
- Get the query:
- In
- Create the search results page:
- Create a new page:
pages/Search.page.tsx. - Add the route in
App.tsx:<Route path="/search" element={<SearchPage />} />.
- Create a new page:
- Hook up the search bar:
- In
Header.tsx, make the search input a controlled component (useuseState). - On form submit (or
Enterkey), useuseNavigatefromreact-router-domto navigate:navigate(/search?q=${encodeURIComponent(query)}). - Basic validation: Check that the query is not empty (or allow empty to show all emails).
- In
- Fetch and display results:
-
In
Search.page.tsx, useuseSearchParamshook to get theqparam from the URL. -
Use
TanStack Query'suseQueryto fetch from the backend:useQuery({ queryKey: ['search', q, page], queryFn: () => fetchSearchResults(q, page, limit) })
- Re-use existing components: The page should map over the results and render your existing
EmailListItem.tsxcomponent for each thread.
- Re-use existing components: The page should map over the results and render your existing
-
Pagination: Re-use
EmailListPagination.tsxcomponent (from milestone 5/3). -
Handle loading and error states.
-
- Backend Unit (Go):
search_handler:- Test that
GET /api/v1/search?q=testcorrectly callsimapService.Search("test", ...). - Test that empty query (
q=) calls search with empty string. - Test pagination params are passed correctly.
- Test error handling (400 for invalid, 500 for IMAP errors).
- Test that
imapService.Search:- Mock the DB and IMAP client.
- Test that
UidSearchis called with correct criteria. - Test that we look up messages in DB by Message-ID.
- Test that threads are deduplicated correctly.
- Test pagination logic.
- Test empty results case.
- Frontend Integration (RTL +
msw):Header.tsx:- Mock
react-router'suseNavigatehook. - Simulate typing "hello" into the search input and pressing "Enter".
- Assert
navigatewas called with/search?q=hello.
- Mock
Search.page.tsx:- Mock
useSearchParamsto returnq=hello. - Mock the
GET /api/v1/search?q=hello&page=1&limit=100API. - Assert the page calls the API and renders the list of
EmailListItemcomponents from the mock response. - Test pagination navigation.
- Test empty results display.
- Mock
- Create search query parser:
- In
/backend/internal/imap/search.go, create:func ParseSearchQuery(query string) (*imap.SearchCriteria, string, error). - Returns: parsed
SearchCriteria, extractedfoldername (or empty string), and error. - Supported syntax:
from:georgeβcriteria.From = []string{"george"}to:aliceβcriteria.To = []string{"alice"}subject:meetingβcriteria.Subject = []string{"meeting"}after:2025-01-01βcriteria.Since = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)before:2025-12-31βcriteria.Before = time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC)folder:Inboxorlabel:Inboxβ extract folder name, return it separately (don't set in criteria)- Plain text (no prefix) β
criteria.Text = []string{text} - Combinations:
from:george after:2025-01-01 cabbageβ combine multiple criteria
- Parsing rules:
- Split query by spaces, but respect quoted strings:
from:"John Doe"should keep "John Doe" together. - Handle multiple filters:
from:george to:alice subject:meeting. - If both
folder:andlabel:are present,folder:takes precedence. - Date parsing: Support
YYYY-MM-DDformat. Return error for invalid dates. - If no filters match, treat the entire query as plain text search.
- Split query by spaces, but respect quoted strings:
- Error handling:
- Invalid date format β return error.
- Empty filter value (e.g.,
from:) β treat as invalid, return error.
- In
- Update
Searchfunction:- Modify
Search()to callParseSearchQuery(query)first. - Use the returned
folderto select the IMAP folder (or default to"INBOX"). - Use the parsed
SearchCriteriainstead ofcriteria.Text = []string{query}. - Handle parser errors by returning
400 Bad Request.
- Modify
- Add basic query validation:
- In
Header.tsxor a new utility file, create a function to validate search queries before submission. - Basic checks:
- Empty filter values:
from:β show warning, don't submit. - Invalid date format:
after:2025-13-45β show warning. - (Optional) Syntax highlighting or autocomplete for filter names.
- Empty filter values:
- Note: Frontend validation is for UX only. Backend must still validate fully.
- In
- Backend Unit (Go):
ParseSearchQuery:- Test
"from:george"β assertscriteria.From = []string{"george"}. - Test
"from:george after:2025-01-01"β asserts both fields are set. - Test
"folder:Inbox from:george"β asserts folder extracted and criteria set. - Test
"cabbage"(plain text) β assertscriteria.Text = []string{"cabbage"}. - Test
"from: to:alice"β asserts error (emptyfrom:value). - Test
"after:invalid-date"β asserts error. - Test quoted strings:
from:"John Doe"β keeps name together. - Test
label:alias works same asfolder:.
- Test
imapService.Searchwith parser:- Test that parsed criteria are passed to
UidSearchcorrectly. - Test that folder from parser is used for
Select().
- Test that parsed criteria are passed to
- Frontend Integration (RTL):
- Test that invalid queries show validation warnings.
- Test that valid queries navigate correctly.
- Threading: When a search result matches message #33 in a 50-message thread, we return the entire thread. This is handled by looking up the message's
thread_idin the DB, then fetching all messages for that thread. - Database vs IMAP: Always search IMAP directly (not the DB cache) for real-time results. The DB may be stale if the user made changes via another email client.
- Empty query: An empty
qparameter should return all emails (paginated), equivalent to browsing the folder. - Folder support: Phase 1 defaults to
INBOX. Phase 2 addsfolder:filter to search any folder. If nofolder:specified, default toINBOX. - Pagination: Reuse the existing pagination implementation from milestone 5/3. Search results should be paginated just like folder views.
- Error handling:
400 Bad Requestfor invalid query syntax or malformed dates.500 Internal Server Errorfor IMAP connection/search errors (log details, return a generic message).200 OKwith empty array for no results.
- Performance: For large result sets, consider:
- Limiting max search results (e.g., 10,000 UIDs) to avoid memory issues.
- Batching UID fetches if needed.
- Caching parsed queries (optional optimization).
- Search across all folders (not just one at a time).
-
has:attachmentfilter. -
is:read/is:unread/is:starredfilters. - Search result caching.
- Hybrid DB/IMAP search for better performance.
This makes the app usable with large inboxes.
- Update
GET /api/v1/threadshandler:- It must read
pageandlimitquery params (e.g.,?page=2&limit=50). Default topage=1, limit=100. - Update your DB query for threads to use
LIMIT $1 OFFSET $2. - Run a second DB query:
SELECT COUNT(*) FROM ...with the sameWHEREclause (to get the total count). - Change the API response to a new object:
{"threads": [...], "pagination": {"total_count": 1234, "page": 2, "per_page": 50}}
- It must read
- Update
GET /api/v1/searchhandler:- Apply the exact same
pageandlimitlogic. - Return the same
{"threads": [...], "pagination": {...}}object.
- Apply the exact same
- Create
EmailListPagination.tsxcomponent:- This component receives the
paginationobject as a prop. - It calculates
totalPages = total_count / per_page. - It renders "Page [page] of [totalPages]" and "Next >" / "< Prev" links.
- This component receives the
- Update
Inbox.page.tsxandSearch.page.tsx:- Read the
pagefromuseSearchParams. - Pass the
pageto theuseQueryhook to fetch the correct data. - Get the
paginationobject from the API response. - Render
<EmailListPagination pagination={data.pagination} />at the bottom. - The "Next" link should use
Mapsto go to/?folder=INBOX&page={page + 1}. - The "Prev" link should use
Mapsto go to/?folder=INBOX&page={page - 1}.
- Read the
- Backend Unit: Test the
GET /api/v1/threadshandler. Assert?page=2&limit=50results inLIMIT 50 OFFSET 50in the SQL query. Assert thepaginationobject in the JSON response is correct. - Frontend Integration:
- Mock the API to return
{"threads": [...], "pagination": {"total_count": 300, "page": 1, "per_page": 100}}. - Assert the pagination component renders "Page 1 of 3".
- Mock
Maps. Click the "Next" button. - Assert
Mapswas called with the new URL (?page=2).
- Mock the API to return