├── .cursor └── rules │ ├── cursor-rules.mdc │ ├── data-fetching.mdc │ ├── environment-variables.mdc │ ├── features │ ├── cleaner.mdc │ ├── knowledge.mdc │ └── reply-tracker.mdc │ ├── form-handling.mdc │ ├── get-api-route.mdc │ ├── gmail-api.mdc │ ├── hooks.mdc │ ├── index.mdc │ ├── installing-packages.mdc │ ├── llm-test.mdc │ ├── llm.mdc │ ├── logging.mdc │ ├── notes.mdc │ ├── page-structure.mdc │ ├── prisma.mdc │ ├── project-structure.mdc │ ├── server-actions.mdc │ ├── task-list.mdc │ ├── testing.mdc │ ├── ui-components.mdc │ └── utilities.mdc ├── .eslintrc.js ├── .github ├── screenshots │ ├── bulk-unsubscriber.png │ ├── email-assistant.png │ ├── email-client.png │ └── reply-zero.png └── workflows │ ├── build_and_publish_docker.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .nvmrc ├── .vscode ├── extensions.json ├── settings.json └── typescriptreact.code-snippets ├── ARCHITECTURE.md ├── CLA.md ├── LICENSE ├── README.md ├── apps ├── mcp-server │ ├── .env.example │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── unsubscriber │ ├── .env.example │ ├── README.md │ ├── package.json │ ├── src │ │ ├── env.ts │ │ ├── llm.ts │ │ ├── main.ts │ │ └── server.ts │ └── tsconfig.json └── web │ ├── .env.example │ ├── .eslintrc.json │ ├── CLAUDE.md │ ├── __tests__ │ ├── ai-categorize-senders.test.ts │ ├── ai-choose-args.test.ts │ ├── ai-choose-rule.test.ts │ ├── ai-create-group.test.ts │ ├── ai-detect-recurring-pattern.test.ts │ ├── ai-diff-rules.test.ts │ ├── ai-example-matches.test.ts │ ├── ai-extract-from-email-history.test.ts │ ├── ai-extract-knowledge.test.ts │ ├── ai-find-snippets.test.ts │ ├── ai-process-user-request.test.ts │ ├── ai-prompt-to-rules.test.ts │ ├── ai-rule-fix.test.ts │ ├── ai │ │ └── reply │ │ │ └── draft-with-knowledge.test.ts │ ├── helpers.ts │ └── writing-style.test.ts │ ├── app │ ├── (app) │ │ ├── (redirects) │ │ │ ├── assistant │ │ │ │ └── page.tsx │ │ │ ├── automation │ │ │ │ └── page.tsx │ │ │ ├── bulk-unsubscribe │ │ │ │ └── page.tsx │ │ │ ├── clean │ │ │ │ └── page.tsx │ │ │ ├── cold-email-blocker │ │ │ │ └── page.tsx │ │ │ ├── reply-zero │ │ │ │ └── page.tsx │ │ │ ├── settings │ │ │ │ └── page.tsx │ │ │ └── setup │ │ │ │ └── page.tsx │ │ ├── ErrorMessages.tsx │ │ ├── [emailAccountId] │ │ │ ├── PermissionsCheck.tsx │ │ │ ├── assess.tsx │ │ │ ├── assistant │ │ │ │ ├── AssistantOnboarding.tsx │ │ │ │ ├── AssistantTabs.tsx │ │ │ │ ├── BulkRunRules.tsx │ │ │ │ ├── ExecutedRulesTable.tsx │ │ │ │ ├── FixWithChat.tsx │ │ │ │ ├── History.tsx │ │ │ │ ├── Pending.tsx │ │ │ │ ├── PersonaDialog.tsx │ │ │ │ ├── Process.tsx │ │ │ │ ├── ProcessResultDisplay.tsx │ │ │ │ ├── ProcessRules.tsx │ │ │ │ ├── ProcessingPromptFileDialog.tsx │ │ │ │ ├── RuleForm.tsx │ │ │ │ ├── RuleTab.tsx │ │ │ │ ├── Rules.tsx │ │ │ │ ├── RulesPrompt.tsx │ │ │ │ ├── RulesSelect.tsx │ │ │ │ ├── SetDateDropdown.tsx │ │ │ │ ├── TestCustomEmailForm.tsx │ │ │ │ ├── consts.ts │ │ │ │ ├── create │ │ │ │ │ ├── examples.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── examples.ts │ │ │ │ ├── group │ │ │ │ │ ├── Groups.tsx │ │ │ │ │ ├── LearnedPatterns.tsx │ │ │ │ │ ├── ViewGroup.tsx │ │ │ │ │ └── [groupId] │ │ │ │ │ │ ├── examples │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── knowledge │ │ │ │ │ ├── KnowledgeBase.tsx │ │ │ │ │ └── KnowledgeForm.tsx │ │ │ │ ├── onboarding │ │ │ │ │ ├── CategoriesSetup.tsx │ │ │ │ │ ├── completed │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── draft-replies │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── rule │ │ │ │ │ ├── [ruleId] │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── examples │ │ │ │ │ │ ├── example-list.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── page.tsx │ │ │ │ │ └── create │ │ │ │ │ └── page.tsx │ │ │ ├── automation │ │ │ │ └── page.tsx │ │ │ ├── bulk-unsubscribe │ │ │ │ ├── ArchiveProgress.tsx │ │ │ │ ├── BulkActions.tsx │ │ │ │ ├── BulkUnsubscribe.tsx │ │ │ │ ├── BulkUnsubscribeDesktop.tsx │ │ │ │ ├── BulkUnsubscribeMobile.tsx │ │ │ │ ├── BulkUnsubscribeSection.tsx │ │ │ │ ├── BulkUnsubscribeSummary.tsx │ │ │ │ ├── SearchBar.tsx │ │ │ │ ├── ShortcutTooltip.tsx │ │ │ │ ├── common.tsx │ │ │ │ ├── hooks.ts │ │ │ │ ├── page.tsx │ │ │ │ └── types.ts │ │ │ ├── clean │ │ │ │ ├── ActionSelectionStep.tsx │ │ │ │ ├── CleanHistory.tsx │ │ │ │ ├── CleanInstructionsStep.tsx │ │ │ │ ├── CleanRun.tsx │ │ │ │ ├── CleanStats.tsx │ │ │ │ ├── ConfirmationStep.tsx │ │ │ │ ├── EmailFirehose.tsx │ │ │ │ ├── EmailFirehoseItem.tsx │ │ │ │ ├── IntroStep.tsx │ │ │ │ ├── PreviewBatch.tsx │ │ │ │ ├── TimeRangeStep.tsx │ │ │ │ ├── consts.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── history │ │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── onboarding │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── run │ │ │ │ │ └── page.tsx │ │ │ │ ├── types.ts │ │ │ │ ├── useEmailStream.ts │ │ │ │ ├── useSkipSettings.ts │ │ │ │ └── useStep.tsx │ │ │ ├── cold-email-blocker │ │ │ │ ├── ColdEmailList.tsx │ │ │ │ ├── ColdEmailPromptForm.tsx │ │ │ │ ├── ColdEmailRejected.tsx │ │ │ │ ├── ColdEmailSettings.tsx │ │ │ │ ├── ColdEmailTest.tsx │ │ │ │ ├── TestRules.tsx │ │ │ │ └── page.tsx │ │ │ ├── compose │ │ │ │ ├── ComposeEmailForm.tsx │ │ │ │ ├── ComposeEmailFormLazy.tsx │ │ │ │ └── page.tsx │ │ │ ├── debug │ │ │ │ ├── drafts │ │ │ │ │ └── page.tsx │ │ │ │ ├── learned │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── mail │ │ │ │ ├── BetaBanner.tsx │ │ │ │ └── page.tsx │ │ │ ├── no-reply │ │ │ │ └── page.tsx │ │ │ ├── permissions │ │ │ │ ├── consent │ │ │ │ │ └── page.tsx │ │ │ │ └── error │ │ │ │ │ └── page.tsx │ │ │ ├── reply-zero │ │ │ │ ├── AwaitingReply.tsx │ │ │ │ ├── EnableReplyTracker.tsx │ │ │ │ ├── NeedsAction.tsx │ │ │ │ ├── NeedsReply.tsx │ │ │ │ ├── ReplyTrackerEmails.tsx │ │ │ │ ├── Resolved.tsx │ │ │ │ ├── TimeRangeFilter.tsx │ │ │ │ ├── date-filter.ts │ │ │ │ ├── fetch-trackers.ts │ │ │ │ ├── onboarding │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── settings │ │ │ │ ├── AboutSectionForm.tsx │ │ │ │ ├── ApiKeysCreateForm.tsx │ │ │ │ ├── ApiKeysSection.tsx │ │ │ │ ├── DeleteSection.tsx │ │ │ │ ├── EmailUpdatesSection.tsx │ │ │ │ ├── LabelsSection.tsx │ │ │ │ ├── ModelSection.tsx │ │ │ │ ├── MultiAccountSection.tsx │ │ │ │ ├── ResetAnalyticsSection.tsx │ │ │ │ ├── SignatureSectionForm.tsx │ │ │ │ ├── WebhookGenerate.tsx │ │ │ │ ├── WebhookSection.tsx │ │ │ │ └── page.tsx │ │ │ ├── setup │ │ │ │ └── page.tsx │ │ │ ├── simple │ │ │ │ ├── SimpleList.tsx │ │ │ │ ├── SimpleModeOnboarding.tsx │ │ │ │ ├── SimpleProgress.tsx │ │ │ │ ├── SimpleProgressProvider.tsx │ │ │ │ ├── Summary.tsx │ │ │ │ ├── ViewMoreButton.tsx │ │ │ │ ├── categories.ts │ │ │ │ ├── completed │ │ │ │ │ ├── OpenMultipleGmailButton.tsx │ │ │ │ │ ├── ShareOnTwitterButton.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── smart-categories │ │ │ │ ├── CategorizeProgress.tsx │ │ │ │ ├── CategorizeWithAiButton.tsx │ │ │ │ ├── CreateCategoryButton.tsx │ │ │ │ ├── Uncategorized.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── setup │ │ │ │ │ ├── SetUpCategories.tsx │ │ │ │ │ ├── SmartCategoriesOnboarding.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── stats │ │ │ │ ├── ActionBar.tsx │ │ │ │ ├── CombinedStatsChart.tsx │ │ │ │ ├── DetailedStats.tsx │ │ │ │ ├── DetailedStatsFilter.tsx │ │ │ │ ├── EmailActionsAnalytics.tsx │ │ │ │ ├── EmailAnalytics.tsx │ │ │ │ ├── EmailsToIncludeFilter.tsx │ │ │ │ ├── LoadProgress.tsx │ │ │ │ ├── LoadStatsButton.tsx │ │ │ │ ├── NewsletterModal.tsx │ │ │ │ ├── Stats.tsx │ │ │ │ ├── StatsChart.tsx │ │ │ │ ├── StatsOnboarding.tsx │ │ │ │ ├── StatsSummary.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── params.ts │ │ │ │ └── useExpanded.tsx │ │ │ └── usage │ │ │ │ ├── page.tsx │ │ │ │ └── usage.tsx │ │ ├── accounts │ │ │ ├── AddAccount.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ ├── AdminUpgradeUserForm.tsx │ │ │ ├── AdminUserControls.tsx │ │ │ ├── page.tsx │ │ │ └── validation.tsx │ │ ├── early-access │ │ │ ├── EarlyAccessFeatures.tsx │ │ │ └── page.tsx │ │ ├── error.tsx │ │ ├── last-login.tsx │ │ ├── layout.tsx │ │ ├── license │ │ │ └── page.tsx │ │ ├── onboarding │ │ │ ├── OnboardingBulkUnsubscriber.tsx │ │ │ ├── OnboardingColdEmailBlocker.tsx │ │ │ ├── OnboardingEmailAssistant.tsx │ │ │ ├── OnboardingFinish.tsx │ │ │ ├── OnboardingNextButton.tsx │ │ │ ├── Steps.tsx │ │ │ └── page.tsx │ │ ├── premium │ │ │ ├── PremiumModal.tsx │ │ │ ├── Pricing.tsx │ │ │ ├── config.ts │ │ │ └── page.tsx │ │ └── sentry-identify.tsx │ ├── (landing) │ │ ├── components │ │ │ ├── TestAction.tsx │ │ │ ├── TestError.tsx │ │ │ ├── page.tsx │ │ │ └── test-action.ts │ │ ├── error.tsx │ │ ├── home │ │ │ ├── CTA.tsx │ │ │ ├── CTAButtons.tsx │ │ │ ├── FAQs.tsx │ │ │ ├── Features.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── Hero.tsx │ │ │ ├── HeroAB.tsx │ │ │ ├── LogoCloud.tsx │ │ │ ├── Privacy.tsx │ │ │ ├── SquaresPattern.tsx │ │ │ ├── Testimonials.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ ├── LoginForm.tsx │ │ │ ├── error │ │ │ │ ├── AutoLogOut.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── oss-friends │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── privacy │ │ │ ├── content.mdx │ │ │ ├── content.tsx │ │ │ └── page.tsx │ │ ├── terms │ │ │ ├── content.mdx │ │ │ ├── content.tsx │ │ │ └── page.tsx │ │ ├── thank-you │ │ │ └── page.tsx │ │ ├── welcome-upgrade │ │ │ ├── WelcomeUpgradeNav.tsx │ │ │ └── page.tsx │ │ └── welcome │ │ │ ├── form.tsx │ │ │ ├── page.tsx │ │ │ ├── sign-up-event.tsx │ │ │ ├── survey.ts │ │ │ └── utms.tsx │ ├── api │ │ ├── ai │ │ │ ├── analyze-sender-pattern │ │ │ │ ├── call-analyze-pattern-api.ts │ │ │ │ └── route.ts │ │ │ ├── compose-autocomplete │ │ │ │ ├── route.ts │ │ │ │ └── validation.ts │ │ │ ├── models │ │ │ │ └── route.ts │ │ │ └── summarise │ │ │ │ ├── controller.ts │ │ │ │ ├── route.ts │ │ │ │ └── validation.ts │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ ├── auth.ts │ │ │ │ └── route.ts │ │ ├── chat │ │ │ └── route.ts │ │ ├── chats │ │ │ ├── [chatId] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── clean │ │ │ ├── gmail │ │ │ │ └── route.ts │ │ │ ├── history │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── email-stream │ │ │ └── route.ts │ │ ├── google │ │ │ ├── contacts │ │ │ │ └── route.ts │ │ │ ├── labels │ │ │ │ ├── create │ │ │ │ │ └── controller.ts │ │ │ │ └── route.ts │ │ │ ├── linking │ │ │ │ ├── auth-url │ │ │ │ │ └── route.ts │ │ │ │ └── callback │ │ │ │ │ └── route.ts │ │ │ ├── messages │ │ │ │ ├── attachment │ │ │ │ │ └── route.ts │ │ │ │ ├── batch │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ └── validation.ts │ │ │ ├── threads │ │ │ │ ├── [id] │ │ │ │ │ └── route.ts │ │ │ │ ├── basic │ │ │ │ │ └── route.ts │ │ │ │ ├── batch │ │ │ │ │ └── route.ts │ │ │ │ ├── controller.ts │ │ │ │ ├── route.ts │ │ │ │ └── validation.ts │ │ │ ├── watch │ │ │ │ ├── all │ │ │ │ │ └── route.ts │ │ │ │ ├── controller.ts │ │ │ │ └── route.ts │ │ │ └── webhook │ │ │ │ ├── block-unsubscribed-emails.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── process-history-item.test.ts │ │ │ │ ├── process-history-item.ts │ │ │ │ ├── process-history.ts │ │ │ │ ├── route.ts │ │ │ │ └── types.ts │ │ ├── knowledge │ │ │ └── route.ts │ │ ├── lemon-squeezy │ │ │ └── webhook │ │ │ │ ├── route.ts │ │ │ │ └── types.ts │ │ ├── reply-tracker │ │ │ ├── disable-unused-auto-draft │ │ │ │ └── route.ts │ │ │ └── process-previous │ │ │ │ └── route.ts │ │ ├── resend │ │ │ └── summary │ │ │ │ ├── all │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── rules │ │ │ └── pending │ │ │ │ └── route.ts │ │ ├── stripe │ │ │ ├── generate-checkout │ │ │ │ └── route.ts │ │ │ ├── success │ │ │ │ └── route.ts │ │ │ └── webhook │ │ │ │ └── route.ts │ │ ├── unsubscribe │ │ │ └── route.ts │ │ ├── user │ │ │ ├── api-keys │ │ │ │ └── route.ts │ │ │ ├── categories │ │ │ │ └── route.ts │ │ │ ├── categorize │ │ │ │ └── senders │ │ │ │ │ ├── batch │ │ │ │ │ ├── handle-batch-validation.ts │ │ │ │ │ ├── handle-batch.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── simple │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── progress │ │ │ │ │ └── route.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── uncategorized │ │ │ │ │ ├── get-senders.ts │ │ │ │ │ ├── get-uncategorized-senders.ts │ │ │ │ │ └── route.ts │ │ │ ├── cold-email │ │ │ │ └── route.ts │ │ │ ├── complete-registration │ │ │ │ └── route.ts │ │ │ ├── draft-actions │ │ │ │ └── route.ts │ │ │ ├── email-account │ │ │ │ └── route.ts │ │ │ ├── email-accounts │ │ │ │ └── route.ts │ │ │ ├── group │ │ │ │ ├── [groupId] │ │ │ │ │ ├── items │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── messages │ │ │ │ │ │ ├── controller.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── rules │ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── labels │ │ │ │ └── route.ts │ │ │ ├── me │ │ │ │ └── route.ts │ │ │ ├── no-reply │ │ │ │ └── route.ts │ │ │ ├── planned │ │ │ │ ├── get-executed-rules.ts │ │ │ │ ├── history │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── rules │ │ │ │ ├── [id] │ │ │ │ │ ├── example │ │ │ │ │ │ ├── controller.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── prompt │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── settings │ │ │ │ └── multi-account │ │ │ │ │ ├── route.ts │ │ │ │ │ └── validation.ts │ │ │ └── stats │ │ │ │ ├── by-period │ │ │ │ └── route.ts │ │ │ │ ├── day │ │ │ │ └── route.ts │ │ │ │ ├── email-actions │ │ │ │ └── route.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── newsletters │ │ │ │ ├── helpers.ts │ │ │ │ ├── route.ts │ │ │ │ └── summary │ │ │ │ │ └── route.ts │ │ │ │ ├── recipients │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── sender-emails │ │ │ │ └── route.ts │ │ │ │ └── senders │ │ │ │ └── route.ts │ │ └── v1 │ │ │ ├── group │ │ │ └── [groupId] │ │ │ │ └── emails │ │ │ │ ├── route.ts │ │ │ │ └── validation.ts │ │ │ ├── helpers.ts │ │ │ ├── openapi │ │ │ └── route.ts │ │ │ └── reply-tracker │ │ │ ├── route.ts │ │ │ └── validation.ts │ ├── global-error.tsx │ ├── icon.png │ ├── layout.tsx │ ├── manifest.json │ ├── not-found.tsx │ ├── opengraph-image.png │ ├── startup-image.ts │ ├── sw.ts │ └── utm.tsx │ ├── components.json │ ├── components │ ├── AccountSwitcher.tsx │ ├── ActionButtons.tsx │ ├── ActionButtonsBulk.tsx │ ├── Alert.tsx │ ├── Badge.tsx │ ├── Banner.tsx │ ├── Button.tsx │ ├── ButtonGroup.tsx │ ├── ButtonList.tsx │ ├── ButtonListSurvey.tsx │ ├── CategoryBadge.tsx │ ├── CategorySelect.tsx │ ├── Celebration.tsx │ ├── Checkbox.tsx │ ├── ClientOnly.tsx │ ├── Combobox.tsx │ ├── CommandK.tsx │ ├── ConfirmDialog.tsx │ ├── Container.tsx │ ├── CopyInput.tsx │ ├── CrispChat.tsx │ ├── DatePickerWithRange.tsx │ ├── EmailCell.tsx │ ├── EmailMessageCell.tsx │ ├── EmailViewer.tsx │ ├── EnableFeatureCard.tsx │ ├── ErrorBoundary.tsx │ ├── ErrorDisplay.tsx │ ├── ErrorPage.tsx │ ├── ExpandableText.tsx │ ├── Form.tsx │ ├── GroupHeading.tsx │ ├── GroupedTable.tsx │ ├── HeroVideoDialog.tsx │ ├── HoverCard.tsx │ ├── Input.tsx │ ├── LabelsSubMenu.tsx │ ├── LegalPage.tsx │ ├── Linkify.tsx │ ├── Loading.tsx │ ├── LoadingContent.tsx │ ├── Logo.tsx │ ├── MultiSelectFilter.tsx │ ├── MuxVideo.tsx │ ├── NavBottom.tsx │ ├── Notice.tsx │ ├── OnboardingModal.tsx │ ├── Panel.tsx │ ├── PlanBadge.tsx │ ├── PremiumAlert.tsx │ ├── ProfileImage.tsx │ ├── ProgressPanel.tsx │ ├── RadioGroup.tsx │ ├── SearchForm.tsx │ ├── Select.tsx │ ├── SideNav.tsx │ ├── SideNavMenu.tsx │ ├── SideNavWithTopNav.tsx │ ├── SlideOverSheet.tsx │ ├── StatsCards.tsx │ ├── TablePagination.tsx │ ├── Tabs.tsx │ ├── TabsToolbar.tsx │ ├── Tag.tsx │ ├── Toast.tsx │ ├── Toggle.tsx │ ├── TokenCheck.tsx │ ├── Tooltip.tsx │ ├── TooltipExplanation.tsx │ ├── TopBar.tsx │ ├── TopNav.tsx │ ├── TopSection.tsx │ ├── Typography.tsx │ ├── ViewEmailButton.tsx │ ├── YouTubeVideo.tsx │ ├── assistant-chat │ │ ├── ChatContext.tsx │ │ ├── chat.tsx │ │ ├── data-stream-handler.tsx │ │ ├── examples-dialog.tsx │ │ ├── icons.tsx │ │ ├── markdown.tsx │ │ ├── message-editor.tsx │ │ ├── message-reasoning.tsx │ │ ├── message.tsx │ │ ├── messages.tsx │ │ ├── multimodal-input.tsx │ │ ├── overview.tsx │ │ ├── submit-button.tsx │ │ ├── suggested-actions.tsx │ │ ├── tools.tsx │ │ ├── types.ts │ │ └── use-scroll-to-bottom.ts │ ├── charts │ │ └── BarList.tsx │ ├── editor │ │ ├── Tiptap.tsx │ │ └── extensions.ts │ ├── email-list │ │ ├── EmailAttachments.tsx │ │ ├── EmailContents.tsx │ │ ├── EmailDate.tsx │ │ ├── EmailDetails.tsx │ │ ├── EmailList.tsx │ │ ├── EmailListItem.tsx │ │ ├── EmailMessage.tsx │ │ ├── EmailPanel.tsx │ │ ├── EmailThread.tsx │ │ ├── PlanActions.tsx │ │ ├── PlanExplanation.tsx │ │ └── types.ts │ ├── layouts │ │ ├── BasicLayout.tsx │ │ └── BlogLayout.tsx │ ├── theme-provider.tsx │ ├── theme-toggle.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ └── tooltip.tsx │ ├── ee │ ├── LICENSE.md │ └── billing │ │ ├── lemon │ │ └── index.ts │ │ └── stripe │ │ ├── index.ts │ │ ├── loops-events.test.ts │ │ ├── loops-events.ts │ │ └── sync-stripe.ts │ ├── entrypoint.sh │ ├── env.ts │ ├── hooks │ ├── use-mobile.tsx │ ├── useAccounts.ts │ ├── useApiKeys.ts │ ├── useAssistantNavigation.ts │ ├── useCategories.ts │ ├── useChatMessages.ts │ ├── useChats.ts │ ├── useDisplayedEmail.ts │ ├── useEmailAccountFull.ts │ ├── useFeatureFlags.ts │ ├── useInterval.ts │ ├── useLabels.ts │ ├── useMessagesBatch.ts │ ├── useModal.tsx │ ├── useModifierKey.ts │ ├── useRule.tsx │ ├── useRules.tsx │ ├── useTableKeyboardNavigation.ts │ ├── useThread.ts │ ├── useThreads.ts │ ├── useThreadsByIds.ts │ ├── useToggleSelect.ts │ └── useUser.ts │ ├── instrumentation-client.ts │ ├── instrumentation.ts │ ├── mdx-components.tsx │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.js │ ├── prettier.config.js │ ├── prisma │ ├── migrations │ │ ├── 20230730073019_init │ │ │ └── migration.sql │ │ ├── 20230804105315_rule_name │ │ │ └── migration.sql │ │ ├── 20230804140051_cascade_delete_executed_rule │ │ │ └── migration.sql │ │ ├── 20230913192346_lemon_squeezy │ │ │ └── migration.sql │ │ ├── 20230919082654_ai_model │ │ │ └── migration.sql │ │ ├── 20231027022923_unique_account │ │ │ └── migration.sql │ │ ├── 20231112182812_onboarding_flag │ │ │ └── migration.sql │ │ ├── 20231207000800_settings │ │ │ └── migration.sql │ │ ├── 20231213064514_newsletter_status │ │ │ └── migration.sql │ │ ├── 20231219225431_unsubscribe_credits │ │ │ └── migration.sql │ │ ├── 20231229221011_remove_summarize_action │ │ │ └── migration.sql │ │ ├── 20240101222135_cold_email_blocker │ │ │ └── migration.sql │ │ ├── 20240116235134_shared_premium │ │ │ └── migration.sql │ │ ├── 20240122015840_remove_old_fields │ │ │ └── migration.sql │ │ ├── 20240131044439_onboarding_answers │ │ │ └── migration.sql │ │ ├── 20240208223501_ai_threads │ │ │ └── migration.sql │ │ ├── 20240317133130_ai_provider │ │ │ └── migration.sql │ │ ├── 20240319131634_executed_actions │ │ │ └── migration.sql │ │ ├── 20240319151146_unique_executed_rule │ │ │ └── migration.sql │ │ ├── 20240319151147_migrate_actions │ │ │ └── migration.sql │ │ ├── 20240319151148_delete_deprecated_fields │ │ │ └── migration.sql │ │ ├── 20240322094912_behaviour_profile │ │ │ └── migration.sql │ │ ├── 20240323230604_last_login │ │ │ └── migration.sql │ │ ├── 20240323230633_utm │ │ │ └── migration.sql │ │ ├── 20240418150351_license_key │ │ │ └── migration.sql │ │ ├── 20240424111051_groups │ │ │ └── migration.sql │ │ ├── 20240426150851_rule_type │ │ │ └── migration.sql │ │ ├── 20240507211259_premium_admin │ │ │ └── migration.sql │ │ ├── 20240509085010_automate_default_off │ │ │ └── migration.sql │ │ ├── 20240513103627_mark_not_cold_email │ │ │ └── migration.sql │ │ ├── 20240516112326_remove_newsletter_cold_email │ │ │ └── migration.sql │ │ ├── 20240516112350_cold_email_model │ │ │ └── migration.sql │ │ ├── 20240528083708_summary_email │ │ │ └── migration.sql │ │ ├── 20240528181840_premium_basic │ │ │ └── migration.sql │ │ ├── 20240624075134_argument_prompt │ │ │ └── migration.sql │ │ ├── 20240728084326_api_key │ │ │ └── migration.sql │ │ ├── 20240730122310_copilot_tier │ │ │ └── migration.sql │ │ ├── 20240820220244_ai_api_key │ │ │ └── migration.sql │ │ ├── 20240917021039_rule_prompt │ │ │ └── migration.sql │ │ ├── 20240917232302_disable_rule │ │ │ └── migration.sql │ │ ├── 20241008234839_error_messages │ │ │ └── migration.sql │ │ ├── 20241020163727_app_onboarding │ │ │ └── migration.sql │ │ ├── 20241023204900_category │ │ │ └── migration.sql │ │ ├── 20241027173153_category_filter │ │ │ └── migration.sql │ │ ├── 20241031212440_auto_categorize_senders │ │ │ └── migration.sql │ │ ├── 20241107151035_applying_execute_status │ │ │ └── migration.sql │ │ ├── 20241107152409_remove_default_executed_status │ │ │ └── migration.sql │ │ ├── 20241119163400_categorize_date_range │ │ │ └── migration.sql │ │ ├── 20241125052523_remove_categorized_time │ │ │ └── migration.sql │ │ ├── 20241128034952_migrate_prompt_fields │ │ │ └── migration.sql │ │ ├── 20241216093030_upgrade_to_v6 │ │ │ └── migration.sql │ │ ├── 20241218123405_multi_conditions │ │ │ └── migration.sql │ │ ├── 20241219122254_rename_to_conditional_operator │ │ │ └── migration.sql │ │ ├── 20241219190656_deprecate_rule_type │ │ │ └── migration.sql │ │ ├── 20241219192522_optional_deprecated_rule_type │ │ │ └── migration.sql │ │ ├── 20241230180925_call_webhook_action │ │ │ └── migration.sql │ │ ├── 20241230204311_action_webhook_url │ │ │ └── migration.sql │ │ ├── 20250112081255_pending_invite │ │ │ └── migration.sql │ │ ├── 20250116101856_mark_read_action │ │ │ └── migration.sql │ │ ├── 20250128141602_cascade_delete_group │ │ │ └── migration.sql │ │ ├── 20250130215802_read_cold_emails │ │ │ └── migration.sql │ │ ├── 20250202092329_reply_tracker │ │ │ └── migration.sql │ │ ├── 20250202154501_remove_deprecated_action │ │ │ └── migration.sql │ │ ├── 20250203174037_reply_tracker_sent_at │ │ │ └── migration.sql │ │ ├── 20250204162638_email_token │ │ │ └── migration.sql │ │ ├── 20250204191020_remove_email_token_action │ │ │ └── migration.sql │ │ ├── 20250209113928_non_null_email │ │ │ └── migration.sql │ │ ├── 20250210224905_summary_indexes │ │ │ └── migration.sql │ │ ├── 20250210225300_tracker_indexes │ │ │ └── migration.sql │ │ ├── 20250212125908_signature │ │ │ └── migration.sql │ │ ├── 20250223190244_draft_replies │ │ │ └── migration.sql │ │ ├── 20250227135610_payments │ │ │ └── migration.sql │ │ ├── 20250227135758_processor_type_enum │ │ │ └── migration.sql │ │ ├── 20250227142620_payment_tax │ │ │ └── migration.sql │ │ ├── 20250227144751_remove_default_timestamps_from_payment │ │ │ └── migration.sql │ │ ├── 20250227173229_remove_prompt_history │ │ │ └── migration.sql │ │ ├── 20250309095123_cleaner │ │ │ └── migration.sql │ │ ├── 20250311110807_job_details │ │ │ └── migration.sql │ │ ├── 20250312172635_skips │ │ │ └── migration.sql │ │ ├── 20250316155443_email_message │ │ │ └── migration.sql │ │ ├── 20250316155944_remove_size_estimate │ │ │ └── migration.sql │ │ ├── 20250316201459_remove_to_domain │ │ │ └── migration.sql │ │ ├── 20250324221721_skip_conversations │ │ │ └── migration.sql │ │ ├── 20250324222007_skipconversation │ │ │ └── migration.sql │ │ ├── 20250403104153_unique_knowledge_title │ │ │ └── migration.sql │ │ ├── 20250406111823_track_thread_action │ │ │ └── migration.sql │ │ ├── 20250406111915_migrate_track_replies_to_actions │ │ │ └── migration.sql │ │ ├── 20250408111051_newsletter_learned_patterns │ │ │ └── migration.sql │ │ ├── 20250410110949_remove_deprecated │ │ │ └── migration.sql │ │ ├── 20250410111325_remove_deprecated_onboarding │ │ │ └── migration.sql │ │ ├── 20250410132704_remove_rule_type │ │ │ └── migration.sql │ │ ├── 20250414091625_rule_system_type │ │ │ └── migration.sql │ │ ├── 20250414103126_migrate_system_rule_types │ │ │ └── migration.sql │ │ ├── 20250415162053_draft_score │ │ │ └── migration.sql │ │ ├── 20250417135524_writing_style │ │ │ └── migration.sql │ │ ├── 20250420131728_email_account_settings │ │ │ └── migration.sql │ │ ├── 20250429192105_mutli_email │ │ │ └── migration.sql │ │ ├── 20250430094808_remove_cleanupjob_email │ │ │ └── migration.sql │ │ ├── 20250502155551_lemon_subscription_status │ │ │ └── migration.sql │ │ ├── 20250504061506_drop_old_userids │ │ │ └── migration.sql │ │ ├── 20250506025728_stripe │ │ │ └── migration.sql │ │ ├── 20250509151934_remove_deprecated │ │ │ └── migration.sql │ │ ├── 20250519090915_add_exclude_to_group_item │ │ │ └── migration.sql │ │ ├── 20250521104911_chat │ │ │ └── migration.sql │ │ ├── 20250521132820_message_parts │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── providers │ ├── AppProviders.tsx │ ├── ComposeModalProvider.tsx │ ├── EmailAccountProvider.tsx │ ├── GlobalProviders.tsx │ ├── GmailProvider.tsx │ ├── PostHogProvider.tsx │ ├── SWRProvider.tsx │ ├── SessionProvider.tsx │ └── StatLoaderProvider.tsx │ ├── public │ ├── icons │ │ ├── icon-192x192.png │ │ └── icon-512x512.png │ ├── images │ │ ├── assistant │ │ │ ├── fix.png │ │ │ ├── process.png │ │ │ ├── rule-edit.png │ │ │ └── rules.png │ │ ├── blog │ │ │ ├── elie-profile.jpg │ │ │ ├── inbox-zero-growth.png │ │ │ ├── messy-vs-clean-inbox.png │ │ │ └── ricardo-batista-profile.png │ │ ├── google.svg │ │ ├── home │ │ │ ├── ai-email-assistant.png │ │ │ ├── bulk-unsubscriber.png │ │ │ ├── cold-email-blocker.png │ │ │ ├── email-analytics.png │ │ │ ├── mail.png │ │ │ ├── product-hunt-badge.svg │ │ │ ├── realtor-gmail.png │ │ │ ├── reply-zero.png │ │ │ └── testimonials │ │ │ │ └── steve-rad.png │ │ ├── illustrations │ │ │ ├── business-success-chart.svg │ │ │ ├── calling-help.svg │ │ │ ├── communication.svg │ │ │ ├── falling.svg │ │ │ └── home-office.svg │ │ ├── logos │ │ │ ├── bytedance.svg │ │ │ ├── doac.svg │ │ │ ├── joco.svg │ │ │ ├── netflix.svg │ │ │ ├── resend.svg │ │ │ └── zendesk.svg │ │ ├── reach-inbox-zero.png │ │ └── testimonials │ │ │ ├── joseph-gonzalez-iFgRcqHznqg-unsplash.jpg │ │ │ └── midas-hofstra-a6PMA5JEmWE-unsplash.jpg │ └── splash_screens │ │ ├── 10.2__iPad_landscape.png │ │ ├── 10.2__iPad_portrait.png │ │ ├── 10.5__iPad_Air_landscape.png │ │ ├── 10.5__iPad_Air_portrait.png │ │ ├── 10.9__iPad_Air_landscape.png │ │ ├── 10.9__iPad_Air_portrait.png │ │ ├── 11__iPad_Pro_M4_landscape.png │ │ ├── 11__iPad_Pro_M4_portrait.png │ │ ├── 11__iPad_Pro__10.5__iPad_Pro_landscape.png │ │ ├── 11__iPad_Pro__10.5__iPad_Pro_portrait.png │ │ ├── 12.9__iPad_Pro_landscape.png │ │ ├── 12.9__iPad_Pro_portrait.png │ │ ├── 13__iPad_Pro_M4_landscape.png │ │ ├── 13__iPad_Pro_M4_portrait.png │ │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png │ │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png │ │ ├── 8.3__iPad_Mini_landscape.png │ │ ├── 8.3__iPad_Mini_portrait.png │ │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png │ │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png │ │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png │ │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png │ │ ├── iPhone_11__iPhone_XR_landscape.png │ │ ├── iPhone_11__iPhone_XR_portrait.png │ │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png │ │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png │ │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png │ │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png │ │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png │ │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png │ │ ├── iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png │ │ ├── iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png │ │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png │ │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png │ │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png │ │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png │ │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png │ │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png │ │ └── icon.png │ ├── sanity.cli.ts │ ├── scripts │ ├── addUsersToResend.ts │ ├── listRedisUsage.ts │ └── listSubQuantitiesLemon.ts │ ├── store │ ├── QueueInitializer.tsx │ ├── ai-categorize-sender-queue.ts │ ├── ai-queue.ts │ ├── archive-queue.ts │ ├── archive-sender-queue.ts │ ├── email.ts │ └── index.ts │ ├── styles │ ├── CalSans-SemiBold.woff2 │ └── globals.css │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── types │ └── gmail-api-parse-message.d.ts │ ├── utils │ ├── __mocks__ │ │ └── prisma.ts │ ├── account.ts │ ├── action-item.ts │ ├── actions │ │ ├── admin.ts │ │ ├── ai-rule.ts │ │ ├── ai-rule.validation.ts │ │ ├── api-key.ts │ │ ├── api-key.validation.ts │ │ ├── assess.ts │ │ ├── categorize.ts │ │ ├── categorize.validation.ts │ │ ├── clean.ts │ │ ├── clean.validation.ts │ │ ├── client.ts │ │ ├── cold-email.ts │ │ ├── cold-email.validation.ts │ │ ├── error-messages.ts │ │ ├── generate-reply.ts │ │ ├── generate-reply.validation.ts │ │ ├── group.ts │ │ ├── group.validation.ts │ │ ├── knowledge.ts │ │ ├── knowledge.validation.ts │ │ ├── mail.ts │ │ ├── middleware.ts │ │ ├── permissions.ts │ │ ├── premium.ts │ │ ├── premium.validation.ts │ │ ├── reply-tracking.ts │ │ ├── rule.ts │ │ ├── rule.validation.ts │ │ ├── safe-action.ts │ │ ├── settings.ts │ │ ├── settings.validation.ts │ │ ├── stats.ts │ │ ├── unsubscriber.ts │ │ ├── unsubscriber.validation.ts │ │ ├── user.ts │ │ ├── webhook.ts │ │ └── whitelist.ts │ ├── admin.test.ts │ ├── admin.ts │ ├── ai │ │ ├── actions.ts │ │ ├── assistant │ │ │ ├── chat.ts │ │ │ └── process-user-request.ts │ │ ├── categorize-sender │ │ │ ├── ai-categorize-senders.ts │ │ │ ├── ai-categorize-single-sender.ts │ │ │ └── format-categories.ts │ │ ├── choose-rule │ │ │ ├── NOTES.md │ │ │ ├── ai-choose-args.test.ts │ │ │ ├── ai-choose-args.ts │ │ │ ├── ai-choose-rule.ts │ │ │ ├── ai-detect-recurring-pattern.ts │ │ │ ├── choose-args.ts │ │ │ ├── draft-management.ts │ │ │ ├── execute.ts │ │ │ ├── match-rules.test.ts │ │ │ ├── match-rules.ts │ │ │ ├── run-rules.ts │ │ │ └── types.ts │ │ ├── clean │ │ │ ├── ai-clean-select-labels.ts │ │ │ └── ai-clean.ts │ │ ├── example-matches │ │ │ └── find-example-matches.ts │ │ ├── group │ │ │ ├── create-group.ts │ │ │ ├── find-newsletters.test.ts │ │ │ ├── find-newsletters.ts │ │ │ ├── find-receipts.test.ts │ │ │ └── find-receipts.ts │ │ ├── knowledge │ │ │ ├── extract-from-email-history.ts │ │ │ ├── extract.ts │ │ │ └── writing-style.ts │ │ ├── reply │ │ │ ├── check-if-needs-reply.ts │ │ │ ├── draft-with-knowledge.ts │ │ │ └── generate-nudge.ts │ │ ├── rule │ │ │ ├── create-prompt-from-rule.test.ts │ │ │ ├── create-prompt-from-rule.ts │ │ │ ├── create-rule-schema.ts │ │ │ ├── create-rule.ts │ │ │ ├── diff-rules.ts │ │ │ ├── find-existing-rules.ts │ │ │ ├── generate-prompt-on-delete-rule.ts │ │ │ ├── generate-prompt-on-update-rule.ts │ │ │ ├── generate-rules-prompt.ts │ │ │ ├── prompt-to-rules.ts │ │ │ └── rule-fix.ts │ │ ├── snippets │ │ │ └── find-snippets.ts │ │ └── types.ts │ ├── api-auth.test.ts │ ├── api-auth.ts │ ├── api-key.ts │ ├── assess.ts │ ├── assistant │ │ ├── is-assistant-email.test.ts │ │ ├── is-assistant-email.ts │ │ └── process-assistant-email.ts │ ├── auth.ts │ ├── braintrust.ts │ ├── categories.ts │ ├── categorize │ │ └── senders │ │ │ └── categorize.ts │ ├── category.server.ts │ ├── celebration.ts │ ├── cold-email │ │ ├── is-cold-email.test.ts │ │ ├── is-cold-email.ts │ │ └── prompt.ts │ ├── colors.ts │ ├── condition.ts │ ├── config.ts │ ├── cookies.ts │ ├── cron.test.ts │ ├── cron.ts │ ├── date.ts │ ├── email.test.ts │ ├── email.ts │ ├── encryption.test.ts │ ├── encryption.ts │ ├── error-messages │ │ └── index.ts │ ├── error.server.ts │ ├── error.ts │ ├── fb.ts │ ├── fetch.ts │ ├── filter-ignored-senders.ts │ ├── get-email-from-message.ts │ ├── gmail │ │ ├── attachment.ts │ │ ├── batch.ts │ │ ├── client.ts │ │ ├── constants.ts │ │ ├── contact.ts │ │ ├── decode.ts │ │ ├── draft.ts │ │ ├── filter.ts │ │ ├── forward.test.ts │ │ ├── forward.ts │ │ ├── history.ts │ │ ├── label.ts │ │ ├── mail.ts │ │ ├── message.ts │ │ ├── permissions.ts │ │ ├── reply.test.ts │ │ ├── reply.ts │ │ ├── retry.ts │ │ ├── scopes.ts │ │ ├── settings.ts │ │ ├── signature.test.ts │ │ ├── signature.ts │ │ ├── snippet.test.ts │ │ ├── snippet.ts │ │ ├── spam.ts │ │ ├── thread.ts │ │ ├── trash.ts │ │ └── watch.ts │ ├── group │ │ ├── find-matching-group.test.ts │ │ ├── find-matching-group.ts │ │ └── group-item.ts │ ├── gtm.ts │ ├── index.ts │ ├── internal-api.ts │ ├── json.ts │ ├── label.ts │ ├── llms │ │ ├── config.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── model.test.ts │ │ ├── model.ts │ │ └── types.ts │ ├── logger.ts │ ├── mail.ts │ ├── middleware.test.ts │ ├── middleware.ts │ ├── parse │ │ ├── calender-event.test.ts │ │ ├── calender-event.ts │ │ ├── cta.ts │ │ ├── extract-reply.client.test.ts │ │ ├── extract-reply.client.ts │ │ ├── parseHtml.client.ts │ │ ├── parseHtml.server.ts │ │ └── unsubscribe.ts │ ├── path.ts │ ├── posthog.ts │ ├── premium │ │ ├── check-and-redirect-for-upgrade.tsx │ │ ├── create-premium.ts │ │ ├── index.ts │ │ └── server.ts │ ├── prisma-extensions.ts │ ├── prisma.ts │ ├── queue │ │ ├── ai-queue.ts │ │ ├── email-action-queue.ts │ │ └── email-actions.ts │ ├── redis │ │ ├── account-validation.ts │ │ ├── categorization-progress.ts │ │ ├── category.ts │ │ ├── clean.ts │ │ ├── clean.types.ts │ │ ├── index.ts │ │ ├── label.ts │ │ ├── message-processing.ts │ │ ├── reply-tracker-analyzing.ts │ │ ├── reply.ts │ │ ├── subscriber.ts │ │ ├── summary.ts │ │ └── usage.ts │ ├── reply-tracker │ │ ├── check-previous-emails.ts │ │ ├── check-sender-reply-history.ts │ │ ├── consts.ts │ │ ├── draft-tracking.ts │ │ ├── enable.ts │ │ ├── generate-draft.ts │ │ ├── inbound.ts │ │ ├── label.ts │ │ └── outbound.ts │ ├── risk.test.ts │ ├── risk.ts │ ├── rule │ │ ├── consts.ts │ │ ├── learned-patterns.ts │ │ ├── prompt-file.ts │ │ └── rule.ts │ ├── scripts │ │ └── lemon.tsx │ ├── sender.ts │ ├── similarity-score.test.ts │ ├── similarity-score.ts │ ├── size.ts │ ├── sleep.ts │ ├── stats.ts │ ├── string.test.ts │ ├── string.ts │ ├── stringify-email.test.ts │ ├── stringify-email.ts │ ├── swr.ts │ ├── template.ts │ ├── text.ts │ ├── thread.ts │ ├── types.ts │ ├── types │ │ └── mail.ts │ ├── unsubscribe.ts │ ├── upstash │ │ ├── categorize-senders.ts │ │ └── index.ts │ ├── url.ts │ ├── usage.ts │ ├── user.ts │ ├── user │ │ ├── delete.ts │ │ ├── get.ts │ │ └── validate.ts │ ├── webhook.ts │ └── zod.ts │ └── vitest.config.mts ├── biome.json ├── clone-marketing.sh ├── docker-compose.yml ├── docker ├── Dockerfile.prod └── Dockerfile.web ├── package.json ├── packages ├── eslint-config │ ├── README.md │ ├── library.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── loops │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── loops.ts │ └── tsconfig.json ├── resend │ ├── README.md │ ├── emails │ │ └── summary.tsx │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── contacts.ts │ │ ├── index.ts │ │ └── send.tsx │ └── tsconfig.json ├── tinybird-ai-analytics │ ├── README.md │ ├── datasources │ │ └── aiCall.datasource │ ├── package.json │ ├── pipes │ │ └── aiCalls.pipe │ ├── src │ │ ├── client.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ └── publish.ts │ └── tsconfig.json ├── tinybird │ ├── README.md │ ├── datasources │ │ ├── email.datasource │ │ ├── email_action.datasource │ │ └── last_and_oldest_emails_mv.datasource │ ├── package.json │ ├── pipes │ │ └── get_email_actions_by_period.pipe │ ├── src │ │ ├── client.ts │ │ ├── delete.ts │ │ ├── index.ts │ │ ├── publish.ts │ │ └── query.ts │ └── tsconfig.json └── tsconfig │ ├── base.json │ ├── nextjs.json │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── turbo.json ├── vercel.json ├── version.txt └── video-thumbnail.png /.cursor/rules/installing-packages.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to install packages 3 | globs: 4 | alwaysApply: false 5 | --- 6 | - Use `pnpm`. 7 | - Don't install in root. Install in `apps/web`: 8 | 9 | ```sh 10 | cd apps/web 11 | pnpm add ... 12 | ``` 13 | -------------------------------------------------------------------------------- /.cursor/rules/logging.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to do backend logging 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Logging 7 | 8 | We use `createScopedLogger` to do logging: 9 | 10 | ```typescript 11 | import { createScopedLogger } from "@/utils/logger"; 12 | 13 | const logger = createScopedLogger("action/rules"); 14 | 15 | logger.log("Created rule", { userId }); 16 | ``` 17 | 18 | Typically this will be added at the top of a file. 19 | If we have a large function that reuses multiple variables we can do this within a function: 20 | 21 | ```typescript 22 | const logger = createScopedLogger("action/rules").with({ userId: user.id }); 23 | 24 | // Can now call without passing userId: 25 | logger.log("Created rule"); 26 | ``` 27 | 28 | Don't use `.with()` for a global logger. Only use within a specific function. -------------------------------------------------------------------------------- /.cursor/rules/notes.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | Do not try and run the project via `dev` or `build` command unless I explicitly ask you to. -------------------------------------------------------------------------------- /.cursor/rules/page-structure.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Page structure 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Page Structure 7 | 8 | - Create new pages at: `apps/web/app/(app)/PAGE_NAME/page.tsx` 9 | - Components for the page are either put in `page.tsx`, or in the `apps/web/app/(app)/PAGE_NAME` folder 10 | - Pages are Server components so you can load data into them directly 11 | - If we're in a deeply nested component we will use `swr` to fetch via API 12 | - If you need to use `onClick` in a component, that component is a client component and file must start with `use client` -------------------------------------------------------------------------------- /.cursor/rules/prisma.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to use Prisma 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Prisma Usage 7 | 8 | We use PostgreSQL. 9 | 10 | This is how we import prisma in the project: 11 | 12 | ```typescript 13 | import prisma from "@/utils/prisma"; 14 | ``` 15 | 16 | The prisma file is located at: `apps/web/prisma/schema.prisma`. -------------------------------------------------------------------------------- /.cursor/rules/utilities.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Util functions 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Utility Functions 7 | 8 | - Use lodash utilities for common operations (arrays, objects, strings) 9 | - Import specific lodash functions to minimize bundle size: 10 | ```typescript 11 | import groupBy from "lodash/groupBy"; 12 | ``` 13 | - Create utility functions in `utils/` folder for reusable logic 14 | - The `utils` folder also contains core app logic such as Next.js Server Actions and Gmail API requests. -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // This configuration only applies to the package manager root. 2 | /** @type {import("eslint").Linter.Config} */ 3 | module.exports = { 4 | ignorePatterns: ["apps/**", "packages/**"], 5 | extends: ["@inboxzero/eslint-config/library.js"], 6 | parser: "@typescript-eslint/parser", 7 | parserOptions: { 8 | project: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/screenshots/bulk-unsubscriber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/.github/screenshots/bulk-unsubscriber.png -------------------------------------------------------------------------------- /.github/screenshots/email-assistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/.github/screenshots/email-assistant.png -------------------------------------------------------------------------------- /.github/screenshots/email-client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/.github/screenshots/email-client.png -------------------------------------------------------------------------------- /.github/screenshots/reply-zero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/.github/screenshots/reply-zero.png -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | # pnpm run lint 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | # public-hoist-pattern[]=*prisma* 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.15.1 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "austenc.tailwind-docs", 5 | "prisma.prisma", 6 | "formulahendry.auto-rename-tag", 7 | "wmaurer.change-case", 8 | "mikestead.dotenv", 9 | "github.vscode-pull-request-github", 10 | "cardinal90.multi-cursor-case-preserve", 11 | "chakrounanas.turbo-console-log", 12 | "unifiedjs.vscode-mdx" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.importModuleSpecifier": "non-relative", 3 | "[typescript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[typescriptreact]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/mcp-server/.env.example: -------------------------------------------------------------------------------- 1 | API_BASE=http://localhost:3000/api/v1 2 | API_KEY=your_api_key_here 3 | -------------------------------------------------------------------------------- /apps/mcp-server/.gitignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /apps/mcp-server/README.md: -------------------------------------------------------------------------------- 1 | # Inbox Zero MCP Server 2 | 3 | An MCP server to manage your inbox efficiently. Use it within Cursor, Windsurf, or Claude desktop to interact with your Inbox Zero personal assistant. 4 | 5 | ## Run it locally 6 | 7 | From this directory: 8 | 9 | ``` 10 | pnpm run build 11 | pnpm start 12 | ``` 13 | 14 | Then use the MCP at path `apps/mcp-server/build/index.js` in Cursor or Claude Desktop. Note, use the full path. 15 | 16 | ATM, you should replace the empty string with your API key (PRs welcome to improve this). You can get your API key from the `/settings` page in the web app: 17 | 18 | ```js 19 | const API_KEY = process.env.API_KEY || ""; 20 | ``` 21 | -------------------------------------------------------------------------------- /apps/mcp-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inbox-zero/mcp-server", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "bin": { 7 | "inbox-zero-ai": "./build/index.js" 8 | }, 9 | "scripts": { 10 | "start": "node build/index.js", 11 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"" 12 | }, 13 | "files": [ 14 | "build" 15 | ], 16 | "dependencies": { 17 | "@modelcontextprotocol/sdk": "1.12.1", 18 | "zod": "3.25.46" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "22.15.29", 22 | "typescript": "5.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/mcp-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/unsubscriber/.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_GENERATIVE_AI_API_KEY= 2 | # If required, set CORS_ORIGIN to allow requests from your frontend 3 | CORS_ORIGIN="http://localhost:3000" 4 | NODE_ENV="development" -------------------------------------------------------------------------------- /apps/unsubscriber/src/llm.ts: -------------------------------------------------------------------------------- 1 | import { google } from "@ai-sdk/google"; 2 | import { openai } from "@ai-sdk/openai"; 3 | import { anthropic } from "@ai-sdk/anthropic"; 4 | import { bedrock } from "@ai-sdk/amazon-bedrock"; 5 | 6 | type LLMProvider = "google" | "openai" | "anthropic" | "bedrock"; 7 | 8 | export function getModel(provider: LLMProvider) { 9 | switch (provider) { 10 | case "google": 11 | return google("gemini-1.5-flash"); 12 | case "openai": 13 | return openai("gpt-4o-mini"); 14 | case "anthropic": 15 | return anthropic("claude-3-7-sonnet-20250219"); 16 | case "bedrock": 17 | return bedrock("anthropic.claude-3-7-sonnet-20250219-v1:0"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/unsubscriber/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "es2015", 5 | "moduleResolution": "bundler", 6 | "lib": ["es2018", "dom"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "outDir": "./dist", 12 | "rootDir": "./src" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@inboxzero/eslint-config/next.js"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "project": true 7 | }, 8 | "rules": { 9 | "no-console": "off" 10 | }, 11 | "overrides": [ 12 | { 13 | "files": ["**/*.test.ts", "**/*.test.tsx", "**/__tests__/**/*", "**/*.tsx"], 14 | "rules": { 15 | "no-console": "off" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/__tests__/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { EmailAccountWithAI } from "@/utils/llms/types"; 2 | import type { EmailForLLM } from "@/utils/types"; 3 | 4 | export function getEmailAccount(): EmailAccountWithAI { 5 | return { 6 | id: "email-account-id", 7 | userId: "user1", 8 | email: "user@test.com", 9 | about: null, 10 | user: { 11 | aiModel: null, 12 | aiProvider: null, 13 | aiApiKey: null, 14 | }, 15 | }; 16 | } 17 | 18 | export function getEmail({ 19 | from = "user@test.com", 20 | subject = "Test Subject", 21 | content = "Test content", 22 | replyTo, 23 | cc, 24 | }: Partial = {}): EmailForLLM { 25 | return { 26 | id: "email-id", 27 | from, 28 | subject, 29 | content, 30 | ...(replyTo && { replyTo }), 31 | ...(cc && { cc }), 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/assistant/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function AssistantPage() { 4 | await redirectToEmailAccountPath("/assistant"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/automation/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function AutomationPage() { 4 | await redirectToEmailAccountPath("/automation"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/bulk-unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function BulkUnsubscribePage() { 4 | await redirectToEmailAccountPath("/bulk-unsubscribe"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/clean/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function CleanPage() { 4 | await redirectToEmailAccountPath("/clean"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/cold-email-blocker/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function ColdEmailBlockerPage() { 4 | await redirectToEmailAccountPath("/cold-email-blocker"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/reply-zero/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function ReplyZeroPage() { 4 | await redirectToEmailAccountPath("/reply-zero"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function SettingsPage() { 4 | await redirectToEmailAccountPath("/settings"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/(redirects)/setup/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirectToEmailAccountPath } from "@/utils/account"; 2 | 3 | export default async function SetupPage() { 4 | await redirectToEmailAccountPath("/setup"); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/assistant/RuleTab.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useQueryState } from "nuqs"; 4 | import { Rule } from "@/app/(app)/[emailAccountId]/assistant/RuleForm"; 5 | import { MessageText } from "@/components/Typography"; 6 | 7 | export function RuleTab() { 8 | const [ruleId] = useQueryState("ruleId"); 9 | 10 | if (!ruleId) 11 | return ( 12 |
13 | No rule selected 14 |
15 | ); 16 | 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/assistant/consts.ts: -------------------------------------------------------------------------------- 1 | export const NONE_RULE_ID = "__NONE__"; 2 | export const NEW_RULE_ID = "__NEW__"; 3 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/assistant/group/[groupId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ViewGroup } from "@/app/(app)/[emailAccountId]/assistant/group/ViewGroup"; 2 | import { Container } from "@/components/Container"; 3 | 4 | // Not in use anymore. Could delete this. 5 | export default async function GroupPage(props: { 6 | params: Promise<{ groupId: string }>; 7 | }) { 8 | const params = await props.params; 9 | return ( 10 |
11 | 12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import { useEffect } from "react"; 5 | import { ErrorDisplay } from "@/components/ErrorDisplay"; 6 | 7 | export default function ErrorBoundary({ error }: any) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/assistant/rule/[ruleId]/examples/types.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedMessage } from "@/utils/types"; 2 | import type { GroupItem, Prisma } from "@prisma/client"; 3 | 4 | export type RuleWithGroup = Prisma.RuleGetPayload<{ 5 | include: { group: { include: { items: true } } }; 6 | }>; 7 | 8 | export type MessageWithGroupItem = ParsedMessage & { 9 | matchingGroupItem?: GroupItem | null; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SquareSlashIcon } from "lucide-react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Tooltip } from "@/components/Tooltip"; 6 | 7 | export function ShortcutTooltip() { 8 | return ( 9 | 12 |

