Merge pull request #951 from Mail-0/staging

Staging
This commit is contained in:
Adam
2025-05-12 10:52:52 -07:00
committed by GitHub
8 changed files with 132 additions and 20 deletions

View File

@@ -6,11 +6,10 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
# Change this to a random string, use `openssl rand -hex 32` to generate a 32 character string
BETTER_AUTH_SECRET=my-better-auth-secret
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
COOKIE_DOMAIN="localhost"
# Change to your project's client ID and secret, these work with localhost:3000 and localhost:3001
# Change to your project's client ID and secret, these work with localhost:8787
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

View File

@@ -32,6 +32,7 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
- Clone the repository and install dependencies: `bun install`
- Start the database locally: `bun docker:up`
- Copy `.env.example` to `.env` in project root
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
- Set up your Google OAuth credentials (see [README.md](../README.md))
- Initialize the database: `bun db:push`

View File

@@ -80,6 +80,7 @@ You can set up Zero in two ways:
cp .env.example .env
```
- Configure your environment variables (see below)
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
- Start the database with the provided docker compose setup: `bun docker:up`
- Initialize the database: `bun db:push`
@@ -148,7 +149,7 @@ bun install
- Create OAuth 2.0 credentials (Web application type)
- Add authorized redirect URIs:
- Development:
- `http://localhost:3000/api/auth/callback/google`
- `http://localhost:8787/api/auth/callback/google`
- Production:
- `https://your-production-url/api/auth/callback/google`
- Add to `.env`:
@@ -210,7 +211,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
2. **Set Up Database Connection**
Make sure your database connection string is in `.env` file.
Make sure your database connection string is in `.env` file. And you have ran `bun run cf-install` to sync the latest env.
For local development use:

View File

