mirror of
https://github.com/Mail-0/Zero.git
synced 2026-07-01 08:16:28 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user