Shortcuts:

13 |

U - Unsubscribe

14 |

E - Auto Archive

15 |

A - Keep (Approve)

16 |

Enter - View more

17 |

Up/down - navigate

18 | 19 | } 20 | > 21 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; 2 | import { BulkUnsubscribe } from "./BulkUnsubscribe"; 3 | 4 | export default async function BulkUnsubscribePage() { 5 | // await checkAndRedirectForUpgrade(); 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/consts.ts: -------------------------------------------------------------------------------- 1 | export const PREVIEW_RUN_COUNT = 50; 2 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/helpers.ts: -------------------------------------------------------------------------------- 1 | import prisma from "utils/prisma"; 2 | 3 | export async function getJobById({ 4 | emailAccountId, 5 | jobId, 6 | }: { 7 | emailAccountId: string; 8 | jobId: string; 9 | }) { 10 | return await prisma.cleanupJob.findUnique({ 11 | where: { id: jobId, emailAccountId }, 12 | }); 13 | } 14 | 15 | export async function getLastJob({ 16 | emailAccountId, 17 | }: { 18 | emailAccountId: string; 19 | }) { 20 | return await prisma.cleanupJob.findFirst({ 21 | where: { emailAccountId }, 22 | orderBy: { createdAt: "desc" }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from "@/components/Loading"; 2 | 3 | export default function LoadingComponent() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/types.ts: -------------------------------------------------------------------------------- 1 | // Define the steps of the flow 2 | export enum CleanStep { 3 | INTRO = 0, 4 | ARCHIVE_OR_READ = 1, 5 | TIME_RANGE = 2, 6 | LABEL_OPTIONS = 3, 7 | FINAL_CONFIRMATION = 4, 8 | } 9 | 10 | export const timeRangeOptions = [ 11 | { value: "0", label: "All emails" }, 12 | { value: "1", label: "Older than 1 day" }, 13 | { value: "3", label: "Older than 3 days" }, 14 | { value: "7", label: "Older than 1 week", recommended: true }, 15 | { value: "14", label: "Older than 2 weeks" }, 16 | { value: "30", label: "Older than 1 month" }, 17 | { value: "90", label: "Older than 3 months" }, 18 | { value: "365", label: "Older than 1 year" }, 19 | ]; 20 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/useSkipSettings.ts: -------------------------------------------------------------------------------- 1 | import { parseAsBoolean, useQueryStates } from "nuqs"; 2 | 3 | export function useSkipSettings() { 4 | return useQueryStates({ 5 | skipReply: parseAsBoolean.withDefault(true), 6 | skipStarred: parseAsBoolean.withDefault(true), 7 | skipCalendar: parseAsBoolean.withDefault(true), 8 | skipReceipt: parseAsBoolean.withDefault(false), 9 | skipAttachment: parseAsBoolean.withDefault(false), 10 | skipConversation: parseAsBoolean.withDefault(false), 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/clean/useStep.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { parseAsInteger, useQueryState } from "nuqs"; 3 | import { CleanStep } from "@/app/(app)/[emailAccountId]/clean/types"; 4 | 5 | export function useStep() { 6 | const [step, setStep] = useQueryState( 7 | "step", 8 | parseAsInteger 9 | .withDefault(CleanStep.INTRO) 10 | .withOptions({ history: "push", shallow: false }), 11 | ); 12 | 13 | const onNext = useCallback(() => { 14 | setStep(step + 1); 15 | }, [step, setStep]); 16 | 17 | return { 18 | step, 19 | setStep, 20 | onNext, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/cold-email-blocker/ColdEmailTest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardDescription, 4 | CardHeader, 5 | CardTitle, 6 | } from "@/components/ui/card"; 7 | import { TestRulesContent } from "@/app/(app)/[emailAccountId]/cold-email-blocker/TestRules"; 8 | 9 | export function ColdEmailTest() { 10 | return ( 11 | 12 | 13 | Test Cold Email Blocker 14 | 15 | 16 | Check how your the cold email blocker performs against previous 17 | emails. 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import dynamic from "next/dynamic"; 4 | import { Loading } from "@/components/Loading"; 5 | 6 | // keep bundle size down by importing dynamically on use 7 | export const ComposeEmailFormLazy = dynamic( 8 | () => import("./ComposeEmailForm").then((mod) => mod.ComposeEmailForm), 9 | { 10 | loading: () => , 11 | }, 12 | ); 13 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/compose/page.tsx: -------------------------------------------------------------------------------- 1 | import { ComposeEmailFormLazy } from "@/app/(app)/[emailAccountId]/compose/ComposeEmailFormLazy"; 2 | import { TopSection } from "@/components/TopSection"; 3 | 4 | export default function ComposePage() { 5 | return ( 6 | <> 7 | 8 | 9 |
10 | 11 |
12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/mail/BetaBanner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLocalStorage } from "usehooks-ts"; 4 | import { Banner } from "@/components/Banner"; 5 | 6 | export function BetaBanner() { 7 | const [bannerVisible, setBannerVisible] = useLocalStorage< 8 | boolean | undefined 9 | >("mailBetaBannerVisibile", true); 10 | 11 | if (bannerVisible && typeof window !== "undefined") 12 | return ( 13 | setBannerVisible(false)} 17 | /> 18 | ); 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/reply-zero/date-filter.ts: -------------------------------------------------------------------------------- 1 | export type TimeRange = "all" | "3d" | "1w" | "2w" | "1m"; 2 | 3 | export function getDateFilter(timeRange: TimeRange) { 4 | if (timeRange === "all") return undefined; 5 | 6 | const now = new Date(); 7 | switch (timeRange) { 8 | case "3d": 9 | now.setDate(now.getDate() - 3); 10 | break; 11 | case "1w": 12 | now.setDate(now.getDate() - 7); 13 | break; 14 | case "2w": 15 | now.setDate(now.getDate() - 14); 16 | break; 17 | case "1m": 18 | now.setMonth(now.getMonth() - 1); 19 | break; 20 | } 21 | return { lte: now }; 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/reply-zero/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import { EnableReplyTracker } from "@/app/(app)/[emailAccountId]/reply-zero/EnableReplyTracker"; 2 | import prisma from "@/utils/prisma"; 3 | import { ActionType } from "@prisma/client"; 4 | 5 | export default async function OnboardingReplyTracker(props: { 6 | params: Promise<{ emailAccountId: string }>; 7 | }) { 8 | const params = await props.params; 9 | 10 | const trackerRule = await prisma.rule.findFirst({ 11 | where: { 12 | emailAccount: { id: params.emailAccountId }, 13 | actions: { some: { type: ActionType.TRACK_THREAD } }, 14 | }, 15 | select: { id: true }, 16 | }); 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/SimpleModeOnboarding.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { OnboardingModalDialog } from "@/components/OnboardingModal"; 4 | import { useOnboarding } from "@/components/OnboardingModal"; 5 | 6 | export function SimpleModeOnboarding() { 7 | const { isOpen, setIsOpen } = useOnboarding("SimpleMode"); 8 | 9 | return ( 10 | 14 | Simple email mode shows your emails for the past 24 hours, and helps 15 | you reach inbox zero for the day quickly. 16 | 17 | } 18 | videoId="YjcGsWWfFYI" 19 | isModalOpen={isOpen} 20 | setIsModalOpen={setIsOpen} 21 | /> 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/ViewMoreButton.tsx: -------------------------------------------------------------------------------- 1 | export function ViewMoreButton({ onClick }: { onClick: () => void }) { 2 | return ( 3 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/categories.ts: -------------------------------------------------------------------------------- 1 | // type - title 2 | export const simpleEmailCategoriesArray: ReadonlyArray< 3 | readonly [string, string] 4 | > = [ 5 | ["IMPORTANT", "important"], 6 | ["CATEGORY_PERSONAL", "personal"], 7 | ["CATEGORY_SOCIAL", "social"], 8 | ["CATEGORY_PROMOTIONS", "promotions"], 9 | ["CATEGORY_UPDATES", "updates"], 10 | // ["CATEGORY_FORUMS", "forums"], 11 | ["OTHER", "other"], 12 | ]; 13 | 14 | export const simpleEmailCategories = new Map(simpleEmailCategoriesArray); 15 | 16 | export const getNextCategory = (category: string): string | null => { 17 | const index = simpleEmailCategoriesArray.findIndex(([id]) => id === category); 18 | const next = simpleEmailCategoriesArray[index + 1]; 19 | 20 | if (next) return next[0]; 21 | 22 | return null; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/completed/OpenMultipleGmailButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ExternalLinkIcon } from "lucide-react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { getGmailUrl } from "@/utils/url"; 6 | 7 | export function OpenMultipleGmailButton({ 8 | threadIds, 9 | userEmail, 10 | }: { 11 | threadIds: string[]; 12 | userEmail: string; 13 | }) { 14 | return ( 15 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SimpleEmailStateProvider } from "@/app/(app)/[emailAccountId]/simple/SimpleProgressProvider"; 2 | 3 | export default async function SimpleLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/simple/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from "@/components/Loading"; 2 | 3 | export default function LoadingComponent() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/stats/LoadProgress.tsx: -------------------------------------------------------------------------------- 1 | import { MessageText } from "@/components/Typography"; 2 | import { ButtonLoader } from "@/components/Loading"; 3 | 4 | export function LoadProgress() { 5 | return ( 6 |
7 | 8 | 9 | Loading new emails... 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/stats/LoadStatsButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AreaChartIcon } from "lucide-react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { ButtonLoader } from "@/components/Loading"; 6 | import { useStatLoader } from "@/providers/StatLoaderProvider"; 7 | 8 | export function LoadStatsButton() { 9 | const { isLoading, onLoadBatch } = useStatLoader(); 10 | 11 | return ( 12 |
13 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/stats/page.tsx: -------------------------------------------------------------------------------- 1 | import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck"; 2 | import { Stats } from "./Stats"; 3 | import { checkAndRedirectForUpgrade } from "@/utils/premium/check-and-redirect-for-upgrade"; 4 | 5 | export default async function StatsPage() { 6 | await checkAndRedirectForUpgrade(); 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/app/(app)/[emailAccountId]/stats/params.ts: -------------------------------------------------------------------------------- 1 | import type { DateRange } from "react-day-picker"; 2 | 3 | export function getDateRangeParams(dateRange?: DateRange) { 4 | const params: { fromDate?: number; toDate?: number } = {}; 5 | if (dateRange?.from) params.fromDate = +dateRange?.from; 6 | if (dateRange?.to) params.toDate = +dateRange?.to; 7 | return params; 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/app/(app)/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import { useEffect } from "react"; 5 | import { ErrorDisplay } from "@/components/ErrorDisplay"; 6 | 7 | export default function ErrorBoundary({ error }: any) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/app/(app)/last-login.tsx: -------------------------------------------------------------------------------- 1 | import { captureException } from "@/utils/error"; 2 | import prisma from "@/utils/prisma"; 3 | 4 | export async function LastLogin({ email }: { email: string }) { 5 | try { 6 | await prisma.user.update({ 7 | where: { email }, 8 | data: { lastLogin: new Date() }, 9 | }); 10 | } catch (error) { 11 | captureException(error); 12 | } 13 | 14 | return null; 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/app/(app)/onboarding/OnboardingFinish.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { completedAppOnboardingAction } from "@/utils/actions/user"; 6 | import { env } from "@/env"; 7 | import { usePremium } from "@/components/PremiumAlert"; 8 | 9 | export const OnboardingFinish = () => { 10 | const { isPremium } = usePremium(); 11 | 12 | function getHref() { 13 | if (isPremium) return env.NEXT_PUBLIC_APP_HOME_PATH; 14 | return env.NEXT_PUBLIC_WELCOME_UPGRADE_ENABLED 15 | ? "/welcome-upgrade" 16 | : env.NEXT_PUBLIC_APP_HOME_PATH; 17 | } 18 | 19 | return ( 20 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /apps/web/app/(app)/onboarding/OnboardingNextButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useSearchParams } from "next/navigation"; 6 | 7 | export function OnboardingNextButton() { 8 | const searchParams = useSearchParams(); 9 | const stepParam = searchParams.get("step"); 10 | const currentStep = stepParam ? Number.parseInt(stepParam) : 1; 11 | const nextStep = Number.isNaN(currentStep) ? 2 : currentStep + 1; 12 | 13 | return ( 14 |
15 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/app/(app)/premium/PremiumModal.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 3 | import { Pricing } from "@/app/(app)/premium/Pricing"; 4 | 5 | export function usePremiumModal() { 6 | const [isOpen, setIsOpen] = useState(false); 7 | 8 | const openModal = () => setIsOpen(true); 9 | 10 | const PremiumModal = useCallback(() => { 11 | return ( 12 | 13 | {/* premium upgrade doesn't support dark mode yet as it appears on homepage */} 14 | 15 | 16 | 17 | 18 | ); 19 | }, [isOpen]); 20 | 21 | return { 22 | openModal, 23 | PremiumModal, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/app/(app)/premium/page.tsx: -------------------------------------------------------------------------------- 1 | import { Pricing } from "@/app/(app)/premium/Pricing"; 2 | 3 | export default function Premium() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/app/(app)/sentry-identify.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import * as Sentry from "@sentry/nextjs"; 5 | 6 | export function SentryIdentify({ email }: { email: string }) { 7 | useEffect(() => { 8 | Sentry.setUser({ email }); 9 | }, [email]); 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/components/TestAction.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { testAction } from "./test-action"; 5 | 6 | export function TestActionButton() { 7 | return ( 8 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/components/TestError.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | 5 | export function TestErrorButton() { 6 | return ( 7 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/components/test-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | export async function testAction() { 4 | console.log("testAction started"); 5 | 6 | // sleep for 5 seconds 7 | await new Promise((resolve) => setTimeout(resolve, 5_000)); 8 | 9 | console.log("testAction completed"); 10 | 11 | return "Action completed"; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import { useEffect } from "react"; 5 | import { ErrorDisplay } from "@/components/ErrorDisplay"; 6 | import { Button } from "@/components/Button"; 7 | import { logOut } from "@/utils/user"; 8 | 9 | export default function ErrorBoundary({ error }: any) { 10 | useEffect(() => { 11 | Sentry.captureException(error); 12 | }, [error]); 13 | 14 | return ( 15 |
16 | 17 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/home/CTAButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/Button"; 4 | import { usePostHog } from "posthog-js/react"; 5 | 6 | export function CTAButtons() { 7 | const posthog = usePostHog(); 8 | return ( 9 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/home/page.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "@/app/(landing)/page"; 2 | 3 | export default HomePage; 4 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { LemonScript } from "@/utils/scripts/lemon"; 2 | 3 | export default async function AppLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | <> 10 | {children} 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/login/error/AutoLogOut.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { logOut } from "@/utils/user"; 5 | 6 | export default function AutoLogOut(props: { loggedIn: boolean }) { 7 | useEffect(() => { 8 | // this may fix the sign in error 9 | // have been seeing this error when a user is not properly logged out and an attempt is made to link accounts instead of logging in. 10 | // More here: https://github.com/nextauthjs/next-auth/issues/3300 11 | if (props.loggedIn) { 12 | console.log("Logging user out"); 13 | logOut(); 14 | } 15 | }, [props.loggedIn]); 16 | 17 | return null; 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/privacy/content.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Content from "./content.mdx"; 4 | import { LegalPage } from "@/components/LegalPage"; 5 | 6 | export function PrivacyContent() { 7 | return ( 8 | } /> 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { PrivacyContent } from "@/app/(landing)/privacy/content"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Privacy Policy - Inbox Zero", 6 | description: "Privacy Policy - Inbox Zero", 7 | alternates: { canonical: "/privacy" }, 8 | }; 9 | 10 | export default function Page() { 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/terms/content.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Content from "./content.mdx"; 4 | import { LegalPage } from "@/components/LegalPage"; 5 | 6 | export function TermsContent() { 7 | return ( 8 | } 12 | /> 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { TermsContent } from "@/app/(landing)/terms/content"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Terms of Service - Inbox Zero", 6 | description: "Terms of Service - Inbox Zero", 7 | alternates: { canonical: "/terms" }, 8 | }; 9 | 10 | export default function Page() { 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/welcome-upgrade/WelcomeUpgradeNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/ui/button"; 5 | import { logOut } from "@/utils/user"; 6 | 7 | export function WelcomeUpgradeNav() { 8 | return ( 9 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/app/(landing)/welcome/sign-up-event.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export const SignUpEvent = () => { 6 | useEffect(() => { 7 | fetch("/api/user/complete-registration", { 8 | method: "POST", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | body: JSON.stringify({}), 13 | }).catch((error) => { 14 | console.error("Failed to complete registration:", error); 15 | }); 16 | }, []); 17 | 18 | return null; 19 | }; 20 | -------------------------------------------------------------------------------- /apps/web/app/api/ai/compose-autocomplete/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const composeAutocompleteBody = z.object({ 4 | prompt: z.string(), 5 | }); 6 | 7 | export type ComposeAutocompleteBody = z.infer; 8 | -------------------------------------------------------------------------------- /apps/web/app/api/ai/summarise/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const summariseBody = z.object({ 4 | textHtml: z.string().optional(), 5 | textPlain: z.string().optional(), 6 | }); 7 | export type SummariseBody = z.infer; 8 | -------------------------------------------------------------------------------- /apps/web/app/api/auth/[...nextauth]/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { getAuthOptions, authOptions } from "@/utils/auth"; 3 | import { createScopedLogger } from "@/utils/logger"; 4 | 5 | const logger = createScopedLogger("Auth API"); 6 | 7 | export const { 8 | handlers: { GET, POST }, 9 | auth, 10 | signOut, 11 | } = NextAuth((req) => { 12 | try { 13 | if (req?.url) { 14 | const url = new URL(req?.url); 15 | const consent = url.searchParams.get("consent"); 16 | if (consent) { 17 | logger.info("Consent requested"); 18 | return getAuthOptions({ consent: true }); 19 | } 20 | } 21 | 22 | return authOptions; 23 | } catch (error) { 24 | logger.error("Auth configuration error", { error }); 25 | throw error; 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /apps/web/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "./auth"; 2 | -------------------------------------------------------------------------------- /apps/web/app/api/chats/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | 5 | export type GetChatsResponse = Awaited>; 6 | 7 | export const GET = withEmailAccount(async (request) => { 8 | const emailAccountId = request.auth.emailAccountId; 9 | const result = await getChats({ emailAccountId }); 10 | return NextResponse.json(result); 11 | }); 12 | 13 | async function getChats({ emailAccountId }: { emailAccountId: string }) { 14 | const chats = await prisma.chat.findMany({ 15 | where: { emailAccountId }, 16 | orderBy: { updatedAt: "desc" }, 17 | }); 18 | 19 | return { chats }; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/app/api/clean/history/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | 5 | export type CleanHistoryResponse = Awaited>; 6 | 7 | async function getCleanHistory({ emailAccountId }: { emailAccountId: string }) { 8 | const result = await prisma.cleanupJob.findMany({ 9 | where: { emailAccountId }, 10 | orderBy: { createdAt: "desc" }, 11 | include: { _count: { select: { threads: true } } }, 12 | }); 13 | return { result }; 14 | } 15 | 16 | export const GET = withEmailAccount(async (request) => { 17 | const emailAccountId = request.auth.emailAccountId; 18 | 19 | const result = await getCleanHistory({ emailAccountId }); 20 | return NextResponse.json(result); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/web/app/api/google/messages/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const messageQuerySchema = z.object({ 4 | q: z.string().nullish(), 5 | pageToken: z.string().nullish(), 6 | }); 7 | export type MessageQuery = z.infer; 8 | -------------------------------------------------------------------------------- /apps/web/app/api/google/threads/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const threadsQuery = z.object({ 4 | fromEmail: z.string().nullish(), 5 | limit: z.coerce.number().max(100).nullish(), 6 | type: z.string().nullish(), 7 | q: z.string().nullish(), 8 | nextPageToken: z.string().nullish(), 9 | labelId: z.string().nullish(), 10 | }); 11 | export type ThreadsQuery = z.infer; 12 | -------------------------------------------------------------------------------- /apps/web/app/api/google/webhook/logger.ts: -------------------------------------------------------------------------------- 1 | import { createScopedLogger } from "@/utils/logger"; 2 | 3 | export const logger = createScopedLogger("google/webhook"); 4 | -------------------------------------------------------------------------------- /apps/web/app/api/google/webhook/types.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | import type { RuleWithActionsAndCategories } from "@/utils/types"; 3 | import type { EmailAccountWithAI } from "@/utils/llms/types"; 4 | import type { EmailAccount } from "@prisma/client"; 5 | 6 | export type ProcessHistoryOptions = { 7 | history: gmail_v1.Schema$History[]; 8 | gmail: gmail_v1.Gmail; 9 | accessToken: string; 10 | rules: RuleWithActionsAndCategories[]; 11 | hasAutomationRules: boolean; 12 | hasAiAccess: boolean; 13 | emailAccount: Pick< 14 | EmailAccount, 15 | "coldEmailPrompt" | "coldEmailBlocker" | "autoCategorizeSenders" 16 | > & 17 | EmailAccountWithAI; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/app/api/knowledge/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | import type { Knowledge } from "@prisma/client"; 5 | 6 | export type GetKnowledgeResponse = { 7 | items: Knowledge[]; 8 | }; 9 | 10 | export const GET = withEmailAccount(async (request) => { 11 | const emailAccountId = request.auth.emailAccountId; 12 | const items = await prisma.knowledge.findMany({ 13 | where: { emailAccountId }, 14 | orderBy: { updatedAt: "desc" }, 15 | }); 16 | 17 | const result: GetKnowledgeResponse = { items }; 18 | 19 | return NextResponse.json(result); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/web/app/api/rules/pending/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | import type { RequestWithEmailAccount } from "@/utils/middleware"; 5 | 6 | export type GetPendingRulesResponse = Awaited< 7 | ReturnType 8 | >; 9 | 10 | export const GET = withEmailAccount(async (req: RequestWithEmailAccount) => { 11 | const { emailAccountId } = req.auth; 12 | const data = await getPendingRules({ emailAccountId }); 13 | return NextResponse.json(data); 14 | }); 15 | 16 | async function getPendingRules({ emailAccountId }: { emailAccountId: string }) { 17 | const rule = await prisma.rule.findFirst({ 18 | where: { emailAccountId, automate: false }, 19 | select: { id: true }, 20 | }); 21 | 22 | return { hasPending: Boolean(rule) }; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/app/api/user/api-keys/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withAuth } from "@/utils/middleware"; 4 | 5 | export type ApiKeyResponse = Awaited>; 6 | 7 | async function getApiKeys({ userId }: { userId: string }) { 8 | const apiKeys = await prisma.apiKey.findMany({ 9 | where: { userId, isActive: true }, 10 | select: { 11 | id: true, 12 | name: true, 13 | createdAt: true, 14 | }, 15 | }); 16 | 17 | return { apiKeys }; 18 | } 19 | 20 | export const GET = withAuth(async (request) => { 21 | const userId = request.auth.userId; 22 | 23 | const apiKeys = await getApiKeys({ userId }); 24 | 25 | return NextResponse.json(apiKeys); 26 | }); 27 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categories/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { withEmailAccount } from "@/utils/middleware"; 3 | import { getUserCategories } from "@/utils/category.server"; 4 | 5 | export type UserCategoriesResponse = Awaited>; 6 | 7 | async function getCategories({ emailAccountId }: { emailAccountId: string }) { 8 | const result = await getUserCategories({ emailAccountId }); 9 | return { result }; 10 | } 11 | 12 | export const GET = withEmailAccount(async (request) => { 13 | const emailAccountId = request.auth.emailAccountId; 14 | const result = await getCategories({ emailAccountId }); 15 | return NextResponse.json(result); 16 | }); 17 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const aiCategorizeSendersSchema = z.object({ 4 | emailAccountId: z.string(), 5 | senders: z.array(z.string()), 6 | }); 7 | export type AiCategorizeSenders = z.infer; 8 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/batch/route.ts: -------------------------------------------------------------------------------- 1 | import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; 2 | import { withError } from "@/utils/middleware"; 3 | import { handleBatchRequest } from "@/app/api/user/categorize/senders/batch/handle-batch"; 4 | 5 | export const maxDuration = 300; 6 | 7 | export const POST = withError(verifySignatureAppRouter(handleBatchRequest)); 8 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/progress/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { getCategorizationProgress } from "@/utils/redis/categorization-progress"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | 5 | export type CategorizeProgress = Awaited< 6 | ReturnType 7 | >; 8 | 9 | async function getCategorizeProgress({ 10 | emailAccountId, 11 | }: { 12 | emailAccountId: string; 13 | }) { 14 | const progress = await getCategorizationProgress({ emailAccountId }); 15 | return progress; 16 | } 17 | 18 | export const GET = withEmailAccount(async (request) => { 19 | const emailAccountId = request.auth.emailAccountId; 20 | const result = await getCategorizeProgress({ emailAccountId }); 21 | return NextResponse.json(result); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/types.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedMessage } from "@/utils/types"; 2 | 3 | export type SenderMap = Map; 4 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/uncategorized/get-senders.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/utils/prisma"; 2 | 3 | export async function getSenders({ 4 | emailAccountId, 5 | offset = 0, 6 | limit = 100, 7 | }: { 8 | emailAccountId: string; 9 | offset?: number; 10 | limit?: number; 11 | }) { 12 | return prisma.emailMessage.findMany({ 13 | where: { 14 | emailAccountId, 15 | sent: false, 16 | }, 17 | select: { 18 | from: true, 19 | }, 20 | distinct: ["from"], 21 | skip: offset, 22 | take: limit, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/app/api/user/categorize/senders/uncategorized/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { withEmailAccount } from "@/utils/middleware"; 3 | import { getUncategorizedSenders } from "@/app/api/user/categorize/senders/uncategorized/get-uncategorized-senders"; 4 | 5 | export type UncategorizedSendersResponse = { 6 | uncategorizedSenders: string[]; 7 | nextOffset?: number; 8 | }; 9 | 10 | export const GET = withEmailAccount(async (request) => { 11 | const emailAccountId = request.auth.emailAccountId; 12 | 13 | const url = new URL(request.url); 14 | const offset = Number.parseInt(url.searchParams.get("offset") || "0"); 15 | 16 | const result = await getUncategorizedSenders({ 17 | emailAccountId, 18 | offset, 19 | }); 20 | 21 | return NextResponse.json(result); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/app/api/user/group/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | 5 | export type GroupsResponse = Awaited>; 6 | 7 | async function getGroups({ emailAccountId }: { emailAccountId: string }) { 8 | const groups = await prisma.group.findMany({ 9 | where: { emailAccountId }, 10 | select: { 11 | id: true, 12 | name: true, 13 | rule: { select: { id: true, name: true } }, 14 | _count: { select: { items: true } }, 15 | }, 16 | }); 17 | return { groups }; 18 | } 19 | 20 | export const GET = withEmailAccount(async (request) => { 21 | const emailAccountId = request.auth.emailAccountId; 22 | const result = await getGroups({ emailAccountId }); 23 | return NextResponse.json(result); 24 | }); 25 | -------------------------------------------------------------------------------- /apps/web/app/api/user/labels/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import prisma from "@/utils/prisma"; 3 | import { withEmailAccount } from "@/utils/middleware"; 4 | 5 | export type UserLabelsResponse = Awaited>; 6 | 7 | async function getLabels(options: { emailAccountId: string }) { 8 | return await prisma.label.findMany({ 9 | where: { emailAccountId: options.emailAccountId }, 10 | }); 11 | } 12 | 13 | export const GET = withEmailAccount(async (request) => { 14 | const emailAccountId = request.auth.emailAccountId; 15 | 16 | const labels = await getLabels({ emailAccountId }); 17 | 18 | return NextResponse.json(labels); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/web/app/api/user/rules/prompt/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { withEmailAccount } from "@/utils/middleware"; 3 | import prisma from "@/utils/prisma"; 4 | 5 | export type RulesPromptResponse = Awaited>; 6 | 7 | async function getRulesPrompt({ emailAccountId }: { emailAccountId: string }) { 8 | return await prisma.emailAccount.findUnique({ 9 | where: { id: emailAccountId }, 10 | select: { rulesPrompt: true }, 11 | }); 12 | } 13 | 14 | export const GET = withEmailAccount(async (request) => { 15 | const emailAccountId = request.auth.emailAccountId; 16 | 17 | const result = await getRulesPrompt({ emailAccountId }); 18 | 19 | return NextResponse.json(result); 20 | }); 21 | -------------------------------------------------------------------------------- /apps/web/app/api/user/rules/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { withEmailAccount } from "@/utils/middleware"; 3 | import prisma from "@/utils/prisma"; 4 | 5 | export type RulesResponse = Awaited>; 6 | 7 | async function getRules({ emailAccountId }: { emailAccountId: string }) { 8 | return await prisma.rule.findMany({ 9 | where: { emailAccountId }, 10 | include: { 11 | actions: true, 12 | group: { select: { name: true } }, 13 | categoryFilters: { select: { id: true, name: true } }, 14 | }, 15 | orderBy: { createdAt: "asc" }, 16 | }); 17 | } 18 | 19 | export const GET = withEmailAccount(async (request) => { 20 | const emailAccountId = request.auth.emailAccountId; 21 | const result = await getRules({ emailAccountId }); 22 | return NextResponse.json(result); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/app/api/user/settings/multi-account/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const saveMultiAccountPremiumBody = z.object({ 4 | emailAddresses: z 5 | .array( 6 | z.object({ 7 | email: z.string(), 8 | }), 9 | ) 10 | .optional(), 11 | }); 12 | export type SaveMultiAccountPremiumBody = z.infer< 13 | typeof saveMultiAccountPremiumBody 14 | >; 15 | -------------------------------------------------------------------------------- /apps/web/app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import { useEffect } from "react"; 5 | import { ErrorDisplay } from "@/components/ErrorDisplay"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | export default function GlobalError({ error }: any) { 9 | useEffect(() => { 10 | Sentry.captureException(error); 11 | }, [error]); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/app/icon.png -------------------------------------------------------------------------------- /apps/web/app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Inbox Zero", 3 | "short_name": "Inbox Zero", 4 | "icons": [ 5 | { 6 | "src": "/icons/icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/icons/icon-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#FFFFFF", 18 | "background_color": "#FFFFFF", 19 | "start_url": "/", 20 | "display": "standalone", 21 | "orientation": "portrait" 22 | } -------------------------------------------------------------------------------- /apps/web/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from "@/components/ErrorPage"; 2 | import { BasicLayout } from "@/components/layouts/BasicLayout"; 3 | 4 | export default function NotFound() { 5 | return ( 6 | 7 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/app/opengraph-image.png -------------------------------------------------------------------------------- /apps/web/app/sw.ts: -------------------------------------------------------------------------------- 1 | import { Serwist, type PrecacheEntry, type SerwistGlobalConfig } from "serwist"; 2 | 3 | // This declares the value of `injectionPoint` to TypeScript. 4 | // `injectionPoint` is the string that will be replaced by the 5 | // actual precache manifest. By default, this string is set to 6 | // `"self.__SW_MANIFEST"`. 7 | declare global { 8 | interface WorkerGlobalScope extends SerwistGlobalConfig { 9 | __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; 10 | } 11 | } 12 | 13 | declare const self: ServiceWorkerGlobalScope; 14 | 15 | const serwist = new Serwist({ 16 | precacheEntries: self.__SW_MANIFEST, 17 | skipWaiting: true, 18 | clientsClaim: true, 19 | navigationPreload: true, 20 | runtimeCaching: [], // caching disabled 21 | disableDevLogs: process.env.NODE_ENV === "production", 22 | }); 23 | 24 | serwist.addEventListeners(); 25 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/utils", 15 | "ui": "@/components/ui/" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | export const Checkbox = forwardRef( 4 | ( 5 | props: { 6 | checked: boolean; 7 | onChange: (event: React.ChangeEvent) => void; 8 | }, 9 | ref: React.Ref, 10 | ) => { 11 | return ( 12 | 19 | ); 20 | }, 21 | ); 22 | 23 | Checkbox.displayName = "Checkbox"; 24 | -------------------------------------------------------------------------------- /apps/web/components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type ReactNode, useEffect, useState } from "react"; 4 | 5 | export const ClientOnly = ({ children }: { children: ReactNode }) => { 6 | const [clientReady, setClientReady] = useState(false); 7 | 8 | useEffect(() => { 9 | setClientReady(true); 10 | }, []); 11 | 12 | return clientReady ? <>{children} : null; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/web/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import clsx from "clsx"; 3 | import { cva } from "class-variance-authority"; 4 | 5 | interface ContainerProps { 6 | children: React.ReactNode; 7 | size?: "lg" | "2xl" | "4xl" | "6xl"; 8 | } 9 | 10 | const containerVariants = cva("mx-auto w-full px-4", { 11 | variants: { 12 | size: { 13 | lg: "max-w-lg", 14 | "2xl": "max-w-2xl", 15 | "4xl": "max-w-4xl", 16 | "6xl": "max-w-6xl", 17 | }, 18 | }, 19 | }); 20 | 21 | export const Container = (props: ContainerProps) => { 22 | const { children, size = "4xl" } = props; 23 | return
{children}
; 24 | }; 25 | Container.displayName = "Container"; 26 | -------------------------------------------------------------------------------- /apps/web/components/EmailCell.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | export const EmailCell = memo(function EmailCell({ 4 | emailAddress, 5 | className, 6 | }: { 7 | emailAddress: string; 8 | className?: string; 9 | }) { 10 | const parseEmail = (name: string) => { 11 | const match = name.match(/<(.+)>/); 12 | return match ? match[1] : name; 13 | }; 14 | const name = emailAddress.split("<")[0].trim(); 15 | const email = parseEmail(emailAddress); 16 | 17 | return ( 18 |
19 |
{name}
20 |
{email}
21 |
22 | ); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/components/HoverCard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HoverCard as HoverCardUi, 3 | HoverCardContent, 4 | HoverCardTrigger, 5 | } from "@/components/ui/hover-card"; 6 | 7 | export function HoverCard(props: { 8 | children: React.ReactNode; 9 | content: React.ReactNode; 10 | className?: string; 11 | }) { 12 | return ( 13 | 14 | {props.children} 15 | 16 | {props.content} 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/components/LabelsSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenuSubContent, 3 | DropdownMenuItem, 4 | } from "@/components/ui/dropdown-menu"; 5 | import type { UserLabel } from "@/hooks/useLabels"; 6 | 7 | export function LabelsSubMenu({ 8 | labels, 9 | onClick, 10 | }: { 11 | labels: UserLabel[]; 12 | onClick: (label: UserLabel) => void; 13 | }) { 14 | return ( 15 | 16 | {labels.length ? ( 17 | labels.map((label) => { 18 | return ( 19 | onClick(label)}> 20 | {label.name} 21 | 22 | ); 23 | }) 24 | ) : ( 25 | You don't have any labels yet. 26 | )} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/components/LegalPage.tsx: -------------------------------------------------------------------------------- 1 | import format from "date-fns/format"; 2 | import parseISO from "date-fns/parseISO"; 3 | 4 | export function LegalPage(props: { 5 | date: string; 6 | title: string; 7 | content: React.ReactNode; 8 | }) { 9 | const { date, title, content } = props; 10 | 11 | return ( 12 |
13 |
14 | 17 |

{title}

18 |
19 |
{content}
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/components/Linkify.tsx: -------------------------------------------------------------------------------- 1 | import LinkifyReact from "linkify-react"; 2 | import Link from "next/link"; 3 | 4 | const renderLink = ({ 5 | attributes, 6 | content, 7 | }: { 8 | attributes: any; 9 | content: any; 10 | }) => { 11 | const { href, ...props } = attributes; 12 | 13 | return ( 14 | 20 | {content} 21 | 22 | ); 23 | }; 24 | 25 | export function Linkify(props: { children: React.ReactNode }) { 26 | return ( 27 | 28 | {props.children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { Loader2Icon } from "lucide-react"; 3 | 4 | export function Loading() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export function LoadingMiniSpinner() { 13 | return ; 14 | } 15 | 16 | export function ButtonLoader() { 17 | return ; 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/components/LoadingContent.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { Loading } from "./Loading"; 3 | import { ErrorDisplay } from "./ErrorDisplay"; 4 | 5 | interface LoadingContentProps { 6 | loading: boolean; 7 | loadingComponent?: React.ReactNode; 8 | error?: { info?: { error: string }; error?: string }; 9 | errorComponent?: React.ReactNode; 10 | children: React.ReactNode; 11 | } 12 | 13 | export function LoadingContent(props: LoadingContentProps) { 14 | if (props.error) { 15 | return props.errorComponent ? ( 16 | <>{props.errorComponent} 17 | ) : ( 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | if (props.loading) return <>{props.loadingComponent || }; 25 | 26 | return <>{props.children}; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/components/Notice.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | 3 | interface NoticeProps { 4 | children: React.ReactNode; 5 | variant?: "info" | "warning" | "success" | "error"; 6 | className?: string; 7 | } 8 | 9 | const variantStyles = { 10 | info: "text-blue-600 bg-blue-50 border-blue-100", 11 | warning: "text-amber-600 bg-amber-50 border-amber-100", 12 | success: "text-green-600 bg-green-50 border-green-100", 13 | error: "text-red-600 bg-red-50 border-red-100", 14 | }; 15 | 16 | export function Notice({ children, variant = "info", className }: NoticeProps) { 17 | return ( 18 |
25 | {children} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/components/ProfileImage.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | 3 | export function ProfileImage({ 4 | image, 5 | label = "", 6 | size = 24, 7 | }: { 8 | image: string | null; 9 | label: string; 10 | size?: number; 11 | }) { 12 | return ( 13 | 14 | 15 | {label.at(0)?.toUpperCase()} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/components/TabsToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | 3 | interface TabsToolbarProps extends React.HTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function TabsToolbar({ 8 | className, 9 | children, 10 | ...props 11 | }: TabsToolbarProps) { 12 | return ( 13 |
20 | {children} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster as SonnerToaster, toast } from "sonner"; 2 | 3 | export function toastSuccess(options: { title?: string; description: string }) { 4 | return toast.success(options.title || "Success", { 5 | description: options.description, 6 | }); 7 | } 8 | 9 | export function toastError(options: { title?: string; description: string }) { 10 | return toast.error(options.title || "Error", { 11 | description: options.description, 12 | duration: 10_000, 13 | }); 14 | } 15 | 16 | export function toastInfo(options: { 17 | title: string; 18 | description: string; 19 | duration?: number; 20 | }) { 21 | return toast(options.title, { 22 | description: options.description, 23 | duration: options.duration, 24 | }); 25 | } 26 | 27 | export const Toaster = SonnerToaster; 28 | -------------------------------------------------------------------------------- /apps/web/components/TokenCheck.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "next-auth/react"; 4 | import { useEffect } from "react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function TokenCheck() { 8 | const { data: session } = useSession(); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (session?.error === "RefreshAccessTokenError") { 13 | router.replace("/login?error=RefreshAccessTokenError"); 14 | return; 15 | } 16 | if (session?.error === "RequiresReconsent") { 17 | router.replace("/login?error=RequiresReconsent"); 18 | return; 19 | } 20 | }, [session, router]); 21 | 22 | return null; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | 3 | interface TopBarProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | sticky?: boolean; 7 | } 8 | 9 | export function TopBar({ children, className, sticky = false }: TopBarProps) { 10 | return ( 11 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/components/YouTubeVideo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import YouTube from "react-youtube"; 3 | import { cn } from "@/utils"; 4 | 5 | export function YouTubeVideo(props: { 6 | videoId: string; 7 | iframeClassName?: string; 8 | className?: string; 9 | opts?: { 10 | height?: string; 11 | width?: string; 12 | playerVars?: { 13 | autoplay?: number; 14 | }; 15 | }; 16 | }) { 17 | return ( 18 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/components/assistant-chat/data-stream-handler.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useChat } from "@ai-sdk/react"; 4 | import { useEffect, useRef } from "react"; 5 | 6 | export type DataStreamDelta = { 7 | type: 8 | | "text-delta" 9 | | "code-delta" 10 | | "sheet-delta" 11 | | "image-delta" 12 | | "title" 13 | | "id" 14 | | "suggestion" 15 | | "clear" 16 | | "finish" 17 | | "kind"; 18 | content: string; 19 | }; 20 | 21 | export function DataStreamHandler({ id }: { id: string }) { 22 | const { data: dataStream } = useChat({ id }); 23 | const lastProcessedIndex = useRef(-1); 24 | 25 | useEffect(() => { 26 | if (!dataStream?.length) return; 27 | lastProcessedIndex.current = dataStream.length - 1; 28 | }, [dataStream]); 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/components/assistant-chat/types.ts: -------------------------------------------------------------------------------- 1 | // TODO: remove null once this feature is live 2 | export type SetInputFunction = React.Dispatch< 3 | React.SetStateAction 4 | > | null; 5 | -------------------------------------------------------------------------------- /apps/web/components/editor/extensions.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/react"; 2 | import { Plugin } from "@tiptap/pm/state"; 3 | 4 | export const EnterHandler = Extension.create({ 5 | name: "enterHandler", 6 | addProseMirrorPlugins() { 7 | return [ 8 | new Plugin({ 9 | props: { 10 | handleKeyDown: (view, event) => { 11 | // Check for Cmd/Ctrl + Enter 12 | if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { 13 | return true; // Prevent default behavior 14 | } 15 | return false; 16 | }, 17 | }, 18 | }), 19 | ]; 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /apps/web/components/email-list/EmailDate.tsx: -------------------------------------------------------------------------------- 1 | import { formatShortDate } from "@/utils/date"; 2 | 3 | export function EmailDate(props: { date: Date }) { 4 | return ( 5 |
6 | {formatShortDate(props.date)} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/components/email-list/types.ts: -------------------------------------------------------------------------------- 1 | import type { ThreadsResponse } from "@/app/api/google/threads/controller"; 2 | 3 | type FullThread = ThreadsResponse["threads"][number]; 4 | // defining it explicitly to make it easier to understand the type 5 | export type Thread = { 6 | id: FullThread["id"]; 7 | messages: FullThread["messages"]; 8 | snippet: FullThread["snippet"]; 9 | plan: FullThread["plan"]; 10 | category: FullThread["category"]; 11 | }; 12 | 13 | export type Executing = Record; 14 | 15 | export type ThreadMessage = Thread["messages"][number]; 16 | -------------------------------------------------------------------------------- /apps/web/components/layouts/BasicLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/app/(landing)/home/Footer"; 2 | import { Header } from "@/app/(landing)/home/Header"; 3 | 4 | export function BasicLayout(props: { children: React.ReactNode }) { 5 | return ( 6 |
7 |
8 |
{props.children}
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/components/layouts/BlogLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/app/(landing)/home/Footer"; 2 | import { Header } from "@/app/(landing)/home/Header"; 3 | 4 | export function BlogLayout(props: { children: React.ReactNode }) { 5 | return ( 6 |
7 |
8 |
9 |
10 |
{props.children}
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import type { ThemeProviderProps } from "next-themes"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 4 | 5 | const Collapsible = CollapsiblePrimitive.Root; 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 12 | -------------------------------------------------------------------------------- /apps/web/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /apps/web/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
15 | ); 16 | } 17 | 18 | export { Skeleton }; 19 | -------------------------------------------------------------------------------- /apps/web/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pnpm install 4 | pnpm prisma migrate dev 5 | pnpm run dev -------------------------------------------------------------------------------- /apps/web/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined, 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/hooks/useAccounts.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { GetEmailAccountsResponse } from "@/app/api/user/email-accounts/route"; 3 | 4 | export function useAccounts() { 5 | return useSWR("/api/user/email-accounts", { 6 | revalidateOnFocus: false, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/hooks/useApiKeys.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { ApiKeyResponse } from "@/app/api/user/api-keys/route"; 3 | import { processSWRResponse } from "@/utils/swr"; 4 | 5 | export function useApiKeys() { 6 | const swrResult = useSWR( 7 | "/api/user/api-keys", 8 | ); 9 | return processSWRResponse(swrResult); 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/hooks/useCategories.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { UserCategoriesResponse } from "@/app/api/user/categories/route"; 3 | 4 | export function useCategories() { 5 | const { data, isLoading, error, mutate } = useSWR( 6 | "/api/user/categories", 7 | ); 8 | 9 | return { categories: data?.result || [], isLoading, error, mutate }; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/hooks/useChatMessages.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { GetChatResponse } from "@/app/api/chats/[chatId]/route"; 3 | 4 | export function useChatMessages(chatId: string | null) { 5 | return useSWR(chatId ? `/api/chats/${chatId}` : null); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/hooks/useChats.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { GetChatsResponse } from "@/app/api/chats/route"; 3 | 4 | export function useChats(shouldFetch: boolean) { 5 | return useSWR(shouldFetch ? "/api/chats" : null); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/hooks/useEmailAccountFull.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { EmailAccountFullResponse } from "@/app/api/user/email-account/route"; 3 | import { processSWRResponse } from "@/utils/swr"; // Import the generic helper 4 | 5 | export function useEmailAccountFull() { 6 | const swrResult = useSWR( 7 | "/api/user/email-account", 8 | ); 9 | return processSWRResponse(swrResult); 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useInterval(callback: () => void, delay: number | null) { 4 | const savedCallback = useRef(callback); 5 | 6 | useEffect(() => { 7 | savedCallback.current = callback; 8 | }, [callback]); 9 | 10 | useEffect(() => { 11 | if (delay === null) return; 12 | 13 | const interval = setInterval(() => savedCallback.current(), delay); 14 | return () => clearInterval(interval); 15 | }, [delay]); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/hooks/useMessagesBatch.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { 3 | MessagesBatchQuery, 4 | MessagesBatchResponse, 5 | } from "@/app/api/google/messages/batch/route"; 6 | 7 | export function useMessagesBatch({ 8 | ids, 9 | parseReplies, 10 | }: Partial) { 11 | const searchParams = new URLSearchParams({}); 12 | if (ids) searchParams.set("ids", ids.join(",")); 13 | if (parseReplies) searchParams.set("parseReplies", parseReplies.toString()); 14 | 15 | const url = `/api/google/messages/batch?${searchParams.toString()}`; 16 | const { data, isLoading, error, mutate } = useSWR( 17 | ids?.length ? url : null, 18 | ); 19 | 20 | return { data, isLoading, error, mutate }; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function useModal() { 4 | const [isModalOpen, setIsModalOpen] = React.useState(false); 5 | const openModal = React.useCallback(() => setIsModalOpen(true), []); 6 | const closeModal = React.useCallback(() => setIsModalOpen(false), []); 7 | 8 | return { isModalOpen, openModal, closeModal, setIsModalOpen }; 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/hooks/useModifierKey.ts: -------------------------------------------------------------------------------- 1 | export function useModifierKey() { 2 | const isMac = 3 | typeof window === "undefined" || 4 | /Mac|iPhone|iPod|iPad/.test(window.navigator.userAgent); 5 | 6 | return { symbol: isMac ? "⌘" : "Ctrl", isMac }; 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/hooks/useRule.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { RuleResponse } from "@/app/api/user/rules/[id]/route"; 3 | 4 | export function useRule(ruleId?: string | null) { 5 | return useSWR( 6 | ruleId ? `/api/user/rules/${ruleId}` : null, 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/hooks/useRules.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { RulesResponse } from "@/app/api/user/rules/route"; 3 | 4 | export function useRules() { 5 | return useSWR("/api/user/rules"); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/hooks/useThread.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { 3 | ThreadQuery, 4 | ThreadResponse, 5 | } from "@/app/api/google/threads/[id]/route"; 6 | 7 | export function useThread( 8 | { id }: ThreadQuery, 9 | options?: { includeDrafts?: boolean }, 10 | ) { 11 | const searchParams = new URLSearchParams(); 12 | if (options?.includeDrafts) searchParams.set("includeDrafts", "true"); 13 | const url = `/api/google/threads/${id}?${searchParams.toString()}`; 14 | return useSWR(url); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/hooks/useThreads.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { ThreadsResponse } from "@/app/api/google/threads/controller"; 3 | 4 | export function useThreads({ 5 | fromEmail, 6 | limit, 7 | type, 8 | refreshInterval, 9 | }: { 10 | fromEmail?: string; 11 | type?: string; 12 | limit?: number; 13 | refreshInterval?: number; 14 | }) { 15 | const searchParams = new URLSearchParams(); 16 | if (fromEmail) searchParams.set("fromEmail", fromEmail); 17 | if (limit) searchParams.set("limit", limit.toString()); 18 | if (type) searchParams.set("type", type); 19 | 20 | const url = `/api/google/threads?${searchParams.toString()}`; 21 | const { data, isLoading, error, mutate } = useSWR(url, { 22 | refreshInterval, 23 | }); 24 | 25 | return { data, isLoading, error, mutate }; 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/hooks/useToggleSelect.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export function useToggleSelect(items: { id: string }[]) { 4 | const [selected, setSelected] = useState>(new Map()); 5 | const isAllSelected = 6 | !!items.length && items.every((item) => selected.get(item.id)); 7 | const onToggleSelect = (id: string) => { 8 | setSelected((prev) => new Map(prev).set(id, !prev.get(id))); 9 | }; 10 | const onToggleSelectAll = useCallback(() => { 11 | const allSelected = items.every((item) => selected.get(item.id)); 12 | 13 | for (const item of items) { 14 | setSelected((prev) => new Map(prev).set(item.id, !allSelected)); 15 | } 16 | }, [items, selected]); 17 | 18 | return { selected, isAllSelected, onToggleSelect, onToggleSelectAll }; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { UserResponse } from "@/app/api/user/me/route"; 3 | import { processSWRResponse } from "@/utils/swr"; // Import the generic helper 4 | 5 | export function useUser() { 6 | const swrResult = useSWR("/api/user/me"); 7 | return processSWRResponse(swrResult); 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { MDXComponents } from "mdx/types"; 4 | 5 | export function useMDXComponents(components: MDXComponents): MDXComponents { 6 | return { 7 | ...components, 8 | /* eslint-disable jsx-a11y/alt-text */ 9 | // @ts-ignore 10 | // img: (props) => , 11 | // Image: (props) => , 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [import("prettier-plugin-tailwindcss")], 3 | }; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230804105315_rule_name/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ADD COLUMN "name" TEXT NOT NULL; 3 | 4 | -- CreateIndex 5 | CREATE UNIQUE INDEX "Rule_name_userId_key" ON "Rule"("name", "userId"); 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230804140051_cascade_delete_executed_rule/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "ExecutedRule" DROP CONSTRAINT "ExecutedRule_ruleId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "ExecutedRule" DROP CONSTRAINT "ExecutedRule_userId_fkey"; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "ExecutedRule" ADD CONSTRAINT "ExecutedRule_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("id") ON DELETE CASCADE ON UPDATE CASCADE; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE "ExecutedRule" ADD CONSTRAINT "ExecutedRule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 12 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230913192346_lemon_squeezy/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "lemonSqueezyCustomerId" INTEGER, 3 | ADD COLUMN "lemonSqueezyRenewsAt" TIMESTAMP(3), 4 | ADD COLUMN "lemonSqueezySubscriptionId" TEXT; 5 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20230919082654_ai_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "aiModel" TEXT, 3 | ADD COLUMN "openAIApiKey" TEXT; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231027022923_unique_account/migration.sql: -------------------------------------------------------------------------------- 1 | -- Only allow one account per user for now. We may remove this constraint in the future 2 | /* 3 | Warnings: 4 | 5 | - A unique constraint covering the columns `[userId]` on the table `Account` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- CreateIndex 9 | CREATE UNIQUE INDEX "Account_userId_key" ON "Account"("userId"); 10 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231112182812_onboarding_flag/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "completedOnboarding" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231207000800_settings/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Frequency" AS ENUM ('NEVER', 'WEEKLY'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ADD COLUMN "categorizeEmails" BOOLEAN NOT NULL DEFAULT true, 6 | ADD COLUMN "statsEmailFrequency" "Frequency" NOT NULL DEFAULT 'WEEKLY'; 7 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231213064514_newsletter_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "NewsletterStatus" AS ENUM ('APPROVED', 'UNSUBSCRIBED', 'AUTO_ARCHIVED'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Newsletter" ( 6 | "id" TEXT NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | "email" TEXT NOT NULL, 10 | "status" "NewsletterStatus", 11 | "userId" TEXT NOT NULL, 12 | 13 | CONSTRAINT "Newsletter_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateIndex 17 | CREATE UNIQUE INDEX "Newsletter_email_userId_key" ON "Newsletter"("email", "userId"); 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "Newsletter" ADD CONSTRAINT "Newsletter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 21 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231219225431_unsubscribe_credits/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "unsubscribeCredits" INTEGER, 3 | ADD COLUMN "unsubscribeMonth" INTEGER; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20231229221011_remove_summarize_action/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The values [SUMMARIZE] on the enum `ActionType` will be removed. If these variants are still used in the database, this will fail. 5 | 6 | */ 7 | -- AlterEnum 8 | BEGIN; 9 | CREATE TYPE "ActionType_new" AS ENUM ('ARCHIVE', 'LABEL', 'REPLY', 'SEND_EMAIL', 'FORWARD', 'DRAFT_EMAIL', 'MARK_SPAM'); 10 | ALTER TABLE "Action" ALTER COLUMN "type" TYPE "ActionType_new" USING ("type"::text::"ActionType_new"); 11 | ALTER TABLE "ExecutedRule" ALTER COLUMN "actions" TYPE "ActionType_new"[] USING ("actions"::text::"ActionType_new"[]); 12 | ALTER TYPE "ActionType" RENAME TO "ActionType_old"; 13 | ALTER TYPE "ActionType_new" RENAME TO "ActionType"; 14 | DROP TYPE "ActionType_old"; 15 | COMMIT; 16 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240101222135_cold_email_blocker/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "ColdEmailStatus" AS ENUM ('COLD_EMAIL'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "ColdEmailSetting" AS ENUM ('DISABLED', 'LIST', 'LABEL', 'ARCHIVE_AND_LABEL'); 6 | 7 | -- AlterTable 8 | ALTER TABLE "Newsletter" ADD COLUMN "coldEmail" "ColdEmailStatus"; 9 | 10 | -- AlterTable 11 | ALTER TABLE "User" ADD COLUMN "coldEmailBlocker" "ColdEmailSetting", 12 | ADD COLUMN "coldEmailPrompt" TEXT; 13 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240131044439_onboarding_answers/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "onboardingAnswers" JSONB; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240208223501_ai_threads/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ADD COLUMN "runOnThreads" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240317133130_ai_provider/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "aiProvider" TEXT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240319151146_unique_executed_rule/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - This deletes duplicate entries before creating a unique constraint. 5 | 6 | */ 7 | 8 | -- Delete duplicate entries 9 | DELETE FROM "ExecutedRule" 10 | WHERE id IN ( 11 | SELECT id 12 | FROM ( 13 | SELECT id, 14 | ROW_NUMBER() OVER ( 15 | PARTITION BY "userId", "threadId", "messageId" 16 | ORDER BY id 17 | ) AS row_num 18 | FROM "ExecutedRule" 19 | ) t 20 | WHERE t.row_num > 1 21 | ); 22 | 23 | -- CreateIndex 24 | CREATE UNIQUE INDEX "ExecutedRule_userId_threadId_messageId_key" ON "ExecutedRule"("userId", "threadId", "messageId"); 25 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240319151147_migrate_actions/migration.sql: -------------------------------------------------------------------------------- 1 | -- Migrate data from ExecutedRule to ExecutedAction 2 | INSERT INTO "ExecutedAction" ("id", "createdAt", "updatedAt", "type", "executedRuleId", "label", "subject", "content", "to", "cc", "bcc") 3 | SELECT 4 | gen_random_uuid(), 5 | CURRENT_TIMESTAMP, 6 | CURRENT_TIMESTAMP, 7 | unnest("actions"), 8 | "ExecutedRule"."id", 9 | "data"->>'label', 10 | "data"->>'subject', 11 | "data"->>'content', 12 | "data"->>'to', 13 | "data"->>'cc', 14 | "data"->>'bcc' 15 | FROM "ExecutedRule"; -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240319151148_delete_deprecated_fields/migration.sql: -------------------------------------------------------------------------------- 1 | -- Remove the deprecated columns from the ExecutedRule table 2 | ALTER TABLE "ExecutedRule" DROP COLUMN "actions"; 3 | ALTER TABLE "ExecutedRule" DROP COLUMN "data"; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240322094912_behaviour_profile/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "behaviorProfile" JSONB; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240323230604_last_login/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240323230633_utm/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "utms" JSONB; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240418150351_license_key/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Premium" ADD COLUMN "lemonLicenseInstanceId" TEXT, 3 | ADD COLUMN "lemonLicenseKey" TEXT; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240426150851_rule_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "RuleType" AS ENUM ('AI', 'STATIC', 'GROUP'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "Rule" ADD COLUMN "type" "RuleType" NOT NULL DEFAULT 'AI'; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240507211259_premium_admin/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "premiumAdminId" TEXT; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "User" ADD CONSTRAINT "User_premiumAdminId_fkey" FOREIGN KEY ("premiumAdminId") REFERENCES "Premium"("id") ON DELETE SET NULL ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240509085010_automate_default_off/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ALTER COLUMN "automate" SET DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240513103627_mark_not_cold_email/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ColdEmailStatus" ADD VALUE 'NOT_COLD_EMAIL'; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Newsletter" ADD COLUMN "coldEmailReason" TEXT; 6 | 7 | -- CreateIndex 8 | CREATE INDEX "Newsletter_email_coldEmail_idx" ON "Newsletter"("email", "coldEmail"); 9 | 10 | -- CreateIndex 11 | CREATE INDEX "Newsletter_email_status_idx" ON "Newsletter"("email", "status"); 12 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240516112326_remove_newsletter_cold_email/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `coldEmail` on the `Newsletter` table. All the data in the column will be lost. 5 | - You are about to drop the column `coldEmailReason` on the `Newsletter` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- DropIndex 9 | DROP INDEX "Newsletter_email_coldEmail_idx"; 10 | 11 | -- DropIndex 12 | DROP INDEX "Newsletter_email_status_idx"; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Newsletter" DROP COLUMN "coldEmail", 16 | DROP COLUMN "coldEmailReason"; 17 | 18 | -- DropEnum 19 | DROP TYPE "ColdEmailStatus"; 20 | 21 | -- CreateIndex 22 | CREATE INDEX "Newsletter_userId_status_idx" ON "Newsletter"("userId", "status"); 23 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240528083708_summary_email/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "lastSummaryEmailAt" TIMESTAMP(3), 3 | ADD COLUMN "summaryEmailFrequency" "Frequency" NOT NULL DEFAULT 'WEEKLY'; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240528181840_premium_basic/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | -- This migration adds more than one value to an enum. 3 | -- With PostgreSQL versions 11 and earlier, this is not possible 4 | -- in a single migration. This can be worked around by creating 5 | -- multiple migrations, each migration adding only one value to 6 | -- the enum. 7 | 8 | 9 | ALTER TYPE "PremiumTier" ADD VALUE 'BASIC_MONTHLY'; 10 | ALTER TYPE "PremiumTier" ADD VALUE 'BASIC_ANNUALLY'; 11 | 12 | -- AlterTable 13 | ALTER TABLE "Premium" ADD COLUMN "bulkUnsubscribeAccess" "FeatureAccess"; 14 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240624075134_argument_prompt/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Action" ADD COLUMN "bccPrompt" TEXT, 3 | ADD COLUMN "ccPrompt" TEXT, 4 | ADD COLUMN "contentPrompt" TEXT, 5 | ADD COLUMN "labelPrompt" TEXT, 6 | ADD COLUMN "subjectPrompt" TEXT, 7 | ADD COLUMN "toPrompt" TEXT; 8 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240728084326_api_key/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "ApiKey" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "name" TEXT, 7 | "hashedKey" TEXT NOT NULL, 8 | "isActive" BOOLEAN NOT NULL DEFAULT true, 9 | "userId" TEXT NOT NULL, 10 | 11 | CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); 16 | 17 | -- CreateIndex 18 | CREATE INDEX "ApiKey_userId_isActive_idx" ON "ApiKey"("userId", "isActive"); 19 | 20 | -- AddForeignKey 21 | ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 22 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240730122310_copilot_tier/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "PremiumTier" ADD VALUE 'COPILOT_MONTHLY'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240820220244_ai_api_key/migration.sql: -------------------------------------------------------------------------------- 1 | -- Rename column 2 | ALTER TABLE "User" RENAME COLUMN "openAIApiKey" TO "aiApiKey"; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240917021039_rule_prompt/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "rulesPrompt" TEXT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240917232302_disable_rule/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241008234839_error_messages/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "errorMessages" JSONB; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241020163727_app_onboarding/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" 3 | ADD COLUMN "completedAppOnboardingAt" TIMESTAMP(3), 4 | ADD COLUMN "completedOnboardingAt" TIMESTAMP(3); 5 | 6 | -- UpdateData 7 | UPDATE "User" 8 | SET "completedOnboardingAt" = CASE 9 | WHEN "completedOnboarding" = true THEN "createdAt" 10 | ELSE NULL 11 | END; 12 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241031212440_auto_categorize_senders/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "autoCategorizeSenders" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241107151035_applying_execute_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | -- This migration adds more than one value to an enum. 3 | -- With PostgreSQL versions 11 and earlier, this is not possible 4 | -- in a single migration. This can be worked around by creating 5 | -- multiple migrations, each migration adding only one value to 6 | -- the enum. 7 | 8 | 9 | ALTER TYPE "ExecutedRuleStatus" ADD VALUE 'APPLYING'; 10 | ALTER TYPE "ExecutedRuleStatus" ADD VALUE 'ERROR'; 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241107152409_remove_default_executed_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "ExecutedRule" ALTER COLUMN "status" DROP DEFAULT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241119163400_categorize_date_range/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `categorizeEmails` on the `User` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" DROP COLUMN "categorizeEmails", 9 | ADD COLUMN "newestCategorizedEmailTime" TIMESTAMP(3), 10 | ADD COLUMN "oldestCategorizedEmailTime" TIMESTAMP(3); 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241125052523_remove_categorized_time/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `newestCategorizedEmailTime` on the `User` table. All the data in the column will be lost. 5 | - You are about to drop the column `oldestCategorizedEmailTime` on the `User` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "User" DROP COLUMN "newestCategorizedEmailTime", 10 | DROP COLUMN "oldestCategorizedEmailTime"; 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241216093030_upgrade_to_v6/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "_CategoryToRule" ADD CONSTRAINT "_CategoryToRule_AB_pkey" PRIMARY KEY ("A", "B"); 3 | 4 | -- DropIndex 5 | DROP INDEX "_CategoryToRule_AB_unique"; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241218123405_multi_conditions/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "LogicalOperator" AS ENUM ('AND', 'OR'); 3 | 4 | -- AlterEnum 5 | ALTER TYPE "RuleType" ADD VALUE 'CATEGORY'; 6 | 7 | -- AlterTable 8 | ALTER TABLE "Rule" ADD COLUMN "typeLogic" "LogicalOperator" NOT NULL DEFAULT 'AND', 9 | ALTER COLUMN "instructions" DROP NOT NULL; 10 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241219122254_rename_to_conditional_operator/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" RENAME COLUMN "typeLogic" TO "conditionalOperator"; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241219190656_deprecate_rule_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- Making sure that all rules are cleaned up before we deprecate the type column 2 | 3 | -- Clean up AI rules 4 | UPDATE "Rule" 5 | SET "groupId" = NULL, 6 | "from" = NULL, 7 | "to" = NULL, 8 | "subject" = NULL, 9 | "body" = NULL 10 | WHERE "type" = 'AI'; 11 | 12 | -- Clean up GROUP rules 13 | UPDATE "Rule" 14 | SET "instructions" = NULL, 15 | "from" = NULL, 16 | "to" = NULL, 17 | "subject" = NULL, 18 | "body" = NULL, 19 | "categoryFilterType" = NULL 20 | WHERE "type" = 'GROUP'; 21 | 22 | -- Clean up STATIC rules 23 | UPDATE "Rule" 24 | SET "instructions" = NULL, 25 | "groupId" = NULL, 26 | "categoryFilterType" = NULL 27 | WHERE "type" = 'STATIC'; 28 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241219192522_optional_deprecated_rule_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ALTER COLUMN "type" DROP NOT NULL, 3 | ALTER COLUMN "type" DROP DEFAULT; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241230180925_call_webhook_action/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ActionType" ADD VALUE 'CALL_WEBHOOK'; 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ADD COLUMN "webhookSecret" TEXT; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20241230204311_action_webhook_url/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Action" ADD COLUMN "url" TEXT; 3 | 4 | -- AlterTable 5 | ALTER TABLE "ExecutedAction" ADD COLUMN "url" TEXT; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250112081255_pending_invite/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Premium" ADD COLUMN "pendingInvites" TEXT[]; 3 | 4 | -- CreateIndex 5 | CREATE INDEX "Premium_pendingInvites_idx" ON "Premium"("pendingInvites"); 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250116101856_mark_read_action/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ActionType" ADD VALUE 'MARK_READ'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Rule" DROP CONSTRAINT "Rule_groupId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Rule" ADD CONSTRAINT "Rule_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; 6 | 7 | -- Delete groups that are not used by any rule 8 | DELETE FROM "Group" 9 | WHERE "id" NOT IN ( 10 | SELECT "groupId" 11 | FROM "Rule" 12 | WHERE "groupId" IS NOT NULL 13 | ); -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250130215802_read_cold_emails/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ColdEmailSetting" ADD VALUE 'ARCHIVE_AND_READ_AND_LABEL'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250203174037_reply_tracker_sent_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "ThreadTracker" ADD COLUMN "sentAt" TIMESTAMP(3) NOT NULL; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250204162638_email_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "EmailToken" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "token" TEXT NOT NULL, 6 | "action" TEXT NOT NULL, 7 | "userId" TEXT NOT NULL, 8 | "expiresAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "EmailToken_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "EmailToken_token_key" ON "EmailToken"("token"); 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "EmailToken" ADD CONSTRAINT "EmailToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 18 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250204191020_remove_email_token_action/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `action` on the `EmailToken` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "EmailToken" DROP COLUMN "action"; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250209113928_non_null_email/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `email` on table `User` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250210224905_summary_indexes/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "ColdEmail_userId_createdAt_idx" ON "ColdEmail"("userId", "createdAt"); 3 | 4 | -- CreateIndex 5 | CREATE INDEX "ExecutedRule_userId_status_createdAt_idx" ON "ExecutedRule"("userId", "status", "createdAt"); 6 | 7 | -- CreateIndex 8 | CREATE INDEX "User_lastSummaryEmailAt_idx" ON "User"("lastSummaryEmailAt"); 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250210225300_tracker_indexes/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "ThreadTracker_userId_resolved_sentAt_type_idx" ON "ThreadTracker"("userId", "resolved", "sentAt", "type"); 3 | 4 | -- CreateIndex 5 | CREATE INDEX "ThreadTracker_userId_type_resolved_sentAt_idx" ON "ThreadTracker"("userId", "type", "resolved", "sentAt"); 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250212125908_signature/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "signature" TEXT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250223190244_draft_replies/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Rule" ADD COLUMN "draftReplies" BOOLEAN, 3 | ADD COLUMN "draftRepliesInstructions" TEXT; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250227135758_processor_type_enum/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `processorType` column on the `Payment` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "ProcessorType" AS ENUM ('LEMON_SQUEEZY'); 9 | 10 | -- AlterTable 11 | ALTER TABLE "Payment" DROP COLUMN "processorType", 12 | ADD COLUMN "processorType" "ProcessorType" NOT NULL DEFAULT 'LEMON_SQUEEZY'; 13 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250227142620_payment_tax/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `tax` to the `Payment` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `taxInclusive` to the `Payment` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "Payment" ADD COLUMN "tax" INTEGER NOT NULL, 10 | ADD COLUMN "taxInclusive" BOOLEAN NOT NULL; 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250227144751_remove_default_timestamps_from_payment/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Payment" ALTER COLUMN "createdAt" DROP DEFAULT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250227173229_remove_prompt_history/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `PromptHistory` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "PromptHistory" DROP CONSTRAINT "PromptHistory_userId_fkey"; 9 | 10 | -- DropTable 11 | DROP TABLE "PromptHistory"; 12 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250311110807_job_details/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "CleanAction" AS ENUM ('ARCHIVE', 'MARK_READ'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "CleanupJob" ADD COLUMN "action" "CleanAction" NOT NULL DEFAULT 'ARCHIVE', 6 | ADD COLUMN "daysOld" INTEGER NOT NULL DEFAULT 7, 7 | ADD COLUMN "instructions" TEXT; 8 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250312172635_skips/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CleanupJob" ADD COLUMN "skipAttachment" BOOLEAN, 3 | ADD COLUMN "skipCalendar" BOOLEAN, 4 | ADD COLUMN "skipReceipt" BOOLEAN, 5 | ADD COLUMN "skipReply" BOOLEAN, 6 | ADD COLUMN "skipStarred" BOOLEAN; 7 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250316155944_remove_size_estimate/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `sizeEstimate` on the `EmailMessage` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "EmailMessage" DROP COLUMN "sizeEstimate"; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250316201459_remove_to_domain/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `toDomain` on the `EmailMessage` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "EmailMessage" DROP COLUMN "toDomain"; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250324221721_skip_conversations/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CleanupJob" ADD COLUMN "skipConversations" BOOLEAN; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250324222007_skipconversation/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "CleanupJob" DROP COLUMN "skipConversations", 3 | ADD COLUMN "skipConversation" BOOLEAN; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250403104153_unique_knowledge_title/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Knowledge" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "title" TEXT NOT NULL, 7 | "content" TEXT NOT NULL, 8 | "userId" TEXT NOT NULL, 9 | 10 | CONSTRAINT "Knowledge_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "Knowledge_userId_title_key" ON "Knowledge"("userId", "title"); 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "Knowledge" ADD CONSTRAINT "Knowledge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 18 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250406111823_track_thread_action/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `ruleId` on the `ThreadTracker` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterEnum 8 | ALTER TYPE "ActionType" ADD VALUE 'TRACK_THREAD'; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "ThreadTracker" DROP CONSTRAINT "ThreadTracker_ruleId_fkey"; 12 | 13 | -- AlterTable 14 | ALTER TABLE "ThreadTracker" DROP COLUMN "ruleId"; 15 | 16 | -- AlterTable 17 | ALTER TABLE "User" ADD COLUMN "outboundReplyTracking" BOOLEAN NOT NULL DEFAULT false; 18 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250406111915_migrate_track_replies_to_actions/migration.sql: -------------------------------------------------------------------------------- 1 | -- Find all rules with trackReplies=true and add TRACK_THREAD actions for them 2 | 3 | -- Insert TRACK_THREAD actions for all rules with trackReplies=true 4 | -- that don't already have a TRACK_THREAD action 5 | INSERT INTO "Action" (id, "createdAt", "updatedAt", type, "ruleId") 6 | SELECT 7 | gen_random_uuid() as id, 8 | NOW() as "createdAt", 9 | NOW() as "updatedAt", 10 | 'TRACK_THREAD' as type, 11 | r.id as "ruleId" 12 | FROM "Rule" r 13 | WHERE r."trackReplies" = true 14 | AND NOT EXISTS ( 15 | SELECT 1 FROM "Action" a 16 | WHERE a."ruleId" = r.id 17 | AND a.type = 'TRACK_THREAD' 18 | ); 19 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250408111051_newsletter_learned_patterns/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Newsletter" ADD COLUMN "lastAnalyzedAt" TIMESTAMP(3), 3 | ADD COLUMN "patternAnalyzed" BOOLEAN NOT NULL DEFAULT false; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250410110949_remove_deprecated/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `draftReplies` on the `Rule` table. All the data in the column will be lost. 5 | - You are about to drop the column `draftRepliesInstructions` on the `Rule` table. All the data in the column will be lost. 6 | - You are about to drop the column `trackReplies` on the `Rule` table. All the data in the column will be lost. 7 | - You are about to drop the column `type` on the `Rule` table. All the data in the column will be lost. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Rule" DROP COLUMN "draftReplies", 12 | DROP COLUMN "draftRepliesInstructions", 13 | DROP COLUMN "trackReplies", 14 | DROP COLUMN "type"; 15 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250410111325_remove_deprecated_onboarding/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `completedOnboarding` on the `User` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "User" DROP COLUMN "completedOnboarding"; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250410132704_remove_rule_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropEnum 2 | DROP TYPE "RuleType"; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250414091625_rule_system_type/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[userId,systemType]` on the table `Rule` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "SystemType" AS ENUM ('TO_REPLY', 'NEWSLETTER', 'MARKETING', 'CALENDAR', 'RECEIPT', 'NOTIFICATION'); 9 | 10 | -- AlterTable 11 | ALTER TABLE "Rule" ADD COLUMN "systemType" "SystemType"; 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "Rule_userId_systemType_key" ON "Rule"("userId", "systemType"); 15 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250414103126_migrate_system_rule_types/migration.sql: -------------------------------------------------------------------------------- 1 | UPDATE "Rule" SET "systemType" = 'TO_REPLY' WHERE "name" = 'To Reply'; 2 | UPDATE "Rule" SET "systemType" = 'NEWSLETTER' WHERE "name" = 'Newsletter'; 3 | UPDATE "Rule" SET "systemType" = 'MARKETING' WHERE "name" = 'Marketing'; 4 | UPDATE "Rule" SET "systemType" = 'CALENDAR' WHERE "name" = 'Calendar'; 5 | UPDATE "Rule" SET "systemType" = 'RECEIPT' WHERE "name" = 'Receipt'; 6 | UPDATE "Rule" SET "systemType" = 'NOTIFICATION' WHERE "name" = 'Notification'; -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250430094808_remove_cleanupjob_email/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `email` on the `CleanupJob` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "CleanupJob" DROP COLUMN "email"; 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250502155551_lemon_subscription_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Premium" ADD COLUMN "lemonSubscriptionStatus" TEXT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250519090915_add_exclude_to_group_item/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "GroupItem" ADD COLUMN "exclude" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250521132820_message_parts/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `content` on the `ChatMessage` table. All the data in the column will be lost. 5 | - Added the required column `parts` to the `ChatMessage` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "ChatMessage" DROP COLUMN "content", 10 | ADD COLUMN "parts" JSONB NOT NULL; 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /apps/web/providers/AppProviders.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { Provider } from "jotai"; 5 | import { ComposeModalProvider } from "@/providers/ComposeModalProvider"; 6 | import { jotaiStore } from "@/store"; 7 | import { ThemeProvider } from "@/components/theme-provider"; 8 | 9 | export function AppProviders(props: { children: React.ReactNode }) { 10 | return ( 11 | 12 | 13 | {props.children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/providers/SessionProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { SessionProvider } from "next-auth/react"; 4 | -------------------------------------------------------------------------------- /apps/web/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /apps/web/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /apps/web/public/images/assistant/fix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/assistant/fix.png -------------------------------------------------------------------------------- /apps/web/public/images/assistant/process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/assistant/process.png -------------------------------------------------------------------------------- /apps/web/public/images/assistant/rule-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/assistant/rule-edit.png -------------------------------------------------------------------------------- /apps/web/public/images/assistant/rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/assistant/rules.png -------------------------------------------------------------------------------- /apps/web/public/images/blog/elie-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/blog/elie-profile.jpg -------------------------------------------------------------------------------- /apps/web/public/images/blog/inbox-zero-growth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/blog/inbox-zero-growth.png -------------------------------------------------------------------------------- /apps/web/public/images/blog/messy-vs-clean-inbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/blog/messy-vs-clean-inbox.png -------------------------------------------------------------------------------- /apps/web/public/images/blog/ricardo-batista-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/blog/ricardo-batista-profile.png -------------------------------------------------------------------------------- /apps/web/public/images/home/ai-email-assistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/ai-email-assistant.png -------------------------------------------------------------------------------- /apps/web/public/images/home/bulk-unsubscriber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/bulk-unsubscriber.png -------------------------------------------------------------------------------- /apps/web/public/images/home/cold-email-blocker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/cold-email-blocker.png -------------------------------------------------------------------------------- /apps/web/public/images/home/email-analytics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/email-analytics.png -------------------------------------------------------------------------------- /apps/web/public/images/home/mail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/mail.png -------------------------------------------------------------------------------- /apps/web/public/images/home/realtor-gmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/realtor-gmail.png -------------------------------------------------------------------------------- /apps/web/public/images/home/reply-zero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/reply-zero.png -------------------------------------------------------------------------------- /apps/web/public/images/home/testimonials/steve-rad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/home/testimonials/steve-rad.png -------------------------------------------------------------------------------- /apps/web/public/images/reach-inbox-zero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/reach-inbox-zero.png -------------------------------------------------------------------------------- /apps/web/public/images/testimonials/joseph-gonzalez-iFgRcqHznqg-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/testimonials/joseph-gonzalez-iFgRcqHznqg-unsplash.jpg -------------------------------------------------------------------------------- /apps/web/public/images/testimonials/midas-hofstra-a6PMA5JEmWE-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/images/testimonials/midas-hofstra-a6PMA5JEmWE-unsplash.jpg -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.2__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.2__iPad_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.2__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.2__iPad_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.5__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.5__iPad_Air_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.5__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.5__iPad_Air_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.9__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.9__iPad_Air_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/10.9__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/10.9__iPad_Air_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/11__iPad_Pro_M4_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/11__iPad_Pro_M4_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/11__iPad_Pro_M4_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/11__iPad_Pro_M4_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/12.9__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/12.9__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/12.9__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/12.9__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/13__iPad_Pro_M4_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/13__iPad_Pro_M4_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/13__iPad_Pro_M4_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/13__iPad_Pro_M4_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/8.3__iPad_Mini_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/8.3__iPad_Mini_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/8.3__iPad_Mini_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/8.3__iPad_Mini_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_11__iPhone_XR_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_11__iPhone_XR_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_11__iPhone_XR_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_11__iPhone_XR_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png -------------------------------------------------------------------------------- /apps/web/public/splash_screens/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/public/splash_screens/icon.png -------------------------------------------------------------------------------- /apps/web/sanity.cli.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This configuration file lets you run `$ sanity [command]` in this folder 3 | * Go to https://www.sanity.io/docs/cli to learn more. 4 | **/ 5 | import { defineCliConfig } from "sanity/cli"; 6 | 7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID; 8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET; 9 | 10 | export default defineCliConfig({ api: { projectId, dataset } }); 11 | -------------------------------------------------------------------------------- /apps/web/scripts/addUsersToResend.ts: -------------------------------------------------------------------------------- 1 | // Run with: `npx tsx scripts/addUsersToResend.ts`. Make sure to set ENV vars 2 | 3 | import { createContact } from "@inboxzero/resend"; 4 | import { PrismaClient } from "@prisma/client"; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | async function main() { 9 | const users = await prisma.user.findMany({ select: { email: true } }); 10 | 11 | for (const user of users) { 12 | try { 13 | if (user.email) { 14 | console.log("Adding user", user.email); 15 | const { error } = await createContact({ email: user.email }); 16 | if (error) console.error(error); 17 | } 18 | } catch (error) { 19 | console.error("Error creating contact for user: ", user.email, error); 20 | } 21 | } 22 | } 23 | 24 | main().finally(() => { 25 | prisma.$disconnect(); 26 | }); 27 | -------------------------------------------------------------------------------- /apps/web/store/QueueInitializer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { processQueue, useQueueState } from "@/store/archive-queue"; 5 | import { useAccount } from "@/providers/EmailAccountProvider"; 6 | 7 | let isInitialized = false; 8 | 9 | function useInitializeQueues() { 10 | const queueState = useQueueState(); 11 | const { emailAccountId } = useAccount(); 12 | 13 | useEffect(() => { 14 | if (!isInitialized) { 15 | isInitialized = true; 16 | if (queueState.activeThreads) { 17 | processQueue({ 18 | threads: queueState.activeThreads, 19 | emailAccountId, 20 | }); 21 | } 22 | } 23 | }, [queueState.activeThreads, emailAccountId]); 24 | } 25 | 26 | export function QueueInitializer() { 27 | useInitializeQueues(); 28 | return null; 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/store/email.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const refetchEmailListAtom = atom< 4 | { refetch: (options?: { removedThreadIds?: string[] }) => void } | undefined 5 | >(undefined); 6 | -------------------------------------------------------------------------------- /apps/web/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "jotai"; 2 | 3 | export const jotaiStore = createStore(); 4 | -------------------------------------------------------------------------------- /apps/web/styles/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/apps/web/styles/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/nextjs.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "plugins": [ 6 | { 7 | "name": "next" 8 | } 9 | ], 10 | "paths": { 11 | "@/*": [ 12 | "./*" 13 | ], 14 | "@next/third-parties/google": [ 15 | "./node_modules/@next/third-parties/dist/google" 16 | ], 17 | "sanity/*": [ 18 | "../node_modules/sanity/*" 19 | ] 20 | }, 21 | "target": "ES2017", 22 | "strictNullChecks": true 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx", 28 | ".next/types/**/*.ts", 29 | "./env.ts" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "public/sw.js" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/types/gmail-api-parse-message.d.ts: -------------------------------------------------------------------------------- 1 | declare module "gmail-api-parse-message"; 2 | -------------------------------------------------------------------------------- /apps/web/utils/__mocks__/prisma.ts: -------------------------------------------------------------------------------- 1 | // https://www.prisma.io/blog/testing-series-1-8eRB5p0Y8o#why-mock-prisma-client 2 | import type { PrismaClient } from "@prisma/client"; 3 | import { beforeEach } from "vitest"; 4 | import { mockDeep, mockReset } from "vitest-mock-extended"; 5 | 6 | const prisma = mockDeep(); 7 | 8 | beforeEach(() => { 9 | mockReset(prisma); 10 | }); 11 | 12 | export default prisma; 13 | -------------------------------------------------------------------------------- /apps/web/utils/actions/api-key.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const createApiKeyBody = z.object({ name: z.string().nullish() }); 4 | export type CreateApiKeyBody = z.infer; 5 | 6 | export const deactivateApiKeyBody = z.object({ id: z.string() }); 7 | export type DeactivateApiKeyBody = z.infer; 8 | -------------------------------------------------------------------------------- /apps/web/utils/actions/categorize.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const createCategoryBody = z.object({ 4 | id: z.string().nullish(), 5 | name: z.string().max(30), 6 | description: z.string().max(300).nullish(), 7 | }); 8 | export type CreateCategoryBody = z.infer; 9 | -------------------------------------------------------------------------------- /apps/web/utils/actions/error-messages.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { clearUserErrorMessages } from "@/utils/error-messages"; 5 | import { actionClientUser } from "@/utils/actions/safe-action"; 6 | 7 | export const clearUserErrorMessagesAction = actionClientUser 8 | .metadata({ name: "clearUserErrorMessages" }) 9 | .action(async ({ ctx: { userId } }) => { 10 | await clearUserErrorMessages({ userId }); 11 | revalidatePath("/(app)", "layout"); 12 | }); 13 | -------------------------------------------------------------------------------- /apps/web/utils/actions/generate-reply.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const messageSchema = z 4 | .object({ 5 | id: z.string(), 6 | from: z.string(), 7 | to: z.string(), 8 | subject: z.string(), 9 | textPlain: z.string().optional(), 10 | textHtml: z.string().optional(), 11 | date: z.string(), 12 | }) 13 | .refine((data) => data.textPlain || data.textHtml, { 14 | message: "At least one of textPlain or textHtml is required", 15 | }); 16 | 17 | export const generateReplySchema = z.object({ 18 | messages: z.array(messageSchema), 19 | }); 20 | 21 | export type GenerateReplySchema = z.infer; 22 | -------------------------------------------------------------------------------- /apps/web/utils/actions/group.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { GroupItemType } from "@prisma/client"; 3 | 4 | export const createGroupBody = z.object({ 5 | ruleId: z.string(), 6 | }); 7 | export type CreateGroupBody = z.infer; 8 | 9 | export const addGroupItemBody = z.object({ 10 | groupId: z.string(), 11 | type: z.enum([GroupItemType.FROM, GroupItemType.SUBJECT]), 12 | value: z.string(), 13 | exclude: z.boolean().optional(), 14 | }); 15 | export type AddGroupItemBody = z.infer; 16 | -------------------------------------------------------------------------------- /apps/web/utils/actions/knowledge.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const createKnowledgeBody = z.object({ 4 | title: z.string().min(1, "Title is required"), 5 | content: z.string(), 6 | }); 7 | 8 | export type CreateKnowledgeBody = z.infer; 9 | 10 | export const updateKnowledgeBody = z.object({ 11 | id: z.string(), 12 | title: z.string().min(1, "Title is required"), 13 | content: z.string(), 14 | }); 15 | 16 | export type UpdateKnowledgeBody = z.infer; 17 | 18 | export const deleteKnowledgeBody = z.object({ 19 | id: z.string(), 20 | }); 21 | 22 | export type DeleteKnowledgeBody = z.infer; 23 | -------------------------------------------------------------------------------- /apps/web/utils/actions/premium.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const activateLicenseKeySchema = z.object({ 4 | licenseKey: z.string(), 5 | }); 6 | export type ActivateLicenseKeyOptions = z.infer< 7 | typeof activateLicenseKeySchema 8 | >; 9 | -------------------------------------------------------------------------------- /apps/web/utils/actions/unsubscriber.validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { NewsletterStatus } from "@prisma/client"; 3 | 4 | export const setNewsletterStatusBody = z.object({ 5 | newsletterEmail: z.string().email(), 6 | status: z.nativeEnum(NewsletterStatus).nullable(), 7 | }); 8 | export type SetNewsletterStatusBody = z.infer; 9 | -------------------------------------------------------------------------------- /apps/web/utils/actions/webhook.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import prisma from "@/utils/prisma"; 4 | import { actionClientUser } from "@/utils/actions/safe-action"; 5 | 6 | export const regenerateWebhookSecretAction = actionClientUser 7 | .metadata({ name: "regenerateWebhookSecret" }) 8 | .action(async ({ ctx: { userId } }) => { 9 | const webhookSecret = generateWebhookSecret(); 10 | 11 | await prisma.user.update({ 12 | where: { id: userId }, 13 | data: { webhookSecret }, 14 | }); 15 | }); 16 | 17 | function generateWebhookSecret(length = 32) { 18 | const chars = 19 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 20 | return Array.from(crypto.getRandomValues(new Uint8Array(length))) 21 | .map((x) => chars[x % chars.length]) 22 | .join(""); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/utils/actions/whitelist.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { env } from "@/env"; 4 | import { createFilter } from "@/utils/gmail/filter"; 5 | import { GmailLabel } from "@/utils/gmail/label"; 6 | import { actionClient } from "@/utils/actions/safe-action"; 7 | import { getGmailClientForEmail } from "@/utils/account"; 8 | 9 | export const whitelistInboxZeroAction = actionClient 10 | .metadata({ name: "whitelistInboxZero" }) 11 | .action(async ({ ctx: { emailAccountId } }) => { 12 | if (!env.WHITELIST_FROM) return; 13 | 14 | const gmail = await getGmailClientForEmail({ emailAccountId }); 15 | 16 | await createFilter({ 17 | gmail, 18 | from: env.WHITELIST_FROM, 19 | addLabelIds: ["CATEGORY_PERSONAL"], 20 | removeLabelIds: [GmailLabel.SPAM], 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web/utils/admin.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | 3 | export function isAdmin({ email }: { email?: string | null }) { 4 | if (!email) return false; 5 | return env.ADMINS?.includes(email); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/utils/ai/categorize-sender/format-categories.ts: -------------------------------------------------------------------------------- 1 | import type { Category } from "@prisma/client"; 2 | 3 | export function formatCategoriesForPrompt( 4 | categories: Pick[], 5 | ): string { 6 | return categories 7 | .map((category) => `- ${category.name}: ${category.description}`) 8 | .join("\n"); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/utils/ai/choose-rule/NOTES.md: -------------------------------------------------------------------------------- 1 | # AI Rules 2 | 3 | When we receive an email for processing: 4 | 5 | 1. We choose how to act on the rule (AI/Static/Group) 6 | 2. If needed we choose the arguments for the rule using AI 7 | 3. We perform the action 8 | 9 | We don't always perform the action immediately. We may need user confirmation from the user first. 10 | -------------------------------------------------------------------------------- /apps/web/utils/ai/types.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedMessage, PartialRecord } from "@/utils/types"; 2 | import type { ExecutedAction } from "@prisma/client"; 3 | 4 | export type EmailForAction = Pick< 5 | ParsedMessage, 6 | | "threadId" 7 | | "id" 8 | | "headers" 9 | | "textPlain" 10 | | "textHtml" 11 | | "attachments" 12 | | "internalDate" 13 | >; 14 | 15 | export type ActionItem = { 16 | id: ExecutedAction["id"]; 17 | type: ExecutedAction["type"]; 18 | label?: ExecutedAction["label"]; 19 | subject?: ExecutedAction["subject"]; 20 | content?: ExecutedAction["content"]; 21 | to?: ExecutedAction["to"]; 22 | cc?: ExecutedAction["cc"]; 23 | bcc?: ExecutedAction["bcc"]; 24 | url?: ExecutedAction["url"]; 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/utils/api-key.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { randomBytes, scryptSync } from "node:crypto"; 3 | 4 | export function generateSecureToken(): string { 5 | return randomBytes(32).toString("base64"); 6 | } 7 | 8 | export function hashApiKey(apiKey: string): string { 9 | if (!env.API_KEY_SALT) throw new Error("API_KEY_SALT is not set"); 10 | const derivedKey = scryptSync(apiKey, env.API_KEY_SALT, 64); 11 | return `${env.API_KEY_SALT}:${derivedKey.toString("hex")}`; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/utils/braintrust.ts: -------------------------------------------------------------------------------- 1 | import { initDataset, type Dataset } from "braintrust"; 2 | 3 | // Used for evals. Not used in production. 4 | export class Braintrust { 5 | private dataset: Dataset | null = null; 6 | 7 | constructor(dataset: string) { 8 | if (process.env.BRAINTRUST_API_KEY) { 9 | this.dataset = initDataset("inbox-zero", { dataset }); 10 | } 11 | } 12 | 13 | insertToDataset(data: { id: string; input: unknown; expected?: unknown }) { 14 | if (!this.dataset) return; 15 | 16 | try { 17 | this.dataset.insert(data); 18 | } catch (error) { 19 | console.error(error); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/utils/celebration.ts: -------------------------------------------------------------------------------- 1 | const urls = [ 2 | "https://illustrations.popsy.co/amber/app-launch.svg", 3 | "https://illustrations.popsy.co/amber/work-party.svg", 4 | "https://illustrations.popsy.co/amber/freelancer.svg", 5 | "https://illustrations.popsy.co/amber/working-vacation.svg", 6 | "https://illustrations.popsy.co/amber/remote-work.svg", 7 | "https://illustrations.popsy.co/amber/man-riding-a-rocket.svg", 8 | "https://illustrations.popsy.co/amber/backpacking.svg", 9 | ]; 10 | 11 | export const getCelebrationImage = () => { 12 | return urls[Math.floor(Math.random() * urls.length)]; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/web/utils/cold-email/prompt.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COLD_EMAIL_PROMPT = `Examples of cold emails: 2 | - Sell a product or service (e.g., agency pitching their services) 3 | - Recruit for a job position 4 | - Request a partnership or collaboration 5 | 6 | Emails that are NOT cold emails include: 7 | - Email from an investor that wants to learn more or invest in the company 8 | - Email from a friend or colleague 9 | - Email from someone you met at a conference 10 | - Email from a customer 11 | - Newsletter 12 | - Password reset 13 | - Welcome emails 14 | - Receipts 15 | - Promotions 16 | - Alerts 17 | - Updates 18 | - Calendar invites 19 | 20 | Regular marketing or automated emails are NOT cold emails, even if unwanted.`; 21 | -------------------------------------------------------------------------------- /apps/web/utils/colors.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | "#ef4444", // Red 500 3 | "#f97316", // Orange 500 4 | "#f59e0b", // Amber 500 5 | "#eab308", // Yellow 500 6 | "#84cc16", // Lime 500 7 | "#22c55e", // Green 500 8 | "#10b981", // Emerald 500 9 | "#14b8a6", // Teal 500 10 | "#06b6d4", // Cyan 500 11 | "#0ea5e9", // Sky 500 12 | "#3b82f6", // Blue 500 13 | "#6366f1", // Indigo 500 14 | "#8b5cf6", // Violet 500 15 | "#a855f7", // Purple 500 16 | "#d946ef", // Fuchsia 500 17 | "#ec4899", // Pink 500 18 | "#f43f5e", // Rose 500 19 | ]; 20 | 21 | export function getRandomColor() { 22 | return colors[Math.floor(Math.random() * colors.length)]; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/utils/config.ts: -------------------------------------------------------------------------------- 1 | export const AI_GENERATED_FIELD_VALUE = "___AI_GENERATE___"; 2 | 3 | export const EMAIL_ACCOUNT_HEADER = "X-Email-Account-ID"; 4 | 5 | export const NO_REFRESH_TOKEN_ERROR_CODE = "NO_REFRESH_TOKEN"; 6 | 7 | export const userCount = "10,000+"; 8 | 9 | export const KNOWLEDGE_BASIC_MAX_ITEMS = 1; 10 | export const KNOWLEDGE_BASIC_MAX_CHARS = 2000; 11 | 12 | export const ConditionType = { 13 | AI: "AI", 14 | STATIC: "STATIC", 15 | GROUP: "GROUP", 16 | CATEGORY: "CATEGORY", 17 | PRESET: "PRESET", 18 | } as const; 19 | 20 | export type ConditionType = (typeof ConditionType)[keyof typeof ConditionType]; 21 | export type CoreConditionType = Exclude; 22 | -------------------------------------------------------------------------------- /apps/web/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | export const ASSISTANT_ONBOARDING_COOKIE = "viewed_assistant_onboarding"; 2 | export const REPLY_ZERO_ONBOARDING_COOKIE = "viewed_reply_zero_onboarding"; 3 | 4 | export function markOnboardingAsCompleted(cookie: string) { 5 | document.cookie = `${cookie}=true; path=/; max-age=${Number.MAX_SAFE_INTEGER}`; 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/utils/error.server.ts: -------------------------------------------------------------------------------- 1 | import { setUser } from "@sentry/nextjs"; 2 | import { trackError } from "@/utils/posthog"; 3 | import { auth } from "@/app/api/auth/[...nextauth]/auth"; 4 | import { createScopedLogger } from "@/utils/logger"; 5 | 6 | const logger = createScopedLogger("error.server"); 7 | 8 | export async function logErrorToPosthog( 9 | type: "api" | "action", 10 | url: string, 11 | errorType: string, 12 | ) { 13 | try { 14 | const session = await auth(); 15 | if (session?.user.email) { 16 | setUser({ email: session.user.email }); 17 | await trackError({ email: session.user.email, errorType, type, url }); 18 | } 19 | } catch (error) { 20 | logger.error("Error logging to PostHog:", { error }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; 2 | 3 | /** 4 | * A wrapper around the native fetch function that automatically adds the 5 | * EMAIL_ACCOUNT_HEADER if an emailAccountId is provided. 6 | */ 7 | export const fetchWithAccount = async ({ 8 | url, 9 | emailAccountId, 10 | init, 11 | }: { 12 | url: string | URL | Request; 13 | emailAccountId?: string | null; 14 | init?: RequestInit; 15 | }): Promise => { 16 | const headers = new Headers(init?.headers); 17 | 18 | if (emailAccountId) { 19 | headers.set(EMAIL_ACCOUNT_HEADER, emailAccountId); 20 | } 21 | 22 | const newInit = { ...init, headers }; 23 | 24 | return fetch(url, newInit); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/utils/filter-ignored-senders.ts: -------------------------------------------------------------------------------- 1 | // NOTE: Can make this an array in the future 2 | // const ignoredSenders = ["Reminder "]; 3 | // return ignoredSenders.includes(sender); 4 | export function isIgnoredSender(sender: string) { 5 | // Superhuman adds reminder emails which are automatically filtered out within Superhuman 6 | return sender === "Reminder "; 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/utils/get-email-from-message.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedMessage, EmailForLLM } from "@/utils/types"; 2 | import { emailToContent, type EmailToContentOptions } from "@/utils/mail"; 3 | import { internalDateToDate } from "@/utils/date"; 4 | 5 | export function getEmailForLLM( 6 | message: ParsedMessage, 7 | contentOptions?: EmailToContentOptions, 8 | ): EmailForLLM { 9 | return { 10 | id: message.id, 11 | from: message.headers.from, 12 | to: message.headers.to, 13 | replyTo: message.headers["reply-to"], 14 | cc: message.headers.cc, 15 | subject: message.headers.subject, 16 | content: emailToContent(message, contentOptions), 17 | date: internalDateToDate(message.internalDate), 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/attachment.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | 3 | export async function getGmailAttachment( 4 | gmail: gmail_v1.Gmail, 5 | messageId: string, 6 | attachmentId: string, 7 | ) { 8 | const attachment = await gmail.users.messages.attachments.get({ 9 | userId: "me", 10 | id: attachmentId, 11 | messageId, 12 | }); 13 | const attachmentData = attachment.data; 14 | return attachmentData; 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/constants.ts: -------------------------------------------------------------------------------- 1 | export const messageVisibility = { 2 | show: "show", 3 | hide: "hide", 4 | } as const; 5 | export type MessageVisibility = 6 | (typeof messageVisibility)[keyof typeof messageVisibility]; 7 | 8 | export const labelVisibility = { 9 | labelShow: "labelShow", 10 | labelShowIfUnread: "labelShowIfUnread", 11 | labelHide: "labelHide", 12 | } as const; 13 | export type LabelVisibility = 14 | (typeof labelVisibility)[keyof typeof labelVisibility]; 15 | 16 | export const GOOGLE_LINKING_STATE_COOKIE_NAME = "google_linking_state"; 17 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/contact.ts: -------------------------------------------------------------------------------- 1 | import type { people_v1 } from "@googleapis/people"; 2 | 3 | export async function searchContacts(client: people_v1.People, query: string) { 4 | const readMasks: (keyof people_v1.Schema$Person)[] = [ 5 | "names", 6 | "emailAddresses", 7 | "photos", 8 | ]; 9 | 10 | const res = await client.people.searchContacts({ 11 | query, 12 | readMask: readMasks.join(","), 13 | pageSize: 10, 14 | }); 15 | 16 | const contacts = 17 | res.data.results?.filter((c) => c.person?.emailAddresses?.[0]) || []; 18 | 19 | return contacts; 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/decode.ts: -------------------------------------------------------------------------------- 1 | import he from "he"; 2 | 3 | export function decodeSnippet(snippet?: string | null) { 4 | if (!snippet) return ""; 5 | return he.decode(snippet).replace(/\u200C|\u200D|\uFEFF/g, ""); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/history.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | 3 | export async function getHistory( 4 | gmail: gmail_v1.Gmail, 5 | options: { 6 | startHistoryId: string; 7 | historyTypes?: string[]; 8 | maxResults?: number; 9 | }, 10 | ) { 11 | const history = await gmail.users.history.list({ 12 | userId: "me", 13 | startHistoryId: options.startHistoryId, 14 | historyTypes: options.historyTypes, 15 | maxResults: options.maxResults, 16 | }); 17 | 18 | return history.data; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/scopes.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | 3 | export const SCOPES = [ 4 | "https://www.googleapis.com/auth/userinfo.profile", 5 | "https://www.googleapis.com/auth/userinfo.email", 6 | 7 | "https://www.googleapis.com/auth/gmail.modify", 8 | "https://www.googleapis.com/auth/gmail.settings.basic", 9 | ...(env.NEXT_PUBLIC_CONTACTS_ENABLED 10 | ? ["https://www.googleapis.com/auth/contacts"] 11 | : []), 12 | ]; 13 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/settings.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | 3 | export async function getFilters(gmail: gmail_v1.Gmail) { 4 | const res = await gmail.users.settings.filters.list({ userId: "me" }); 5 | return res.data.filter || []; 6 | } 7 | 8 | export async function getForwardingAddresses(gmail: gmail_v1.Gmail) { 9 | const res = await gmail.users.settings.forwardingAddresses.list({ 10 | userId: "me", 11 | }); 12 | return res.data.forwardingAddresses || []; 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/snippet.ts: -------------------------------------------------------------------------------- 1 | // NOTE: this only works for English. May want to support other languages in the future. 2 | export function snippetRemoveReply(snippet?: string | null): string { 3 | if (!snippet) return ""; 4 | try { 5 | const regex = /On (Mon|Tue|Wed|Thu|Fri|Sat|Sun),/; 6 | const match = snippet.split(regex)[0]; 7 | return match.trim(); 8 | } catch (error) { 9 | return snippet; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/spam.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | import { GmailLabel } from "@/utils/gmail/label"; 3 | 4 | export async function markSpam(options: { 5 | gmail: gmail_v1.Gmail; 6 | threadId: string; 7 | }) { 8 | const { gmail, threadId } = options; 9 | 10 | return gmail.users.threads.modify({ 11 | userId: "me", 12 | id: threadId, 13 | requestBody: { 14 | addLabelIds: [GmailLabel.SPAM], 15 | }, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/utils/gmail/watch.ts: -------------------------------------------------------------------------------- 1 | import type { gmail_v1 } from "@googleapis/gmail"; 2 | import { GmailLabel } from "./label"; 3 | import { env } from "@/env"; 4 | 5 | export async function watchGmail(gmail: gmail_v1.Gmail) { 6 | const res = await gmail.users.watch({ 7 | userId: "me", 8 | requestBody: { 9 | labelIds: [GmailLabel.INBOX, GmailLabel.SENT], 10 | labelFilterBehavior: "include", 11 | topicName: env.GOOGLE_PUBSUB_TOPIC_NAME, 12 | }, 13 | }); 14 | 15 | return res.data; 16 | } 17 | 18 | export async function unwatchGmail(gmail: gmail_v1.Gmail) { 19 | await gmail.users.stop({ userId: "me" }); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/utils/group/group-item.ts: -------------------------------------------------------------------------------- 1 | import prisma, { isDuplicateError } from "@/utils/prisma"; 2 | import type { GroupItemType } from "@prisma/client"; 3 | import { captureException } from "@/utils/error"; 4 | 5 | export async function addGroupItem(data: { 6 | groupId: string; 7 | type: GroupItemType; 8 | value: string; 9 | exclude?: boolean; 10 | }) { 11 | try { 12 | return await prisma.groupItem.create({ data }); 13 | } catch (error) { 14 | if (isDuplicateError(error)) { 15 | captureException(error, { extra: { items: data } }); 16 | } else { 17 | throw error; 18 | } 19 | } 20 | } 21 | 22 | export async function deleteGroupItem({ 23 | id, 24 | emailAccountId, 25 | }: { 26 | id: string; 27 | emailAccountId: string; 28 | }) { 29 | await prisma.groupItem.delete({ 30 | where: { id, group: { emailAccountId } }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/utils/gtm.ts: -------------------------------------------------------------------------------- 1 | import { sendGTMEvent } from "@next/third-parties/google"; 2 | import { env } from "@/env"; 3 | 4 | export const signUpEvent = () => { 5 | if (env.NEXT_PUBLIC_GTM_ID) sendGTMEvent({ event: "CompleteRegistration" }); 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | /** 9 | * Removes null and undefined properties from an object 10 | */ 11 | export function filterNullProperties>( 12 | obj: T, 13 | ): Partial { 14 | return Object.fromEntries( 15 | Object.entries(obj).filter(([_, value]) => value != null), 16 | ) as Partial; 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/utils/internal-api.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import type { Logger } from "@/utils/logger"; 3 | 4 | export const INTERNAL_API_KEY_HEADER = "x-api-key"; 5 | 6 | export const isValidInternalApiKey = ( 7 | headers: Headers, 8 | logger: Logger, 9 | ): boolean => { 10 | if (!env.INTERNAL_API_KEY) { 11 | logger.error("No internal API key set"); 12 | return false; 13 | } 14 | const apiKey = headers.get(INTERNAL_API_KEY_HEADER); 15 | const isValid = apiKey === env.INTERNAL_API_KEY; 16 | if (!isValid) logger.error("Invalid API key", { invalidApiKey: apiKey }); 17 | return isValid; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/utils/json.ts: -------------------------------------------------------------------------------- 1 | import json5 from "json5"; 2 | 3 | export const parseJSON = (text: string) => { 4 | try { 5 | return json5.parse(text); 6 | } catch (error) { 7 | console.error(`Error parsing JSON. Text: ${text}`); 8 | throw error; 9 | } 10 | }; 11 | 12 | // OpenAI sends us multiline JSON with newlines inside strings which we need to fix. 13 | export function parseJSONWithMultilines(text: string) { 14 | try { 15 | const escapedNewlines = text 16 | .split('"') 17 | .map((s, i) => { 18 | const inQuotes = i % 2 === 1; 19 | if (inQuotes) return s.replaceAll("\n", "\\n"); 20 | return s; 21 | }) 22 | .join('"'); 23 | 24 | return JSON.parse(escapedNewlines); 25 | } catch (error) { 26 | console.error(`Error parsing JSON with multiline. Text: ${text}`); 27 | throw error; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/utils/llms/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getTodayForLLM(date: Date = new Date()) { 2 | return `Today's date and time is: ${date.toISOString()}.`; 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/utils/llms/types.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from "@prisma/client"; 2 | 3 | export type UserAIFields = Prisma.UserGetPayload<{ 4 | select: { 5 | aiProvider: true; 6 | aiModel: true; 7 | aiApiKey: true; 8 | }; 9 | }>; 10 | export type EmailAccountWithAI = Prisma.EmailAccountGetPayload<{ 11 | select: { 12 | id: true; 13 | userId: true; 14 | email: true; 15 | about: true; 16 | user: { 17 | select: { 18 | aiProvider: true; 19 | aiModel: true; 20 | aiApiKey: true; 21 | }; 22 | }; 23 | }; 24 | }>; 25 | -------------------------------------------------------------------------------- /apps/web/utils/parse/cta.ts: -------------------------------------------------------------------------------- 1 | const ctaKeywords = [ 2 | "see more", // e.g. "See more details" 3 | "view it", // e.g. "View it on GitHub" 4 | "view reply", 5 | "view comment", 6 | "view question", 7 | "view message", 8 | "view in", // e.g. "View in Airtable" 9 | "confirm", // e.g. "Confirm subscription" 10 | "join the conversation", // e.g. LinkedIn 11 | "go to console", 12 | "open messenger", // Facebook 13 | "open in", // e.g. Slack 14 | "reply", 15 | ]; 16 | 17 | export function containsCtaKeyword(text: string) { 18 | const maxLength = 30; // Avoid CTAs that are sentences 19 | return ctaKeywords.some( 20 | (keyword) => text.includes(keyword) && text.length < maxLength, 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/utils/parse/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | const unsubscribeKeywords = [ 2 | "unsubscribe", 3 | "email preferences", 4 | "email settings", 5 | "email options", 6 | "notification preferences", 7 | ]; 8 | 9 | export function containsUnsubscribeKeyword(text: string) { 10 | return unsubscribeKeywords.some((keyword) => text.includes(keyword)); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/utils/path.ts: -------------------------------------------------------------------------------- 1 | export const prefixPath = (emailAccountId: string, path: `/${string}`) => { 2 | if (emailAccountId) return `/${emailAccountId}${path}`; 3 | return path; 4 | }; 5 | -------------------------------------------------------------------------------- /apps/web/utils/premium/create-premium.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/utils/prisma"; 2 | 3 | export async function createPremiumForUser({ userId }: { userId: string }) { 4 | return await prisma.premium.create({ 5 | data: { 6 | users: { connect: { id: userId } }, 7 | admins: { connect: { id: userId } }, 8 | }, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/utils/queue/ai-queue.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PQueue from "p-queue"; 4 | 5 | // Avoid overwhelming AI API 6 | export const aiQueue = new PQueue({ concurrency: 1 }); 7 | -------------------------------------------------------------------------------- /apps/web/utils/queue/email-action-queue.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PQueue from "p-queue"; 4 | 5 | // Avoid overwhelming Gmail API 6 | export const emailActionQueue = new PQueue({ concurrency: 1 }); 7 | -------------------------------------------------------------------------------- /apps/web/utils/redis/clean.types.ts: -------------------------------------------------------------------------------- 1 | export type CleanThread = { 2 | emailAccountId: string; 3 | threadId: string; 4 | jobId: string; 5 | status: "processing" | "applying" | "completed"; 6 | createdAt: string; 7 | from: string; 8 | subject: string; 9 | snippet: string; 10 | date: Date; 11 | 12 | archive?: boolean; 13 | label?: string; 14 | undone?: boolean; 15 | }; 16 | -------------------------------------------------------------------------------- /apps/web/utils/redis/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { Redis } from "@upstash/redis"; 3 | 4 | export const redis = new Redis({ 5 | url: env.UPSTASH_REDIS_URL, 6 | token: env.UPSTASH_REDIS_TOKEN, 7 | }); 8 | 9 | export async function expire(key: string, seconds: number) { 10 | return redis.expire(key, seconds); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/utils/redis/message-processing.ts: -------------------------------------------------------------------------------- 1 | import { redis } from "@/utils/redis"; 2 | 3 | function getProcessingKey({ 4 | userEmail, 5 | messageId, 6 | }: { 7 | userEmail: string; 8 | messageId: string; 9 | }) { 10 | return `processing-message:${userEmail}:${messageId}`; 11 | } 12 | 13 | export async function markMessageAsProcessing({ 14 | userEmail, 15 | messageId, 16 | }: { 17 | userEmail: string; 18 | messageId: string; 19 | }): Promise { 20 | const result = await redis.set( 21 | getProcessingKey({ userEmail, messageId }), 22 | "true", 23 | { 24 | ex: 60 * 5, // 5 minutes 25 | nx: true, // Only set if key doesn't exist 26 | }, 27 | ); 28 | 29 | // Redis returns "OK" if the key was set, and null if it was already set 30 | return result === "OK"; 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/utils/redis/reply.ts: -------------------------------------------------------------------------------- 1 | import { redis } from "@/utils/redis"; 2 | 3 | function getReplyKey({ 4 | emailAccountId, 5 | messageId, 6 | }: { 7 | emailAccountId: string; 8 | messageId: string; 9 | }) { 10 | return `reply:${emailAccountId}:${messageId}`; 11 | } 12 | 13 | export async function getReply({ 14 | emailAccountId, 15 | messageId, 16 | }: { 17 | emailAccountId: string; 18 | messageId: string; 19 | }): Promise { 20 | return redis.get(getReplyKey({ emailAccountId, messageId })); 21 | } 22 | 23 | export async function saveReply({ 24 | emailAccountId, 25 | messageId, 26 | reply, 27 | }: { 28 | emailAccountId: string; 29 | messageId: string; 30 | reply: string; 31 | }) { 32 | return redis.set(getReplyKey({ emailAccountId, messageId }), reply, { 33 | ex: 60 * 60 * 24, // 1 day 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/utils/redis/summary.ts: -------------------------------------------------------------------------------- 1 | import { redis } from "@/utils/redis"; 2 | 3 | export async function getSummary(text: string): Promise { 4 | return redis.get(text); 5 | } 6 | 7 | export async function saveSummary(text: string, summary: string) { 8 | return redis.set(text, summary); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/utils/reply-tracker/consts.ts: -------------------------------------------------------------------------------- 1 | export const NEEDS_REPLY_LABEL_NAME = "To Reply"; 2 | export const AWAITING_REPLY_LABEL_NAME = "Awaiting Reply"; 3 | 4 | export const defaultReplyTrackerInstructions = `Apply this to emails needing my direct response. Exclude: 5 | - All automated notifications (LinkedIn, Facebook, GitHub, social media, marketing) 6 | - System emails (order confirmations, calendar invites) 7 | 8 | Only flag when someone: 9 | - Asks me a direct question 10 | - Requests information or action 11 | - Needs my specific input 12 | - Follows up on a conversation`; 13 | -------------------------------------------------------------------------------- /apps/web/utils/rule/consts.ts: -------------------------------------------------------------------------------- 1 | // The default names we give to rules in our database. The user can edit these 2 | export const RuleName = { 3 | ToReply: "To Reply", 4 | Newsletter: "Newsletter", 5 | Marketing: "Marketing", 6 | Calendar: "Calendar", 7 | Receipt: "Receipt", 8 | Notification: "Notification", 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web/utils/sender.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/utils/prisma"; 2 | import { extractEmailAddress } from "@/utils/email"; 3 | 4 | export async function findSenderByEmail({ 5 | emailAccountId, 6 | email, 7 | }: { 8 | emailAccountId: string; 9 | email: string; 10 | }) { 11 | if (!email) return null; 12 | const extractedEmail = extractEmailAddress(email); 13 | 14 | const newsletter = await prisma.newsletter.findFirst({ 15 | where: { 16 | emailAccountId, 17 | email: { contains: extractedEmail }, 18 | }, 19 | }); 20 | 21 | if (!newsletter) return null; 22 | if ( 23 | newsletter.email !== extractedEmail || 24 | newsletter.email.endsWith(`<${extractedEmail}>`) 25 | ) 26 | return null; 27 | 28 | return newsletter; 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/utils/size.ts: -------------------------------------------------------------------------------- 1 | export function bytesToMegabytes(bytes: number): number { 2 | return bytes / (1024 * 1024); 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | 5 | export const exponentialBackoff = (retryCount: number, ms: number) => 6 | 2 ** retryCount * ms; 7 | -------------------------------------------------------------------------------- /apps/web/utils/stats.ts: -------------------------------------------------------------------------------- 1 | export function formatStat(stat?: number) { 2 | return stat ? stat.toLocaleString() : 0; 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/utils/template.ts: -------------------------------------------------------------------------------- 1 | // Returns true if contains "{{" and "}}". 2 | export const hasVariables = (text: string | undefined | null) => 3 | text ? /\{\{.*?\}\}/g.test(text) : false; 4 | -------------------------------------------------------------------------------- /apps/web/utils/text.ts: -------------------------------------------------------------------------------- 1 | import type { PortableTextBlock } from "@portabletext/react"; 2 | import type { PortableTextSpan } from "sanity"; 3 | 4 | export const slugify = (text: string) => { 5 | return text 6 | .toLowerCase() 7 | .replace(/\s+/g, "-") 8 | .replace(/[^\w-]+/g, ""); 9 | }; 10 | 11 | export const extractTextFromPortableTextBlock = ( 12 | block: PortableTextBlock, 13 | ): string => { 14 | return block.children 15 | .filter( 16 | (child): child is PortableTextSpan => 17 | typeof child === "object" && "_type" in child && "text" in child, 18 | ) 19 | .map((child) => child.text) 20 | .join(""); 21 | }; 22 | -------------------------------------------------------------------------------- /apps/web/utils/thread.ts: -------------------------------------------------------------------------------- 1 | // The first message id in a thread is the threadId 2 | export function isReplyInThread(messageId: string, threadId: string): boolean { 3 | return !!(messageId && messageId !== threadId); 4 | } 5 | -------------------------------------------------------------------------------- /apps/web/utils/types/mail.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const zodAttachment = z.object({ 4 | filename: z.string(), 5 | content: z.string(), 6 | contentType: z.string(), 7 | }); 8 | export type Attachment = z.infer; 9 | -------------------------------------------------------------------------------- /apps/web/utils/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | import addDays from "date-fns/addDays"; 2 | import prisma from "./prisma"; 3 | import { generateSecureToken } from "./api-key"; 4 | 5 | export async function createUnsubscribeToken({ 6 | emailAccountId, 7 | }: { 8 | emailAccountId: string; 9 | }) { 10 | const token = generateSecureToken(); 11 | 12 | await prisma.emailToken.create({ 13 | data: { 14 | token, 15 | emailAccountId, 16 | expiresAt: addDays(new Date(), 30), 17 | }, 18 | }); 19 | 20 | return token; 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/utils/user.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signOut } from "next-auth/react"; 4 | 5 | export async function logOut(callbackUrl?: string) { 6 | return signOut({ callbackUrl }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { defineConfig } from 'vitest/config' 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths()], 7 | test: { 8 | environment: 'node', 9 | env: { 10 | ...config({ path: "./.env.test" }).parsed, 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /docker/Dockerfile.web: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | WORKDIR /app 4 | 5 | RUN apk add --no-cache openssl 6 | 7 | RUN npm install -g pnpm 8 | 9 | COPY . . 10 | 11 | WORKDIR /app/apps/web 12 | 13 | CMD ["/bin/sh", "/app/apps/web/entrypoint.sh"] -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 8 | plugins: ["only-warn"], 9 | globals: { 10 | React: true, 11 | JSX: true, 12 | }, 13 | env: { 14 | node: true, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | ".*.js", 26 | "node_modules/", 27 | "dist/", 28 | ], 29 | overrides: [ 30 | { 31 | files: ["*.js?(x)", "*.ts?(x)"], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inboxzero/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "library.js", 7 | "next.js", 8 | "react-internal.js" 9 | ], 10 | "devDependencies": { 11 | "@typescript-eslint/eslint-plugin": "8.33.0", 12 | "@typescript-eslint/parser": "8.33.0", 13 | "@vercel/style-guide": "6.0.0", 14 | "eslint-config-prettier": "10.1.5", 15 | "eslint-config-turbo": "2.5.4", 16 | "eslint-plugin-only-warn": "1.1.0", 17 | "typescript": "5.8.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/loops/README.md: -------------------------------------------------------------------------------- 1 | ## Loops Package 2 | 3 | This package contains the code for the Loops which is used to send marketing emails to users. 4 | -------------------------------------------------------------------------------- /packages/loops/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inboxzero/loops", 3 | "version": "0.0.0", 4 | "main": "src/index.ts", 5 | "dependencies": { 6 | "loops": "^5.0.1" 7 | }, 8 | "devDependencies": { 9 | "@types/node": "22.15.29", 10 | "tsconfig": "workspace:*", 11 | "typescript": "5.8.3" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/loops/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./loops"; 2 | -------------------------------------------------------------------------------- /packages/loops/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "exclude": ["node_modules"], 4 | "compilerOptions": {} 5 | } 6 | -------------------------------------------------------------------------------- /packages/resend/README.md: -------------------------------------------------------------------------------- 1 | # Email updates 2 | 3 | This package is used to send transactional emails to users. 4 | 5 | ## Running locally 6 | 7 | To run: 8 | 9 | ```bash 10 | pnpm dev 11 | ``` 12 | 13 | Then visit http://localhost:3010/ to view email previews. 14 | -------------------------------------------------------------------------------- /packages/resend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inboxzero/resend", 3 | "version": "0.0.0", 4 | "main": "src/index.ts", 5 | "scripts": { 6 | "dev": "email dev --port 3010" 7 | }, 8 | "dependencies": { 9 | "@react-email/components": "0.0.41", 10 | "@react-email/render": "1.1.2", 11 | "nanoid": "5.1.5", 12 | "react": "19.1.0", 13 | "react-dom": "19.1.0", 14 | "react-email": "4.0.15", 15 | "resend": "4.5.1" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "22.15.29", 19 | "@types/react": "19.1.6", 20 | "@types/react-dom": "19.1.5", 21 | "tsconfig": "workspace:*", 22 | "typescript": "5.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/resend/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | export const resend = process.env.RESEND_API_KEY 4 | ? new Resend(process.env.RESEND_API_KEY) 5 | : null; 6 | -------------------------------------------------------------------------------- /packages/resend/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./send"; 2 | export * from "./contacts"; 3 | -------------------------------------------------------------------------------- /packages/resend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "jsx": "react-jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First time: 4 | 5 | ```sh 6 | python3 -m venv .venv 7 | source .venv/bin/activate 8 | pip install tinybird-cli 9 | tb auth 10 | ``` 11 | 12 | More notes: [Quickstart](https://www.tinybird.co/docs/quick-start-cli.html) 13 | 14 | Thereafter: 15 | 16 | ```sh 17 | source .venv/bin/activate 18 | ``` 19 | 20 | ### Docker 21 | 22 | You can also use the Docker image. This worked a lot better for me. 23 | 24 | Run the following from this directory: 25 | 26 | ```sh 27 | docker run -v .:/mnt/data -it tinybirdco/tinybird-cli-docker 28 | ``` 29 | 30 | Then within Docker: 31 | 32 | ```sh 33 | cd mnt/data 34 | tb push datasources 35 | tb push pipes 36 | ``` 37 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/datasources/aiCall.datasource: -------------------------------------------------------------------------------- 1 | SCHEMA > 2 | `userId` String `json:$.userId`, 3 | `timestamp` Int64 `json:$.timestamp`, 4 | `totalTokens` UInt64 `json:$.totalTokens`, 5 | `completionTokens` UInt64 `json:$.completionTokens`, 6 | `promptTokens` UInt64 `json:$.promptTokens`, 7 | `cost` Float32 `json:$.cost`, 8 | `model` String `json:$.model`, 9 | `provider` String `json:$.provider`, 10 | `label` Nullable(String) `json:$.label`, 11 | `data` Nullable(String) `json:$.data` 12 | 13 | ENGINE "MergeTree" 14 | ENGINE_SORTING_KEY userId, timestamp 15 | ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(timestamp))" 16 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inboxzero/tinybird-ai-analytics", 3 | "version": "0.0.0", 4 | "main": "src/index.ts", 5 | "dependencies": { 6 | "@chronark/zod-bird": "0.3.10", 7 | "zod": "3.25.46" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "22.15.29", 11 | "tsconfig": "workspace:*", 12 | "typescript": "5.8.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Tinybird } from "@chronark/zod-bird"; 2 | 3 | let tb: Tinybird; 4 | 5 | export const getTinybird = () => { 6 | if (!process.env.TINYBIRD_TOKEN) return; 7 | 8 | if (!tb) { 9 | tb = new Tinybird({ 10 | token: process.env.TINYBIRD_TOKEN, 11 | baseUrl: process.env.TINYBIRD_BASE_URL, 12 | }); 13 | } 14 | 15 | return tb; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | export * from "./publish"; 3 | export * from "./delete"; 4 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/src/publish.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { getTinybird } from "./client"; 3 | 4 | const tinybirdAiCall = z.object({ 5 | userId: z.string(), 6 | timestamp: z.number(), // date 7 | totalTokens: z.number().int(), 8 | completionTokens: z.number().int(), 9 | promptTokens: z.number().int(), 10 | cost: z.number(), 11 | model: z.string(), 12 | provider: z.string(), 13 | label: z.string().optional(), 14 | data: z.string().optional(), 15 | }); 16 | export type TinybirdAiCall = z.infer; 17 | 18 | const tb = getTinybird(); 19 | 20 | export const publishAiCall = tb 21 | ? tb.buildIngestEndpoint({ 22 | datasource: "aiCall", 23 | event: tinybirdAiCall, 24 | }) 25 | : () => {}; 26 | -------------------------------------------------------------------------------- /packages/tinybird-ai-analytics/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "exclude": ["node_modules"], 4 | "compilerOptions": {} 5 | } 6 | -------------------------------------------------------------------------------- /packages/tinybird/datasources/email_action.datasource: -------------------------------------------------------------------------------- 1 | SCHEMA > 2 | `ownerEmail` String `json:$.ownerEmail`, 3 | `threadId` String `json:$.threadId`, 4 | `action` LowCardinality(String) `json:$.action`, 5 | `actionSource` LowCardinality(String) `json:$.actionSource`, 6 | `timestamp` Int64 `json:$.timestamp` 7 | 8 | ENGINE "MergeTree" 9 | ENGINE_SORTING_KEY ownerEmail, action, actionSource, timestamp 10 | ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(timestamp))" -------------------------------------------------------------------------------- /packages/tinybird/datasources/last_and_oldest_emails_mv.datasource: -------------------------------------------------------------------------------- 1 | # Data Source created from Pipe 'last_and_oldest_emails_mat' 2 | 3 | SCHEMA > 4 | `ownerEmail` String, 5 | `latest_message` AggregateFunction(argMax, String, Int64), 6 | `latest_message_ts` AggregateFunction(max, Int64), 7 | `oldest_message` AggregateFunction(argMin, String, Int64), 8 | `oldest_message_ts` AggregateFunction(min, Int64) 9 | 10 | ENGINE "AggregatingMergeTree" 11 | ENGINE_SORTING_KEY "ownerEmail" 12 | -------------------------------------------------------------------------------- /packages/tinybird/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inboxzero/tinybird", 3 | "version": "0.0.0", 4 | "main": "src/index.ts", 5 | "dependencies": { 6 | "@chronark/zod-bird": "0.3.10", 7 | "p-retry": "^6.2.1", 8 | "zod": "3.25.46" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "22.15.29", 12 | "tsconfig": "workspace:*", 13 | "typescript": "5.8.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/tinybird/pipes/get_email_actions_by_period.pipe: -------------------------------------------------------------------------------- 1 | NODE get_email_actions 2 | DESCRIPTION > 3 | Get the number of email actions by period 4 | 5 | SQL > 6 | % 7 | SELECT 8 | toDate(fromUnixTimestamp64Milli(timestamp)) AS date, 9 | countIf(action = 'archive') AS archive_count, 10 | countIf(action = 'delete') AS delete_count 11 | FROM email_action 12 | WHERE ownerEmail = {{ String(ownerEmail) }} 13 | GROUP BY date 14 | ORDER BY date 15 | 16 | TYPE endpoint 17 | -------------------------------------------------------------------------------- /packages/tinybird/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Tinybird } from "@chronark/zod-bird"; 2 | 3 | export const tb = new Tinybird({ 4 | token: process.env.TINYBIRD_TOKEN!, 5 | baseUrl: process.env.TINYBIRD_BASE_URL, 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tinybird/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | export * from "./publish"; 3 | export * from "./query"; 4 | export * from "./delete"; 5 | -------------------------------------------------------------------------------- /packages/tinybird/src/query.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { tb } from "./client"; 3 | 4 | export const zodPeriod = z.enum(["day", "week", "month", "year"]); 5 | export type ZodPeriod = z.infer; 6 | 7 | export const getEmailActionsByDay = tb.buildPipe({ 8 | pipe: "get_email_actions_by_period", 9 | parameters: z.object({ 10 | ownerEmail: z.string(), 11 | }), 12 | data: z.object({ 13 | date: z.string(), 14 | archive_count: z.number(), 15 | delete_count: z.number(), 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /packages/tinybird/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "exclude": ["node_modules"], 4 | "compilerOptions": {} 5 | } 6 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "Bundler", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "allowJs": true, 8 | "declaration": false, 9 | "declarationMap": false, 10 | "incremental": true, 11 | "jsx": "preserve", 12 | "lib": ["dom", "dom.iterable", "esnext", "webworker"], 13 | "module": "esnext", 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "target": "es5" 18 | }, 19 | "include": ["src", "next-env.d.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "publishConfig": { 6 | "access": "public" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - apps/* 4 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "cd ../.. && turbo run build --filter={apps/web}...", 3 | "installCommand": "cd ../.. && bash clone-marketing.sh && pnpm install", 4 | "ignoreCommand": "" 5 | } 6 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | v1.2.41 -------------------------------------------------------------------------------- /video-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elie222/inbox-zero/42f8ba0a80fb811a0afaeef340710b7580ea96a6/video-thumbnail.png --------------------------------------------------------------------------------