Refactor mail actions and types to improve thread handling and response structure. Update getMail to return IGetThreadResponse, modify related components to utilize new structure, and enhance error handling in driver functions.

This commit is contained in:
Aj Wazzan
2025-04-16 15:47:35 -07:00
parent 21b00f2ce7
commit 8db5a64a99
10 changed files with 230 additions and 208 deletions

View File

@@ -1,5 +1,6 @@
'use server';
import { deleteActiveConnection, FatalErrors, getActiveDriver } from './utils';
import { IGetThreadResponse } from '@/app/api/driver/types';
import { ParsedMessage } from '@/types';
export const getMails = async ({
@@ -29,7 +30,7 @@ export const getMails = async ({
}
};
export const getMail = async ({ id }: { id: string }): Promise<ParsedMessage[]> => {
export const getMail = async ({ id }: { id: string }): Promise<IGetThreadResponse> => {
if (!id) {
throw new Error('Missing required fields');
}
@@ -128,17 +129,17 @@ export const toggleStar = async ({ ids }: { ids: string[] }) => {
return { success: false, error: 'No thread IDs provided' };
}
const threadResults = await Promise.allSettled(
threadIds.map(id => driver.get(id))
);
const threadResults = await Promise.allSettled(threadIds.map((id) => driver.get(id)));
let anyStarred = false;
let processedThreads = 0;
for (const result of threadResults) {
if (result.status === 'fulfilled' && result.value && result.value.length > 0) {
if (result.status === 'fulfilled' && result.value && result.value.messages.length > 0) {
processedThreads++;
const isThreadStarred = result.value.some((message: ParsedMessage) => message.tags?.includes('STARRED'));
const isThreadStarred = result.value.messages.some((message: ParsedMessage) =>
message.tags?.includes('STARRED'),
);
if (isThreadStarred) {
anyStarred = true;
break;

View File

@@ -10,7 +10,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="flex h-screen w-screen overflow-hidden">
<SWRConfig
value={{
provider: typeof window !== 'undefined' ? dexieStorageProvider : undefined,
// provider: typeof window !== 'undefined' ? dexieStorageProvider : undefined,
revalidateOnFocus: false,
revalidateIfStale: false,
shouldRetryOnError: false,

View File

@@ -1,15 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getMail } from '@/actions/mail';
import { Ratelimit } from '@upstash/ratelimit';
import { checkRateLimit, getRatelimitModule, processIP } from '../../utils';
import { type NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { getMail } from '@/actions/mail';
export const GET = async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const finalIp = processIP(req)
const finalIp = processIP(req);
const { id } = await params;
const ratelimit = getRatelimitModule({
prefix: `ratelimit:get-mail`,
prefix: `ratelimit:get-mail-${id}`,
limiter: Ratelimit.slidingWindow(60, '1m'),
})
});
const { success, headers } = await checkRateLimit(ratelimit, finalIp);
if (!success) {
return NextResponse.json(
@@ -17,7 +17,6 @@ export const GET = async (req: NextRequest, { params }: { params: Promise<{ id:
{ status: 429, headers },
);
}
const { id } = await params;
const threadResponse = await getMail({
id,

View File

@@ -1,10 +1,10 @@
import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils';
import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types';
import { type IConfig, type MailManager } from './types';
import { type gmail_v1, google } from 'googleapis';
import { EnableBrain } from '@/actions/brain';
import { IOutgoingMessage, Sender, type ParsedMessage } from '@/types';
import * as he from 'he';
import { createMimeMessage } from 'mimetext';
import * as he from 'he';
function fromBase64Url(str: string) {
return str.replace(/-/g, '+').replace(/_/g, '/');
@@ -80,7 +80,7 @@ const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => {
};
// Helper function for delays
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const driver = async (config: IConfig): Promise<MailManager> => {
const auth = new google.auth.OAuth2(
@@ -148,11 +148,12 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
.map((h) => h.value)
.filter((v) => typeof v === 'string') || [];
const cc = ccHeaders.length > 0
? ccHeaders
.filter(header => header.trim().length > 0)
.flatMap(header => parseAddressList(header))
: null;
const cc =
ccHeaders.length > 0
? ccHeaders
.filter((header) => header.trim().length > 0)
.flatMap((header) => parseAddressList(header))
: null;
const receivedHeaders =
payload?.headers
@@ -183,13 +184,21 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
messageId,
};
};
const parseOutgoing = async ({ to, subject, message, attachments, headers, cc, bcc }: IOutgoingMessage) => {
const parseOutgoing = async ({
to,
subject,
message,
attachments,
headers,
cc,
bcc,
}: IOutgoingMessage) => {
const msg = createMimeMessage();
const fromEmail = config.auth?.email || 'nobody@example.com';
console.log('Debug - From email:', fromEmail);
console.log('Debug - Original to recipients:', JSON.stringify(to, null, 2));
msg.setSender({ name: '', addr: fromEmail });
// Track unique recipients to avoid duplicates
@@ -207,7 +216,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
// Handle all To recipients
const toRecipients = to
.filter(recipient => {
.filter((recipient) => {
if (!recipient || !recipient.email) {
console.log('Debug - Skipping invalid recipient:', recipient);
return false;
@@ -219,9 +228,9 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
normalizedEmail: email,
fromEmail,
isDuplicate: uniqueRecipients.has(email),
isSelf: email === fromEmail
isSelf: email === fromEmail,
});
// Only check for duplicates, allow sending to yourself
if (!uniqueRecipients.has(email)) {
uniqueRecipients.add(email);
@@ -229,9 +238,9 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
}
return false;
})
.map(recipient => ({
.map((recipient) => ({
name: recipient.name || '',
addr: recipient.email
addr: recipient.email,
}));
console.log('Debug - Filtered to recipients:', JSON.stringify(toRecipients, null, 2));
@@ -242,7 +251,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
console.error('Debug - No valid recipients after filtering:', {
originalTo: to,
filteredTo: toRecipients,
fromEmail
fromEmail,
});
throw new Error('No valid recipients found in To field');
}
@@ -250,7 +259,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
// Handle CC recipients
if (Array.isArray(cc) && cc.length > 0) {
const ccRecipients = cc
.filter(recipient => {
.filter((recipient) => {
const email = recipient.email.toLowerCase();
if (!uniqueRecipients.has(email) && email !== fromEmail) {
uniqueRecipients.add(email);
@@ -258,9 +267,9 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
}
return false;
})
.map(recipient => ({
.map((recipient) => ({
name: recipient.name || '',
addr: recipient.email
addr: recipient.email,
}));
if (ccRecipients.length > 0) {
@@ -271,7 +280,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
// Handle BCC recipients
if (Array.isArray(bcc) && bcc.length > 0) {
const bccRecipients = bcc
.filter(recipient => {
.filter((recipient) => {
const email = recipient.email.toLowerCase();
if (!uniqueRecipients.has(email) && email !== fromEmail) {
uniqueRecipients.add(email);
@@ -279,11 +288,11 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
}
return false;
})
.map(recipient => ({
.map((recipient) => ({
name: recipient.name || '',
addr: recipient.email
addr: recipient.email,
}));
if (bccRecipients.length > 0) {
msg.setBcc(bccRecipients);
}
@@ -293,7 +302,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
msg.addMessage({
contentType: 'text/html',
data: message.trim()
data: message.trim(),
});
// Set headers for reply/reply-all/forward
@@ -302,12 +311,15 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
if (value) {
// Ensure References header includes all previous message IDs
if (key.toLowerCase() === 'references' && value) {
const refs = value.split(' ').filter(Boolean).map(ref => {
// Add angle brackets if not present
if (!ref.startsWith('<')) ref = `<${ref}`;
if (!ref.endsWith('>')) ref = `${ref}>`;
return ref;
});
const refs = value
.split(' ')
.filter(Boolean)
.map((ref) => {
// Add angle brackets if not present
if (!ref.startsWith('<')) ref = `<${ref}`;
if (!ref.endsWith('>')) ref = `${ref}>`;
return ref;
});
msg.setHeader(key, refs.join(' '));
} else {
msg.setHeader(key, value);
@@ -321,23 +333,23 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
for (const file of attachments) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64Content = buffer.toString("base64");
const base64Content = buffer.toString('base64');
msg.addAttachment({
filename: file.name,
contentType: file.type || "application/octet-stream",
data: base64Content
contentType: file.type || 'application/octet-stream',
data: base64Content,
});
}
}
const emailContent = msg.asRaw();
const encodedMessage = Buffer.from(emailContent).toString("base64");
const encodedMessage = Buffer.from(emailContent).toString('base64');
return {
raw: encodedMessage,
}
}
};
};
const normalizeSearch = (folder: string, q: string) => {
// Handle special folders
if (folder === 'bin') {
@@ -355,20 +367,20 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
const gmail = google.gmail({ version: 'v1', auth });
const modifyThreadLabels = async (
threadIds: string[],
requestBody: gmail_v1.Schema$ModifyThreadRequest
threadIds: string[],
requestBody: gmail_v1.Schema$ModifyThreadRequest,
) => {
if (threadIds.length === 0) {
return;
if (threadIds.length === 0) {
return;
}
const chunkSize = 15;
const chunkSize = 15;
const delayBetweenChunks = 100;
const allResults = [];
for (let i = 0; i < threadIds.length; i += chunkSize) {
const chunk = threadIds.slice(i, i + chunkSize);
const promises = chunk.map(async (threadId) => {
try {
const response = await gmail.users.threads.modify({
@@ -377,7 +389,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
requestBody: requestBody,
});
return { threadId, status: 'fulfilled' as const, value: response.data };
} catch (error: any) {
} catch (error: any) {
const errorMessage = error?.errors?.[0]?.message || error.message || error;
console.error(`Failed bulk modify operation for thread ${threadId}:`, errorMessage);
return { threadId, status: 'rejected' as const, reason: { error: errorMessage } };
@@ -392,10 +404,13 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
}
}
const failures = allResults.filter(result => result.status === 'rejected');
const failures = allResults.filter((result) => result.status === 'rejected');
if (failures.length > 0) {
const failureReasons = failures.map(f => ({ threadId: f.threadId, reason: f.reason }));
console.error(`Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`, failureReasons);
const failureReasons = failures.map((f) => ({ threadId: f.threadId, reason: f.reason }));
console.error(
`Failed bulk modify operation for ${failures.length}/${threadIds.length} threads:`,
failureReasons,
);
}
};
@@ -488,45 +503,15 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
maxResults,
pageToken: pageToken ? pageToken : undefined,
});
const threads = await Promise.all(
(res.data.threads || [])
.map(async (thread) => {
if (!thread.id) return null;
const msg = await gmail.users.threads.get({
userId: 'me',
id: thread.id,
format: 'metadata',
metadataHeaders: ['From', 'Subject', 'Date'],
});
const labelIds = [
...new Set(msg.data.messages?.flatMap((message) => message.labelIds || [])),
];
const latestMessage = msg.data.messages?.reverse()?.find((msg) => {
const parsedMessage = parse({ ...msg, labelIds });
return parsedMessage.sender.email !== config.auth?.email
})
const message = latestMessage ? latestMessage : msg.data.messages?.[0]
const parsed = parse({ ...message, labelIds });
return {
...parsed,
body: '',
processedHtml: '',
blobUrl: '',
totalReplies: msg.data.messages?.length || 0,
threadId: thread.id,
};
})
.filter((msg): msg is NonNullable<typeof msg> => msg !== null),
);
return { ...res.data, threads } as any;
return { ...res.data, threads: res.data.threads } as any;
},
get: async (id: string): Promise<ParsedMessage[]> => {
get: async (id: string) => {
console.log('Fetching thread:', id);
const res = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
if (!res.data.messages) return [];
const messages = await Promise.all(
if (!res.data.messages)
return { messages: [], latest: undefined, hasUnread: false, totalReplies: 0 };
let hasUnread = false;
const messages: ParsedMessage[] = await Promise.all(
res.data.messages.map(async (message) => {
const bodyData =
message.payload?.body?.data ||
@@ -546,19 +531,27 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
// Process inline images if present
let processedBody = decodedBody;
if (message.payload?.parts) {
const inlineImages = message.payload.parts
.filter(part => {
const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || '';
const isInline = contentDisposition.toLowerCase().includes('inline');
const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id');
return isInline && hasContentId;
});
const inlineImages = message.payload.parts.filter((part) => {
const contentDisposition =
part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ||
'';
const isInline = contentDisposition.toLowerCase().includes('inline');
const hasContentId = part.headers?.some(
(h) => h.name?.toLowerCase() === 'content-id',
);
return isInline && hasContentId;
});
for (const part of inlineImages) {
const contentId = part.headers?.find(h => h.name?.toLowerCase() === 'content-id')?.value;
const contentId = part.headers?.find(
(h) => h.name?.toLowerCase() === 'content-id',
)?.value;
if (contentId && part.body?.attachmentId) {
try {
const imageData = await manager.getAttachment(message.id!, part.body.attachmentId);
const imageData = await manager.getAttachment(
message.id!,
part.body.attachmentId,
);
if (imageData) {
// Remove < and > from Content-ID if present
const cleanContentId = contentId.replace(/[<>]/g, '');
@@ -567,7 +560,7 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
// Replace cid: URL with data URL
processedBody = processedBody.replace(
new RegExp(`cid:${escapedContentId}`, 'g'),
`data:${part.mimeType};base64,${imageData}`
`data:${part.mimeType};base64,${imageData}`,
);
}
} catch (error) {
@@ -588,12 +581,16 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
message.payload?.parts
?.filter((part) => {
if (!part.filename || part.filename.length === 0) return false;
const contentDisposition = part.headers?.find(h => h.name?.toLowerCase() === 'content-disposition')?.value || '';
const contentDisposition =
part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')
?.value || '';
const isInline = contentDisposition.toLowerCase().includes('inline');
const hasContentId = part.headers?.some(h => h.name?.toLowerCase() === 'content-id');
const hasContentId = part.headers?.some(
(h) => h.name?.toLowerCase() === 'content-id',
);
return !isInline || (isInline && !hasContentId);
})
?.map(async (part) => {
@@ -651,27 +648,29 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
labels: fullEmailData.tags,
});
if (fullEmailData.unread) hasUnread = true;
return fullEmailData;
}),
);
return messages;
return { messages, latest: messages[0], hasUnread, totalReplies: messages.length };
},
create: async (data) => {
const { raw } = await parseOutgoing(data)
const { raw } = await parseOutgoing(data);
console.log('Debug - Sending message with threading info:', {
threadId: data.threadId,
headers: data.headers
headers: data.headers,
});
const res = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw,
threadId: data.threadId
}
threadId: data.threadId,
},
});
console.log('Debug - Message sent successfully:', {
messageId: res.data.id,
threadId: res.data.threadId
threadId: res.data.threadId,
});
return res.data;
},
@@ -685,7 +684,10 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
);
return { threadIds };
},
modifyLabels: async (threadIds: string[], options: { addLabels: string[]; removeLabels: string[] }) => {
modifyLabels: async (
threadIds: string[],
options: { addLabels: string[]; removeLabels: string[] },
) => {
await modifyThreadLabels(threadIds, {
addLabelIds: options.addLabels,
removeLabelIds: options.removeLabels,
@@ -740,11 +742,11 @@ export const driver = async (config: IConfig): Promise<MailManager> => {
console.log(`Fetched draft ${draft.id}:`, msg.data);
const message = msg.data.message;
if (!message) return null;
const parsed = parse(message as any);
const headers = message.payload?.headers || [];
const date = headers.find(h => h.name?.toLowerCase() === 'date')?.value;
const date = headers.find((h) => h.name?.toLowerCase() === 'date')?.value;
return {
...parsed,
id: draft.id,

View File

@@ -1,22 +1,11 @@
import { checkRateLimit, getRatelimitModule, processIP } from '../utils';
import { type NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { defaultPageSize } from '@/lib/utils';
import { getMails } from '@/actions/mail';
import { checkRateLimit, getRatelimitModule, processIP } from '../utils';
export const GET = async (req: NextRequest) => {
const finalIp = processIP(req)
const ratelimit = getRatelimitModule({
prefix: `ratelimit:list-threads`,
limiter: Ratelimit.slidingWindow(60, '1m'),
})
const { success, headers } = await checkRateLimit(ratelimit, finalIp);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers },
);
}
const finalIp = processIP(req);
const searchParams = req.nextUrl.searchParams;
let [folder, pageToken, q, max] = [
searchParams.get('folder'),
@@ -24,6 +13,17 @@ export const GET = async (req: NextRequest) => {
searchParams.get('q'),
Number(searchParams.get('max')),
];
const ratelimit = getRatelimitModule({
prefix: `ratelimit:list-threads-${folder}`,
limiter: Ratelimit.slidingWindow(60, '1m'),
});
const { success, headers } = await checkRateLimit(ratelimit, finalIp);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers },
);
}
if (!folder) folder = 'inbox';
if (!pageToken) pageToken = '';
if (!q) q = '';

View File

@@ -1,7 +1,14 @@
import { type IOutgoingMessage, type InitialThread, type ParsedMessage } from '@/types';
export interface IGetThreadResponse {
messages: ParsedMessage[];
latest: ParsedMessage | undefined;
hasUnread: boolean;
totalReplies: number;
}
export interface MailManager {
get(id: string): Promise<ParsedMessage[] | undefined>;
get(id: string): Promise<IGetThreadResponse>;
create(data: IOutgoingMessage): Promise<any>;
createDraft(data: any): Promise<any>;
getDraft: (id: string) => Promise<any>;
@@ -35,6 +42,6 @@ export interface IConfig {
auth?: {
access_token: string;
refresh_token: string;
email: string
email: string;
};
}

View File

@@ -11,7 +11,7 @@ import {
User,
Users,
} from 'lucide-react';
import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode } from '@/types';
import type { ConditionalThreadProps, InitialThread, MailListProps, MailSelectMode, ParsedMessage } from '@/types';
import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { EmptyState, type FolderType } from '@/components/mail/empty-state';
@@ -20,7 +20,7 @@ import { useParams, useRouter } from 'next/navigation';
import { cn, FOLDERS, formatDate, getEmailLogo } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar';
import { useMailNavigation } from '@/hooks/use-mail-navigation';
import { preloadThread, useThreads } from '@/hooks/use-threads';
import { preloadThread, useThread, useThreads } from '@/hooks/use-threads';
import { useHotKey, useKeyState } from '@/hooks/use-hot-key';
import { useSearchValue } from '@/hooks/use-search-value';
import { markAsRead, markAsUnread } from '@/actions/mail';
@@ -36,6 +36,7 @@ import { useQueryState } from 'nuqs';
import { Categories } from './mail';
import items from './demo.json';
import { toast } from 'sonner';
import { Skeleton } from '../ui/skeleton';
const HOVER_DELAY = 1000;
const ThreadWrapper = ({
@@ -80,6 +81,7 @@ const Thread = memo(
onClick,
sessionData,
isKeyboardFocused,
demoMessage,
}: ConditionalThreadProps) => {
const [mail] = useMail();
const [searchValue] = useSearchValue();
@@ -90,17 +92,22 @@ const Thread = memo(
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isHovering = useRef<boolean>(false);
const hasPrefetched = useRef<boolean>(false);
const isMailSelected = useMemo(() => {
if (!threadId) return false;
const _threadId = message.threadId ?? message.id;
return _threadId === threadId || threadId === mail.selected;
}, [threadId, message.id, message.threadId, mail.selected]);
const {data: getThreadData, isLoading} = useThread(demo ? null : message.id);
const isMailBulkSelected = mail.bulkSelected.includes(message.threadId ?? message.id);
const latestMessage = demo ? demoMessage : getThreadData?.latest;
const isMailSelected = useMemo(() => {
if (!threadId || !latestMessage) return false;
const _threadId = latestMessage.threadId ?? message.id;
return _threadId === threadId || threadId === mail.selected;
}, [threadId, message.id, latestMessage, mail.selected]);
const isMailBulkSelected = mail.bulkSelected.includes(latestMessage?.threadId ?? message.id);
const threadLabels = useMemo(() => {
return [...(message.tags || [])];
}, [message.tags]);
if (!latestMessage) return [];
return [...(latestMessage.tags || [])];
}, [latestMessage]);
const isFolderInbox = folder === FOLDERS.INBOX || !folder;
const isFolderSpam = folder === FOLDERS.SPAM;
@@ -108,7 +115,7 @@ const Thread = memo(
const isFolderBin = folder === FOLDERS.BIN;
const handleMouseEnter = () => {
if (demo) return;
if (demo || !latestMessage) return;
isHovering.current = true;
// Prefetch only in single select mode
@@ -121,7 +128,7 @@ const Thread = memo(
// Set new timeout for prefetch
hoverTimeoutRef.current = setTimeout(() => {
if (isHovering.current) {
const messageId = message.threadId ?? message.id;
const messageId = latestMessage.threadId ?? message.id;
// Only prefetch if still hovering and hasn't been prefetched
console.log(
`🕒 Hover threshold reached for email ${messageId}, initiating prefetch...`,
@@ -154,15 +161,30 @@ const Thread = memo(
};
}, []);
const demoContent = <div className="p-1 px-3" onClick={onClick ? onClick(message) : undefined}>
if (isLoading || !latestMessage || !getThreadData) return <div className="flex flex-col px-4 py-3">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="mt-2 h-3 w-32" />
<Skeleton className="mt-2 h-3 w-full" />
<div className="mt-2 flex gap-2">
<Skeleton className="h-4 w-16 rounded-md" />
<Skeleton className="h-4 w-16 rounded-md" />
</div>
</div>
const demoContent = <div className="p-1 px-3" onClick={onClick ? onClick(latestMessage) : undefined}>
<div
data-thread-id={message.threadId ?? message.id}
data-thread-id={latestMessage.threadId ?? message.id}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
key={message.threadId ?? message.id}
key={latestMessage.threadId ?? message.id}
className={cn(
'hover:bg-offsetLight hover:bg-primary/5 group relative flex cursor-pointer flex-col items-start overflow-clip rounded-lg border border-transparent px-4 py-3 text-left text-sm transition-all hover:opacity-100',
isMailSelected || (!message.unread && 'opacity-80'),
isMailSelected || (!latestMessage.unread && 'opacity-80'),
(isMailSelected || isMailBulkSelected || isKeyboardFocused) &&
'border-border bg-primary/5 opacity-100',
isKeyboardFocused && 'ring-primary/50 ring-2',
@@ -178,10 +200,10 @@ const Thread = memo(
<Avatar className="h-8 w-8">
<AvatarImage
className="bg-muted-foreground/50 dark:bg-muted/50 p-2"
src={getEmailLogo(message.sender.email)}
src={getEmailLogo(latestMessage.sender.email)}
/>
<AvatarFallback className="bg-muted-foreground/50 dark:bg-muted/50">
{message?.sender?.name[0]?.toUpperCase()}
{latestMessage.sender?.name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex w-full justify-between">
@@ -190,39 +212,39 @@ const Thread = memo(
<div className="flex flex-row items-center gap-1">
<p
className={cn(
message.unread && !isMailSelected ? 'font-bold' : 'font-medium',
latestMessage.unread && !isMailSelected ? 'font-bold' : 'font-medium',
'text-md flex items-baseline gap-1 group-hover:opacity-100',
)}
>
<span className={cn(threadId ? 'max-w-[3ch] truncate' : '')}>
{highlightText(message.sender.name, searchValue.highlight)}
{highlightText(latestMessage.sender.name, searchValue.highlight)}
</span>{' '}
{message.unread && !isMailSelected ? (
{latestMessage.unread && !isMailSelected ? (
<span className="size-2 rounded bg-[#006FFE]" />
) : null}
</p>
<MailLabels labels={threadLabels} />
{message.totalReplies > 1 ? (
{Math.random() > 0.5 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="rounded-md border border-dotted px-[5px] py-[1px] text-xs opacity-70">
{message.totalReplies}
{Math.random() * 10}
</span>
</TooltipTrigger>
<TooltipContent className="px-1 py-0 text-xs">
{t('common.mail.replies', { count: message.totalReplies })}
{t('common.mail.replies', { count: Math.random() * 10 })}
</TooltipContent>
</Tooltip>
) : null}
</div>
{message.receivedOn ? (
{latestMessage.receivedOn ? (
<p
className={cn(
'text-nowrap text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100',
isMailSelected && 'opacity-100',
)}
>
{formatDate(message.receivedOn.split('.')[0] || '')}
{formatDate(latestMessage.receivedOn.split('.')[0] || '')}
</p>
) : null}
</div>
@@ -231,7 +253,7 @@ const Thread = memo(
'mt-1 line-clamp-1 text-xs opacity-70 transition-opacity',
)}
>
{highlightText(message.subject, searchValue.highlight)}
{highlightText(latestMessage.subject, searchValue.highlight)}
</p>
</div>
</div>
@@ -240,15 +262,15 @@ const Thread = memo(
</div>
const content = (
<div className="p-1 px-3" onClick={onClick ? onClick(message) : undefined}>
<div className="p-1 px-3" onClick={onClick ? onClick(latestMessage) : undefined}>
<div
data-thread-id={message.threadId ?? message.id}
data-thread-id={latestMessage.threadId ?? latestMessage.id}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
key={message.threadId ?? message.id}
key={latestMessage.threadId ?? latestMessage.id}
className={cn(
'hover:bg-offsetLight hover:bg-primary/5 group relative flex cursor-pointer flex-col items-start overflow-clip rounded-lg border border-transparent px-4 py-3 text-left text-sm transition-all hover:opacity-100',
(isMailSelected || !message.unread && !['sent', 'archive', 'bin'].includes(folder)) && 'dark:opacity-50 opacity-80 dark:hover:opacity-80',
(isMailSelected || !getThreadData.hasUnread && !['sent', 'archive', 'bin'].includes(folder)) && 'dark:opacity-50 opacity-80 dark:hover:opacity-80',
(isMailSelected || isMailBulkSelected || isKeyboardFocused) &&
'border-border bg-primary/5 opacity-100',
isKeyboardFocused && 'ring-primary/50 ring-2',
@@ -264,10 +286,10 @@ const Thread = memo(
<Avatar className="h-8 w-8">
<AvatarImage
className="bg-muted-foreground/50 dark:bg-muted/50 p-2"
src={getEmailLogo(message.sender.email)}
src={getEmailLogo(latestMessage.sender.email)}
/>
<AvatarFallback className="bg-muted-foreground/50 dark:bg-muted/50">
{message?.sender?.name[0]?.toUpperCase()}
{latestMessage.sender.name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex w-full justify-between">
@@ -276,46 +298,46 @@ const Thread = memo(
<div className="flex flex-row items-center gap-1">
<p
className={cn(
message.unread && !isMailSelected ? 'font-bold' : 'font-medium',
getThreadData.hasUnread && !isMailSelected ? 'font-bold' : 'font-medium',
'text-md flex items-baseline gap-1 group-hover:opacity-100',
)}
>
<span
className={cn('truncate', threadId ? 'max-w-[20ch] truncate' : '')}
>
{highlightText(message.sender.name, searchValue.highlight)}
{highlightText(latestMessage.sender.name, searchValue.highlight)}
</span>{' '}
{message.unread && !isMailSelected ? (
{getThreadData.hasUnread && !isMailSelected ? (
<span className="size-2 rounded bg-[#006FFE]" />
) : null}
</p>
{message.totalReplies > 1 ? (
{getThreadData.totalReplies > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="rounded-md border border-dotted px-[5px] py-[1px] text-xs opacity-70">
{message.totalReplies}
{getThreadData.totalReplies}
</span>
</TooltipTrigger>
<TooltipContent className="px-1 py-0 text-xs">
{t('common.mail.replies', { count: message.totalReplies })}
{t('common.mail.replies', { count: getThreadData.totalReplies })}
</TooltipContent>
</Tooltip>
) : null}
</div>
{message.receivedOn ? (
{latestMessage.receivedOn ? (
<p
className={cn(
'text-nowrap text-xs font-normal opacity-70 transition-opacity group-hover:opacity-100',
isMailSelected && 'opacity-100',
)}
>
{formatDate(message.receivedOn.split('.')[0] || '')}
{formatDate(latestMessage.receivedOn.split('.')[0] || '')}
</p>
) : null}
</div>
<div className="flex justify-between">
<p className={cn('mt-1 line-clamp-1 text-xs opacity-70 transition-opacity')}>
{highlightText(message.subject, searchValue.highlight)}
{highlightText(latestMessage.subject, searchValue.highlight)}
</p>
<MailLabels labels={threadLabels} />
</div>
@@ -329,7 +351,7 @@ const Thread = memo(
return demo ? demoContent : (
<ThreadWrapper
emailId={message.id}
threadId={message.threadId ?? message.id}
threadId={latestMessage.threadId ?? message.id}
isFolderInbox={isFolderInbox}
isFolderSpam={isFolderSpam}
isFolderSent={isFolderSent}
@@ -363,6 +385,7 @@ export function MailListDemo({
message={item}
selectMode={'single'}
onClick={(message) => () => onSelectMail && onSelectMail(message)}
demoMessage={item as any}
/>
) : null;
})}
@@ -563,7 +586,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
}, [isKeyPressed]);
const handleMailClick = useCallback(
(message: InitialThread) => () => {
(message: ParsedMessage) => () => {
handleMouseEnter(message.id);
const messageThreadId = message.threadId ?? message.id;

View File

@@ -451,7 +451,7 @@ function BulkSelectActions() {
await new Promise((resolve) => setTimeout(resolve, 499));
const emailData = await getMail({ id: bulkSelected });
if (emailData) {
const [firstEmail] = emailData;
const firstEmail = emailData.latest;
if (firstEmail)
return handleUnsubscribe({ emailData: firstEmail }).catch((e) => {
toast.error(e.message ?? 'Unknown error while unsubscribing');

View File

@@ -1,14 +1,15 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { IGetThreadResponse } from '@/app/api/driver/types';
import type { InitialThread, ParsedMessage } from '@/types';
import { useSearchValue } from '@/hooks/use-search-value';
import { useSession } from '@/lib/auth-client';
import { defaultPageSize } from '@/lib/utils';
import useSWRInfinite from 'swr/infinite';
import useSWR, { preload } from 'swr';
import { useQueryState } from 'nuqs';
import { useMemo } from 'react';
import axios from 'axios';
import { useQueryState } from 'nuqs';
export const preloadThread = async (userId: string, threadId: string, connectionId: string) => {
console.log(`🔄 Prefetching email ${threadId}...`);
@@ -51,7 +52,7 @@ const fetchEmails = async ([
const fetchThread = async (args: any[]) => {
const [_, id] = args;
try {
const response = await axios.get<ParsedMessage[]>(`/api/driver/${id}`);
const response = await axios.get<IGetThreadResponse>(`/api/driver/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching email:', error);
@@ -125,14 +126,14 @@ export const useThreads = () => {
export const useThread = (threadId: string | null) => {
const { data: session } = useSession();
const [_threadId] = useQueryState('threadId');
const id = threadId ? threadId : _threadId
const id = threadId ? threadId : _threadId;
const { data, isLoading, error, mutate } = useSWR<ParsedMessage[]>(
const { data, isLoading, error, mutate } = useSWR<IGetThreadResponse>(
session?.user.id && id ? [session.user.id, id, session.connectionId] : null,
fetchThread,
);
const hasUnread = useMemo(() => data?.some((e) => e.unread), [data]);
const hasUnread = useMemo(() => data?.messages.some((e) => e.unread), [data]);
return { data, isLoading, error, hasUnread, mutate };
};

View File

@@ -70,16 +70,6 @@ export interface IConnection {
export interface InitialThread {
id: string;
threadId?: string;
title: string;
tags: string[];
sender: Sender;
receivedOn: string;
unread: boolean;
subject: string;
totalReplies: number;
references?: string;
inReplyTo?: string;
}
export interface Attachment {
@@ -95,19 +85,20 @@ export interface MailListProps {
isCompact?: boolean;
}
export type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow";
export type MailSelectMode = 'mass' | 'range' | 'single' | 'selectAllBelow';
export type ThreadProps = {
message: InitialThread;
message: { id: string };
selectMode: MailSelectMode;
// TODO: enforce types instead of sprinkling "any"
onClick?: (message: InitialThread) => () => void;
onClick?: (message: ParsedMessage) => () => void;
isCompact?: boolean;
folder?: string;
isKeyboardFocused?: boolean;
isInQuickActionMode?: boolean;
selectedQuickActionIndex?: number;
resetNavigation?: () => void;
demoMessage?: ParsedMessage;
};
export type ConditionalThreadProps = ThreadProps &
@@ -116,15 +107,13 @@ export type ConditionalThreadProps = ThreadProps &
| { demo?: false; sessionData: { userId: string; connectionId: string | null } }
);
export interface IOutgoingMessage {
to: Sender[];
cc?: Sender[];
bcc?: Sender[];
subject: string
message: string
attachments: any[]
headers: Record<string, string>
threadId?: string
}
subject: string;
message: string;
attachments: any[];
headers: Record<string, string>;
threadId?: string;
}