scheduled-send-layout.test.ts5.1 KBView on GitHub /**
* Placement rules for scheduled ("send later") emails inside a thread.
*
* The bug these encode: scheduling a reply left NOTHING visible in the conversation. The
* provider draft stays put (the queue consumes it only when it fires), but the composer
* was cleared on schedule, so the thread showed a blank editor and the queued email
* existed only in the Scheduled folder.
*
* A scheduled send therefore renders as an ordinary message in the chain until the user
* presses Edit, which hands the draft back to the composer. Only pending sends reach here
* — a cancelled one leaves nothing to draw.
*/
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import {
asScheduledMessage,
indexScheduledByDraftId,
partitionDraftsBySchedule,
type ScheduledSendEntry,
} from '@/modules/threads/thread/lib/scheduled-send-layout';
const draft = (id: string, draftId?: string): ParsedMessage =>
({
id,
draftId,
isDraft: true,
subject: 'Re: pricing',
processedHtml: '<p>hi</p>',
receivedOn: '2026-08-11T10:00:00.000Z',
sender: { email: '<email>', name: 'Me' },
tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
to: [{ email: '<email>' }],
}) as unknown as ParsedMessage;
const entry = (over: Partial<ScheduledSendEntry> = {}): ScheduledSendEntry => ({
id: 'sched-1',
threadId: 'thread-1',
draftId: 'draft-1',
sendAt: '2026-08-12T09:00:00.000Z',
subject: 'Re: pricing',
snippet: 'hi',
to: [{ email: '<email>' }],
...over,
});
describe('indexScheduledByDraftId', () => {
it('keeps the freshest schedule when a draft was re-armed', () => {
const older = entry({ id: 'sched-old', sendAt: '2026-08-12T09:00:00.000Z' });
const newer = entry({ id: 'sched-new', sendAt: '2026-08-13T09:00:00.000Z' });
expect(indexScheduledByDraftId([older, newer]).get('draft-1')?.id).toBe('sched-new');
expect(indexScheduledByDraftId([newer, older]).get('draft-1')?.id).toBe('sched-new');
});
it('ignores rows with no draft behind them', () => {
expect(indexScheduledByDraftId([entry({ draftId: null })]).size).toBe(0);
});
});
describe('asScheduledMessage', () => {
const identity = { email: '<email>', name: 'Jesse' };
it('swaps DRAFT for SENT so the header renders the real recipient, not "You"', () => {
const message = asScheduledMessage(draft('m1', 'draft-1'), identity);
expect(message.isDraft).toBe(false);
expect(message.tags?.map((t) => t.id)).toEqual(['SENT']);
});
it('fills the sender from the account when the provider draft has none', () => {
const senderless = { ...draft('m1', 'draft-1'), sender: { email: '', name: '' } };
const message = asScheduledMessage(senderless as ParsedMessage, identity);
expect(message.sender).toEqual({ email: '<email>', name: 'Jesse' });
});
it('falls back to the address when nothing else names the sender', () => {
const senderless = { ...draft('m1', 'draft-1'), sender: { email: '', name: '' } };
const message = asScheduledMessage(senderless as ParsedMessage, { email: '<email>' });
expect(message.sender).toEqual({ email: '<email>', name: '<email>' });
});
it('leaves the body, subject and recipients alone', () => {
const source = draft('m1', 'draft-1');
const message = asScheduledMessage(source, identity);
expect(message.processedHtml).toBe(source.processedHtml);
expect(message.subject).toBe(source.subject);
expect(message.to).toEqual(source.to);
// The date slot shows the scheduled time, so the draft's own stamp must not be faked.
expect(message.receivedOn).toBe(source.receivedOn);
});
});
describe('partitionDraftsBySchedule', () => {
it('renders a scheduled draft as a message, not a composer', () => {
const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
[draft('m1', 'draft-1')],
indexScheduledByDraftId([entry()]),
new Set(),
);
expect(scheduledDrafts).toHaveLength(1);
expect(scheduledDrafts[0].entry.id).toBe('sched-1');
expect(editableDrafts).toHaveLength(0);
});
it('hands the draft back to the composer once the user presses Edit', () => {
const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
[draft('m1', 'draft-1')],
indexScheduledByDraftId([entry()]),
new Set(['sched-1']),
);
expect(scheduledDrafts).toHaveLength(0);
expect(editableDrafts).toHaveLength(1);
});
it('leaves ordinary drafts editable', () => {
const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
[draft('m1', 'draft-other'), draft('m2')],
indexScheduledByDraftId([entry()]),
new Set(),
);
expect(scheduledDrafts).toHaveLength(0);
expect(editableDrafts).toHaveLength(2);
});
it('leaves a draft editable once its send is gone — a cancel removes the scheduled view', () => {
const { scheduledDrafts, editableDrafts } = partitionDraftsBySchedule(
[draft('m1', 'draft-1')],
indexScheduledByDraftId([]),
new Set(),
);
expect(scheduledDrafts).toHaveLength(0);
expect(editableDrafts).toHaveLength(1);
});
});