diff --git a/apps/mail/app/(routes)/settings/labels/colors.ts b/apps/mail/app/(routes)/settings/labels/colors.ts
index 0e8b26fa41..3ad2209ea5 100644
--- a/apps/mail/app/(routes)/settings/labels/colors.ts
+++ b/apps/mail/app/(routes)/settings/labels/colors.ts
@@ -1,104 +1,9 @@
export const COLORS = [
- '#000000',
- '#434343',
- '#666666',
- '#999999',
- '#cccccc',
- '#efefef',
- '#f3f3f3',
- '#ffffff',
- '#fb4c2f',
- '#ffad47',
- '#fad165',
- '#16a766',
- '#43d692',
- '#4a86e8',
- '#a479e2',
- '#f691b3',
- '#f6c5be',
- '#ffe6c7',
- '#fef1d1',
- '#b9e4d0',
- '#c6f3de',
- '#c9daf8',
- '#e4d7f5',
- '#fcdee8',
- '#efa093',
- '#ffd6a2',
- '#fce8b3',
- '#89d3b2',
- '#a0eac9',
- '#a4c2f4',
- '#d0bcf1',
- '#fbc8d9',
- '#e66550',
- '#ffbc6b',
- '#fcda83',
- '#44b984',
- '#68dfa9',
- '#6d9eeb',
- '#b694e8',
- '#f7a7c0',
- '#cc3a21',
- '#eaa041',
- '#f2c960',
- '#149e60',
- '#3dc789',
- '#3c78d8',
- '#8e63ce',
- '#e07798',
- '#ac2b16',
- '#cf8933',
- '#d5ae49',
- '#0b804b',
- '#2a9c68',
- '#285bac',
- '#653e9b',
- '#b65775',
- '#822111',
- '#a46a21',
- '#aa8831',
- '#076239',
- '#1a764d',
- '#1c4587',
- '#41236d',
- '#83334c',
- '#464646',
- '#e7e7e7',
- '#0d3472',
- '#b6cff5',
- '#0d3b44',
- '#98d7e4',
- '#3d188e',
- '#e3d7ff',
- '#711a36',
- '#fbd3e0',
- '#8a1c0a',
- '#f2b2a8',
- '#7a2e0b',
- '#ffc8af',
- '#7a4706',
- '#ffdeb5',
- '#594c05',
- '#fbe983',
- '#684e07',
- '#fdedc1',
- '#0b4f30',
- '#b3efd3',
- '#04502e',
- '#a2dcc1',
- '#c2c2c2',
- '#4986e7',
- '#2da2bb',
- '#b99aff',
- '#994a64',
- '#f691b2',
- '#ff7537',
- '#ffad46',
- '#662e37',
- '#ebdbde',
- '#cca6ac',
- '#094228',
- '#42d692',
- '#16a765',
+ '#FFFFFF', // White
+ '#000000', // Black
+ '#0000FF', // Blue
+ '#FF0000', // Red
+ '#FFFF00', // Yellow
+ '#FFA500', // Orange
+ '#800080', // Purple
];
diff --git a/apps/mail/app/(routes)/settings/labels/page.tsx b/apps/mail/app/(routes)/settings/labels/page.tsx
index 5e36e187a2..36fb9bbed6 100644
--- a/apps/mail/app/(routes)/settings/labels/page.tsx
+++ b/apps/mail/app/(routes)/settings/labels/page.tsx
@@ -15,9 +15,10 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
+import { createLabel, updateLabel, deleteLabel } from '@/hooks/use-labels';
import { useLabels, type Label as LabelType } from '@/hooks/use-labels';
import { SettingsCard } from '@/components/settings/settings-card';
-import { Check, Pencil, Plus, Trash2 } from 'lucide-react';
+import { Check, Plus, Pencil } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
@@ -29,10 +30,20 @@ import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { COLORS } from './colors';
import { useState } from 'react';
+import { toast } from 'sonner';
+import { HexColorPicker } from 'react-colorful';
+import { Command } from 'lucide-react';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { Bin } from '@/components/icons/icons';
+import { CurvedArrow } from '@/components/icons/icons';
export default function LabelsPage() {
const t = useTranslations();
- const { labels, isLoading, error, createLabel, updateLabel, deleteLabel } = useLabels();
+ const { labels, isLoading, error, mutate } = useLabels();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState
(null);
const form = useForm({
@@ -46,26 +57,34 @@ export default function LabelsPage() {
const onSubmit = async (data: LabelType) => {
try {
- if (editingLabel) {
- await updateLabel(editingLabel.id!, data);
- } else {
- await createLabel(data);
- }
+ toast.promise(editingLabel ? updateLabel(editingLabel.id!, data) : createLabel(data), {
+ loading: 'Saving label...',
+ success: 'Label saved successfully',
+ error: 'Failed to save label',
+ });
+ } catch (error) {
+ console.error('Error saving label:', error);
+ } finally {
+ await mutate();
handleClose();
- } catch (err) {
- console.error('Error saving label:', err);
}
};
const handleDelete = async (id: string) => {
try {
- await deleteLabel(id);
- } catch (err) {
- console.error('Error deleting label:', err);
+ toast.promise(deleteLabel(id), {
+ loading: 'Deleting label...',
+ success: 'Label deleted successfully',
+ error: 'Failed to delete label',
+ });
+ } catch (error) {
+ console.error('Error deleting label:', error);
+ } finally {
+ await mutate();
}
};
- const handleEdit = (label: LabelType) => {
+ const handleEdit = async (label: LabelType) => {
setEditingLabel(label);
form.reset({
name: label.name,
@@ -98,69 +117,82 @@ export default function LabelsPage() {
Create Label
-
-
-
- {editingLabel ? 'Edit Label' : 'Create New Label'}
-
-
-
- (
-
- Label Name
-
-
-
-
-
- )}
- />
-
-
-
Color
-
- {COLORS.map((color) => (
-
- form.setValue('color', {
- backgroundColor: color,
- textColor: '#ffffff',
- })
- }
- style={{
- height: '2rem',
- backgroundColor: color,
- color: '#ffffff',
- borderRadius: '0.375rem',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- ...(formColor?.backgroundColor === color && {
- outline: '2px solid var(--primary)',
- outlineOffset: '2px',
- }),
- }}
- >
- {formColor?.backgroundColor === color && }
-
- ))}
+
+
+
+
+ {editingLabel ? 'Edit Label' : 'Create New Label'}
+
+
+
+ (
+
+ Label Name
+
+
+
+
+
+ )}
+ />
+
+
Color
+
+
+ {[
+ // Row 1 - Grayscale
+ '#000000', '#434343', '#666666', '#999999', '#cccccc', '#ffffff',
+ // Row 2 - Warm colors
+ '#fb4c2f', '#ffad47', '#fad165', '#ff7537', '#cc3a21', '#8a1c0a',
+ // Row 3 - Cool colors
+ '#16a766', '#43d692', '#4a86e8', '#285bac', '#3c78d8', '#0d3472',
+ // Row 4 - Purple tones
+ '#a479e2', '#b99aff', '#653e9b', '#3d188e', '#f691b3', '#994a64',
+ // Row 5 - Pastels
+ '#f6c5be', '#ffe6c7', '#c6f3de', '#c9daf8'
+ ].map((color) => (
+ form.setValue('color', {
+ backgroundColor: color,
+ textColor: '#ffffff'
+ })}
+ />
+ ))}
+
+
+
+
+
+
+ Cancel
+
+
+ {editingLabel ? 'Save Changes' : 'Create Label'}
+
+
+
+
+
+
-
-
-
- Cancel
-
-
- {editingLabel ? 'Save Changes' : 'Create Label'}
-
-
+
+
@@ -181,32 +213,58 @@ export default function LabelsPage() {
No labels created yet. Click the button above to create one.
) : (
- labels.map((label) => {
- return (
-
-
-
-
- {label.name}
-
-
-
-
-
handleEdit(label)}>
-
-
-
handleDelete(label.id!)}>
-
-
+
+ {labels.map((label) => {
+ return (
+
+
+
+
+ {label.name}
+
+
+
+
+
+
+ handleEdit(label)}
+ >
+
+
+
+
+ Edit Label
+
+
+
+
+ handleDelete(label.id!)}
+ >
+
+
+
+
+ Delete Label
+
+
+
-
- );
- })
+ );
+ })}
+
)}
diff --git a/apps/mail/app/(routes)/settings/shortcuts/page.tsx b/apps/mail/app/(routes)/settings/shortcuts/page.tsx
index 5c04c64775..6921d1b975 100644
--- a/apps/mail/app/(routes)/settings/shortcuts/page.tsx
+++ b/apps/mail/app/(routes)/settings/shortcuts/page.tsx
@@ -1,70 +1,71 @@
'use client';
-import { SettingsCard } from '@/components/settings/settings-card';
import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts';
+import { SettingsCard } from '@/components/settings/settings-card';
+import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils';
+import { useShortcutCache } from '@/lib/hotkeys/use-hotkey-utils';
+import { useState, type ReactNode, useEffect } from 'react';
import type { MessageKey } from '@/config/navigation';
import { HotkeyRecorder } from './hotkey-recorder';
-import { useState, type ReactNode, useEffect } from 'react';
import { Button } from '@/components/ui/button';
+import { useSession } from '@/lib/auth-client';
import { useTranslations } from 'next-intl';
-import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils';
-import { hotkeysDB } from '@/lib/hotkeys/hotkeys-db';
import { toast } from 'sonner';
export default function ShortcutsPage() {
- const [shortcuts, setShortcuts] = useState
(keyboardShortcuts);
const t = useTranslations();
-
- useEffect(() => {
- // Load any custom shortcuts from IndexedDB
- hotkeysDB.getAllHotkeys()
- .then(savedShortcuts => {
- if (savedShortcuts.length > 0) {
- const updatedShortcuts = keyboardShortcuts.map(defaultShortcut => {
- const savedShortcut = savedShortcuts.find(s => s.action === defaultShortcut.action);
- return savedShortcut || defaultShortcut;
- });
- setShortcuts(updatedShortcuts);
- }
- })
- .catch(console.error);
- }, []);
+ const { data: session } = useSession();
+ const {
+ shortcuts,
+ // TODO: Implement shortcuts syncing and caching
+ // updateShortcut,
+ } = useShortcutCache(session?.user?.id);
return (
- {
- try {
- // Save all default shortcuts to IndexedDB
- await Promise.all(keyboardShortcuts.map(shortcut => hotkeysDB.saveHotkey(shortcut)));
- setShortcuts(keyboardShortcuts);
- toast.success('Shortcuts reset to defaults');
- } catch (error) {
- console.error('Failed to reset shortcuts:', error);
- toast.error('Failed to reset shortcuts');
- }
- }}
- >
- {t('common.actions.resetToDefaults')}
-
-
- }
+ // footer={
+ //
+ // {
+ // try {
+ // await Promise.all(keyboardShortcuts.map((shortcut) => updateShortcut(shortcut)));
+ // toast.success('Shortcuts reset to defaults');
+ // } catch (error) {
+ // console.error('Failed to reset shortcuts:', error);
+ // toast.error('Failed to reset shortcuts');
+ // }
+ // }}
+ // >
+ // {t('common.actions.resetToDefaults')}
+ //
+ //
+ // }
>
-
- {shortcuts.map((shortcut, index) => (
-
- {t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey)}
-
+
+ {Object.entries(
+ shortcuts.reduce
>((acc, shortcut) => {
+ const scope = shortcut.scope;
+ if (!acc[scope]) acc[scope] = [];
+ acc[scope].push(shortcut);
+ return acc;
+ }, {}),
+ ).map(([scope, scopedShortcuts]) => (
+
+
+ {scope.split('-').join(' ')}
+
+
+ {scopedShortcuts.map((shortcut, index) => (
+
+ {t(`pages.settings.shortcuts.actions.${shortcut.action}` as MessageKey)}
+
+ ))}
+
+
))}
@@ -72,36 +73,46 @@ export default function ShortcutsPage() {
);
}
-function Shortcut({ children, keys, action }: { children: ReactNode; keys: string[]; action: string }) {
- const [isRecording, setIsRecording] = useState(false);
+function Shortcut({
+ children,
+ keys,
+ action,
+}: {
+ children: ReactNode;
+ keys: string[];
+ action: string;
+}) {
+ // const [isRecording, setIsRecording] = useState(false);
const displayKeys = formatDisplayKeys(keys);
+ const { data: session } = useSession();
+ // const { updateShortcut } = useShortcutCache(session?.user?.id);
+
+ // const handleHotkeyRecorded = async (newKeys: string[]) => {
+ // try {
+ // // Find the original shortcut to preserve its type and description
+ // const originalShortcut = keyboardShortcuts.find((s) => s.action === action);
+ // if (!originalShortcut) {
+ // throw new Error('Original shortcut not found');
+ // }
- const handleHotkeyRecorded = async (newKeys: string[]) => {
- try {
- // Find the original shortcut to preserve its type and description
- const originalShortcut = keyboardShortcuts.find(s => s.action === action);
- if (!originalShortcut) {
- throw new Error('Original shortcut not found');
- }
+ // const updatedShortcut: Shortcut = {
+ // ...originalShortcut,
+ // keys: newKeys,
+ // };
- const updatedShortcut: Shortcut = {
- ...originalShortcut,
- keys: newKeys,
- };
-
- await hotkeysDB.saveHotkey(updatedShortcut);
- toast.success('Shortcut saved successfully');
- } catch (error) {
- console.error('Failed to save shortcut:', error);
- toast.error('Failed to save shortcut');
- }
- };
+ // await updateShortcut(updatedShortcut);
+ // toast.success('Shortcut saved successfully');
+ // } catch (error) {
+ // console.error('Failed to save shortcut:', error);
+ // toast.error('Failed to save shortcut');
+ // }
+ // };
return (
<>
setIsRecording(true)}
+ // onClick={() => setIsRecording(true)}
role="button"
tabIndex={0}
>
@@ -117,12 +128,12 @@ function Shortcut({ children, keys, action }: { children: ReactNode; keys: strin
))}
- setIsRecording(false)}
onHotkeyRecorded={handleHotkeyRecorded}
currentKeys={keys}
- />
+ /> */}
>
);
}
diff --git a/apps/mail/app/api/ai-search/route.ts b/apps/mail/app/api/ai-search/route.ts
new file mode 100644
index 0000000000..1a97f488bc
--- /dev/null
+++ b/apps/mail/app/api/ai-search/route.ts
@@ -0,0 +1,173 @@
+import { getActiveDriver } from '@/actions/utils';
+import { type gmail_v1 } from 'googleapis';
+import { openai } from '@ai-sdk/openai';
+import { generateText, tool } from 'ai';
+import { headers } from 'next/headers';
+import { auth } from '@/lib/auth';
+import { z } from 'zod';
+
+// Define our email search tool
+const emailSearchTool = tool({
+ description: 'Search through emails using Gmail-compatible search syntax',
+ parameters: z.object({
+ query: z.string().describe('The Gmail search query to use'),
+ explanation: z.string().describe('A brief explanation of what this search will find'),
+ }),
+ execute: async ({ query, explanation }) => {
+ return {
+ query,
+ explanation,
+ status: 'success',
+ };
+ },
+});
+
+export async function POST(req: Request) {
+ try {
+ // Check authentication
+ const headersList = await headers();
+ const session = await auth.api.getSession({ headers: headersList });
+
+ if (!session?.user) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const { messages } = await req.json();
+ const lastMessage = messages[messages.length - 1];
+
+ // Create a system message to guide the AI
+ const systemMessage = {
+ role: 'system',
+ content: `You are an email search assistant. Your task is to:
+1. Understand the user's search intent
+2. Convert their request into a Gmail-compatible search query
+3. Focus on finding emails related to the search terms
+4. Consider both subject and content when searching
+5. Return the search query in a format that can be used directly with Gmail's search syntax
+6. Provide a clear explanation of what the search will find
+
+For example:
+- "find emails about billing" -> query: "subject:(bill OR invoice OR receipt OR payment OR charge) OR (bill OR invoice OR receipt OR payment OR charge)"
+- "find emails from john" -> query: "from:john"
+- "find emails from example.com" -> query: "(from:*@example.com) OR (example.com)"
+- "show me messages about meetings" -> query: "subject:meeting OR meeting"
+- "find emails with attachments" -> query: "has:attachment"
+
+Context Handling:
+- When the user asks about a completely new topic (e.g. "show me emails from adam" after "find vercel emails"), treat it as a new search
+- Only use previous search context when the user explicitly refers to the previous results or asks for more/similar results
+- For follow-ups about the same topic (e.g. "show me more recent ones" or "any older ones?"), modify the previous search query
+- For refinements (e.g. "only from last week" or "with attachments"), add to the previous query
+
+Examples of context switching:
+- Previous: "find vercel emails", New: "what about emails from adam" -> Create new search: "from:adam"
+- Previous: "find vercel emails", New: "show me more recent ones" -> Modify previous search: "from:vercel newer_than:7d"
+- Previous: "find vercel emails", New: "any with attachments?" -> Add to previous: "from:vercel has:attachment"
+
+When searching for emails from a domain:
+- Include both emails from that domain (from:*@domain.com)
+- AND emails containing that domain name in the content
+- This ensures we find both emails sent from that domain and emails mentioning it
+
+Always try to expand search terms to include related keywords to ensure comprehensive results.
+For sender searches, use the exact name/email provided by the user.
+For domain searches, search both the from: field and general content.
+For subject/content searches, include relevant synonyms and related terms.
+
+Important: This is a search-only assistant. Do not generate email content or handle email composition requests.`,
+ };
+
+ const { text, steps } = await generateText({
+ model: openai('gpt-3.5-turbo'),
+ messages: [systemMessage, ...messages],
+ tools: {
+ emailSearch: emailSearchTool,
+ },
+ maxSteps: 2,
+ temperature: 0.7,
+ });
+
+ // Extract the search query and explanation from the tool call
+ const toolCall = steps
+ .flatMap((step) => step.toolCalls)
+ .find((call) => call.toolName === 'emailSearch');
+
+ if (!toolCall?.args?.query) {
+ throw new Error('Failed to generate search query');
+ }
+
+ const searchQuery = toolCall.args.query;
+ const searchExplanation = toolCall.args.explanation || 'matching your search criteria';
+
+ // Get the email driver and fetch results
+ const driver = await getActiveDriver();
+ const results = await driver.list('', searchQuery, 20);
+
+ // Process the results - use the raw response from Gmail API
+ const processResultPromises =
+ results?.threads?.map(async (thread) => {
+ const rawThread = thread as gmail_v1.Schema$Thread;
+
+ try {
+ // Get the thread data using our existing driver
+ const threadData = await driver.get(rawThread.id!);
+ const firstMessage = threadData.messages[0];
+
+ if (!firstMessage) {
+ throw new Error('No messages found in thread');
+ }
+
+ return {
+ id: rawThread.id!,
+ snippet: rawThread.snippet || '',
+ historyId: rawThread.historyId,
+ subject: firstMessage.subject || 'No subject',
+ from: firstMessage.sender.email || firstMessage.sender.name || 'Unknown sender',
+ };
+ } catch (error) {
+ console.error('Error processing thread:', error);
+ return {
+ id: rawThread.id!,
+ snippet: rawThread.snippet || '',
+ historyId: rawThread.historyId,
+ subject: 'Error loading subject',
+ from: 'Error loading sender',
+ };
+ }
+ }) || [];
+
+ // Resolve all promises
+ const resolvedResults = await Promise.all(processResultPromises);
+
+ // Create a natural response using the AI's text and search results
+ const hasResults = resolvedResults.length > 0;
+
+ // Let the AI's response text lead the way
+ let summary = text;
+
+ // Add result information
+ if (hasResults) {
+ summary += `\n\nI found ${resolvedResults.length} email${resolvedResults.length === 1 ? '' : 's'} ${searchExplanation}. Here ${resolvedResults.length === 1 ? 'it is' : 'they are'}:`;
+ } else {
+ summary += `\n\nI couldn't find any emails ${searchExplanation}. Would you like to try a different search?`;
+ }
+
+ return new Response(
+ JSON.stringify({
+ content: summary,
+ searchQuery,
+ searchDisplay: `Searched for "${searchQuery}"`,
+ results: resolvedResults,
+ }),
+ {
+ headers: { 'Content-Type': 'application/json' },
+ },
+ );
+ } catch (error) {
+ console.error('AI Search error:', error);
+ return new Response(JSON.stringify({ error: 'Failed to process search request' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+}
diff --git a/apps/mail/app/api/auth/settings/route.ts b/apps/mail/app/api/auth/settings/route.ts
index c13c84e9de..467e1be721 100644
--- a/apps/mail/app/api/auth/settings/route.ts
+++ b/apps/mail/app/api/auth/settings/route.ts
@@ -21,6 +21,10 @@ export const GET = async (req: NextRequest) => {
);
}
+ if (!userId) {
+ return NextResponse.json({ settings: defaultUserSettings }, { status: 200 });
+ }
+
const [result] = await db
.select()
.from(userSettings)
diff --git a/apps/mail/app/api/chat/route.ts b/apps/mail/app/api/chat/route.ts
new file mode 100644
index 0000000000..c21066a360
--- /dev/null
+++ b/apps/mail/app/api/chat/route.ts
@@ -0,0 +1,58 @@
+import { generateCompletions } from '@/lib/groq';
+import { NextResponse } from 'next/server';
+
+export async function POST(req: Request) {
+ try {
+ const { messages, context } = await req.json();
+
+ const lastMessage = messages[messages.length - 1].content;
+
+ let systemPrompt =
+ 'You are a helpful AI assistant. Provide clear, concise, and accurate responses.';
+
+ // If this is an email request, modify the system prompt
+ if (context?.isEmailRequest) {
+ systemPrompt = `You are an email writing assistant. Generate professional, well-structured emails.
+When generating an email, always follow this format:
+1. Keep the tone professional but friendly
+2. Be concise and clear
+3. Include a clear subject line
+4. Structure the email with a greeting, body, and closing
+5. Use appropriate formatting
+
+Output format:
+{
+ "emailContent": "The full email content",
+ "subject": "A clear subject line",
+ "content": "A brief message explaining the generated email"
+}`;
+ }
+
+ const { completion } = await generateCompletions({
+ model: 'llama3-8b-8192',
+ systemPrompt,
+ prompt: context?.isEmailRequest
+ ? `Generate a professional email for the following request: ${lastMessage}`
+ : lastMessage,
+ temperature: 0.7,
+ max_tokens: 500,
+ userName: 'User',
+ });
+
+ // If this was an email request, try to parse the JSON response
+ if (context?.isEmailRequest) {
+ try {
+ const emailData = JSON.parse(completion);
+ return NextResponse.json(emailData);
+ } catch (error) {
+ // If parsing fails, return the completion as regular content
+ return NextResponse.json({ content: completion });
+ }
+ }
+
+ return NextResponse.json({ content: completion });
+ } catch (error) {
+ console.error('Chat API Error:', error);
+ return NextResponse.json({ error: 'Failed to generate response' }, { status: 400 });
+ }
+}
diff --git a/apps/mail/app/api/driver/drafts/[id]/route.ts b/apps/mail/app/api/driver/drafts/[id]/route.ts
new file mode 100644
index 0000000000..d4bdcddf01
--- /dev/null
+++ b/apps/mail/app/api/driver/drafts/[id]/route.ts
@@ -0,0 +1,31 @@
+import { processIP, getRatelimitModule, checkRateLimit } from '@/app/api/utils';
+import { NextRequest, NextResponse } from 'next/server';
+import { getActiveDriver } from '@/actions/utils';
+import { Ratelimit } from '@upstash/ratelimit';
+
+export const GET = async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
+ try {
+ const finalIp = processIP(req);
+ const { id } = await params;
+ const ratelimit = getRatelimitModule({
+ prefix: `ratelimit:get-draft-${id}`,
+ 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 driver = await getActiveDriver();
+ const draftResponse = await driver.getDraft(id);
+ return NextResponse.json(draftResponse, {
+ status: 200,
+ headers,
+ });
+ } catch (error) {
+ return NextResponse.json({}, { status: 400 });
+ }
+};
diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts
index 175359e00a..6e38128b62 100644
--- a/apps/mail/app/api/driver/google.ts
+++ b/apps/mail/app/api/driver/google.ts
@@ -3,10 +3,9 @@ import { deleteActiveConnection, FatalErrors } from '@/actions/utils';
import { IOutgoingMessage, type ParsedMessage } from '@/types';
import { type IConfig, type MailManager } from './types';
import { type gmail_v1, google } from 'googleapis';
-import { filterSuggestions } from '@/lib/filter';
-import { GMAIL_COLORS } from '@/lib/constants';
import { cleanSearchValue } from '@/lib/utils';
import { createMimeMessage } from 'mimetext';
+import { toByteArray } from 'base64-js';
import * as he from 'he';
class StandardizedError extends Error {
@@ -29,14 +28,8 @@ function fromBase64Url(str: string) {
}
function fromBinary(str: string) {
- return decodeURIComponent(
- atob(str.replace(/-/g, '+').replace(/_/g, '/'))
- .split('')
- .map(function (c) {
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
- })
- .join(''),
- );
+ const bytes = toByteArray(str.replace(/-/g, '+').replace(/_/g, '/'));
+ return new TextDecoder().decode(bytes);
}
const findHtmlBody = (parts: any[]): string => {
@@ -52,15 +45,7 @@ const findHtmlBody = (parts: any[]): string => {
return '';
};
-interface ParsedDraft {
- id: string;
- to?: string[];
- subject?: string;
- content?: string;
- rawMessage?: gmail_v1.Schema$Message;
-}
-
-const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => {
+const parseDraft = (draft: gmail_v1.Schema$Draft) => {
if (!draft.message) return null;
const headers = draft.message.payload?.headers || [];
@@ -77,7 +62,7 @@ const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => {
if (payload) {
if (payload.parts) {
- const textPart = payload.parts.find((part) => part.mimeType === 'text/plain');
+ const textPart = payload.parts.find((part) => part.mimeType === 'text/html');
if (textPart?.body?.data) {
content = fromBinary(textPart.body.data);
}
@@ -86,6 +71,8 @@ const parseDraft = (draft: gmail_v1.Schema$Draft): ParsedDraft | null => {
}
}
+ // TODO: Hook up CC and BCC from the draft so it can populate the composer on open.
+
return {
id: draft.id || '',
to,
@@ -268,7 +255,7 @@ export const driver = async (config: IConfig): Promise => {
threadId: threadId || '',
title: snippet ? he.decode(snippet).trim() : 'ERROR',
tls: wasSentWithTLS(receivedHeaders) || !!hasTLSReport,
- tags: labelIds || [],
+ tags: labelIds?.map((l) => ({ id: l, name: l })) || [],
listUnsubscribe,
listUnsubscribePost,
replyTo,
@@ -559,6 +546,12 @@ export const driver = async (config: IConfig): Promise => {
);
},
getScope,
+ getIdType: (id: string) => {
+ if (id.startsWith('r')) {
+ return 'draft';
+ }
+ return 'thread';
+ },
getUserInfo: (tokens: IConfig['auth']) => {
return withErrorHandler(
'getUserInfo',
@@ -666,9 +659,17 @@ export const driver = async (config: IConfig): Promise => {
format: 'full',
quotaUser: config.auth?.email,
});
+
if (!res.data.messages)
- return { messages: [], latest: undefined, hasUnread: false, totalReplies: 0 };
+ return {
+ messages: [],
+ latest: undefined,
+ hasUnread: false,
+ totalReplies: 0,
+ labels: [],
+ };
let hasUnread = false;
+ const labels = new Set();
const messages: ParsedMessage[] = await Promise.all(
res.data.messages.map(async (message) => {
const bodyData =
@@ -677,7 +678,18 @@ export const driver = async (config: IConfig): Promise => {
message.payload?.parts?.[0]?.body?.data ||
'';
- const decodedBody = bodyData ? fromBinary(bodyData) : '';
+ const decodedBody = bodyData
+ ? he
+ .decode(fromBinary(bodyData))
+ .replace(/<[^>]*>/g, '')
+ .trim() === fromBinary(bodyData).trim()
+ ? he.decode(fromBinary(bodyData).replace(/\n/g, ' '))
+ : he.decode(fromBinary(bodyData))
+ : '';
+
+ if (id === '196784c9e42c15cb') {
+ console.log('decodedBody', bodyData);
+ }
let processedBody = decodedBody;
if (message.payload?.parts) {
@@ -720,6 +732,14 @@ export const driver = async (config: IConfig): Promise => {
}
const parsedData = parse(message);
+ if (parsedData.tags) {
+ parsedData.tags.forEach((tag) => {
+ if (tag.id) {
+ if (labels.has(tag.id)) return;
+ labels.add(tag.id);
+ }
+ });
+ }
const attachments = await Promise.all(
message.payload?.parts
@@ -781,7 +801,13 @@ export const driver = async (config: IConfig): Promise => {
return fullEmailData;
}),
);
- return { messages, latest: messages[0], hasUnread, totalReplies: messages.length };
+ return {
+ labels: Array.from(labels).map((id) => ({ id, name: id })),
+ messages,
+ latest: messages[messages.length - 1],
+ hasUnread,
+ totalReplies: messages.length,
+ };
});
},
{ id, email: config.auth?.email },
@@ -932,7 +958,7 @@ export const driver = async (config: IConfig): Promise => {
return dateB - dateA;
});
- return { ...res.data, drafts: sortedDrafts } as any;
+ return { ...res.data, threads: sortedDrafts };
},
{ q, maxResults, pageToken },
);
@@ -1004,7 +1030,18 @@ export const driver = async (config: IConfig): Promise => {
const res = await gmail.users.labels.list({
userId: 'me',
});
- return res.data.labels;
+ // wtf google, null values for EVERYTHING?
+ return (
+ res.data.labels?.map((label) => ({
+ id: label.id ?? '',
+ name: label.name ?? '',
+ type: label.type ?? '',
+ color: {
+ backgroundColor: label.color?.backgroundColor ?? '',
+ textColor: label.color?.textColor ?? '',
+ },
+ })) ?? []
+ );
},
getLabel: async (labelId: string) => {
const res = await gmail.users.labels.get({
diff --git a/apps/mail/app/api/driver/index.ts b/apps/mail/app/api/driver/index.ts
index 3e616290b4..8e452b4ca0 100644
--- a/apps/mail/app/api/driver/index.ts
+++ b/apps/mail/app/api/driver/index.ts
@@ -1,20 +1,23 @@
-import { driver as googleDriver } from './google'
import { type IConfig, type MailManager } from './types';
+import { driver as microsoftDriver } from './microsoft';
+import { driver as googleDriver } from './google';
const SupportedProviders = {
- google: googleDriver,
+ google: googleDriver,
+ microsoft: microsoftDriver,
};
export const createDriver = async (
- provider: keyof typeof SupportedProviders | string,
- config: IConfig,
+ provider: keyof typeof SupportedProviders | string,
+ config: IConfig,
): Promise => {
- const factory = SupportedProviders[provider as keyof typeof SupportedProviders];
- if (!factory) throw new Error("Provider not supported");
- switch (provider) {
- case "google":
- return factory(config);
- default:
- throw new Error("Provider not supported");
- }
+ const factory = SupportedProviders[provider as keyof typeof SupportedProviders];
+ if (!factory) throw new Error('Provider not supported');
+ switch (provider) {
+ case 'microsoft':
+ case 'google':
+ return factory(config);
+ default:
+ throw new Error('Provider not supported');
+ }
};
diff --git a/apps/mail/app/api/driver/microsoft.ts b/apps/mail/app/api/driver/microsoft.ts
new file mode 100644
index 0000000000..7474431ffd
--- /dev/null
+++ b/apps/mail/app/api/driver/microsoft.ts
@@ -0,0 +1,441 @@
+import { IOutgoingMessage, Sender, type ParsedMessage, type InitialThread } from '@/types';
+import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils';
+import { fromBinary, fromBase64Url, findHtmlBody } from '@/actions/utils';
+import { Conversation } from '@microsoft/microsoft-graph-types';
+import type { Message } from '@microsoft/microsoft-graph-types';
+import { Client } from '@microsoft/microsoft-graph-client';
+import { delay, withExponentialBackoff } from '../utils';
+import { filterSuggestions } from '@/lib/filter';
+import { cleanSearchValue } from '@/lib/utils';
+import { IConfig, MailManager } from './types';
+import { createMimeMessage } from 'mimetext';
+import * as he from 'he';
+
+export const driver = async (config: IConfig): Promise => {
+ const getClient = (accessToken: string) => {
+ return Client.initWithMiddleware({
+ authProvider: {
+ getAccessToken: async () => accessToken,
+ },
+ });
+ };
+
+ const getScope = () =>
+ 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access';
+
+ const parseMessage = (message: Message): ParsedMessage => {
+ const headers = message.internetMessageHeaders || [];
+ const dateHeader = headers.find((h) => h.name?.toLowerCase() === 'date');
+ const receivedOn = (dateHeader?.value ||
+ message.receivedDateTime ||
+ new Date().toISOString()) as string;
+ const sender = headers.find((h) => h.name?.toLowerCase() === 'from')?.value || 'Failed';
+ const subject = headers.find((h) => h.name?.toLowerCase() === 'subject')?.value || '';
+ const references = headers.find((h) => h.name?.toLowerCase() === 'references')?.value || '';
+ const inReplyTo = headers.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value || '';
+ const messageId = headers.find((h) => h.name?.toLowerCase() === 'message-id')?.value || '';
+ const listUnsubscribe = headers.find(
+ (h) => h.name?.toLowerCase() === 'list-unsubscribe',
+ )?.value;
+ const listUnsubscribePost = headers.find(
+ (h) => h.name?.toLowerCase() === 'list-unsubscribe-post',
+ )?.value;
+ const replyTo = headers.find((h) => h.name?.toLowerCase() === 'reply-to')?.value;
+ const to = headers.find((h) => h.name?.toLowerCase() === 'to')?.value || '';
+ const cc = headers.find((h) => h.name?.toLowerCase() === 'cc')?.value || '';
+ const receivedHeaders = headers
+ .filter((h) => h.name?.toLowerCase() === 'received')
+ .map((h) => h.value || '');
+ const hasTLSReport = headers.some((h) => h.name?.toLowerCase() === 'tls-report');
+
+ return {
+ id: message.id || 'ERROR',
+ bcc: [],
+ threadId: message.conversationId || '',
+ title: message.subject || 'ERROR',
+ tls: wasSentWithTLS(receivedHeaders) || !!hasTLSReport,
+ tags: message.categories?.map((c) => ({ id: c, name: c })) || [],
+ // listUnsubscribe,
+ // listUnsubscribePost,
+ // replyTo,
+ references,
+ inReplyTo,
+ sender: {
+ email: sender,
+ name: sender,
+ },
+ unread: !message.isRead,
+ to: parseAddressList(to),
+ cc: cc ? parseAddressList(cc) : null,
+ receivedOn,
+ subject: subject ? subject.replace(/"/g, '').trim() : '(no subject)',
+ messageId,
+ body: message.body?.content || '',
+ processedHtml: message.body?.content || '',
+ blobUrl: '',
+ };
+ };
+
+ const parseOutgoing = async ({
+ to,
+ subject,
+ message,
+ attachments,
+ headers,
+ cc,
+ bcc,
+ }: IOutgoingMessage) => {
+ const msg = createMimeMessage();
+ const fromEmail = config.auth?.email || 'nobody@example.com';
+ msg.setSender({ name: '', addr: fromEmail });
+
+ const uniqueRecipients = new Set();
+
+ if (!Array.isArray(to) || to.length === 0) {
+ throw new Error('Recipient address required');
+ }
+
+ const toRecipients = to
+ .filter((recipient) => {
+ if (!recipient || !recipient.email) return false;
+ const email = recipient.email.toLowerCase();
+ if (!uniqueRecipients.has(email)) {
+ uniqueRecipients.add(email);
+ return true;
+ }
+ return false;
+ })
+ .map((recipient) => ({
+ name: recipient.name || '',
+ addr: recipient.email,
+ }));
+
+ if (toRecipients.length === 0) {
+ throw new Error('No valid recipients found in To field');
+ }
+
+ msg.setTo(toRecipients);
+
+ if (Array.isArray(cc) && cc.length > 0) {
+ const ccRecipients = cc
+ .filter((recipient) => {
+ const email = recipient.email.toLowerCase();
+ if (!uniqueRecipients.has(email)) {
+ uniqueRecipients.add(email);
+ return true;
+ }
+ return false;
+ })
+ .map((recipient) => ({
+ name: recipient.name || '',
+ addr: recipient.email,
+ }));
+ msg.setCc(ccRecipients);
+ }
+
+ if (Array.isArray(bcc) && bcc.length > 0) {
+ const bccRecipients = bcc
+ .filter((recipient) => {
+ const email = recipient.email.toLowerCase();
+ if (!uniqueRecipients.has(email)) {
+ uniqueRecipients.add(email);
+ return true;
+ }
+ return false;
+ })
+ .map((recipient) => ({
+ name: recipient.name || '',
+ addr: recipient.email,
+ }));
+ msg.setBcc(bccRecipients);
+ }
+
+ msg.setSubject(subject || '');
+ msg.addMessage({
+ contentType: 'text/html',
+ data: message.trim(),
+ });
+
+ if (attachments && attachments.length > 0) {
+ for (const attachment of attachments) {
+ msg.addAttachment({
+ filename: attachment.filename,
+ contentType: attachment.contentType,
+ data: attachment.content,
+ });
+ }
+ }
+
+ return msg.asRaw();
+ };
+
+ const normalizeSearch = (folder: string, q: string) => {
+ if (!q) return '';
+ const searchValue = cleanSearchValue(q);
+ return `contains(subject,'${searchValue}') or contains(body,'${searchValue}')`;
+ };
+
+ return {
+ get: async (id: string) => {
+ const client = getClient(config.auth?.access_token || '');
+ console.log('get', id);
+ const message: Message = await client.api(`/me/messages/${id}`).get();
+ console.log('message', message);
+
+ // Get all messages in the conversation using the conversationId
+ // const conversationMessages = await client
+ // .api('/me/messages')
+ // .filter(`conversationId eq '${message.conversationId}'`)
+ // .get();
+
+ // console.log('conversationMessages', conversationMessages);
+
+ // const messages = [null]
+ return {
+ messages: [
+ {
+ decodedBody: message.body?.content,
+ processedHtml: message.body?.content,
+ title: message.subject,
+ blobUrl: message.body?.content,
+ to: [],
+ receivedOn: message.receivedDateTime
+ ? new Date(message.receivedDateTime).toISOString()
+ : new Date().toISOString(),
+ threadId: message.id,
+ id: message.id,
+ messageId: message.id,
+ subject: message.subject,
+ sender: {
+ email: message.sender?.emailAddress?.address,
+ name: message.sender?.emailAddress?.name || message.sender?.emailAddress?.address,
+ },
+ },
+ ],
+ latest: {
+ to: [],
+ receivedOn: message.receivedDateTime
+ ? new Date(message.receivedDateTime).toISOString()
+ : new Date().toISOString(),
+ threadId: message.id,
+ id: message.id,
+ messageId: message.id,
+ subject: message.subject,
+ sender: {
+ email: message.sender?.emailAddress?.address,
+ name: message.sender?.emailAddress?.name || message.sender?.emailAddress?.address,
+ },
+ },
+ hasUnread: false,
+ totalReplies: 4,
+ };
+ },
+
+ create: async (data: IOutgoingMessage) => {
+ const client = getClient(config.auth?.access_token || '');
+ const rawMessage = await parseOutgoing(data);
+ return client.api('/me/sendMail').post({
+ message: {
+ subject: data.subject,
+ body: {
+ contentType: 'HTML',
+ content: data.message,
+ },
+ toRecipients: data.to.map((r) => ({ emailAddress: { address: r.email } })),
+ ccRecipients: data.cc?.map((r) => ({ emailAddress: { address: r.email } })),
+ bccRecipients: data.bcc?.map((r) => ({ emailAddress: { address: r.email } })),
+ },
+ });
+ },
+
+ createDraft: async (data: any) => {
+ const client = getClient(config.auth?.access_token || '');
+ return client.api('/me/messages').post({
+ subject: data.subject,
+ body: {
+ contentType: 'HTML',
+ content: data.message,
+ },
+ toRecipients: data.to.map((r: any) => ({ emailAddress: { address: r.email } })),
+ ccRecipients: data.cc?.map((r: any) => ({ emailAddress: { address: r.email } })),
+ bccRecipients: data.bcc?.map((r: any) => ({ emailAddress: { address: r.email } })),
+ });
+ },
+ getUserLabels() {
+ return new Promise((resolve) => resolve([]));
+ },
+
+ getDraft: async (id: string) => {
+ const client = getClient(config.auth?.access_token || '');
+ const draft = await client.api(`/me/messages/${id}`).get();
+ // return parseMessage(draft);
+ return { id: id };
+ },
+
+ listDrafts: async (q?: string, maxResults = 20, pageToken?: string) => {
+ const client = getClient(config.auth?.access_token || '');
+ const response = await client
+ .api('/me/messages')
+ .filter('isDraft eq true')
+ .top(1)
+ .skip(pageToken ? parseInt(pageToken) : 0)
+ .get();
+ return {
+ drafts: response.value.map(parseMessage),
+ nextPageToken: response['@odata.nextLink']
+ ? (parseInt(pageToken || '0') + maxResults).toString()
+ : undefined,
+ };
+ },
+
+ delete: async (id: string) => {
+ const client = getClient(config.auth?.access_token || '');
+ return client.api(`/me/messages/${id}`).delete();
+ },
+
+ list: async (
+ folder: string,
+ query?: string,
+ maxResults = 20,
+ labelIds?: string[],
+ pageToken?: string | number,
+ ): Promise<(T & { threads: InitialThread[] }) | undefined> => {
+ const client = getClient(config.auth?.access_token || '');
+ // const searchQuery = query ? normalizeSearch(folder, query) : '';
+ const response = await client
+ .api('/me/messages')
+ // .filter(searchQuery)
+ .top(3)
+ // .skip(pageToken ? parseInt(pageToken.toString()) : 0)
+ .get();
+
+ // console.log(response);
+
+ const threads: InitialThread[] = (response.value as Message[]).map((message) => ({
+ id: message.id ?? '',
+ subject: message.subject,
+ snippet: message.bodyPreview,
+ unread: !message.isRead,
+ date: message.receivedDateTime,
+ }));
+
+ const result = {
+ threads,
+ nextPageToken: response['@odata.nextLink']
+ ? (parseInt(pageToken?.toString() || '0') + maxResults).toString()
+ : undefined,
+ };
+
+ return result as unknown as T & { threads: InitialThread[] };
+ },
+
+ count: async () => {
+ // const client = getClient(config.auth?.access_token || '');
+ // const response = await client.api('/me/messages').get();
+ return [];
+ },
+
+ generateConnectionAuthUrl: (userId: string) => {
+ const params = new URLSearchParams({
+ client_id: process.env.MICROSOFT_CLIENT_ID as string,
+ redirect_uri: process.env.MICROSOFT_REDIRECT_URI as string,
+ response_type: 'code',
+ scope: getScope(),
+ state: userId,
+ });
+ return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params.toString()}`;
+ },
+
+ getTokens: async (code: string) => {
+ const params = new URLSearchParams({
+ client_id: process.env.MICROSOFT_CLIENT_ID as string,
+ client_secret: process.env.MICROSOFT_CLIENT_SECRET as string,
+ code,
+ redirect_uri: process.env.MICROSOFT_REDIRECT_URI as string,
+ grant_type: 'authorization_code',
+ });
+
+ const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: params.toString(),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to get tokens');
+ }
+
+ const data = await response.json();
+ return {
+ tokens: {
+ access_token: data.access_token,
+ refresh_token: data.refresh_token,
+ expiry_date: Date.now() + data.expires_in * 1000,
+ },
+ };
+ },
+
+ getUserInfo: async (tokens: IConfig['auth']) => {
+ if (!tokens?.access_token) throw new Error('No access token provided');
+ const client = getClient(tokens.access_token);
+ const user = await client.api('/me').get();
+ return {
+ address: user.mail || user.userPrincipalName,
+ name: user.displayName,
+ photo: null,
+ };
+ },
+
+ getScope,
+
+ markAsRead: async (ids: string[]) => {
+ const client = getClient(config.auth?.access_token || '');
+ await Promise.all(
+ ids.map((id) =>
+ client.api(`/me/messages/${id}`).patch({
+ isRead: true,
+ }),
+ ),
+ );
+ },
+
+ markAsUnread: async (ids: string[]) => {
+ const client = getClient(config.auth?.access_token || '');
+ await Promise.all(
+ ids.map((id) =>
+ client.api(`/me/messages/${id}`).patch({
+ isRead: false,
+ }),
+ ),
+ );
+ },
+
+ normalizeIds: (ids: string[]) => ({
+ threadIds: ids,
+ }),
+
+ modifyLabels: async (
+ ids: string[],
+ options: { addLabels: string[]; removeLabels: string[] },
+ ) => {
+ const client = getClient(config.auth?.access_token || '');
+ await Promise.all(
+ ids.map((id) =>
+ client.api(`/me/messages/${id}`).patch({
+ categories: options.addLabels,
+ }),
+ ),
+ );
+ },
+
+ getAttachment: async (messageId: string, attachmentId: string) => {
+ const client = getClient(config.auth?.access_token || '');
+ const attachment = await client
+ .api(`/me/messages/${messageId}/attachments/${attachmentId}`)
+ .get();
+ return attachment.contentBytes;
+ },
+ };
+};
diff --git a/apps/mail/app/api/driver/route.ts b/apps/mail/app/api/driver/route.ts
index 90dfabc622..eba0cfcced 100644
--- a/apps/mail/app/api/driver/route.ts
+++ b/apps/mail/app/api/driver/route.ts
@@ -1,8 +1,8 @@
import { checkRateLimit, getAuthenticatedUserId, getRatelimitModule, processIP } from '../utils';
import { type NextRequest, NextResponse } from 'next/server';
+import { defaultPageSize, FOLDERS } from '@/lib/utils';
import { getActiveDriver } from '@/actions/utils';
import { Ratelimit } from '@upstash/ratelimit';
-import { defaultPageSize } from '@/lib/utils';
export const GET = async (req: NextRequest) => {
try {
@@ -31,6 +31,13 @@ export const GET = async (req: NextRequest) => {
if (!q) q = '';
if (!max) max = defaultPageSize;
const driver = await getActiveDriver();
+ if (folder === FOLDERS.DRAFT) {
+ const drafts = await driver.listDrafts(q, max, pageToken);
+ return NextResponse.json(drafts, {
+ status: 200,
+ headers,
+ });
+ }
const threadsResponse = await driver.list(folder, q, max, undefined, pageToken);
return NextResponse.json(threadsResponse, {
status: 200,
diff --git a/apps/mail/app/api/driver/types.ts b/apps/mail/app/api/driver/types.ts
index eefbef28a3..78c1474513 100644
--- a/apps/mail/app/api/driver/types.ts
+++ b/apps/mail/app/api/driver/types.ts
@@ -1,18 +1,30 @@
import { type IOutgoingMessage, type InitialThread, type ParsedMessage } from '@/types';
+import { Label } from '@/hooks/use-labels';
export interface IGetThreadResponse {
messages: ParsedMessage[];
latest: ParsedMessage | undefined;
hasUnread: boolean;
totalReplies: number;
+ labels: { id: string; name: string }[];
+}
+
+export interface ParsedDraft {
+ id: string;
+ to?: string[];
+ subject?: string;
+ content?: string;
+ // todo: add
+ rawMessage?: any;
}
export interface MailManager {
+ getIdType: (id: string) => 'thread' | 'draft';
get(id: string): Promise;
create(data: IOutgoingMessage): Promise;
sendDraft(id: string, data: IOutgoingMessage): Promise;
createDraft(data: any): Promise;
- getDraft: (id: string) => Promise;
+ getDraft: (id: string) => Promise;
listDrafts: (q?: string, maxResults?: number, pageToken?: string) => Promise;
delete(id: string): Promise;
list(
@@ -37,7 +49,7 @@ export interface MailManager {
options: { addLabels: string[]; removeLabels: string[] },
): Promise;
getAttachment(messageId: string, attachmentId: string): Promise;
- getUserLabels(): Promise;
+ getUserLabels(): Promise;
getLabel: (labelId: string) => Promise;
createLabel(label: {
name: string;
diff --git a/apps/mail/app/api/utils.ts b/apps/mail/app/api/utils.ts
index 72c80e606d..8152a17d96 100644
--- a/apps/mail/app/api/utils.ts
+++ b/apps/mail/app/api/utils.ts
@@ -47,3 +47,46 @@ export const processIP = (req: NextRequest) => {
const cleanIp = ip?.split(',')[0]?.trim() ?? null;
return cfIP ?? cleanIp ?? '127.0.0.1';
};
+
+// Helper function for delays
+export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// Exponential backoff helper function
+export const withExponentialBackoff = async (
+ operation: () => Promise,
+ maxRetries = 3,
+ initialDelay = 1000,
+ maxDelay = 10000,
+): Promise => {
+ let retries = 0;
+ let delayMs = initialDelay;
+
+ while (true) {
+ try {
+ return await operation();
+ } catch (error: any) {
+ if (retries >= maxRetries) {
+ throw error;
+ }
+
+ // Check if error is rate limit related
+ const isRateLimit =
+ error?.code === 429 ||
+ error?.errors?.[0]?.reason === 'rateLimitExceeded' ||
+ error?.errors?.[0]?.reason === 'userRateLimitExceeded';
+
+ if (!isRateLimit) {
+ throw error;
+ }
+
+ console.log(
+ `Rate limit hit, retrying in ${delayMs}ms (attempt ${retries + 1}/${maxRetries})`,
+ );
+ await delay(delayMs);
+
+ // Exponential backoff with jitter
+ delayMs = Math.min(delayMs * 2 + Math.random() * 1000, maxDelay);
+ retries++;
+ }
+ }
+};
diff --git a/apps/mail/app/api/v1/hotkeys/route.ts b/apps/mail/app/api/v1/hotkeys/route.ts
deleted file mode 100644
index 4760094f44..0000000000
--- a/apps/mail/app/api/v1/hotkeys/route.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { checkRateLimit, getAuthenticatedUserId, getRatelimitModule } from '../../utils';
-import type { Shortcut } from '@/config/shortcuts';
-import { Ratelimit } from '@upstash/ratelimit';
-import { userHotkeys } from '@zero/db/schema';
-import { NextResponse } from 'next/server';
-import { headers } from 'next/headers';
-import { auth } from '@/lib/auth';
-import { eq } from 'drizzle-orm';
-import { db } from '@zero/db';
-
-export async function GET() {
- const userId = await getAuthenticatedUserId();
-
- const ratelimit = getRatelimitModule({
- prefix: 'ratelimit:hotkeys',
- limiter: Ratelimit.slidingWindow(60, '1m'),
- });
-
- const { success, headers } = await checkRateLimit(ratelimit, userId);
- if (!success) {
- return NextResponse.json(
- { error: 'Too many requests. Please try again later.' },
- { status: 429, headers },
- );
- }
-
- const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, userId));
-
- return NextResponse.json(result[0]?.shortcuts || []);
-}
-
-export async function POST(request: Request) {
- const userId = await getAuthenticatedUserId();
-
- const ratelimit = getRatelimitModule({
- prefix: 'ratelimit:hotkeys-post',
- limiter: Ratelimit.slidingWindow(60, '1m'),
- });
-
- const { success, headers } = await checkRateLimit(ratelimit, userId);
- if (!success) {
- return NextResponse.json(
- { error: 'Too many requests. Please try again later.' },
- { status: 429, headers },
- );
- }
- const shortcuts = (await request.json()) as Shortcut[];
- const now = new Date();
-
- await db
- .insert(userHotkeys)
- .values({
- userId: userId,
- shortcuts,
- createdAt: now,
- updatedAt: now,
- })
- .onConflictDoUpdate({
- target: [userHotkeys.userId],
- set: {
- shortcuts,
- updatedAt: now,
- },
- });
-
- return NextResponse.json({ success: true });
-}
-
-export async function PUT(request: Request) {
- const userId = await getAuthenticatedUserId();
-
- const ratelimit = getRatelimitModule({
- prefix: 'ratelimit:hotkeys-put',
- limiter: Ratelimit.slidingWindow(60, '1m'),
- });
-
- const { success, headers } = await checkRateLimit(ratelimit, userId);
- if (!success) {
- return NextResponse.json(
- { error: 'Too many requests. Please try again later.' },
- { status: 429, headers },
- );
- }
- const shortcut = (await request.json()) as Shortcut;
- const now = new Date();
-
- const result = await db.select().from(userHotkeys).where(eq(userHotkeys.userId, userId));
-
- const existingShortcuts = (result[0]?.shortcuts || []) as Shortcut[];
- const updatedShortcuts = existingShortcuts.map((s: Shortcut) =>
- s.action === shortcut.action ? shortcut : s,
- );
-
- if (!existingShortcuts.some((s: Shortcut) => s.action === shortcut.action)) {
- updatedShortcuts.push(shortcut);
- }
-
- await db
- .insert(userHotkeys)
- .values({
- userId,
- shortcuts: updatedShortcuts,
- createdAt: now,
- updatedAt: now,
- })
- .onConflictDoUpdate({
- target: [userHotkeys.userId],
- set: {
- shortcuts: updatedShortcuts,
- updatedAt: now,
- },
- });
-
- return NextResponse.json({ success: true });
-}
diff --git a/apps/mail/app/api/v1/labels/route.ts b/apps/mail/app/api/v1/labels/route.ts
index 09754d339e..577ad98487 100644
--- a/apps/mail/app/api/v1/labels/route.ts
+++ b/apps/mail/app/api/v1/labels/route.ts
@@ -2,15 +2,7 @@ import { processIP, getRatelimitModule, checkRateLimit, getAuthenticatedUserId }
import { NextRequest, NextResponse } from 'next/server';
import { getActiveDriver } from '@/actions/utils';
import { Ratelimit } from '@upstash/ratelimit';
-
-interface Label {
- name: string;
- color?: {
- backgroundColor: string;
- textColor: string;
- };
- type?: 'user' | 'system';
-}
+import { Label } from '@/hooks/use-labels';
export async function GET(req: NextRequest) {
const userId = await getAuthenticatedUserId();
@@ -30,17 +22,14 @@ export async function GET(req: NextRequest) {
try {
const driver = await getActiveDriver();
- if (!driver) {
- return NextResponse.json({ error: 'Email driver not configured' }, { status: 500 });
- }
const labels = await driver.getUserLabels();
if (!labels) {
return NextResponse.json([], { status: 200 });
}
- return NextResponse.json(labels.filter((label: Label) => label.type === 'user'));
+ return NextResponse.json(labels.filter((label) => label.type === 'user'));
} catch (error) {
console.error('Error fetching labels:', error);
- return NextResponse.json({ error: 'Failed to fetch labels' }, { status: 500 });
+ return NextResponse.json({ error: 'Failed to fetch labels' }, { status: 400 });
}
}
@@ -67,11 +56,11 @@ export async function POST(req: NextRequest) {
type: 'user',
};
const driver = await getActiveDriver();
- const result = await driver?.createLabel(label);
+ const result = await driver.createLabel(label);
return NextResponse.json(result);
} catch (error) {
console.error('Error creating label:', error);
- return NextResponse.json({ error: 'Failed to create label' }, { status: 500 });
+ return NextResponse.json({ error: 'Failed to create label' }, { status: 400 });
}
}
@@ -94,11 +83,11 @@ export async function PATCH(req: NextRequest) {
try {
const { id, ...label } = (await req.json()) as Label & { id: string } & { type: string };
const driver = await getActiveDriver();
- const result = await driver?.updateLabel(id, label);
+ const result = await driver.updateLabel(id, label);
return NextResponse.json(result);
} catch (error) {
console.error('Error updating label:', error);
- return NextResponse.json({ error: 'Failed to update label' }, { status: 500 });
+ return NextResponse.json({ error: 'Failed to update label' }, { status: 400 });
}
}
@@ -121,10 +110,10 @@ export async function DELETE(req: NextRequest) {
try {
const { id } = (await req.json()) as { id: string };
const driver = await getActiveDriver();
- await driver?.deleteLabel(id);
+ await driver.deleteLabel(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting label:', error);
- return NextResponse.json({ error: 'Failed to delete label' }, { status: 500 });
+ return NextResponse.json({ error: 'Failed to delete label' }, { status: 400 });
}
}
diff --git a/apps/mail/app/api/v1/shortcuts/route.ts b/apps/mail/app/api/v1/shortcuts/route.ts
new file mode 100644
index 0000000000..5d53070b1d
--- /dev/null
+++ b/apps/mail/app/api/v1/shortcuts/route.ts
@@ -0,0 +1,32 @@
+import { getAuthenticatedUserId, processIP, getRatelimitModule, checkRateLimit } from '../../utils';
+import { NextRequest, NextResponse } from 'next/server';
+import { Ratelimit } from '@upstash/ratelimit';
+import { db } from '@zero/db';
+
+export const GET = async (req: NextRequest) => {
+ const userId = await getAuthenticatedUserId();
+ if (!userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ const finalIp = processIP(req);
+ const ratelimit = getRatelimitModule({
+ prefix: `ratelimit:get-shortcuts-${userId}`,
+ 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 },
+ );
+ }
+ try {
+ const result = await db.query.userHotkeys.findFirst({
+ where: (hotkeys, { eq }) => eq(hotkeys.userId, userId),
+ });
+ return NextResponse.json(result?.shortcuts || []);
+ } catch (error) {
+ console.error('Error fetching shortcuts:', error);
+ return NextResponse.json([], { status: 400 });
+ }
+};
diff --git a/apps/mail/app/api/v1/thread-labels/route.ts b/apps/mail/app/api/v1/thread-labels/route.ts
deleted file mode 100644
index f86ba1901f..0000000000
--- a/apps/mail/app/api/v1/thread-labels/route.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { processIP, getRatelimitModule, checkRateLimit, getAuthenticatedUserId } from '../../utils';
-import { NextRequest, NextResponse } from 'next/server';
-import { getActiveDriver } from '@/actions/utils';
-import { Ratelimit } from '@upstash/ratelimit';
-import { Label } from '@/hooks/use-labels';
-
-export async function GET(req: NextRequest) {
- const userId = await getAuthenticatedUserId();
- const finalIp = processIP(req);
- const ratelimit = getRatelimitModule({
- prefix: `ratelimit:get-thread-labels-${userId}`,
- 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 },
- );
- }
- try {
- const { searchParams } = new URL(req.url);
- const ids = searchParams.get('ids');
-
- if (!ids) {
- return NextResponse.json({ error: 'Thread IDs are required' }, { status: 400 });
- }
-
- const threadIds = ids.split(',');
- const driver = await getActiveDriver();
-
- const labels = await Promise.all(threadIds.map(async (id) => await driver.getLabel(id)));
-
- const userLabels: Label[] = labels
- .filter((label): label is Label => {
- return label && typeof label === 'object' && label.type === 'user';
- })
- .map((label) => ({
- id: label.id,
- name: label.name,
- type: label.type,
- color: label.color,
- }));
-
- return NextResponse.json(userLabels);
- } catch (error) {
- console.error('Error fetching thread labels:', error);
- return NextResponse.json({ error: 'Failed to fetch thread labels' }, { status: 500 });
- }
-}
diff --git a/apps/mail/app/globals.css b/apps/mail/app/globals.css
index 7a9f735095..b3bc0a1e63 100644
--- a/apps/mail/app/globals.css
+++ b/apps/mail/app/globals.css
@@ -61,7 +61,7 @@
}
.dark {
- --background: 240 3.9% 7%;
+ --background: 1;
--foreground: 0 0% 98%;
--card: 240 5.9% 10%;
--card-foreground: 0 0% 98%;
diff --git a/apps/mail/app/layout.tsx b/apps/mail/app/layout.tsx
index 8c75f22306..b509494b47 100644
--- a/apps/mail/app/layout.tsx
+++ b/apps/mail/app/layout.tsx
@@ -1,10 +1,12 @@
+import { CircleX, AlertCircle, AlertOctagon } from 'lucide-react';
import { CookieProvider } from '@/providers/cookie-provider';
import { getLocale, getMessages } from 'next-intl/server';
+import { CircleCheck } from '@/components/icons/icons';
import { Geist, Geist_Mono } from 'next/font/google';
import { Analytics } from '@vercel/analytics/react';
import { NextIntlClientProvider } from 'next-intl';
+import CustomToaster from '@/components/ui/toast';
import { siteConfig } from '@/lib/site-config';
-import { Toast } from '@/components/ui/toast';
import { Providers } from '@/lib/providers';
import { headers } from 'next/headers';
import type { Viewport } from 'next';
@@ -51,16 +53,17 @@ export default async function RootLayout({
content={(await headers()).get('x-user-eu-region') || 'false'}
/>
-
+
-
- {children}
- {cookies}
-
-
- {/* {isEuRegion && } */}
-
+ {children}
+ {cookies}
+
+
+ {/* {isEuRegion && } */}
diff --git a/apps/mail/app/toast-test/page.tsx b/apps/mail/app/toast-test/page.tsx
new file mode 100644
index 0000000000..42db8b41cf
--- /dev/null
+++ b/apps/mail/app/toast-test/page.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { toast } from 'sonner';
+import React from 'react';
+
+const ToastTestPage = () => {
+ const fakePromise = () => {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (Math.random() > 0.5) {
+ resolve('Operation completed successfully!');
+ } else {
+ reject(new Error('Operation failed!'));
+ }
+ }, 1400);
+ });
+ };
+
+ return (
+
+ toast.success('Success!')}>Success
+ toast.error('Error!')}>Error
+ toast.warning('Warning!')}>Warning
+ toast.info('Info!')}>Info
+
+ toast.promise(fakePromise(), {
+ loading: 'Processing...',
+ success: (data) => data as string,
+ error: (err) => (err as Error).message,
+ })
+ }
+ >
+ Fake Promise
+
+
+ );
+};
+
+export default ToastTestPage;
diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx
index 88aa2fadda..82d7209d68 100644
--- a/apps/mail/components/context/thread-context.tsx
+++ b/apps/mail/components/context/thread-context.tsx
@@ -25,19 +25,23 @@ import {
Trash,
MailOpen,
} from 'lucide-react';
-import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions';
import { deleteThread, markAsRead, markAsUnread, toggleStar } from '@/actions/mail';
+import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions';
+import { backgroundQueueAtom } from '@/store/backgroundQueue';
import { useThread, useThreads } from '@/hooks/use-threads';
import { useSearchValue } from '@/hooks/use-search-value';
import { useParams, useRouter } from 'next/navigation';
+import { useLabels } from '@/hooks/use-labels';
import { modifyLabels } from '@/actions/mail';
import { LABELS, FOLDERS } from '@/lib/utils';
import { useStats } from '@/hooks/use-stats';
import { useTranslations } from 'next-intl';
import { useMail } from '../mail/use-mail';
+import { Checkbox } from '../ui/checkbox';
import { type ReactNode } from 'react';
import { useQueryState } from 'nuqs';
import { useMemo } from 'react';
+import { useAtom } from 'jotai';
import { toast } from 'sonner';
interface EmailAction {
@@ -61,6 +65,49 @@ interface EmailContextMenuProps {
refreshCallback?: () => void;
}
+const LabelsList = ({ threadId }: { threadId: string }) => {
+ const { labels } = useLabels();
+ const { data: thread, mutate } = useThread(threadId);
+ const t = useTranslations();
+
+ if (!labels || !thread) return null;
+
+ const handleToggleLabel = async (labelId: string) => {
+ if (!labelId) return;
+ const hasLabel = thread.labels?.map((label) => label.id).includes(labelId);
+ await modifyLabels({
+ threadId: [threadId],
+ addLabels: hasLabel ? [] : [labelId],
+ removeLabels: hasLabel ? [labelId] : [],
+ });
+ mutate();
+ };
+
+ return (
+ <>
+ {labels
+ .filter((label) => label.id)
+ .map((label) => (
+ label.id && handleToggleLabel(label.id)}
+ className="font-normal"
+ >
+
+ label.id).includes(label.id) : false
+ }
+ className="mr-2 h-4 w-4"
+ />
+ {label.name}
+
+
+ ))}
+ >
+ );
+};
+
export function ThreadContextMenu({
children,
emailId,
@@ -73,39 +120,32 @@ export function ThreadContextMenu({
}: EmailContextMenuProps) {
const { folder } = useParams<{ folder: string }>();
const [mail, setMail] = useMail();
- const {
- data: { threads },
- mutate,
- isLoading,
- isValidating,
- } = useThreads();
+ const { mutate, isLoading, isValidating } = useThreads();
const currentFolder = folder ?? '';
const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE;
const { mutate: mutateStats } = useStats();
const t = useTranslations();
- const router = useRouter();
const [, setMode] = useQueryState('mode');
const [, setThreadId] = useQueryState('threadId');
+ const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
const { mutate: mutateThread, data: threadData } = useThread(threadId);
- const selectedThreads = useMemo(() => {
- if (mail.bulkSelected.length) {
- return threads.filter((thread) => mail.bulkSelected.includes(thread.id));
- }
- return threads.filter((thread) => thread.id === threadId);
- }, [mail.bulkSelected, threadId, threads]);
+ // const selectedThreads = useMemo(() => {
+ // if (mail.bulkSelected.length) {
+ // return threads.filter((thread) => mail.bulkSelected.includes(thread.id));
+ // }
+ // return threads.filter((thread) => thread.id === threadId);
+ // }, [mail.bulkSelected, threadId, threads]);
const isUnread = useMemo(() => {
return threadData?.hasUnread ?? false;
}, [threadData]);
const isStarred = useMemo(() => {
- // TODO
- return false;
- // if (mail.bulkSelected.length) {
- // return selectedThreads.every((thread) => thread.tags?.includes('STARRED'));
- // }
- // return selectedThreads[0]?.tags?.includes('STARRED') ?? false;
- }, [selectedThreads, mail.bulkSelected]);
+ // TODO support bulk select
+ return threadData?.messages.some((message) =>
+ message.tags?.some((tag) => tag.name.toLowerCase() === 'starred'),
+ );
+ }, [threadData]);
const noopAction = () => async () => {
toast.info(t('common.actions.featureNotImplemented'));
@@ -130,87 +170,65 @@ export function ThreadContextMenu({
threadIds: targets,
currentFolder: currentFolder,
destination,
- }).then(async () => {
- await Promise.all([mutate(), mutateStats()]);
- setMail({ ...mail, bulkSelected: [] });
});
-
- let loadingMessage = t('common.actions.moving');
- let successMessage = t('common.actions.movedToInbox');
-
- if (destination === FOLDERS.INBOX) {
- loadingMessage = t('common.actions.movingToInbox');
- successMessage = t('common.actions.movedToInbox');
- } else if (destination === FOLDERS.SPAM) {
- loadingMessage = t('common.actions.movingToSpam');
- successMessage = t('common.actions.movedToSpam');
- } else if (destination === FOLDERS.ARCHIVE) {
- loadingMessage = t('common.actions.archiving');
- successMessage = t('common.actions.archived');
- } else if (destination === FOLDERS.BIN) {
- loadingMessage = t('common.actions.movingToBin');
- successMessage = t('common.actions.movedToBin');
- }
-
+ targets.forEach((threadId) => setBackgroundQueue({ type: 'add', threadId }));
toast.promise(promise, {
- loading: loadingMessage,
- success: successMessage,
+ finally: async () => {
+ await Promise.all([mutate(), mutateStats()]);
+ setMail({ ...mail, bulkSelected: [] });
+ targets.forEach((threadId) => setBackgroundQueue({ type: 'delete', threadId }));
+ },
error: t('common.actions.failedToMove'),
});
-
- await promise;
} catch (error) {
console.error(`Error moving ${threadId ? 'email' : 'thread'}:`, error);
}
};
- const handleFavorites = () => {
+ const handleFavorites = async () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
- const promise = toggleStar({ ids: targets }).then(() => {
- setMail((prev) => ({ ...prev, bulkSelected: [] }));
- return mutate();
- });
-
- toast.promise(promise, {
- loading: isStarred
- ? t('common.actions.removingFromFavorites')
- : t('common.actions.addingToFavorites'),
- success: isStarred
- ? t('common.actions.removedFromFavorites')
- : t('common.actions.addedToFavorites'),
- error: t('common.actions.failedToModifyFavorites'),
- });
+ if (!isStarred) {
+ toast.success(t('common.actions.addedToFavorites'));
+ } else {
+ toast.success(t('common.actions.removedFromFavorites'));
+ }
+ await toggleStar({ ids: targets });
+ setMail((prev) => ({ ...prev, bulkSelected: [] }));
+ return await Promise.allSettled([mutateThread(), mutate()]);
};
const handleReadUnread = () => {
const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId];
const action = isUnread ? markAsRead : markAsUnread;
- const promise = action({ ids: targets }).then(() => {
- setMail((prev) => ({ ...prev, bulkSelected: [] }));
- return mutateThread();
- });
+ const promise = action({ ids: targets });
toast.promise(promise, {
- loading: t(isUnread ? 'common.actions.markingAsRead' : 'common.actions.markingAsUnread'),
- success: t(isUnread ? 'common.mail.markedAsRead' : 'common.mail.markedAsUnread'),
error: t(isUnread ? 'common.mail.failedToMarkAsRead' : 'common.mail.failedToMarkAsUnread'),
+ async finally() {
+ setMail((prev) => ({ ...prev, bulkSelected: [] }));
+ await Promise.allSettled([mutateThread(), mutate()]);
+ },
});
};
+ const [, setActiveReplyId] = useQueryState('activeReplyId');
const handleThreadReply = () => {
setMode('reply');
setThreadId(threadId);
+ if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const handleThreadReplyAll = () => {
setMode('replyAll');
setThreadId(threadId);
+ if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const handleThreadForward = () => {
setMode('forward');
setThreadId(threadId);
+ if (threadData?.latest) setActiveReplyId(threadData?.latest?.id);
};
const primaryActions: EmailAction[] = [
@@ -237,21 +255,20 @@ export function ThreadContextMenu({
},
];
const handleDelete = () => async () => {
- try {
- const promise = deleteThread({ id: threadId }).then(() => {
- setMail(prev => ({ ...prev, bulkSelected: [] }));
- return mutate();
- });
- toast.promise(promise, {
- loading: t('common.actions.deletingMail'),
- success: t('common.actions.deletedMail'),
- error: t('common.actions.failedToDeleteMail'),
- });
+ try {
+ const promise = deleteThread({ id: threadId }).then(() => {
+ setMail((prev) => ({ ...prev, bulkSelected: [] }));
+ return mutate();
+ });
+ toast.promise(promise, {
+ loading: t('common.actions.deletingMail'),
+ success: t('common.actions.deletedMail'),
+ error: t('common.actions.failedToDeleteMail'),
+ });
} catch (error) {
- console.error(`Error deleting ${threadId? 'email' : 'thread'}:`, error);
- }
- };
-
+ console.error(`Error deleting ${threadId ? 'email' : 'thread'}:`, error);
+ }
+ };
const getActions = () => {
if (isSpam) {
@@ -288,7 +305,7 @@ export function ThreadContextMenu({
icon: ,
action: handleDelete(),
disabled: false,
- }
+ },
];
}
@@ -376,7 +393,6 @@ export function ThreadContextMenu({
),
action: handleFavorites,
- disabled: true,
},
{
id: 'mute',
@@ -412,30 +428,23 @@ export function ThreadContextMenu({
+
+
+
+ {t('common.mail.labels')}
+
+
+
+
+
+
+
+
{getActions().map(renderAction as any)}
{otherActions.map(renderAction)}
-
- {/*
-
-
-
-
- {t('common.mail.labels')}
-
-
-
-
- {t('common.mail.createNewLabel')}
-
-
-
- {t('common.mail.noLabelsAvailable')}
-
-
- */}
);
diff --git a/apps/mail/components/create/ai-assistant.tsx b/apps/mail/components/create/ai-assistant.tsx
deleted file mode 100644
index 5de8d9a4e7..0000000000
--- a/apps/mail/components/create/ai-assistant.tsx
+++ /dev/null
@@ -1,485 +0,0 @@
-import { Sparkles, X, Check, RefreshCw } from 'lucide-react';
-import { motion, AnimatePresence } from 'framer-motion';
-import { generateAIEmailBody, generateAISubject } from '@/actions/ai';
-import { useState, useEffect, useRef } from 'react';
-import { generateConversationId } from '@/lib/utils';
-import { useIsMobile } from '@/hooks/use-mobile';
-import { Button } from '@/components/ui/button';
-import { useSession } from '@/lib/auth-client';
-import { Input } from '@/components/ui/input';
-import { type JSONContent } from 'novel';
-import { toast } from 'sonner';
-import posthog from 'posthog-js';
-
-// Types
-interface AIAssistantProps {
- currentContent?: string;
- recipients?: string[];
- subject?: string;
- userContext?: {
- name?: string;
- email?: string;
- };
- onContentGenerated?: (content: JSONContent, subject?: string) => void;
-}
-
-type MessageType = 'email' | 'question' | 'system';
-type MessageRole = 'user' | 'assistant' | 'system';
-
-interface Message {
- role: MessageRole;
- content: string;
- type: MessageType;
- timestamp: number;
-}
-
-// Animation variants
-const animations = {
- container: {
- initial: { width: 32, opacity: 0 },
- animate: (width: number) => ({
- width: width < 640 ? '200px' : '400px',
- opacity: 1,
- transition: {
- width: { type: 'spring', stiffness: 250, damping: 35 },
- opacity: { duration: 0.4 },
- },
- }),
- exit: {
- width: 32,
- opacity: 0,
- transition: {
- width: { type: 'spring', stiffness: 250, damping: 35 },
- opacity: { duration: 0.4 },
- },
- },
- },
- content: {
- initial: { opacity: 0 },
- animate: { opacity: 1, transition: { delay: 0.15, duration: 0.4 } },
- exit: { opacity: 0, transition: { duration: 0.3 } },
- },
- input: {
- initial: { y: 10, opacity: 0 },
- animate: { y: 0, opacity: 1, transition: { delay: 0.3, duration: 0.4 } },
- exit: { y: 10, opacity: 0, transition: { duration: 0.3 } },
- },
- button: {
- initial: { opacity: 0, scale: 0.8 },
- animate: { opacity: 1, scale: 1, transition: { delay: 0.4, duration: 0.3 } },
- exit: { opacity: 0, scale: 0.8, transition: { duration: 0.2 } },
- },
- card: {
- initial: { opacity: 0, y: 10, scale: 0.95 },
- animate: { opacity: 1, y: -10, scale: 1, transition: { duration: 0.3 } },
- exit: { opacity: 0, y: 10, scale: 0.95, transition: { duration: 0.2 } },
- },
-};
-
-// LoadingSpinner component
-const LoadingSpinner = () => (
-
-
-
-
-
-);
-
-// ContentPreview component
-const ContentPreview = ({ content, animations }: { content: string; animations: any }) => (
-
-
-
-);
-
-// ActionButtons component
-const ActionButtons = ({
- isLoading,
- onClose,
- onRefresh,
- onSubmit,
- onAccept,
- hasContent,
- hasPrompt,
- animations,
-}: {
- isLoading: boolean;
- onClose: (e: React.MouseEvent) => void;
- onRefresh: () => void;
- onSubmit: (e?: React.MouseEvent) => Promise;
- onAccept: () => void;
- hasContent: boolean;
- hasPrompt: boolean;
- animations: any;
-}) => (
-
- {isLoading ? (
-
- ) : (
- <>
-
-
-
-
-
-
- {hasContent ? (
-
-
-
- ) : (
-
-
-
- )}
- >
- )}
-
-);
-
-// Main component
-export const AIAssistant = ({
- currentContent = '',
- recipients = [],
- subject = '',
- userContext,
- onContentGenerated,
-}: AIAssistantProps) => {
- // State
- const [isExpanded, setIsExpanded] = useState(false);
- const [prompt, setPrompt] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const [generatedBody, setGeneratedBody] = useState<{
- content: string;
- jsonContent: JSONContent;
- } | null>(null);
- const [generatedSubject, setGeneratedSubject] = useState(undefined);
- const [showActions, setShowActions] = useState(false);
- const [messages, setMessages] = useState([]);
- const [isAskingQuestion, setIsAskingQuestion] = useState(false);
- const [errorOccurred, setErrorOccurred] = useState(false);
-
- // Generate conversation ID immediately without useEffect
- const conversationId = generateConversationId();
-
- // Refs
- const inputRef = useRef(null);
- const errorFlagRef = useRef(false);
-
- // Hooks
- const isMobile = useIsMobile();
- const { data: session } = useSession();
-
- // User context using activeConnection from session
- const activeConnection = session?.activeConnection;
- const userName = userContext?.name || activeConnection?.name || session?.user.name || '';
- const userEmail = userContext?.email || activeConnection?.email || session?.user.email || '';
-
- // Focus input when expanded
- useEffect(() => {
- if (isExpanded && inputRef.current) {
- setTimeout(() => inputRef.current?.focus(), 300);
- }
- }, [isExpanded]);
-
- // Add a message to the conversation
- const addMessage = (role: MessageRole, content: string, type: MessageType) => {
- setMessages((prev) => [...prev, { role, content, type, timestamp: Date.now() }]);
- };
-
- // Reset states
- const resetStates = (includeExpanded = true) => {
- setPrompt('');
- setGeneratedBody(null);
- setGeneratedSubject(undefined);
- setShowActions(false);
- setIsAskingQuestion(false);
- setErrorOccurred(false);
- if (includeExpanded) setIsExpanded(false);
- };
-
- // Handle chat with AI button
- const handleChatWithAI = () => {
- setIsExpanded(!isExpanded);
-
- if (!isExpanded && messages.length === 0) {
- addMessage(
- 'system',
- 'Try asking me to write an email for you. For example:\n• Write a professional email to John about the project update\n• Draft a thank you email to Sarah for her help\n• Create a meeting invitation for the team',
- 'system',
- );
- }
- };
-
- // Handle submit
- const handleSubmit = async (e?: React.MouseEvent, overridePrompt?: string): Promise => {
- e?.stopPropagation();
- const promptToUse = overridePrompt || prompt;
- if (!promptToUse.trim() || isLoading) return;
-
- try {
- setIsLoading(true);
- setErrorOccurred(false);
- errorFlagRef.current = false;
-
- posthog.capture('Create Email AI Assistant Submit');
- addMessage('user', promptToUse, 'question');
-
- setIsAskingQuestion(false);
- setShowActions(false);
- setGeneratedBody(null);
- setGeneratedSubject(undefined);
-
- // --- Step 1: Generate Body ---
- console.log('AI Assistant: Requesting email body...');
- const bodyResult = await generateAIEmailBody({
- prompt: promptToUse,
- currentContent: generatedBody?.content || currentContent,
- subject,
- to: recipients,
- conversationId,
- userContext: { name: userName, email: userEmail },
- });
- console.log('AI Assistant: Received Body Result:', JSON.stringify(bodyResult));
-
- if (bodyResult.type === 'system') {
- addMessage('system', bodyResult.content, 'system');
- toast.error(bodyResult.content || "Failed to generate email body.");
- setErrorOccurred(true);
- setPrompt('');
- throw new Error("Body generation failed with system message.");
- } else if (bodyResult.type === 'question') {
- setIsAskingQuestion(true);
- addMessage('assistant', bodyResult.content, 'question');
- setPrompt('');
- return; // Stop processing, wait for user answer
- }
-
- // Store the generated body
- setGeneratedBody({
- content: bodyResult.content,
- jsonContent: bodyResult.jsonContent,
- });
-
- let finalSubject: string | undefined = undefined;
-
- // --- Step 2: Generate Subject ---
- if (bodyResult.content && bodyResult.content.trim() !== '') {
- console.log('AI Assistant: Requesting email subject...');
- const subjectResult = await generateAISubject({ body: bodyResult.content });
- console.log('AI Assistant: Received Subject Result:', subjectResult);
-
- if (subjectResult && subjectResult.trim() !== '') {
- finalSubject = subjectResult;
- setGeneratedSubject(finalSubject);
- addMessage('assistant', `Subject: ${finalSubject}\n\n${bodyResult.content}`, 'email');
- } else {
- console.warn('AI Assistant: Subject generation failed or returned empty.');
- addMessage('assistant', bodyResult.content, 'email');
- toast.warning("Generated email body, but failed to generate subject.");
- }
- } else {
- console.warn('AI Assistant: Body generation returned empty content.');
- addMessage('system', "AI generated an empty email body.", 'system');
- setErrorOccurred(true);
- throw new Error("Body generation resulted in empty content.");
- }
-
- setShowActions(true);
- setPrompt('');
-
- } catch (error) {
- if (!(error instanceof Error && (error.message.includes("Body generation failed") || error.message.includes("Body generation resulted")))) {
- console.error('AI Assistant Error (handleSubmit):', error);
- const errorMessage = error instanceof Error ? error.message : 'Failed to generate email content. Please try again.';
- toast.error(errorMessage);
- addMessage('system', errorMessage, 'system');
- }
- setErrorOccurred(true);
- errorFlagRef.current = true;
- } finally {
- setIsLoading(false);
- // Use a local flag to track errors deterministically
- const hadError = isAskingQuestion ? false : !!errorFlagRef.current;
- setIsExpanded(!hadError);
- }
- };
-
- // Handle accept
- const handleAccept = () => {
- if (generatedBody && onContentGenerated) {
- onContentGenerated(generatedBody.jsonContent, generatedSubject);
-
- // Keep posthog event from staging merge
- posthog.capture('Create Email AI Assistant Accept');
-
- addMessage('system', 'Email content applied successfully.', 'system');
- resetStates();
- toast.success('AI content applied to your email');
- }
- };
-
- // Handle reject
- const handleReject = () => {
- addMessage('system', 'Email content rejected.', 'system');
- resetStates();
- toast.info('AI content rejected');
- };
-
- // Handle refresh
- const handleRefresh = async () => {
- // Re-trigger handleSubmit using the last user message
- const lastUserMessage = [...messages].reverse().find((item) => item.role === 'user');
- if (lastUserMessage && !isLoading) {
- const refreshedPrompt = lastUserMessage.content;
- setPrompt(refreshedPrompt);
- await handleSubmit(undefined, refreshedPrompt);
- } else if (prompt.trim() && !isLoading) {
- // If there's text in the input but no history, submit that
- await handleSubmit();
- }
- };
-
- // Handle close
- const handleClose = (e: React.MouseEvent) => {
- e.stopPropagation();
- resetStates();
- };
-
- // Handle keydown
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- if (e.key === 'Escape') {
- resetStates();
- }
- };
-
- return (
-
-
- {/* Floating card for generated content */}
-
- {showActions && generatedBody && (
-
- )}
-
-
- {/* Fixed position Sparkles icon */}
-
-
-
-
- {/* Button */}
-
-
- {/* Expanded state */}
-
- {isExpanded && (
- e.stopPropagation()}
- style={{ transformOrigin: 'left center' }}
- >
- {/* Empty space for the fixed icon */}
-
-
- {/* Expanding content */}
-
-
- setPrompt(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Ask AI to write an email..."
- className="h-8 flex-grow border-0 bg-white px-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-black"
- disabled={isLoading}
- />
-
-
-
- {/* Action buttons */}
-
-
- )}
-
-
-
- );
-};
diff --git a/apps/mail/components/create/ai-chat.tsx b/apps/mail/components/create/ai-chat.tsx
index 621fea8525..d12d8e8ee8 100644
--- a/apps/mail/components/create/ai-chat.tsx
+++ b/apps/mail/components/create/ai-chat.tsx
@@ -1,302 +1,474 @@
'use client';
-import {
- ImageIcon,
- FileUp,
- Figma,
- MonitorIcon,
- CircleUserRound,
- ArrowUpIcon,
- Paperclip,
- PlusIcon,
- Mic,
-} from 'lucide-react';
-import { useEffect, useRef, useCallback } from 'react';
+import { ArrowUpIcon, Mic, CheckIcon, XIcon, Plus, Command } from 'lucide-react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useSearchValue } from '@/hooks/use-search-value';
+import { useConnections } from '@/hooks/use-connections';
+import { useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
+import { useSession } from '@/lib/auth-client';
+import { CurvedArrow } from '../icons/icons';
import { AITextarea } from './ai-textarea';
import { cn } from '@/lib/utils';
import { useState } from 'react';
+import VoiceChat from './voice';
+import { nanoid } from 'nanoid';
import { toast } from 'sonner';
+import Link from 'next/link';
+
+interface Message {
+ id: string;
+ role: 'user' | 'assistant';
+ content: string;
+ timestamp: Date;
+ type?: 'email' | 'search';
+ emailContent?: {
+ subject?: string;
+ content: string;
+ };
+ searchContent?: {
+ searchDisplay: string;
+ results: Array<{
+ id: string;
+ snippet: string;
+ historyId: string;
+ subject: string;
+ from: string;
+ }>;
+ };
+}
-interface UseAutoResizeTextareaProps {
- minHeight: number;
- maxHeight?: number;
+interface AIChatProps {
+ editor: any;
+ onMessagesChange?: (messages: Message[]) => void;
+ onReset?: () => void;
}
-function useAutoResizeTextarea({ minHeight, maxHeight }: UseAutoResizeTextareaProps) {
+export function AIChat({ editor, onMessagesChange, onReset }: AIChatProps) {
+ const [value, setValue] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [messages, setMessages] = useState([]);
+ const [showVoiceChat, setShowVoiceChat] = useState(false);
+ const [expandedResults, setExpandedResults] = useState>(new Set());
+ const [searchValue, setSearchValue] = useSearchValue();
const textareaRef = useRef(null);
+ const messagesEndRef = useRef(null);
+ const messagesContainerRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const pathname = usePathname();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { data: session } = useSession();
+ const { data: connections } = useConnections();
+
+ const activeAccount = connections?.find((connection) => connection.id === session?.connectionId);
+
+ // Scroll to bottom function
+ const scrollToBottom = useCallback(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, []);
- const adjustHeight = useCallback(
- (reset?: boolean) => {
- const textarea = textareaRef.current;
- if (!textarea) return;
-
- if (reset) {
- textarea.style.height = `${minHeight}px`;
- return;
- }
+ // Auto scroll when messages change
+ useEffect(() => {
+ scrollToBottom();
+ if (onMessagesChange) {
+ onMessagesChange(messages);
+ }
+ }, [messages, onMessagesChange, scrollToBottom]);
+
+ // Add reset function
+ const resetChat = useCallback(() => {
+ setMessages([]);
+ setValue('');
+ setIsLoading(false);
+ setShowVoiceChat(false);
+ setExpandedResults(new Set());
+ if (onReset) {
+ onReset();
+ }
+ }, [onReset]);
- // Temporarily shrink to get the right scrollHeight
- textarea.style.height = `${minHeight}px`;
+ useEffect(() => {
+ if (onReset) {
+ onReset();
+ }
+ }, [onReset]);
- // Calculate new height
- const newHeight = Math.max(
- minHeight,
- Math.min(textarea.scrollHeight, maxHeight ?? Number.POSITIVE_INFINITY),
- );
+ const handleSendMessage = async () => {
+ if (!value.trim() || isLoading) return;
- textarea.style.height = `${newHeight}px`;
- },
- [minHeight, maxHeight],
- );
+ const userMessage: Message = {
+ id: generateId(),
+ role: 'user',
+ content: value.trim(),
+ timestamp: new Date(),
+ };
- useEffect(() => {
- // Set initial height
- const textarea = textareaRef.current;
- if (textarea) {
- textarea.style.height = `${minHeight}px`;
- }
- }, [minHeight]);
+ setMessages((prev) => [...prev, userMessage]);
+ setValue('');
+ setIsLoading(true);
- // Adjust height on window resize
- useEffect(() => {
- const handleResize = () => adjustHeight();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, [adjustHeight]);
+ try {
+ // Always treat messages as search requests for now
+ const response = await fetch('/api/ai-search', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ messages: [...messages, userMessage],
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to get response');
+ }
- return { textareaRef, adjustHeight };
-}
+ const data = await response.json();
+
+ // Update the search value
+ setSearchValue({
+ value: data.searchQuery,
+ highlight: value.trim(),
+ isLoading: false,
+ isAISearching: false,
+ folder: searchValue.folder,
+ });
+
+ // Add assistant message with search results
+ const assistantMessage: Message = {
+ id: generateId(),
+ role: 'assistant',
+ content: data.content,
+ timestamp: new Date(),
+ type: 'search',
+ searchContent: {
+ searchDisplay: data.searchDisplay,
+ results: data.results,
+ },
+ };
+
+ setMessages((prev) => [...prev, assistantMessage]);
+ } catch (error) {
+ console.error('Error:', error);
+ toast.error('Failed to generate response. Please try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
-export function AIChat() {
- const [value, setValue] = useState('');
- const [isRecording, setIsRecording] = useState(false);
- const [audioData, setAudioData] = useState(Array(30).fill(0));
- const [isListening, setIsListening] = useState(false);
- const fileInputRef = useRef(null);
- const audioContextRef = useRef(null);
- const analyserRef = useRef(null);
- const animationFrameRef = useRef(undefined);
- const mediaStreamRef = useRef(null);
- const recognitionRef = useRef(null);
- const { textareaRef, adjustHeight } = useAutoResizeTextarea({
- minHeight: 60,
- maxHeight: 200,
- });
-
- const updateAudioData = useCallback(() => {
- if (!analyserRef.current) return;
-
- const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
- analyserRef.current.getByteFrequencyData(dataArray);
-
- // Convert the audio data to wave heights (values between 0 and 1)
- // Using frequency data for better visualization
- const normalizedData = Array.from(dataArray)
- .slice(0, 30)
- .map((value) => value / 255);
-
- setAudioData(normalizedData);
- animationFrameRef.current = requestAnimationFrame(updateAudioData);
- }, []);
+ const handleAcceptSuggestion = (emailContent: { subject?: string; content: string }) => {
+ if (!editor) {
+ toast.error('Editor not found');
+ return;
+ }
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- if (value.trim()) {
- setValue('');
- adjustHeight(true);
+ try {
+ // Format the content to preserve line breaks
+ const formattedContent = emailContent.content
+ .split('\n')
+ .map((line) => `${line}
`)
+ .join('');
+
+ // Set the content in the editor
+ editor.commands.setContent(formattedContent);
+
+ // Find the create-email component and update its content
+ const createEmailElement = document.querySelector('[data-create-email]');
+ if (createEmailElement) {
+ const handler = (createEmailElement as any).onContentGenerated;
+ if (handler && typeof handler === 'function') {
+ handler({ content: emailContent.content, subject: emailContent.subject });
+ }
}
+
+ toast.success('Email content applied successfully');
+ } catch (error) {
+ console.error('Error applying suggestion:', error);
+ toast.error('Failed to apply email content');
}
};
- const handleFileClick = () => {
- fileInputRef.current?.click();
+ const handleRejectSuggestion = (messageId: string) => {
+ toast.info('Email suggestion rejected');
};
- const handleFileChange = (e: React.ChangeEvent) => {
- const files = e.target.files;
- if (files && files.length > 0) {
- // Handle file upload here
- console.log('Selected files:', files);
- // You can implement file upload logic here
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleSendMessage();
}
};
- const handleMicClick = async () => {
- try {
- if (!isRecording) {
- // Start recording
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- mediaStreamRef.current = stream;
-
- // Set up audio context for visualization
- audioContextRef.current = new AudioContext();
- analyserRef.current = audioContextRef.current.createAnalyser();
- const source = audioContextRef.current.createMediaStreamSource(stream);
- source.connect(analyserRef.current);
- analyserRef.current.fftSize = 256;
-
- // Start visualization
- updateAudioData();
- setIsRecording(true);
- setIsListening(true);
-
- // Set up speech recognition
- if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
- const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
- recognitionRef.current = new SpeechRecognition();
- recognitionRef.current.continuous = true;
- recognitionRef.current.interimResults = true;
-
- recognitionRef.current.onresult = (event) => {
- const transcript = Array.from(event.results)
- .map((result) => result[0]?.transcript || '')
- .join('');
-
- setValue((prev) => {
- // Only update if we have new content
- if (transcript && transcript !== prev) {
- return transcript;
- }
- return prev;
- });
-
- // Adjust textarea height when text changes
- adjustHeight();
- };
-
- recognitionRef.current.onend = () => {
- // Restart if we're still recording
- if (isRecording && recognitionRef.current) {
- recognitionRef.current.start();
- }
- };
-
- recognitionRef.current.start();
- } else {
- toast.error('Your browser does not support speech recognition.');
- }
- } else {
- // Stop recording
- if (mediaStreamRef.current) {
- mediaStreamRef.current.getTracks().forEach((track) => track.stop());
- mediaStreamRef.current = null;
- }
+ const generateId = () => nanoid();
- if (audioContextRef.current) {
- audioContextRef.current.close();
- audioContextRef.current = null;
- }
+ const formatTimestamp = (date: Date) => {
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- animationFrameRef.current = undefined;
- }
+ if (minutes < 1) return 'just now';
+ if (minutes === 1) return '1 minute ago';
+ if (minutes < 60) return `${minutes} minutes ago`;
- if (recognitionRef.current) {
- recognitionRef.current.stop();
- recognitionRef.current = null;
- }
+ const hours = Math.floor(minutes / 60);
+ if (hours === 1) return '1 hour ago';
+ if (hours < 24) return `${hours} hours ago`;
- setIsRecording(false);
- setIsListening(false);
- setAudioData(Array(30).fill(0));
- }
- } catch (error) {
- console.error('Error accessing microphone:', error);
- }
+ return date.toLocaleDateString();
};
- useEffect(() => {
- return () => {
- // Clean up resources when component unmounts
- if (animationFrameRef.current) {
- cancelAnimationFrame(animationFrameRef.current);
- }
+ const handleThreadClick = (threadId: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('threadId', threadId);
+ router.push(`${pathname}?${params.toString()}`);
+ };
- if (audioContextRef.current) {
- audioContextRef.current.close();
+ const toggleExpandResults = (messageId: string) => {
+ setExpandedResults((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(messageId)) {
+ newSet.delete(messageId);
+ } else {
+ newSet.add(messageId);
}
+ return newSet;
+ });
+ };
+
+ const sanitizeSnippet = (snippet: string) => {
+ return snippet
+ .replace(/<\/?[^>]+(>|$)/g, '') // Remove HTML tags
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&');
+ };
- if (mediaStreamRef.current) {
- mediaStreamRef.current.getTracks().forEach((track) => track.stop());
+ const handleFileUpload = async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ if (!files || files.length === 0) return;
+
+ try {
+ setIsLoading(true);
+ // Create FormData to send files
+ const formData = new FormData();
+ Array.from(files).forEach((file) => {
+ formData.append('files', file);
+ });
+
+ // Send files to your API endpoint
+ const response = await fetch('/api/upload-files', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to upload files');
}
- if (recognitionRef.current) {
- recognitionRef.current.stop();
+ const data = await response.json();
+
+ // Add a message with the uploaded files
+ const userMessage: Message = {
+ id: generateId(),
+ role: 'user',
+ content: `I've uploaded ${files.length} file(s)`,
+ timestamp: new Date(),
+ type: 'email',
+ emailContent: {
+ content: `Files uploaded: ${Array.from(files)
+ .map((f) => f.name)
+ .join(', ')}`,
+ },
+ };
+
+ setMessages((prev) => [...prev, userMessage]);
+ toast.success('Files uploaded successfully');
+ } catch (error) {
+ console.error('Error uploading files:', error);
+ toast.error('Failed to upload files');
+ } finally {
+ setIsLoading(false);
+ // Reset the file input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
}
- };
- }, []);
+ }
+ };
return (
-
-
-
-
- {isRecording ? (
-
-
- {audioData.map((height, index) => (
-
- ))}
+
+ {/* Messages container */}
+
+
+ {messages.map((message, index) => (
+
+
{message.content}
+
+ {message.type === 'search' &&
+ message.searchContent &&
+ message.searchContent.results.length > 0 && (
+
+ {(expandedResults.has(message.id)
+ ? message.searchContent.results
+ : message.searchContent.results.slice(0, 5)
+ ).map((result: any, i: number) => (
+
+
+
+ {result.subject.toLowerCase().includes('meeting') ? (
+ 📅 {result.subject}
+ ) : (
+ result.subject || 'No subject'
+ )}
+
+
+ from {result.from || 'Unknown sender'}
+
+
+
+ {sanitizeSnippet(result.snippet)}
+
+
+ handleThreadClick(result.id)}
+ className="cursor-pointer border-none bg-transparent p-0 text-blue-500 hover:underline"
+ >
+ Open email
+
+
+
+ ))}
+ {message.searchContent.results.length > 5 && (
+
toggleExpandResults(message.id)}
+ >
+ {expandedResults.has(message.id)
+ ? `Show less (${message.searchContent.results.length - 5} fewer results)`
+ : `Show more (${message.searchContent.results.length - 5} more results)`}
+
+ )}
+
+ )}
+
+ {message.type === 'email' && message.emailContent && (
+
+ {message.emailContent.subject && (
+
+ Subject: {message.emailContent.subject}
+
+ )}
+
{message.emailContent.content}
+
+ handleAcceptSuggestion(message.emailContent!)}
+ >
+
+ Accept
+
+ handleRejectSuggestion(message.id)}
+ >
+
+ Reject
+
+
-
- ) : (
-
{
- setValue(e.target.value);
- adjustHeight();
- }}
- onKeyDown={handleKeyDown}
- placeholder="Ask Zero a question..."
- className="text-foreground placeholder:text-muted-foreground dark:placeholder:text-muted-foreground min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-3 text-sm placeholder:text-sm focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white"
- style={{
- overflow: 'hidden',
- }}
- />
- )}
-
-
-
-
-
+ )}
+
+ ))}
+ {/* Invisible element to scroll to */}
+
+
+ {/* Loading indicator */}
+ {isLoading && (
+
-
-
-
+
zero is thinking...
-
-
+ )}
+
+
+
+ {/* Fixed input at bottom */}
+
+
+ {showVoiceChat ? (
+
setShowVoiceChat(false)} />
+ ) : (
+
+
+
setValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Ask AI to do anything..."
+ className="placeholder:text-muted-foreground h-[44px] w-full resize-none rounded-[5px] bg-transparent px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
+ />
+
+
+ {/*
+
+
fileInputRef.current?.click()}
+ className="inline-flex h-7 items-center justify-center gap-0.5 overflow-hidden rounded-md bg-gradient-to-b from-black/5 to-black/10 dark:from-white/20 dark:to-white/10 px-1.5 outline outline-1 outline-offset-[-1px] outline-black/5 dark:outline-white/5 border border-border/50"
+ disabled={isLoading}
+ >
+
+
+
+ {isLoading ? 'Uploading...' : 'Add files'}
+
+
+
+
*/}
+
+
-
-
+
+
+
+
+
+
-
-
+ )}
diff --git a/apps/mail/components/create/ai-textarea.tsx b/apps/mail/components/create/ai-textarea.tsx
index bec6abdc99..df891f5f52 100644
--- a/apps/mail/components/create/ai-textarea.tsx
+++ b/apps/mail/components/create/ai-textarea.tsx
@@ -1,16 +1,18 @@
-import * as React from 'react';
+'use client';
+import React from 'react';
import { cn } from '@/lib/utils';
-export interface TextareaProps extends React.TextareaHTMLAttributes
{}
+interface TextareaProps extends React.TextareaHTMLAttributes {}
const AITextarea = React.forwardRef(
({ className, ...props }, ref) => {
return (