@@ -104,11 +104,14 @@ export function EmailComposer({
const [draftId, setDraftId] = useState<string | null>(urlDraftId ?? null);
const [aiGeneratedMessage, setAiGeneratedMessage] = useState<string | null>(null);
const [aiIsLoading, setAiIsLoading] = useState(false);
const [isGeneratingSubject, setIsGeneratingSubject] = useState(false);
const trpc = useTRPC();
const { mutateAsync: aiCompose } = useMutation(trpc.ai.compose.mutationOptions());
const { mutateAsync: createDraft } = useMutation(trpc.drafts.create.mutationOptions());
const { mutateAsync: generateEmailSubject } = useMutation(
trpc.ai.generateEmailSubject.mutationOptions(),
);
useEffect(() => {
if (isComposeOpen === 'true' && toInputRef.current) {
toInputRef.current.focus();
@@ -355,6 +358,13 @@ export function EmailComposer({
}
};
const handleGenerateSubject = async () => {
setIsGeneratingSubject(true);
const { subject } = await generateEmailSubject({ message: editor.getText() });
setValue('subject', subject);
setIsGeneratingSubject(false);
};
useEffect(() => {
if (urlDraftId !== draftId) {
setDraftId(urlDraftId ?? null);
@@ -661,11 +671,31 @@ export function EmailComposer({
setHasUnsavedChanges(true);
}}
/>
<button
className=""
onClick={handleGenerateSubject}
disabled={isLoading || isGeneratingSubject}
>
<div className="flex items-center justify-center gap-2.5 pl-0.5">
<div className="flex h-5 items-center justify-center gap-1 rounded-sm">
{isGeneratingSubject ? (
<Loader className="h-3.5 w-3.5 animate-spin fill-black dark:fill-white" />
) : (
<Sparkles className="h-3.5 w-3.5 fill-black dark:fill-white" />
)}
</div>
</div>
</button>
</div>
{/* Message Content */}
<div className="relative -bottom-1 flex flex-col items-start justify-start gap-2 self-stretch border-t bg-[#FFFFFF] px-3 py-3 outline-white/5 dark:bg-[#202020]">
<div className="flex flex-col gap-2.5 self-stretch">
<div
className={cn(
'flex flex-col gap-2.5 self-stretch',
aiGeneratedMessage !== null ? 'blur-sm' : '',
)}
>
<EditorContent editor={editor} />
</div>
@@ -853,9 +883,8 @@ export function EmailComposer({
<button
className="flex h-7 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-md border border-[#8B5CF6] pl-1.5 pr-2 dark:bg-[#252525]"
onClick={async () => {
if (!toEmails.length || !subjectInput.trim()) {
toast.error('Please enter a recipient and subject');
return;
if (!subjectInput.trim()) {
await handleGenerateSubject();
}
setAiGeneratedMessage(null);
await handleAiGenerate();
@@ -894,7 +923,7 @@ export function EmailComposer({
<TooltipTrigger asChild>
<button
disabled
className="flex hidden h-7 items-center gap-0.5 overflow-hidden rounded-md bg-white/5 px-1.5 shadow-sm hover:bg-white/10 disabled:opacity-50 md:flex"
className="flex h-7 items-center gap-0.5 overflow-hidden rounded-md bg-white/5 px-1.5 shadow-sm hover:bg-white/10 disabled:opacity-50 md:flex"
>
{messageLength < 50 && <ShortStack className="h-3 w-3 fill-[#9A9A9A]" />}
{messageLength >= 50 && messageLength < 200 && (
@@ -970,7 +999,7 @@ const ContentPreview = ({
initial="initial"
animate="animate"
exit="exit"
className="absolute bottom-full right-0 z-30 w-[400px] overflow-hidden rounded-xl border bg-white shadow-md dark:bg-black"
className="dark:bg-subtleBlack absolute bottom-full right-0 z-30 w-[400px] overflow-hidden rounded-xl border bg-white p-1 shadow-md"
>
<div
className="max-h-60 min-h-[150px] overflow-y-auto rounded-md p-1 text-sm"

View File

@@ -172,7 +172,7 @@ const Thread = memo(
const [searchValue, setSearchValue] = useSearchValue();
const t = useTranslations();
const { folder } = useParams<{ folder: string }>();
const [{ refetch: refetchThreads }] = useThreads();
const [{ refetch: refetchThreads }, threads] = useThreads();
const [threadId] = useQueryState('threadId');
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
const { refetch: refetchStats } = useStats();
@@ -181,6 +181,9 @@ const Thread = memo(
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
const [id, setThreadId] = useQueryState('threadId');
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
useEffect(() => {
if (getThreadData?.latest?.tags) {
@@ -206,6 +209,21 @@ const Thread = memo(
[getThreadData, message.id, isStarred, refetchThreads, t],
);
const handleNext = useCallback(
(id: string) => {
if (!id || !threads.length || focusedIndex === null) return setThreadId(null);
if (focusedIndex < threads.length - 1) {
const nextThread = threads[focusedIndex];
if (nextThread) {
setThreadId(nextThread.id);
setActiveReplyId(null);
setFocusedIndex(focusedIndex);
}
}
},
[threads, id, focusedIndex],
);
const moveThreadTo = useCallback(
async (destination: ThreadDestination) => {
if (!message.id) return;
@@ -215,7 +233,7 @@ const Thread = memo(
destination,
});
setBackgroundQueue({ type: 'add', threadId: `thread:${message.id}` });
handleNext(message.id);
toast.success(
destination === 'inbox'
? t('common.actions.movedToInbox')
@@ -590,7 +608,7 @@ const Thread = memo(
) : (
<p
className={cn(
'mt-1 line-clamp-1 max-w-[25ch] sm:max-w-[50ch] text-sm text-[#8C8C8C] md:max-w-[40ch]',
'mt-1 line-clamp-1 max-w-[25ch] text-sm text-[#8C8C8C] sm:max-w-[50ch] md:max-w-[40ch]',
)}
>
{highlightText(latestMessage.subject, searchValue.highlight)}

View File

@@ -1,11 +1,11 @@
import { env, WorkerEntrypoint } from 'cloudflare:workers';
import { mailtoHandler } from './routes/mailto-handler';
import type { HonoContext, HonoVariables } from './ctx';
import { routePartykitRequest } from 'partyserver';
import { partyserverMiddleware } from 'hono-party';
import { trpcServer } from '@hono/trpc-server';
import { DurableMailbox } from './lib/party';
import { chatHandler } from './routes/chat';
import type { HonoVariables } from './ctx';
import { createAuth } from './lib/auth';
import { createDb } from '@zero/db';
import { appRouter } from './trpc';
@@ -18,7 +18,7 @@ const api = new Hono<{ Variables: HonoVariables; Bindings: Env }>()
.use(
'*',
cors({
origin: (_, c: HonoContext) => env.NEXT_PUBLIC_APP_URL,
origin: () => env.NEXT_PUBLIC_APP_URL,
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
}),
@@ -76,14 +76,14 @@ const app = new Hono<{ Variables: HonoVariables; Bindings: Env }>()
}),
);
export default class extends WorkerEntrypoint {
export default class extends WorkerEntrypoint<typeof env> {
fetch(request: Request): Response | Promise<Response> {
if (request.url.includes('/zero/durable-mailbox')) {
return routePartykitRequest(request, env as any, {
return routePartykitRequest(request, env as unknown as Record<string, unknown>, {
prefix: 'zero',
}) as Promise<Response>;
}
return app.fetch(request);
return app.fetch(request, this.env, this.ctx);
}
public async notifyUser({

View File

@@ -105,6 +105,28 @@ export const compose = activeConnectionProcedure
};
});
export const generateEmailSubject = activeConnectionProcedure
.input(
z.object({
message: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { activeConnection } = ctx;
const { message } = input;
const writingStyleMatrix = await getWritingStyleMatrixForConnectionId({
connectionId: activeConnection.id,
c: ctx.c,
});
const subject = await generateSubject(message, writingStyleMatrix?.style as WritingStyleMatrix);
return {
subject,
};
});
const MessagePrompt = ({
from,
to,
@@ -196,3 +218,44 @@ const EmailAssistantPrompt = ({
return parts.join('\n\n');
};
const generateSubject = async (message: string, styleProfile?: WritingStyleMatrix | null) => {
const parts: string[] = [];
parts.push('# Email Subject Generation Task');
if (styleProfile) {
parts.push('## Style Profile');
parts.push(`\`\`\`json
${JSON.stringify(styleProfile, null, 2)}
\`\`\``);
}
parts.push('## Email Content');
parts.push(escapeXml(message));
parts.push('');
parts.push(
'Generate a concise, clear subject line that summarizes the main point of the email. The subject should be professional and under 100 characters.',
);
const { text } = await generateText({
model: openai('gpt-4o'),
messages: [
{
role: 'system',
content:
'You are an email subject line generator. Generate a concise, clear subject line that summarizes the main point of the email. The subject should be professional and under 100 characters.',
},
{
role: 'user',
content: parts.join('\n\n'),
},
],
maxTokens: 50,
temperature: 0.3,
frequencyPenalty: 0.1,
presencePenalty: 0.1,
maxRetries: 1,
});
return text.trim();
};

View File

@@ -1,8 +1,9 @@
import { compose, generateEmailSubject } from './compose';
import { generateSearchQuery } from './search';
import { router } from '../../trpc';
import { compose } from './compose';
export const aiRouter = router({
generateSearchQuery: generateSearchQuery,
compose: compose,
generateEmailSubject: generateEmailSubject,
});