├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── .dockerignore
├── .env.example
├── .github
├── CODEOWNERS.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ └── bug_report.yml
├── PULL_REQUEST_TEMPLATE.md
├── TRANSLATION.md
└── workflows
│ ├── ci.yml
│ ├── deploy-to-prod-command.yml
│ ├── lingo-dev.yml
│ └── sync-production.yml
├── .gitignore
├── .husky
└── pre-commit
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── LICENSE
├── MCP.md
├── README.md
├── ROADMAP.md
├── apps
├── mail
│ ├── .gitignore
│ ├── app
│ │ ├── (auth)
│ │ │ ├── login
│ │ │ │ ├── error-message.tsx
│ │ │ │ ├── login-client.tsx
│ │ │ │ └── page.tsx
│ │ │ └── zero
│ │ │ │ ├── login
│ │ │ │ └── page.tsx
│ │ │ │ └── signup
│ │ │ │ └── page.tsx
│ │ ├── (full-width)
│ │ │ ├── about.tsx
│ │ │ ├── contributors.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── pricing.tsx
│ │ │ ├── privacy.tsx
│ │ │ └── terms.tsx
│ │ ├── (routes)
│ │ │ ├── developer
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── mail
│ │ │ │ ├── [folder]
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── compose
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── create
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── under-construction
│ │ │ │ │ └── [path]
│ │ │ │ │ ├── back-button.tsx
│ │ │ │ │ └── page.tsx
│ │ │ ├── settings
│ │ │ │ ├── [...settings]
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── appearance
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── connections
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── danger-zone
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── general
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── labels
│ │ │ │ │ ├── colors.ts
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── notifications
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── privacy
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── security
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── shortcuts
│ │ │ │ │ ├── hotkey-recorder.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── signatures
│ │ │ │ │ └── page.tsx
│ │ │ └── toast-test.tsx
│ │ ├── entry.client.tsx
│ │ ├── entry.server.tsx
│ │ ├── globals.css
│ │ ├── home
│ │ │ └── page.tsx
│ │ ├── mailto-handler.ts
│ │ ├── meta-files
│ │ │ ├── manifest.ts
│ │ │ ├── microsoft-identity-association.json.ts
│ │ │ ├── microsoft-identity-association.ts
│ │ │ └── not-found.ts
│ │ ├── og-api
│ │ │ ├── create.tsx
│ │ │ └── home.tsx
│ │ ├── page.tsx
│ │ ├── root.tsx
│ │ └── routes.ts
│ ├── components.json
│ ├── components
│ │ ├── ai-toggle-button.tsx
│ │ ├── connection
│ │ │ └── add.tsx
│ │ ├── context
│ │ │ ├── command-palette-context.tsx
│ │ │ ├── label-sidebar-context.tsx
│ │ │ ├── sidebar-context.tsx
│ │ │ └── thread-context.tsx
│ │ ├── cookies
│ │ │ └── cookie-trigger.tsx
│ │ ├── create
│ │ │ ├── ai-chat.tsx
│ │ │ ├── ai-textarea.tsx
│ │ │ ├── create-email.tsx
│ │ │ ├── editor-autocomplete.ts
│ │ │ ├── editor-buttons.tsx
│ │ │ ├── editor-menu.tsx
│ │ │ ├── editor.colors.tsx
│ │ │ ├── editor.link-selector.tsx
│ │ │ ├── editor.node-selector.tsx
│ │ │ ├── editor.text-buttons.tsx
│ │ │ ├── editor.tsx
│ │ │ ├── email-composer.tsx
│ │ │ ├── email-phrases.ts
│ │ │ ├── extensions.ts
│ │ │ ├── ghost-text.css
│ │ │ ├── image-upload.ts
│ │ │ ├── prosemirror.css
│ │ │ ├── selectors
│ │ │ │ ├── link-selector.tsx
│ │ │ │ ├── math-selector.tsx
│ │ │ │ ├── node-selector.tsx
│ │ │ │ └── text-buttons.tsx
│ │ │ ├── signature-display.tsx
│ │ │ ├── slash-command.tsx
│ │ │ ├── uploaded-file-icon.tsx
│ │ │ └── voice.tsx
│ │ ├── home
│ │ │ ├── HomeContent.tsx
│ │ │ ├── footer.tsx
│ │ │ └── pixelated-bg.tsx
│ │ ├── icons
│ │ │ ├── animated
│ │ │ │ ├── moon.tsx
│ │ │ │ ├── square-pen.tsx
│ │ │ │ └── sun.tsx
│ │ │ └── icons.tsx
│ │ ├── labels
│ │ │ └── label-dialog.tsx
│ │ ├── magicui
│ │ │ └── file-tree.tsx
│ │ ├── mail
│ │ │ ├── attachment-dialog.tsx
│ │ │ ├── attachments-accordion.tsx
│ │ │ ├── data.tsx
│ │ │ ├── mail-display.tsx
│ │ │ ├── mail-iframe.tsx
│ │ │ ├── mail-list.tsx
│ │ │ ├── mail-quick-actions.tsx
│ │ │ ├── mail-skeleton.tsx
│ │ │ ├── mail.tsx
│ │ │ ├── nav-main.tsx
│ │ │ ├── navbar.tsx
│ │ │ ├── note-panel.tsx
│ │ │ ├── optimistic-thread-state.tsx
│ │ │ ├── render-labels.tsx
│ │ │ ├── reply-composer.tsx
│ │ │ ├── search-bar.tsx
│ │ │ ├── signature-preview.tsx
│ │ │ ├── thread-display.tsx
│ │ │ ├── thread-subject.tsx
│ │ │ └── use-mail.ts
│ │ ├── motion-primitives
│ │ │ └── text-effect.tsx
│ │ ├── navbar.tsx
│ │ ├── navigation.tsx
│ │ ├── onboarding.tsx
│ │ ├── party.tsx
│ │ ├── pricing
│ │ │ ├── comparision.tsx
│ │ │ └── pricing-card.tsx
│ │ ├── providers
│ │ │ ├── editor-provider.tsx
│ │ │ └── hotkey-provider-wrapper.tsx
│ │ ├── responsive-modal.tsx
│ │ ├── settings
│ │ │ └── settings-card.tsx
│ │ ├── theme
│ │ │ ├── mode-toggle.tsx
│ │ │ ├── sidebar-theme-switcher.tsx
│ │ │ ├── theme-switcher.tsx
│ │ │ └── theme-toggle.tsx
│ │ ├── ui
│ │ │ ├── accordion.tsx
│ │ │ ├── ai-sidebar.tsx
│ │ │ ├── animated-number.tsx
│ │ │ ├── app-sidebar.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── calendar.tsx
│ │ │ ├── card.tsx
│ │ │ ├── chart.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── command-menu.tsx
│ │ │ ├── command.tsx
│ │ │ ├── context-menu.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── drawer.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── envelop.tsx
│ │ │ ├── form.tsx
│ │ │ ├── gauge.tsx
│ │ │ ├── input-otp.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── nav-main.tsx
│ │ │ ├── nav-user.tsx
│ │ │ ├── navigation-menu.tsx
│ │ │ ├── page-header.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── pricing-dialog.tsx
│ │ │ ├── pricing-switch.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── prompts-dialog.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── recursive-folder.tsx
│ │ │ ├── resizable.tsx
│ │ │ ├── responsive-modal.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── settings-content.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── sidebar-labels.tsx
│ │ │ ├── sidebar-toggle.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── spinner.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── text-shimmer.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── toast.tsx
│ │ │ ├── toggle-group.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── tooltip.tsx
│ │ └── user
│ │ │ └── user-button.tsx
│ ├── config
│ │ ├── navigation.ts
│ │ └── shortcuts.ts
│ ├── eslint.config.ts
│ ├── hooks
│ │ ├── driver
│ │ │ ├── use-delete.ts
│ │ │ └── use-move-to.ts
│ │ ├── ui
│ │ │ └── use-background-queue.ts
│ │ ├── use-billing.ts
│ │ ├── use-compose-editor.ts
│ │ ├── use-connections.ts
│ │ ├── use-copy-to-clipboard.ts
│ │ ├── use-debounce.ts
│ │ ├── use-drafts.ts
│ │ ├── use-email-aliases.ts
│ │ ├── use-geo-location.ts
│ │ ├── use-hot-key.ts
│ │ ├── use-image-loading.ts
│ │ ├── use-labels.ts
│ │ ├── use-mail-navigation.ts
│ │ ├── use-media-query.ts
│ │ ├── use-mobile.tsx
│ │ ├── use-notes.tsx
│ │ ├── use-open-compose-modal.ts
│ │ ├── use-optimistic-actions.ts
│ │ ├── use-previous.ts
│ │ ├── use-search-value.ts
│ │ ├── use-settings.ts
│ │ ├── use-stats.ts
│ │ ├── use-summary.ts
│ │ └── use-threads.ts
│ ├── i18n
│ │ ├── config.ts
│ │ └── request.ts
│ ├── instrument.ts
│ ├── lib
│ │ ├── auth-client.ts
│ │ ├── auth-proxy.ts
│ │ ├── constants.tsx
│ │ ├── countries.ts
│ │ ├── email-utils.client.tsx
│ │ ├── email-utils.ts
│ │ ├── filter.ts
│ │ ├── hotkeys
│ │ │ ├── compose-hotkeys.tsx
│ │ │ ├── global-hotkeys.tsx
│ │ │ ├── mail-list-hotkeys.tsx
│ │ │ ├── navigation-hotkeys.tsx
│ │ │ ├── thread-display-hotkeys.tsx
│ │ │ └── use-hotkey-utils.ts
│ │ ├── label-colors.ts
│ │ ├── notes-utils.ts
│ │ ├── posthog-provider.tsx
│ │ ├── prompts.ts
│ │ ├── redis.ts
│ │ ├── sanitize-tip-tap-html.tsx
│ │ ├── schemas.ts
│ │ ├── site-config.ts
│ │ ├── thread-actions.ts
│ │ ├── timezones.ts
│ │ ├── trpc.server.ts
│ │ └── utils.ts
│ ├── locales
│ │ ├── ar.json
│ │ ├── ca.json
│ │ ├── cs.json
│ │ ├── de.json
│ │ ├── en.json
│ │ ├── es.json
│ │ ├── fa.json
│ │ ├── fr.json
│ │ ├── hi.json
│ │ ├── hu.json
│ │ ├── ja.json
│ │ ├── ko.json
│ │ ├── lv.json
│ │ ├── nl.json
│ │ ├── pl.json
│ │ ├── pt.json
│ │ ├── ru.json
│ │ ├── tr.json
│ │ ├── vi.json
│ │ ├── zh_CN.json
│ │ └── zh_TW.json
│ ├── middleware.ts
│ ├── package.json
│ ├── providers
│ │ ├── client-providers.tsx
│ │ ├── query-provider.tsx
│ │ └── server-providers.tsx
│ ├── public
│ │ ├── adam.jpg
│ │ ├── ahmet.jpg
│ │ ├── ai-chat.png
│ │ ├── ai-summary.png
│ │ ├── ai.svg
│ │ ├── assets
│ │ │ ├── attachment-icons
│ │ │ │ ├── audio.svg
│ │ │ │ ├── csv.svg
│ │ │ │ ├── excel.svg
│ │ │ │ ├── figma.svg
│ │ │ │ ├── file.svg
│ │ │ │ ├── html.svg
│ │ │ │ ├── pdf.svg
│ │ │ │ ├── powerpoint.svg
│ │ │ │ ├── video.svg
│ │ │ │ ├── word.svg
│ │ │ │ └── zip.svg
│ │ │ ├── m0 rounded edges.png
│ │ │ ├── m0 rounded edges.svg
│ │ │ ├── m0 w lines rounded edges.png
│ │ │ ├── m0 w lines rounded edges.svg
│ │ │ ├── m0 w lines.png
│ │ │ ├── m0 w lines.svg
│ │ │ ├── m0.png
│ │ │ ├── m0.svg
│ │ │ ├── mail.svg
│ │ │ ├── mail0.io - Text + logo w lines.png
│ │ │ ├── mail0.io - Text + logo w lines.svg
│ │ │ ├── mail0.io - Text w lines.png
│ │ │ ├── mail0.io - Text w lines.svg
│ │ │ └── rocket.svg
│ │ ├── black-icon.svg
│ │ ├── claude.png
│ │ ├── compose.png
│ │ ├── dudu.jpg
│ │ ├── email-preview.png
│ │ ├── empty-state-light.svg
│ │ ├── empty-state.svg
│ │ ├── favicon.ico
│ │ ├── fonts
│ │ │ └── geist
│ │ │ │ ├── Geist-Black.ttf
│ │ │ │ ├── Geist-Bold.ttf
│ │ │ │ ├── Geist-ExtraBold.ttf
│ │ │ │ ├── Geist-ExtraLight.ttf
│ │ │ │ ├── Geist-Light.ttf
│ │ │ │ ├── Geist-Medium.ttf
│ │ │ │ ├── Geist-Regular.ttf
│ │ │ │ ├── Geist-SemiBold.ttf
│ │ │ │ └── Geist-Thin.ttf
│ │ ├── gradient.svg
│ │ ├── homepage-image.png
│ │ ├── icons-pwa
│ │ │ ├── icon-180.png
│ │ │ ├── icon-192.png
│ │ │ └── icon-512.png
│ │ ├── lock.svg
│ │ ├── mail-list.png
│ │ ├── mail-pixel.svg
│ │ ├── mail.svg
│ │ ├── netflix.svg
│ │ ├── nizzy.jpg
│ │ ├── onboarding
│ │ │ ├── coming-soon.png
│ │ │ ├── get-started.png
│ │ │ ├── ready.png
│ │ │ ├── step1.gif
│ │ │ ├── step2.gif
│ │ │ └── step3.gif
│ │ ├── openai.png
│ │ ├── opened-mail.svg
│ │ ├── pixel.svg
│ │ ├── pricing-gradient.png
│ │ ├── purple-gradient.png
│ │ ├── purple-zap.svg
│ │ ├── ryan.jpg
│ │ ├── search.png
│ │ ├── small-pixel.png
│ │ ├── snooze-home.png
│ │ ├── star-home.png
│ │ ├── star.svg
│ │ ├── stripe.svg
│ │ ├── verified-home.png
│ │ ├── white-icon.svg
│ │ ├── yc-small.svg
│ │ ├── yc.svg
│ │ └── zap.svg
│ ├── react-router.config.ts
│ ├── store
│ │ ├── backgroundQueue.ts
│ │ ├── draftStates.ts
│ │ └── optimistic-updates.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ ├── types
│ │ ├── index.ts
│ │ ├── speech-recognition.d.ts
│ │ ├── tailwind.d.ts
│ │ └── tools.ts
│ ├── vite.config.ts
│ ├── worker.ts
│ └── wrangler.jsonc
└── server
│ ├── drizzle.config.ts
│ ├── eslint.config.ts
│ ├── package.json
│ ├── src
│ ├── ctx.ts
│ ├── db
│ │ ├── index.ts
│ │ ├── migrations
│ │ │ ├── 0000_fine_steel_serpent.sql
│ │ │ ├── 0001_greedy_darkhawk.sql
│ │ │ ├── 0002_flimsy_nightshade.sql
│ │ │ ├── 0003_purple_kylun.sql
│ │ │ ├── 0004_quiet_grey_gargoyle.sql
│ │ │ ├── 0005_mature_lady_deathstrike.sql
│ │ │ ├── 0006_small_unicorn.sql
│ │ │ ├── 0007_tense_wrecking_crew.sql
│ │ │ ├── 0008_freezing_hydra.sql
│ │ │ ├── 0009_boring_big_bertha.sql
│ │ │ ├── 0010_dry_hemingway.sql
│ │ │ ├── 0011_huge_newton_destine.sql
│ │ │ ├── 0012_even_johnny_storm.sql
│ │ │ ├── 0013_calm_timeslip.sql
│ │ │ ├── 0014_cuddly_energizer.sql
│ │ │ ├── 0015_minor_mister_sinister.sql
│ │ │ ├── 0016_neat_ogun.sql
│ │ │ ├── 0017_bouncy_shotgun.sql
│ │ │ ├── 0018_far_lady_mastermind.sql
│ │ │ ├── 0019_mean_war_machine.sql
│ │ │ ├── 0020_bright_gladiator.sql
│ │ │ ├── 0021_outgoing_mariko_yashida.sql
│ │ │ ├── 0022_round_violations.sql
│ │ │ ├── 0023_narrow_maria_hill.sql
│ │ │ ├── 0024_familiar_wiccan.sql
│ │ │ ├── 0025_far_echo.sql
│ │ │ ├── 0025_nervous_paper_doll.sql
│ │ │ ├── 0026_smooth_norrin_radd.sql
│ │ │ ├── 0027_vengeful_golden_guardian.sql
│ │ │ └── meta
│ │ │ │ ├── 0000_snapshot.json
│ │ │ │ ├── 0001_snapshot.json
│ │ │ │ ├── 0002_snapshot.json
│ │ │ │ ├── 0003_snapshot.json
│ │ │ │ ├── 0004_snapshot.json
│ │ │ │ ├── 0005_snapshot.json
│ │ │ │ ├── 0006_snapshot.json
│ │ │ │ ├── 0007_snapshot.json
│ │ │ │ ├── 0008_snapshot.json
│ │ │ │ ├── 0009_snapshot.json
│ │ │ │ ├── 0010_snapshot.json
│ │ │ │ ├── 0011_snapshot.json
│ │ │ │ ├── 0012_snapshot.json
│ │ │ │ ├── 0013_snapshot.json
│ │ │ │ ├── 0014_snapshot.json
│ │ │ │ ├── 0015_snapshot.json
│ │ │ │ ├── 0016_snapshot.json
│ │ │ │ ├── 0017_snapshot.json
│ │ │ │ ├── 0018_snapshot.json
│ │ │ │ ├── 0019_snapshot.json
│ │ │ │ ├── 0020_snapshot.json
│ │ │ │ ├── 0021_snapshot.json
│ │ │ │ ├── 0022_snapshot.json
│ │ │ │ ├── 0023_snapshot.json
│ │ │ │ ├── 0024_snapshot.json
│ │ │ │ ├── 0025_snapshot.json
│ │ │ │ ├── 0026_snapshot.json
│ │ │ │ ├── 0027_snapshot.json
│ │ │ │ └── _journal.json
│ │ └── schema.ts
│ ├── lib
│ │ ├── auth-providers.ts
│ │ ├── auth.ts
│ │ ├── brain.fallback.prompts.ts
│ │ ├── brain.ts
│ │ ├── cookies.ts
│ │ ├── driver
│ │ │ ├── google-label-color-map.ts
│ │ │ ├── google.ts
│ │ │ ├── index.ts
│ │ │ ├── microsoft.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── email-utils.ts
│ │ ├── filter.ts
│ │ ├── notes-manager.ts
│ │ ├── party.ts
│ │ ├── prompts.ts
│ │ ├── sanitize-tip-tap-html.ts
│ │ ├── schemas.ts
│ │ ├── server-utils.ts
│ │ ├── services.ts
│ │ ├── shortcuts.ts
│ │ ├── timezones.ts
│ │ └── utils.ts
│ ├── main.ts
│ ├── overrides.d.ts
│ ├── routes
│ │ ├── agent
│ │ │ ├── shared.ts
│ │ │ ├── tools.ts
│ │ │ └── utils.ts
│ │ ├── autumn.ts
│ │ └── chat.ts
│ ├── services
│ │ └── writing-style-service.ts
│ ├── trpc
│ │ ├── index.ts
│ │ ├── routes
│ │ │ ├── ai
│ │ │ │ ├── compose.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── search.ts
│ │ │ ├── brain.ts
│ │ │ ├── connections.ts
│ │ │ ├── cookies.ts
│ │ │ ├── drafts.ts
│ │ │ ├── label.ts
│ │ │ ├── mail.ts
│ │ │ ├── notes.ts
│ │ │ ├── settings.ts
│ │ │ ├── shortcut.ts
│ │ │ └── user.ts
│ │ └── trpc.ts
│ └── types.ts
│ ├── tsconfig.json
│ └── wrangler.jsonc
├── crowdin.yml
├── docker-compose.db.yaml
├── docker-compose.prod.yaml
├── docker
├── app
│ └── Dockerfile
└── db
│ └── Dockerfile
├── eslint.config.mjs
├── i18n.json
├── i18n.lock
├── package.json
├── packages
├── cli
│ ├── package.json
│ ├── src
│ │ ├── cli.ts
│ │ ├── commands
│ │ │ ├── fix-env.ts
│ │ │ ├── index.ts
│ │ │ ├── reinstall-node-modules.ts
│ │ │ └── sync.ts
│ │ └── utils.ts
│ └── tsconfig.json
├── eslint-config
│ ├── config.ts
│ ├── package.json
│ └── tsconfig.json
├── tailwind-config
│ ├── package.json
│ ├── tailwind.config.ts
│ └── tsconfig.json
└── tsconfig
│ ├── base.json
│ └── package.json
├── patches
└── novel.patch
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── public
├── better-auth.png
├── coderabbit.png
├── drizzle-orm.png
└── vercel.png
├── scripts
├── README.md
├── docker
│ ├── entrypoint.sh
│ └── replace-placeholder.sh
├── package.json
├── run.ts
├── seed-style
│ ├── seeder.ts
│ └── styles
│ │ ├── concise_emails.json
│ │ ├── friendly_emails.json
│ │ ├── genz_emails.json
│ │ ├── persuasive_emails.json
│ │ └── professional_emails.json
├── send-emails
│ └── index.ts
└── tsconfig.json
├── tsconfig.json
└── turbo.json
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Ubuntu as the base image
2 | FROM ubuntu:latest
3 |
4 | # Avoid prompts from apt
5 | ENV DEBIAN_FRONTEND=noninteractive
6 |
7 | # Install basic dependencies
8 | RUN apt-get update && apt-get install -y \
9 | git \
10 | curl \
11 | wget \
12 | build-essential \
13 | pkg-config \
14 | zip \
15 | unzip \
16 | zsh \
17 | && rm -rf /var/lib/apt/lists/*
18 |
19 | # Create workspace directory
20 | RUN mkdir -p /workspaces
21 |
22 | # Set working directory
23 | WORKDIR /workspaces
24 |
25 | # Install zsh
26 | RUN apt-get update && apt-get install -y zsh
27 |
28 | # install Oh My Zsh
29 | RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
30 |
31 | # Bun and pnpm installation
32 | RUN curl -fsSL https://bun.sh/install | bash
33 | RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash -
34 |
35 | # Register Bun in bashrc and zshrc
36 | RUN echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bashrc
37 | RUN echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshrc
38 |
39 | # Install NVM and Node.js
40 | ENV NVM_DIR=/root/.nvm
41 | RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
42 |
43 | # Install Node.js and set it up using a shell
44 | RUN . $NVM_DIR/nvm.sh && \
45 | nvm install --lts && \
46 | nvm use --lts && \
47 | nvm alias default 'lts/*' && \
48 | npm install -g bun
49 |
50 | # Register Nvm in bashrc and zshrc
51 | RUN echo 'export NVM_DIR="$HOME/.nvm"' >> ~/.bashrc
52 | RUN echo 'export NVM_DIR="$HOME/.nvm"' >> ~/.zshrc
53 | RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >> ~/.bashrc
54 | RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm' >> ~/.zshrc
55 |
56 | # Switch back to dialog for any ad-hoc use of apt-get
57 | ENV DEBIAN_FRONTEND=dialog
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
3 | {
4 | "name": "0.email Dev Container",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "context": "."
8 | },
9 | // The optional 'workspaceFolder' property is the path VS Code should open by default when
10 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml
11 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
12 |
13 | // Features to add to the dev container
14 | "features": {
15 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}
16 | },
17 |
18 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
19 | "forwardPorts": [],
20 |
21 | // Configure tool-specific properties.
22 | "customizations": {
23 | "vscode": {
24 | "extensions": [
25 | "ms-azuretools.vscode-docker",
26 | "eamodio.gitlens",
27 | "streetsidesoftware.code-spell-checker"
28 | ],
29 | "settings": {}
30 | }
31 | },
32 |
33 | // Use 'postCreateCommand' to run commands after the container is created.
34 | "postCreateCommand": "zsh"
35 | }
36 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /apps/*/node_modules
4 | /packages/*/node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 | bun-debug.log*
33 | # env files (can opt-in for committing if needed)
34 | .env
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
43 | # ide
44 | .idea
45 | .vscode
46 | .turbo
47 | i18n.cache
48 | apps/mail/scripts.ts
49 | .gitignore
50 | .husky
51 | .github
52 | .devcontainer
53 | .env.example
54 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_PUBLIC_APP_URL=http://localhost:3000
2 | VITE_PUBLIC_BACKEND_URL=http://localhost:8787
3 |
4 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
5 |
6 | # Change this to a random string, use `openssl rand -hex 32` to generate a 32 character string
7 | BETTER_AUTH_SECRET=my-better-auth-secret
8 | BETTER_AUTH_URL=http://localhost:3000
9 |
10 | COOKIE_DOMAIN="localhost"
11 |
12 | # Change to your project's client ID and secret, these work with localhost:8787
13 | GOOGLE_CLIENT_ID=
14 | GOOGLE_CLIENT_SECRET=
15 |
16 | # Upstash/Local Redis Instance
17 | REDIS_URL="http://localhost:8079"
18 | REDIS_TOKEN="upstash-local-token"
19 |
20 | # Resend API Key
21 | RESEND_API_KEY=
22 |
23 | # OpenAI API Key
24 | OPENAI_API_KEY=
25 | PERPLEXITY_API_KEY=
26 |
27 | #AI PROMPT
28 | AI_SYSTEM_PROMPT=""
29 |
30 | NODE_ENV="development"
31 |
32 | AUTUMN_SECRET_KEY=
33 |
34 | TWILIO_ACCOUNT_SID=
35 | TWILIO_AUTH_TOKEN=
36 | TWILIO_PHONE_NUMBER=
--------------------------------------------------------------------------------
/.github/CODEOWNERS.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/.github/CODEOWNERS.md
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/.github/CODE_OF_CONDUCT.md
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [nizzyabi]
4 |
5 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: autofix.ci
2 |
3 | on:
4 | pull_request:
5 |
6 | concurrency:
7 | group: ${{ github.workflow }}-${{ github.ref }}
8 | cancel-in-progress: true
9 |
10 | jobs:
11 | autofix:
12 | timeout-minutes: 10
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout Code 🛎
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup pnpm 🌟
19 | uses: pnpm/action-setup@v4
20 |
21 | - name: Setup Node 📦
22 | uses: actions/setup-node@v4
23 |
24 | - name: Install dependencies 📦
25 | run: pnpm install
26 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-to-prod-command.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to Production Command
2 |
3 | on:
4 | issue_comment:
5 | types:
6 | - created
7 |
8 | permissions:
9 | contents: write
10 | pull-requests: write
11 |
12 | jobs:
13 | deploy-to-production:
14 | runs-on: ubuntu-latest
15 | name: Merge Staging to Production
16 | if: github.event.issue.pull_request && contains(github.event.issue.labels.*.name, 'production-deploy') && startsWith(github.event.comment.body, '/deploy') && github.event.comment.author_association == 'MEMBER'
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 |
23 | - name: Rebase the main branch on staging
24 | id: rebase
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | run: |
28 | git fetch origin staging
29 | git checkout main
30 | git rebase staging
31 | echo "rebase_status=$?" >> $GITHUB_OUTPUT
32 |
33 | - name: Error if rebase was not successful
34 | if: ${{ steps.rebase.outputs.rebase_status != 0 }}
35 | uses: mshick/add-pr-comment@v2
36 | with:
37 | message: |
38 | Failed to rebase staging on main, please rebase manually and run the command again.
39 |
40 | - name: Push changes if rebase was successful
41 | if: ${{ steps.rebase.outputs.rebase_status == 0 }}
42 | env:
43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 | run: git push --force-with-lease origin main
45 |
--------------------------------------------------------------------------------
/.github/workflows/lingo-dev.yml:
--------------------------------------------------------------------------------
1 | name: lingo-dev
2 |
3 | on:
4 | push:
5 | branches:
6 | - staging
7 | workflow_dispatch:
8 | inputs:
9 | skip_localization:
10 | description: 'Skip Lingo.dev step'
11 | type: 'boolean'
12 | default: false
13 |
14 | permissions:
15 | contents: write
16 | pull-requests: write
17 |
18 | concurrency:
19 | group: ${{ github.workflow }}-${{ github.ref }}
20 | cancel-in-progress: true
21 |
22 | jobs:
23 | main:
24 | timeout-minutes: 15
25 | runs-on: ubuntu-latest
26 | steps:
27 | - name: Checkout Code 🛎
28 | uses: actions/checkout@v4
29 |
30 | - name: Setup pnpm 🌟
31 | uses: pnpm/action-setup@v4
32 |
33 | - name: Setup Node 📦
34 | uses: actions/setup-node@v4
35 |
36 | - name: Install dependencies 📦
37 | run: pnpm install
38 |
39 | - name: Run Lingo.dev Localization 🌐
40 | if: ${{ !inputs.skip_localization }}
41 | uses: lingodotdev/lingo.dev@main
42 | env:
43 | GH_TOKEN: ${{ github.token }}
44 | with:
45 | api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
46 | pull-request: true
47 |
--------------------------------------------------------------------------------
/.github/workflows/sync-production.yml:
--------------------------------------------------------------------------------
1 | name: Sync Production
2 |
3 | on:
4 | push:
5 | branches:
6 | - staging
7 |
8 | permissions:
9 | contents: read
10 | pull-requests: write
11 |
12 | jobs:
13 | sync-branches:
14 | runs-on: ubuntu-latest
15 | name: Syncing branches
16 |
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 |
21 | - name: Set up Node
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: 22
25 |
26 | - name: Opening pull request
27 | id: pull
28 | uses: JDTX0/branch-sync@v1.5.1
29 | with:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 | FROM_BRANCH: 'staging'
32 | TO_BRANCH: 'main'
33 | PULL_REQUEST_TITLE: 'Deploy to production (Automated)'
34 | CONTENT_COMPARISON: true
35 | PULL_REQUEST_BODY: |
36 | This is an automated pull request to deploy the staging branch to production.
37 | Please review the pull request and comment `/deploy` to merge this PR and deploy to production.
38 | LABELS: '["production-deploy"]'
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | .next
18 | out
19 |
20 | # production
21 | build
22 | dist
23 |
24 | # misc
25 | .DS_Store
26 | *.pem
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 | .pnpm-debug.log*
33 | bun-debug.log*
34 | # env files (can opt-in for committing if needed)
35 | .env
36 | .dev.vars
37 |
38 | # vercel
39 | .vercel
40 |
41 | # typescript
42 | *.tsbuildinfo
43 | next-env.d.ts
44 |
45 | # ide
46 | .idea
47 | .vscode
48 | .turbo
49 | i18n.cache
50 | apps/mail/scripts.ts
51 |
52 | .wrangler
53 | worker-configuration.d.ts
54 |
55 | .dev.vars.*
56 | .react-router
57 |
58 | # devcontainer
59 | .pnpm-store
60 | tsx-0/
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | bun lint-staged
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # don't show warnings if package versions don't match
2 | strict-peer-dependencies=false
3 | auto-install-peers=true
4 | save-exact=true
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v22
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | bun.lockb
2 | node_modules
3 | .next
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "useTabs": false,
4 | "tabWidth": 2,
5 | "semi": true,
6 | "singleQuote": true,
7 | "plugins": ["prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"]
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Zero Email
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MCP.md:
--------------------------------------------------------------------------------
1 | ### Zero MCP
2 |
3 | ## Capabilties
4 |
5 | Zero MCP provides the following capabilities:
6 |
7 | ### Email Management
8 |
9 | - Get email threads by ID
10 | - List emails in specific folders
11 | - Create and send new emails
12 | - Create email drafts
13 | - Send existing drafts
14 | - Delete emails
15 | - Mark emails as read/unread
16 | - Modify email labels
17 | - Bulk delete emails
18 | - Bulk archive emails
19 |
20 | ### Label Management
21 |
22 | - Get all user labels
23 | - Create new custom labels with custom colors
24 | - Delete existing labels
25 |
26 | ### AI-Powered Features
27 |
28 | - Compose emails with AI assistance
29 | - Ask questions about mailbox content
30 | - Ask questions about specific email threads
31 | - Web search using Perplexity AI
32 |
33 | ### Search and Organization
34 |
35 | - Search emails with custom queries
36 | - Filter emails by labels
37 | - Manage email organization through labels
38 | - Archive and trash management
39 |
40 | ## How to use?
41 |
42 | You can connecto ZeroMCP using two methods:
43 |
44 | 1. Better Auth session token
45 | 2. OAuth (Coming soon)
46 |
47 | ## Better Auth session token
48 |
49 | Copy the session cookie from your browser cookies and place it into the Authorization header. You can copy the entire cookie field used in Zero webapp and it will work. Or you can use the format: `better-auth-{env}.session_token={value}`.
50 | Replace `env` with `dev` for local development, `value` is your session token.
51 |
--------------------------------------------------------------------------------
/ROADMAP.md:
--------------------------------------------------------------------------------
1 | # 0.email Roadmap 🛤️
2 |
3 | This document outlines the development roadmap for 0.email. Our vision is to create a powerful, user-friendly, and privacy-focused email experience.
4 |
5 | ## Current Development Focus
6 |
7 | ### 1. Core Email Connectivity
8 |
9 | - Connect main email providers
10 | - Support for multiple email accounts
11 | - Unified inbox experience
12 |
13 | ### 2. Email Usage Improvements
14 |
15 | - AI-powered email assistance
16 | - Advanced drag-and-drop tools
17 | - Customizable keyboard shortcuts
18 | - Performance optimization
19 | - Enhanced search capabilities
20 | - Deep customization options
21 |
22 | ### 3. Infrastructure
23 |
24 | - Domain management
25 | - Optimized email client
26 | - Self-hosting capabilities
27 |
28 | ## Development Priorities
29 |
30 | 1. Building a robust foundation for email management
31 | 2. Implementing user-requested features
32 | 3. Ensuring seamless integration with existing email providers
33 | 4. Maintaining high performance and reliability
34 |
35 | ## Contributing
36 |
37 | We welcome community input and contributions to help shape these features and priorities. If you have suggestions or would like to contribute, please:
38 |
39 | 1. Open an issue to discuss new feature ideas
40 | 2. Submit pull requests for improvements
41 | 3. Join discussions in existing issues
42 |
43 | ## Timeline
44 |
45 | This roadmap is a living document and will be updated as development progresses and priorities evolve based on community feedback and technological advances.
46 |
47 | ---
48 |
49 | Last updated: Feb 2025
50 |
--------------------------------------------------------------------------------
/apps/mail/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | output.json
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 | bun-debug.log*
33 | # env files (can opt-in for committing if needed)
34 | .env
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
43 | # ide
44 | .idea
45 |
46 | # Sentry Config File
47 | .env.sentry-build-plugin
48 |
49 | # Sentry Config File
50 | .sentryclirc
51 |
--------------------------------------------------------------------------------
/apps/mail/app/(auth)/login/error-message.tsx:
--------------------------------------------------------------------------------
1 | import { TriangleAlert } from 'lucide-react';
2 | import { useTranslations } from 'use-intl';
3 | import { useQueryState } from 'nuqs';
4 | import { useEffect } from 'react';
5 | import { toast } from 'sonner';
6 |
7 | const errorMessages = ['required_scopes_missing'] as const;
8 |
9 | const errorToasts = ['early_access_required', 'unauthorized'] as const;
10 |
11 | type ErrorToast = (typeof errorToasts)[number];
12 | type ErrorMessage = (typeof errorMessages)[number];
13 |
14 | const isErrorToast = (error: string): error is (typeof errorToasts)[number] =>
15 | errorToasts.includes(error as ErrorToast);
16 |
17 | const isErrorMessage = (error: string): error is (typeof errorMessages)[number] =>
18 | errorMessages.includes(error as ErrorMessage);
19 |
20 | const ErrorMessage = () => {
21 | const [error] = useQueryState('error');
22 | const t = useTranslations();
23 |
24 | useEffect(() => {
25 | if (error && isErrorToast(error)) {
26 | toast.error(t(`errorMessages.${error}`));
27 | }
28 | });
29 |
30 | if (!error || !isErrorMessage(error)) {
31 | return null;
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 |
39 | {t(`errorMessages.${error}`)}
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default ErrorMessage;
47 |
--------------------------------------------------------------------------------
/apps/mail/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { authProviders, customProviders, isProviderEnabled } from '@zero/server/auth-providers';
2 | import { LoginClient } from './login-client';
3 | import { useLoaderData } from 'react-router';
4 | import { env } from 'cloudflare:workers';
5 |
6 | export function loader() {
7 | const isProd = !import.meta.env.DEV;
8 |
9 | const authProviderStatus = authProviders(env as unknown as Record).map(
10 | (provider) => {
11 | const envVarStatus =
12 | provider.envVarInfo?.map((envVar) => {
13 | const envVarName = envVar.name as keyof typeof env;
14 | return {
15 | name: envVar.name,
16 | set: !!env[envVarName],
17 | source: envVar.source,
18 | defaultValue: envVar.defaultValue,
19 | };
20 | }) || [];
21 |
22 | return {
23 | id: provider.id,
24 | name: provider.name,
25 | enabled: isProviderEnabled(provider, env as unknown as Record),
26 | required: provider.required,
27 | envVarInfo: provider.envVarInfo,
28 | envVarStatus,
29 | };
30 | },
31 | );
32 |
33 | const customProviderStatus = customProviders.map((provider) => {
34 | return {
35 | id: provider.id,
36 | name: provider.name,
37 | enabled: true,
38 | isCustom: provider.isCustom,
39 | customRedirectPath: provider.customRedirectPath,
40 | envVarStatus: [],
41 | };
42 | });
43 |
44 | const allProviders = [...customProviderStatus, ...authProviderStatus];
45 |
46 | return {
47 | allProviders,
48 | isProd,
49 | };
50 | }
51 |
52 | export default function LoginPage() {
53 | const { allProviders, isProd } = useLoaderData();
54 |
55 | return (
56 |
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/apps/mail/app/(full-width)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router';
2 |
3 | export default function FullWidthLayout() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper';
2 | import { CommandPaletteProvider } from '@/components/context/command-palette-context';
3 | import { Outlet } from 'react-router';
4 |
5 | export default function Layout() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/compose/page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogDescription,
5 | DialogTitle,
6 | DialogTrigger,
7 | } from '@/components/ui/dialog';
8 | import { CreateEmail } from '@/components/create/create-email';
9 | import { authProxy } from '@/lib/auth-proxy';
10 | import { useLoaderData } from 'react-router';
11 | import type { Route } from './+types/page';
12 |
13 | export async function loader({ request }: Route.LoaderArgs) {
14 | const session = await authProxy.api.getSession({ headers: request.headers });
15 | if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
16 | const url = new URL(request.url);
17 | if (url.searchParams.get('to')?.startsWith('mailto:')) {
18 | return Response.redirect(
19 | `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`,
20 | );
21 | }
22 |
23 | return Object.fromEntries(url.searchParams.entries()) as {
24 | to?: string;
25 | subject?: string;
26 | body?: string;
27 | draftId?: string;
28 | cc?: string;
29 | bcc?: string;
30 | };
31 | }
32 |
33 | export default function ComposePage() {
34 | const params = useLoaderData();
35 |
36 | return (
37 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/create/page.tsx:
--------------------------------------------------------------------------------
1 | import { authProxy } from '@/lib/auth-proxy';
2 | import type { Route } from './+types/page';
3 |
4 | export async function loader({ request }: Route.LoaderArgs) {
5 | const session = await authProxy.api.getSession({ headers: request.headers });
6 | if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
7 |
8 | const url = new URL(request.url);
9 | const params = Object.fromEntries(url.searchParams.entries()) as {
10 | to?: string;
11 | subject?: string;
12 | body?: string;
13 | };
14 | const toParam = params.to || 'someone@someone.com';
15 | return Response.redirect(
16 | `${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
17 | );
18 | }
19 |
20 | // export async function generateMetadata({ searchParams }: any) {
21 | // // Need to await searchParams in Next.js 15+
22 | // const params = await searchParams;
23 |
24 | // const toParam = params.to || 'someone';
25 |
26 | // // Create common metadata properties
27 | // const title = `Email ${toParam} on Zero`;
28 | // const description = 'Zero - The future of email is here';
29 | // const imageUrl = `/og-api/create?to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`;
30 |
31 | // // Create metadata object
32 | // return {
33 | // title,
34 | // description,
35 | // openGraph: {
36 | // title,
37 | // description,
38 | // images: [imageUrl],
39 | // },
40 | // twitter: {
41 | // card: 'summary_large_image',
42 | // title,
43 | // description,
44 | // images: [imageUrl],
45 | // },
46 | // };
47 | // }
48 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper';
2 | import { OnboardingWrapper } from '@/components/onboarding';
3 | import { NotificationProvider } from '@/components/party';
4 | import { AppSidebar } from '@/components/ui/app-sidebar';
5 | import { Outlet, useLoaderData } from 'react-router';
6 | import type { Route } from './+types/layout';
7 |
8 | export async function loader({ request }: Route.LoaderArgs) {
9 | return {
10 | headers: Object.fromEntries(request.headers.entries()),
11 | };
12 | }
13 |
14 | export default function MailLayout() {
15 | const { headers } = useLoaderData();
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'react-router';
2 |
3 | export function loader() {
4 | throw redirect(`/mail/inbox`);
5 | }
6 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/under-construction/[path]/back-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { ArrowLeft } from 'lucide-react';
3 |
4 | export default function BackButton() {
5 | return (
6 |
7 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/mail/under-construction/[path]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SidebarToggle } from '@/components/ui/sidebar-toggle';
2 | import { Construction } from 'lucide-react';
3 | import BackButton from './back-button';
4 | import { use } from 'react';
5 |
6 | interface UnderConstructionProps {
7 | params: Promise<{
8 | path: string;
9 | }>;
10 | }
11 |
12 | export default function UnderConstruction({ params }: UnderConstructionProps) {
13 | const resolvedParams = use(params);
14 | // Decode the path parameter
15 | const decodedPath = decodeURIComponent(resolvedParams.path);
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
Under Construction
27 |
28 | The {decodedPath} page is currently under construction.
29 | Check back soon!
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/settings/[...settings]/page.tsx:
--------------------------------------------------------------------------------
1 | import NotificationsPage from '../notifications/page';
2 | import ConnectionsPage from '../connections/page';
3 | import AppearancePage from '../appearance/page';
4 | import ShortcutsPage from '../shortcuts/page';
5 | import SecurityPage from '../security/page';
6 | import { useTranslations } from 'use-intl';
7 | import GeneralPage from '../general/page';
8 | import { useParams } from 'react-router';
9 | import LabelsPage from '../labels/page';
10 |
11 | const settingsPages: Record = {
12 | general: GeneralPage,
13 | connections: ConnectionsPage,
14 | security: SecurityPage,
15 | appearance: AppearancePage,
16 | shortcuts: ShortcutsPage,
17 | notifications: NotificationsPage,
18 | labels: LabelsPage,
19 | };
20 |
21 | export default function SettingsPage() {
22 | const params = useParams();
23 | const section = params.settings?.[0] || 'general';
24 | const t = useTranslations();
25 |
26 | const SettingsComponent = settingsPages[section];
27 |
28 | if (!SettingsComponent) {
29 | return {t('pages.error.settingsNotFound')}
;
30 | }
31 |
32 | return ;
33 | }
34 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/settings/labels/colors.ts:
--------------------------------------------------------------------------------
1 | export const COLORS = [
2 | '#FFFFFF', // White
3 | '#000000', // Black
4 | '#0000FF', // Blue
5 | '#FF0000', // Red
6 | '#FFFF00', // Yellow
7 | '#FFA500', // Orange
8 | '#800080', // Purple
9 | ];
10 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { SettingsLayoutContent } from '@/components/ui/settings-content';
2 | import { Outlet } from 'react-router';
3 |
4 | export default function SettingsLayout() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/mail/app/(routes)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'react-router';
2 |
3 | export function loader() {
4 | throw redirect(`/settings/general`);
5 | }
6 |
--------------------------------------------------------------------------------
/apps/mail/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import '../instrument';
2 |
3 | import { startTransition, StrictMode } from 'react';
4 | import { HydratedRouter } from 'react-router/dom';
5 | import { hydrateRoot } from 'react-dom/client';
6 | import * as Sentry from '@sentry/react';
7 |
8 | startTransition(() => {
9 | hydrateRoot(
10 | document,
11 |
12 |
13 | ,
14 | {
15 | onUncaughtError: Sentry.reactErrorHandler((error, errorInfo) => {
16 | console.warn('Uncaught error', error, errorInfo.componentStack);
17 | }),
18 | // Callback called when React catches an error in an ErrorBoundary.
19 | onCaughtError: Sentry.reactErrorHandler(),
20 | // Callback called when React automatically recovers from errors.
21 | onRecoverableError: Sentry.reactErrorHandler(),
22 | },
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/apps/mail/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext, EntryContext } from 'react-router';
2 | import { renderToReadableStream } from 'react-dom/server';
3 | import { ServerRouter } from 'react-router';
4 | import { isbot } from 'isbot';
5 |
6 | export default async function handleRequest(
7 | request: Request,
8 | responseStatusCode: number,
9 | responseHeaders: Headers,
10 | routerContext: EntryContext,
11 | _loadContext: AppLoadContext,
12 | ) {
13 | let shellRendered = false;
14 | const userAgent = request.headers.get('user-agent');
15 |
16 | const body = await renderToReadableStream(
17 | ,
18 | {
19 | onError(error: unknown) {
20 | responseStatusCode = 500;
21 | // Log streaming rendering errors from inside the shell. Don't log
22 | // errors encountered during initial shell rendering since they'll
23 | // reject and get logged in handleDocumentRequest.
24 | if (shellRendered) {
25 | console.error(error);
26 | }
27 | },
28 | },
29 | );
30 | shellRendered = true;
31 |
32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding
33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
35 | await body.allReady;
36 | }
37 |
38 | responseHeaders.set('Content-Type', 'text/html');
39 | return new Response(body, {
40 | headers: responseHeaders,
41 | status: responseStatusCode,
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/apps/mail/app/home/page.tsx:
--------------------------------------------------------------------------------
1 | import HomeContent from '@/components/home/HomeContent';
2 |
3 | export default function HomeRoute() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/apps/mail/app/meta-files/manifest.ts:
--------------------------------------------------------------------------------
1 | export function loader() {
2 | return Response.json({
3 | name: 'Zero',
4 | short_name: '0',
5 | description: 'Zero - the first open source email app that puts your privacy and safety first.',
6 | scope: '/',
7 | start_url: '/mail/inbox',
8 | display: 'standalone',
9 | background_color: '#000',
10 | theme_color: '#fff',
11 | icons: [
12 | {
13 | src: '/icons-pwa/icon-512.png',
14 | sizes: '512x512',
15 | type: 'image/png',
16 | purpose: 'any maskable',
17 | },
18 | {
19 | src: '/icons-pwa/icon-192.png',
20 | sizes: '192x192',
21 | type: 'image/png',
22 | purpose: 'any maskable',
23 | },
24 | {
25 | src: '/icons-pwa/icon-180.png',
26 | sizes: '180x180',
27 | type: 'image/png',
28 | },
29 | ],
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/apps/mail/app/meta-files/microsoft-identity-association.json.ts:
--------------------------------------------------------------------------------
1 | export function loader() {
2 | return Response.json({
3 | associatedApplications: [
4 | {
5 | applicationId: 'ecf043a0-41bb-4c89-bd31-7e3f272f8e3c',
6 | },
7 | ],
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/apps/mail/app/meta-files/microsoft-identity-association.ts:
--------------------------------------------------------------------------------
1 | export function loader() {
2 | return Response.json({
3 | associatedApplications: [
4 | {
5 | applicationId: '80b11343-e52c-4969-81e8-faebfed78a67',
6 | },
7 | ],
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/apps/mail/app/meta-files/not-found.ts:
--------------------------------------------------------------------------------
1 | export function loader() {
2 | throw new Response('Not Found', { status: 404 });
3 | }
4 |
5 | export default function NotFound() {
6 | return null;
7 | }
8 |
--------------------------------------------------------------------------------
/apps/mail/app/page.tsx:
--------------------------------------------------------------------------------
1 | import HomeContent from '@/components/home/HomeContent';
2 | import { authProxy } from '@/lib/auth-proxy';
3 | import type { Route } from './+types/page';
4 | import { redirect } from 'react-router';
5 |
6 | export async function loader({ request }: Route.LoaderArgs) {
7 | const session = await authProxy.api.getSession({ headers: request.headers });
8 | if (session?.user.id) throw redirect('/mail/inbox');
9 | return null;
10 | }
11 |
12 | export default function Home() {
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/apps/mail/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.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/apps/mail/components/ai-toggle-button.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip';
2 | import { useAISidebar } from './ui/ai-sidebar';
3 | import { Button } from './ui/button';
4 |
5 | // AI Toggle Button Component
6 | const AIToggleButton = () => {
7 | const { toggleOpen: toggleAISidebar, open: isSidebarOpen } = useAISidebar();
8 |
9 | return (
10 | !isSidebarOpen && (
11 |
12 |
13 |
14 |
42 |
43 | Toggle AI Assistant
44 |
45 |
46 | )
47 | );
48 | };
49 |
50 | export default AIToggleButton;
51 |
--------------------------------------------------------------------------------
/apps/mail/components/cookies/cookie-trigger.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { Cookie } from 'lucide-react';
3 | import { cn } from '@/lib/utils';
4 |
5 | interface CookieTriggerProps {
6 | variant?: 'link' | 'button' | 'prominent' | 'icon';
7 | className?: string;
8 | children?: React.ReactNode;
9 | onClick?: () => void;
10 | }
11 |
12 | export function CookieTrigger({
13 | variant = 'button',
14 | className,
15 | children,
16 | onClick,
17 | }: CookieTriggerProps) {
18 | const variants = {
19 | link: 'text-primary underline-offset-4 hover:underline p-0 h-auto',
20 | button: 'bg-primary text-primary-foreground hover:bg-primary/90',
21 | prominent: 'b text-white font-medium px-6',
22 | icon: 'h-9 w-9 rounded-full border-zinc-800 dark:text-black text-white shadow-lg bg-black dark:bg-white relative top-2 left-2',
23 | };
24 |
25 | return (
26 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/apps/mail/components/create/ai-textarea.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 | import React from 'react';
3 |
4 | interface TextareaProps extends React.TextareaHTMLAttributes {}
5 |
6 | const AITextarea = React.forwardRef(
7 | ({ className, ...props }, ref) => {
8 | return (
9 |
19 | );
20 | },
21 | );
22 |
23 | AITextarea.displayName = 'AITextarea';
24 |
25 | export { AITextarea };
26 |
--------------------------------------------------------------------------------
/apps/mail/components/create/editor-menu.tsx:
--------------------------------------------------------------------------------
1 | import { EditorBubble, useEditor } from 'novel';
2 | import { removeAIHighlight } from 'novel';
3 |
4 | import { type ReactNode, useEffect } from 'react';
5 |
6 | interface EditorMenuProps {
7 | children: ReactNode;
8 | open: boolean;
9 | onOpenChange: (open: boolean) => void;
10 | }
11 |
12 | export default function EditorMenu({ children, open, onOpenChange }: EditorMenuProps) {
13 | const { editor } = useEditor();
14 |
15 | useEffect(() => {
16 | if (!editor) return;
17 | if (!open) removeAIHighlight(editor);
18 | }, [open]);
19 |
20 | return (
21 | {
25 | onOpenChange(false);
26 | editor?.chain().unsetHighlight().run();
27 | },
28 | }}
29 | className="border-muted bg-background flex w-fit max-w-[90vw] overflow-hidden rounded-md border shadow-xl"
30 | >
31 | {!open && children}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/apps/mail/components/create/image-upload.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/components/create/image-upload.ts
--------------------------------------------------------------------------------
/apps/mail/components/create/selectors/math-selector.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import { SigmaIcon } from 'lucide-react';
3 | import { useEditor } from 'novel';
4 | import { cn } from '@/lib/utils';
5 |
6 | export const MathSelector = () => {
7 | const { editor } = useEditor();
8 |
9 | if (!editor) return null;
10 |
11 | return (
12 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/apps/mail/components/create/selectors/text-buttons.tsx:
--------------------------------------------------------------------------------
1 | import { MessageSquare, FileText, Edit } from 'lucide-react';
2 | import type { SelectorItem } from './node-selector';
3 | import { EditorBubbleItem, useEditor } from 'novel';
4 | import { Button } from '@/components/ui/button';
5 | import { cn } from '@/lib/utils';
6 |
7 | export const TextButtons = () => {
8 | const { editor } = useEditor();
9 | if (!editor) return null;
10 |
11 | // Define AI action handlers
12 | const handleChatWithAI = () => {
13 | // Get selected text
14 | const selection = editor.state.selection;
15 | const selectedText = selection.empty
16 | ? ''
17 | : editor.state.doc.textBetween(selection.from, selection.to);
18 |
19 | console.log('Chat with AI about:', selectedText);
20 | // Implement chat with AI functionality
21 | };
22 |
23 | const items = [
24 | {
25 | name: 'chat-with-zero',
26 | label: 'Chat with Zero',
27 | action: handleChatWithAI,
28 | useImage: true,
29 | imageSrc: '/ai.svg',
30 | },
31 | ];
32 |
33 | return (
34 |
35 | {items.map((item) => (
36 |
{
39 | item.action();
40 | }}
41 | >
42 |
57 |
58 | ))}
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/apps/mail/components/mail/nav-main.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from 'use-intl';
2 | import React, { useState } from 'react';
3 | import { cn } from '@/lib/utils';
4 |
5 | const NavMain: React.FC = () => {
6 | const t = useTranslations();
7 | const [state, setState] = useState<'collapsed' | 'expanded'>('collapsed');
8 |
9 | const handleToggle = () => {
10 | setState((prevState) => (prevState === 'collapsed' ? 'expanded' : 'collapsed'));
11 | };
12 |
13 | return {/* Rest of the component code */}
;
14 | };
15 |
16 | export default NavMain;
17 |
--------------------------------------------------------------------------------
/apps/mail/components/mail/thread-subject.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
2 | import { cn } from '@/lib/utils';
3 | import { useRef } from 'react';
4 |
5 | interface ThreadSubjectProps {
6 | subject?: string;
7 | }
8 |
9 | export default function ThreadSubject({ subject }: ThreadSubjectProps) {
10 | const textRef = useRef(null);
11 | const subjectContent = subject || '(no subject)';
12 |
13 | return (
14 |
15 |
22 | {subjectContent.trim()}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/apps/mail/components/mail/use-mail.ts:
--------------------------------------------------------------------------------
1 | import { atom, useAtom } from 'jotai';
2 |
3 | export type Config = {
4 | selected: string | null;
5 | bulkSelected: string[];
6 | replyComposerOpen: boolean;
7 | replyAllComposerOpen: boolean;
8 | forwardComposerOpen: boolean;
9 | showImages: boolean;
10 | };
11 |
12 | const configAtom = atom({
13 | selected: null,
14 | bulkSelected: [],
15 | replyComposerOpen: false,
16 | replyAllComposerOpen: false,
17 | forwardComposerOpen: false,
18 | showImages: false,
19 | });
20 |
21 | export function useMail() {
22 | return useAtom(configAtom);
23 | }
24 |
25 | export const clearBulkSelectionAtom = atom(null, (get, set) => {
26 | const current = get(configAtom);
27 | set(configAtom, { ...current, bulkSelected: [] });
28 | });
29 |
--------------------------------------------------------------------------------
/apps/mail/components/providers/editor-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from 'react';
2 | import { Editor } from '@tiptap/react';
3 |
4 | interface EditorContextType {
5 | editor: Editor | null;
6 | setEditor: (editor: Editor | null) => void;
7 | }
8 |
9 | export const EditorContext = createContext({
10 | editor: null,
11 | setEditor: () => {},
12 | });
13 |
14 | export function EditorProvider({ children }: { children: React.ReactNode }) {
15 | const [editor, setEditor] = useState(null);
16 |
17 | return {children};
18 | }
19 |
20 | export function useEditor() {
21 | return useContext(EditorContext);
22 | }
23 |
--------------------------------------------------------------------------------
/apps/mail/components/providers/hotkey-provider-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { ThreadDisplayHotkeys } from '@/lib/hotkeys/thread-display-hotkeys';
2 | import { NavigationHotkeys } from '@/lib/hotkeys/navigation-hotkeys';
3 | import { MailListHotkeys } from '@/lib/hotkeys/mail-list-hotkeys';
4 | import { ComposeHotkeys } from '@/lib/hotkeys/compose-hotkeys';
5 | import { GlobalHotkeys } from '@/lib/hotkeys/global-hotkeys';
6 | import { HotkeysProvider } from 'react-hotkeys-hook';
7 | import React from 'react';
8 |
9 | interface HotkeyProviderWrapperProps {
10 | children: React.ReactNode;
11 | }
12 |
13 | export function HotkeyProviderWrapper({ children }: HotkeyProviderWrapperProps) {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/apps/mail/components/responsive-modal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogHeader,
5 | DialogDescription,
6 | DialogTitle,
7 | } from '@/components/ui/dialog';
8 | import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
9 | import { VisuallyHidden } from 'radix-ui';
10 | import { type ReactElement } from 'react';
11 | // import { useMedia } from 'react-use';
12 |
13 | type ResponsiveModalProps = {
14 | children: React.ReactNode;
15 | open: boolean;
16 | onOpenChange: (open: boolean) => void;
17 | };
18 |
19 | export default function ResponsiveModal({
20 | children,
21 | open,
22 | onOpenChange,
23 | }: ResponsiveModalProps): ReactElement {
24 | const isDesktop = true;
25 |
26 | if (isDesktop) {
27 | return (
28 |
39 | );
40 | }
41 |
42 | return (
43 |
44 |
45 |
46 |
47 | Title
48 |
49 |
50 |
51 | {children}
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/apps/mail/components/settings/settings-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
2 | import { PricingDialog } from '../ui/pricing-dialog';
3 | import { cn } from '@/lib/utils';
4 |
5 | interface SettingsCardProps extends React.HTMLAttributes {
6 | title: string;
7 | description?: string;
8 | children: React.ReactNode;
9 | footer?: React.ReactNode;
10 | action?: React.ReactNode;
11 | }
12 |
13 | export function SettingsCard({
14 | title,
15 | description,
16 | children,
17 | footer,
18 | action,
19 | className,
20 | }: SettingsCardProps) {
21 | return (
22 |
28 |
29 |
30 | {title}
31 | {description && {description}}
32 |
33 | {action && {action}
}
34 |
35 | {children}
36 | {footer && {footer}
}
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/apps/mail/components/theme/mode-toggle.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/components/theme/mode-toggle.tsx
--------------------------------------------------------------------------------
/apps/mail/components/theme/sidebar-theme-switcher.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes';
2 |
3 | import { MoonIcon } from '../icons/animated/moon';
4 | import { SunIcon } from '../icons/animated/sun';
5 | import { useEffect, useState } from 'react';
6 |
7 | export function SidebarThemeSwitch() {
8 | const [isRendered, setIsRendered] = useState(false);
9 | const { theme, resolvedTheme, setTheme } = useTheme();
10 |
11 | // Prevents hydration error
12 | useEffect(() => setIsRendered(true), []);
13 |
14 | async function handleThemeToggle() {
15 | const newTheme = theme === 'dark' ? 'light' : 'dark';
16 |
17 | function update() {
18 | setTheme(newTheme);
19 | }
20 |
21 | if (document.startViewTransition && newTheme !== resolvedTheme) {
22 | document.documentElement.style.viewTransitionName = 'theme-transition';
23 | await document.startViewTransition(update).finished;
24 | document.documentElement.style.viewTransitionName = '';
25 | } else {
26 | update();
27 | }
28 | }
29 |
30 | if (!isRendered) return null;
31 |
32 | return (
33 |
34 | {theme === 'dark' ?
:
}
35 |
App Theme
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/apps/mail/components/theme/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon } from '@/components/icons/animated/moon';
2 | import { SunIcon } from '@/components/icons/animated/sun';
3 | import { useEffect, useState } from 'react';
4 | import { useTheme } from 'next-themes';
5 |
6 | interface ThemeToggleProps {
7 | className?: string;
8 | showLabel?: boolean;
9 | }
10 |
11 | export function ThemeToggle({ className = '', showLabel = false }: ThemeToggleProps) {
12 | const [isRendered, setIsRendered] = useState(false);
13 | const { theme, resolvedTheme, setTheme } = useTheme();
14 |
15 | useEffect(() => setIsRendered(true), []);
16 |
17 | async function handleThemeToggle() {
18 | const newTheme = theme === 'dark' ? 'light' : 'dark';
19 |
20 | function update() {
21 | setTheme(newTheme);
22 | }
23 |
24 | if (document.startViewTransition && newTheme !== resolvedTheme) {
25 | document.documentElement.style.viewTransitionName = 'theme-transition';
26 | await document.startViewTransition(update).finished;
27 | document.documentElement.style.viewTransitionName = '';
28 | } else {
29 | update();
30 | }
31 | }
32 |
33 | if (!isRendered) return null;
34 |
35 | return (
36 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/animated-number.tsx:
--------------------------------------------------------------------------------
1 | import { motion, type SpringOptions, useSpring, useTransform } from 'motion/react';
2 | import { useEffect } from 'react';
3 | import { cn } from '@/lib/utils';
4 |
5 | export type AnimatedNumberProps = {
6 | value: number;
7 | className?: string;
8 | springOptions?: SpringOptions;
9 | as?: React.ElementType;
10 | };
11 |
12 | export function AnimatedNumber({
13 | value,
14 | className,
15 | springOptions,
16 | as = 'span',
17 | }: AnimatedNumberProps) {
18 | const MotionComponent = motion.create(as);
19 |
20 | const spring = useSpring(value, springOptions);
21 | const display = useTransform(spring, (current) => Math.round(current).toLocaleString());
22 |
23 | useEffect(() => {
24 | spring.set(value);
25 | }, [spring, value]);
26 |
27 | return {display};
28 | }
29 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar as AvatarPrimitive } from 'radix-ui';
2 | import { cn } from '@/lib/utils';
3 | import * as React from 'react';
4 |
5 | const Avatar = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
14 | ));
15 | Avatar.displayName = AvatarPrimitive.Root.displayName;
16 |
17 | const AvatarImage = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
26 | ));
27 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
28 |
29 | const AvatarFallback = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef
32 | >(({ className, ...props }, ref) => (
33 |
38 | ));
39 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
40 |
41 | export { Avatar, AvatarImage, AvatarFallback };
42 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'border-transparent bg-primary text-primary-foreground ',
12 | secondary:
13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
14 | destructive:
15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
16 | outline: 'text-foreground',
17 | important: 'border-0 text-amber-700 dark:text-amber-500 ',
18 | promotions: 'border-0 text-red-700 dark:text-red-500 ',
19 | personal: 'border-0 text-green-700 dark:text-green-500 ',
20 | updates: 'border-0 text-purple-700 dark:text-purple-500 ',
21 | forums: 'border-transparent text-blue-500 dark:text-blue-400',
22 | },
23 | },
24 | defaultVariants: {
25 | variant: 'default',
26 | },
27 | },
28 | );
29 |
30 | export interface BadgeProps
31 | extends React.HTMLAttributes,
32 | VariantProps {}
33 |
34 | function Badge({ className, variant, ...props }: BadgeProps) {
35 | return ;
36 | }
37 |
38 | export { Badge, badgeVariants };
39 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox as CheckboxPrimitive } from 'radix-ui';
2 | import { Check } from 'lucide-react';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
20 |
21 |
22 |
23 | ));
24 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
25 |
26 | export { Checkbox };
27 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
2 |
3 | const Collapsible = CollapsiblePrimitive.Root;
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
10 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/command-menu.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/components/ui/command-menu.tsx
--------------------------------------------------------------------------------
/apps/mail/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | },
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import { Label as LabelPrimitive } from 'radix-ui';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef & VariantProps
14 | >(({ className, ...props }, ref) => (
15 |
16 | ));
17 | Label.displayName = LabelPrimitive.Root.displayName;
18 |
19 | export { Label };
20 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/page-header.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 | import React from 'react';
3 |
4 | interface PageHeaderProps extends React.HTMLAttributes {}
5 |
6 | const PageHeader = React.forwardRef(
7 | ({ className, children, ...props }, ref) => {
8 | return (
9 |
14 | {children}
15 |
16 | );
17 | },
18 | );
19 | PageHeader.displayName = 'PageHeader';
20 |
21 | interface PageHeaderTitleProps extends React.HTMLAttributes {}
22 |
23 | const PageHeaderTitle = React.forwardRef(
24 | ({ className, children, ...props }, ref) => {
25 | return (
26 |
27 | {children}
28 |
29 | );
30 | },
31 | );
32 | PageHeaderTitle.displayName = 'PageHeaderTitle';
33 |
34 | interface PageHeaderDescriptionProps extends React.HTMLAttributes {}
35 |
36 | const PageHeaderDescription = React.forwardRef(
37 | ({ className, children, ...props }, ref) => {
38 | return (
39 |
40 | {children}
41 |
42 | );
43 | },
44 | );
45 | PageHeaderDescription.displayName = 'PageHeaderDescription';
46 |
47 | // Attach subcomponents to PageHeader
48 | // @ts-expect-error, fix types
49 | PageHeader.Title = PageHeaderTitle;
50 | // @ts-expect-error, fix types
51 | PageHeader.Description = PageHeaderDescription;
52 |
53 | export { PageHeader };
54 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import { Popover as PopoverPrimitive } from 'radix-ui';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverTrigger, PopoverContent };
30 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/pricing-switch.tsx:
--------------------------------------------------------------------------------
1 | import { Switch as SwitchPrimitives } from 'radix-ui';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const PricingSwitch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | PricingSwitch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { PricingSwitch };
28 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import { Progress as ProgressPrimitive } from 'radix-ui';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
15 |
19 |
20 | ));
21 | Progress.displayName = ProgressPrimitive.Root.displayName;
22 |
23 | export { Progress };
24 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
2 | import { Circle } from 'lucide-react';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return ;
12 | });
13 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
14 |
15 | const RadioGroupItem = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => {
19 | return (
20 |
28 |
29 |
30 |
31 |
32 | );
33 | });
34 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
35 |
36 | export { RadioGroup, RadioGroupItem };
37 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | import * as ResizablePrimitive from 'react-resizable-panels';
2 | import { GripVertical } from 'lucide-react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const ResizablePanelGroup = ({
7 | className,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
14 | );
15 |
16 | const ResizablePanel = ResizablePrimitive.Panel;
17 |
18 | const ResizableHandle = ({
19 | withHandle,
20 | className,
21 | ...props
22 | }: React.ComponentProps & {
23 | withHandle?: boolean;
24 | }) => (
25 | div]:rotate-90',
28 | className,
29 | )}
30 | {...props}
31 | >
32 | {withHandle && (
33 |
34 |
35 |
36 | )}
37 |
38 | );
39 |
40 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
41 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import { Separator as SeparatorPrimitive } from 'radix-ui';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
10 |
21 | ));
22 | Separator.displayName = SeparatorPrimitive.Root.displayName;
23 |
24 | export { Separator };
25 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/settings-content.tsx:
--------------------------------------------------------------------------------
1 | import { SidebarToggle } from '@/components/ui/sidebar-toggle';
2 | import { AppSidebar } from '@/components/ui/app-sidebar';
3 | import { ScrollArea } from '@/components/ui/scroll-area';
4 |
5 | export function SettingsLayoutContent({ children }: { children: React.ReactNode }) {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {children}
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/sidebar-toggle.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react';
2 |
3 | import { type SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
4 | import { Button } from '@/components/ui/button';
5 | import { PanelLeftOpen } from '../icons/icons';
6 | import { cn } from '@/lib/utils';
7 |
8 | export function SidebarToggle({ className }: ComponentProps) {
9 | const { toggleSidebar } = useSidebar();
10 |
11 | return (
12 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return ;
5 | }
6 |
7 | export { Skeleton };
8 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'motion/react';
2 |
3 | interface SpinnerProps {
4 | size?: number;
5 | color?: string;
6 | }
7 |
8 | export const Spinner = ({ size = 24, color = 'currentColor' }: SpinnerProps) => {
9 | return (
10 |
23 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import { Switch as SwitchPrimitives } from 'radix-ui';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/text-shimmer.tsx:
--------------------------------------------------------------------------------
1 | // Source: https://motion-primitives.com/docs/text-shimmer
2 |
3 | import React, { useMemo, type JSX } from 'react';
4 | import { motion } from 'motion/react';
5 | import { cn } from '@/lib/utils';
6 |
7 | export type TextShimmerProps = {
8 | children: string;
9 | as?: React.ElementType;
10 | className?: string;
11 | duration?: number;
12 | spread?: number;
13 | };
14 |
15 | export function TextShimmer({
16 | children,
17 | as: Component = 'p',
18 | className,
19 | duration = 2,
20 | spread = 2,
21 | }: TextShimmerProps) {
22 | const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements);
23 |
24 | const dynamicSpread = useMemo(() => {
25 | return children.length * spread;
26 | }, [children, spread]);
27 |
28 | return (
29 |
51 | {children}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Textarea = React.forwardRef>(
6 | ({ className, ...props }, ref) => {
7 | return (
8 |
16 | );
17 | },
18 | );
19 | Textarea.displayName = 'Textarea';
20 |
21 | export { Textarea };
22 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CircleCheck,
3 | ExclamationCircle2,
4 | ExclamationTriangle,
5 | InfoCircle,
6 | } from '@/components/icons/icons';
7 | import { Toaster } from 'sonner';
8 | import React from 'react';
9 |
10 | type Props = {};
11 |
12 | const CustomToaster = (props: Props) => {
13 | return (
14 | ,
18 | error: ,
19 | warning: ,
20 | info: ,
21 | }}
22 | toastOptions={{
23 | classNames: {
24 | title: 'title flex-1 justify-center text-black dark:text-white text-sm leading-none',
25 | description: 'text-black dark:text-white text-xs',
26 | toast: 'px-3',
27 | actionButton: 'bg-[#DBDBDB] text-lg',
28 | cancelButton: 'bg-[#DBDBDB] text-lg',
29 | closeButton: 'bg-[#DBDBDB] text-lg',
30 | loading: 'pl-3 pr-2',
31 | loader: 'pl-3 pr-2',
32 | icon: 'pl-3 pr-2',
33 | content: 'pl-2',
34 | default:
35 | 'w-96 px-1.5 bg-white dark:bg-[#2C2C2C] rounded-xl inline-flex items-center gap-2 overflow-visible border dark:border-none',
36 | },
37 | }}
38 | />
39 | );
40 | };
41 |
42 | export default CustomToaster;
43 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
2 | import { type VariantProps } from 'class-variance-authority';
3 | import * as React from 'react';
4 |
5 | import { toggleVariants } from '@/components/ui/toggle';
6 | import { cn } from '@/lib/utils';
7 |
8 | const ToggleGroupContext = React.createContext>({
9 | size: 'default',
10 | variant: 'default',
11 | });
12 |
13 | const ToggleGroup = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, variant, size, children, ...props }, ref) => (
18 |
23 | {children}
24 |
25 | ));
26 |
27 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
28 |
29 | const ToggleGroupItem = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, children, variant, size, ...props }, ref) => {
34 | const context = React.useContext(ToggleGroupContext);
35 |
36 | return (
37 |
48 | {children}
49 |
50 | );
51 | });
52 |
53 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
54 |
55 | export { ToggleGroup, ToggleGroupItem };
56 |
--------------------------------------------------------------------------------
/apps/mail/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import { Toggle as TogglePrimitive } from 'radix-ui';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const toggleVariants = cva(
8 | 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-transparent',
13 | outline:
14 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
15 | },
16 | size: {
17 | default: 'h-9 px-2 min-w-9',
18 | sm: 'h-8 px-1.5 min-w-8',
19 | lg: 'h-10 px-2.5 min-w-10',
20 | },
21 | },
22 | defaultVariants: {
23 | variant: 'default',
24 | size: 'default',
25 | },
26 | },
27 | );
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef & VariantProps
32 | >(({ className, variant, size, ...props }, ref) => (
33 |
38 | ));
39 |
40 | Toggle.displayName = TogglePrimitive.Root.displayName;
41 |
42 | export { Toggle, toggleVariants };
43 |
--------------------------------------------------------------------------------
/apps/mail/components/user/user-button.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/components/user/user-button.tsx
--------------------------------------------------------------------------------
/apps/mail/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import config from '@zero/eslint-config';
2 |
3 | export default config;
4 |
--------------------------------------------------------------------------------
/apps/mail/hooks/driver/use-delete.ts:
--------------------------------------------------------------------------------
1 | import useBackgroundQueue from '@/hooks/ui/use-background-queue';
2 | import { useMail } from '@/components/mail/use-mail';
3 | import { useTRPC } from '@/providers/query-provider';
4 | import { useMutation } from '@tanstack/react-query';
5 | import { useThreads } from '@/hooks/use-threads';
6 | import { useStats } from '@/hooks/use-stats';
7 | import { useTranslations } from 'use-intl';
8 | import { useState } from 'react';
9 | import { toast } from 'sonner';
10 |
11 | const useDelete = () => {
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [mail, setMail] = useMail();
14 | const [{ refetch: refetchThreads }] = useThreads();
15 | const { refetch: refetchStats } = useStats();
16 | const t = useTranslations();
17 | const { addToQueue, deleteFromQueue } = useBackgroundQueue();
18 | const trpc = useTRPC();
19 | const { mutateAsync: deleteThread } = useMutation(trpc.mail.delete.mutationOptions());
20 |
21 | return {
22 | mutate: (id: string, type: 'thread' | 'email' = 'thread') => {
23 | setIsLoading(true);
24 | addToQueue(id);
25 | return toast.promise(
26 | deleteThread({
27 | id,
28 | }),
29 | {
30 | loading: t('common.actions.deletingMail'),
31 | success: t('common.actions.deletedMail'),
32 | error: (error) => {
33 | console.error(`Error deleting ${type}:`, error);
34 |
35 | return t('common.actions.failedToDeleteMail');
36 | },
37 | finally: async () => {
38 | setMail({
39 | ...mail,
40 | bulkSelected: [],
41 | });
42 | setIsLoading(false);
43 | await Promise.all([refetchThreads(), refetchStats()]);
44 | },
45 | },
46 | );
47 | },
48 | isLoading,
49 | };
50 | };
51 |
52 | export default useDelete;
53 |
--------------------------------------------------------------------------------
/apps/mail/hooks/ui/use-background-queue.ts:
--------------------------------------------------------------------------------
1 | import { backgroundQueueAtom } from '@/store/backgroundQueue';
2 | import { useAtom } from 'jotai';
3 |
4 | const useBackgroundQueue = () => {
5 | const [backgroundQueue, setBackgroundQueue] = useAtom(backgroundQueueAtom);
6 |
7 | return {
8 | addToQueue: (threadId: string) =>
9 | setBackgroundQueue({
10 | type: 'add',
11 | threadId: threadId.startsWith('thread:') ? threadId : `thread:${threadId}`,
12 | }),
13 | deleteFromQueue: (threadId: string) =>
14 | setBackgroundQueue({
15 | type: 'delete',
16 | threadId: threadId.startsWith('thread:') ? threadId : `thread:${threadId}`,
17 | }),
18 | clearQueue: () => setBackgroundQueue({ type: 'clear' }),
19 | queue: backgroundQueue,
20 | };
21 | };
22 |
23 | export default useBackgroundQueue;
24 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-connections.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 |
4 | export const useConnections = () => {
5 | const trpc = useTRPC();
6 | const connectionsQuery = useQuery(trpc.connections.list.queryOptions());
7 | return connectionsQuery;
8 | };
9 |
10 | export const useActiveConnection = () => {
11 | const trpc = useTRPC();
12 | const connectionsQuery = useQuery(trpc.connections.getDefault.queryOptions());
13 | return connectionsQuery;
14 | };
15 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-copy-to-clipboard.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { toast } from 'sonner';
3 |
4 | export function useCopyToClipboard(resetDelay = 2000) {
5 | const [copiedValue, setCopiedValue] = useState(null);
6 |
7 | const copyToClipboard = (value: string, id: string) => {
8 | navigator.clipboard.writeText(value);
9 | setCopiedValue(id);
10 | toast.success("Link copied to clipboard!");
11 |
12 | setTimeout(() => {
13 | setCopiedValue(null);
14 | }, resetDelay);
15 | };
16 |
17 | return { copiedValue, copyToClipboard };
18 | }
--------------------------------------------------------------------------------
/apps/mail/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react';
2 |
3 | export const useDebounce = any>(callback: T, delay: number): T => {
4 | const timeoutRef = useRef(null);
5 |
6 | const debouncedCallback = useCallback(
7 | (...args: Parameters) => {
8 | if (timeoutRef.current) {
9 | clearTimeout(timeoutRef.current);
10 | }
11 |
12 | timeoutRef.current = setTimeout(() => {
13 | callback(...args);
14 | timeoutRef.current = null;
15 | }, delay);
16 | },
17 | [callback, delay],
18 | ) as T;
19 |
20 | return debouncedCallback;
21 | };
22 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-drafts.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useSession } from '@/lib/auth-client';
4 |
5 | export const useDraft = (id: string | null) => {
6 | const { data: session } = useSession();
7 | const trpc = useTRPC();
8 | const draftQuery = useQuery(
9 | trpc.drafts.get.queryOptions(
10 | { id: id! },
11 | { enabled: !!session?.user.id && !!id, staleTime: 1000 * 60 * 60 },
12 | ),
13 | );
14 | return draftQuery;
15 | };
16 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-email-aliases.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 |
4 | export function useEmailAliases() {
5 | const trpc = useTRPC();
6 | const emailAliasesQuery = useQuery(
7 | trpc.mail.getEmailAliases.queryOptions(void 0, {
8 | initialData: [] as { email: string; name: string; primary?: boolean }[],
9 | staleTime: 1000 * 60 * 60, // 1 hour
10 | }),
11 | );
12 | return emailAliasesQuery;
13 | }
14 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-geo-location.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useGeoLocation() {
4 | const [isEuRegion, setIsEuRegion] = useState(false);
5 | const [country, setCountry] = useState("");
6 |
7 | useEffect(() => {
8 | // Get values from response headers set by middleware
9 | const userCountry =
10 | document.querySelector('meta[name="x-user-country"]')?.getAttribute("content") || "";
11 | const userEuRegion =
12 | document.querySelector('meta[name="x-user-eu-region"]')?.getAttribute("content") === "true";
13 |
14 | setCountry(userCountry);
15 | setIsEuRegion(userEuRegion);
16 | }, []);
17 |
18 | return { isEuRegion, country };
19 | }
20 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-image-loading.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Hook for handling image loading within a document
3 | * This hook monitors all images in a document and calls the callback
4 | * when all images have loaded (or failed to load)
5 | */
6 | export function useImageLoading(doc: Document, callback: () => void) {
7 | const images = doc.getElementsByTagName('img');
8 | let loadedImages = 0;
9 |
10 | if (images.length > 0) {
11 | Array.from(images).forEach((img) => {
12 | if (img.complete) {
13 | loadedImages++;
14 | if (loadedImages === images.length) {
15 | setTimeout(callback, 0);
16 | }
17 | } else {
18 | img.onload = img.onerror = () => {
19 | loadedImages++;
20 | if (loadedImages === images.length) {
21 | setTimeout(callback, 0);
22 | }
23 | };
24 | }
25 | });
26 | } else {
27 | // If there are no images, call the callback immediately
28 | callback();
29 | }
30 |
31 | // Return a cleanup function
32 | return () => {
33 | // Remove event listeners from images
34 | if (images.length > 0) {
35 | Array.from(images).forEach((img) => {
36 | img.onload = null;
37 | img.onerror = null;
38 | });
39 | }
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-labels.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useMemo } from 'react';
4 |
5 | export function useLabels() {
6 | const trpc = useTRPC();
7 | const labelQuery = useQuery(
8 | trpc.labels.list.queryOptions(void 0, {
9 | staleTime: 1000 * 60 * 60, // 1 hour
10 | }),
11 | );
12 | return labelQuery;
13 | }
14 |
15 | export function useThreadLabels(ids: string[]) {
16 | const { data: labels = [] } = useLabels();
17 |
18 | const threadLabels = useMemo(() => {
19 | if (!labels) return [];
20 | return labels.filter((label) => (label.id ? ids.includes(label.id) : false));
21 | }, [labels, ids]);
22 |
23 | return { labels: threadLabels };
24 | }
25 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export function useMediaQuery(query: string) {
4 | const [matches, setMatches] = useState(false);
5 |
6 | useEffect(() => {
7 | const media = window.matchMedia(query);
8 |
9 | setMatches(media.matches);
10 |
11 | const listener = (event: MediaQueryListEvent) => {
12 | setMatches(event.matches);
13 | };
14 |
15 | media.addEventListener('change', listener);
16 |
17 | return () => {
18 | media.removeEventListener('change', listener);
19 | };
20 | }, [query]);
21 |
22 | return matches;
23 | }
24 |
--------------------------------------------------------------------------------
/apps/mail/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(undefined);
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
12 | };
13 | mql.addEventListener('change', onChange);
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
15 | return () => mql.removeEventListener('change', onChange);
16 | }, []);
17 |
18 | return !!isMobile;
19 | }
20 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-notes.tsx:
--------------------------------------------------------------------------------
1 | import { useActiveConnection } from './use-connections';
2 | import { useTRPC } from '@/providers/query-provider';
3 | import { useQuery } from '@tanstack/react-query';
4 | import { useSession } from '@/lib/auth-client';
5 | import { useTranslations } from 'use-intl';
6 | import type { Note } from '@/types';
7 |
8 | export const useThreadNotes = (threadId: string) => {
9 | const t = useTranslations();
10 | const { data: session } = useSession();
11 | const trpc = useTRPC();
12 | const { data: activeConnection } = useActiveConnection();
13 |
14 | const noteQuery = useQuery(
15 | trpc.notes.list.queryOptions(
16 | { threadId },
17 | {
18 | enabled: !!activeConnection?.id && !!threadId,
19 | staleTime: 1000 * 60 * 5, // 5 minutes
20 | initialData: { notes: [] as Note[] },
21 | meta: {
22 | customError: t('common.notes.errors.failedToLoadNotes'),
23 | },
24 | },
25 | ),
26 | );
27 |
28 | return noteQuery;
29 | };
30 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-open-compose-modal.ts:
--------------------------------------------------------------------------------
1 | import { parseAsBoolean, useQueryState } from 'nuqs';
2 |
3 | export const useOpenComposeModal = () => {
4 | const [isOpen, setIsOpen] = useQueryState(
5 | 'open-compose',
6 | parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true }),
7 | );
8 |
9 | const open = () => setIsOpen(true);
10 | const close = () => setIsOpen(false);
11 |
12 | return {
13 | open,
14 | close,
15 | isOpen,
16 | setIsOpen,
17 | };
18 | };
19 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-previous.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export function usePrevious(value: T) {
4 | const [current, setCurrent] = useState(value);
5 | const [previous, setPrevious] = useState(null);
6 |
7 | if (value !== current) {
8 | setPrevious(current);
9 | setCurrent(value);
10 | }
11 |
12 | return previous;
13 | }
14 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-search-value.ts:
--------------------------------------------------------------------------------
1 | import { atom, useAtom } from 'jotai';
2 |
3 | type Config = {
4 | value: string;
5 | highlight: string;
6 | folder: string;
7 | isLoading?: boolean;
8 | isAISearching?: boolean;
9 | };
10 |
11 | const configAtom = atom({
12 | value: '',
13 | highlight: '',
14 | folder: '',
15 | isLoading: false,
16 | isAISearching: false,
17 | });
18 |
19 | export function useSearchValue() {
20 | return useAtom(configAtom);
21 | }
22 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-settings.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useSession } from '@/lib/auth-client';
4 |
5 | export function useSettings() {
6 | const { data: session } = useSession();
7 | const trpc = useTRPC();
8 |
9 | const settingsQuery = useQuery(
10 | trpc.settings.get.queryOptions(void 0, {
11 | enabled: !!session?.user.id,
12 | staleTime: Infinity,
13 | }),
14 | );
15 |
16 | return settingsQuery;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-stats.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { useSession } from '@/lib/auth-client';
4 |
5 | export const useStats = () => {
6 | const { data: session } = useSession();
7 | const trpc = useTRPC();
8 |
9 | const statsQuery = useQuery(
10 | trpc.mail.count.queryOptions(void 0, {
11 | enabled: !!session?.user.id,
12 | staleTime: 1000 * 60 * 60, // 1 hour
13 | }),
14 | );
15 |
16 | return statsQuery;
17 | };
18 |
--------------------------------------------------------------------------------
/apps/mail/hooks/use-summary.ts:
--------------------------------------------------------------------------------
1 | import { useTRPC } from '@/providers/query-provider';
2 | import { useQuery } from '@tanstack/react-query';
3 |
4 | export const useSummary = (threadId: string | null) => {
5 | const trpc = useTRPC();
6 | const summaryQuery = useQuery(
7 | trpc.brain.generateSummary.queryOptions(
8 | { threadId: threadId! },
9 | {
10 | enabled: !!threadId,
11 | },
12 | ),
13 | );
14 |
15 | return summaryQuery;
16 | };
17 |
18 | export const useBrainState = () => {
19 | const trpc = useTRPC();
20 | const brainStateQuery = useQuery(trpc.brain.getState.queryOptions());
21 |
22 | return brainStateQuery;
23 | };
24 |
--------------------------------------------------------------------------------
/apps/mail/i18n/config.ts:
--------------------------------------------------------------------------------
1 | import type enLocale from '../locales/en.json';
2 |
3 | const LANGUAGES = {
4 | en: 'English',
5 | ar: 'Arabic',
6 | zh_TW: 'Chinese (Traditional)',
7 | zh_CN: 'Chinese (Simplified)',
8 | ca: 'Catalan',
9 | de: 'German',
10 | es: 'Spanish',
11 | fr: 'French',
12 | hi: 'Hindi',
13 | ja: 'Japanese',
14 | ko: 'Korean',
15 | pl: 'Polish',
16 | pt: 'Portuguese',
17 | ru: 'Russian',
18 | tr: 'Turkish',
19 | lv: 'Latvian',
20 | hu: 'Hungarian',
21 | fa: 'Farsi',
22 | vi: 'Vietnamese',
23 | } as const;
24 |
25 | export type Locale = keyof typeof LANGUAGES;
26 |
27 | export type IntlMessages = typeof enLocale;
28 |
29 | export const languageConfig = LANGUAGES;
30 |
31 | export const defaultLocale = 'en';
32 |
33 | export const locales: Locale[] = Object.keys(LANGUAGES) as Locale[];
34 |
35 | export const availableLocales = locales.map((code) => ({
36 | code,
37 | name: LANGUAGES[code],
38 | }));
39 |
40 | declare module 'use-intl' {
41 | interface AppConfig {
42 | Locale: Locale;
43 | Messages: IntlMessages;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/apps/mail/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { locales, defaultLocale, type Locale, type IntlMessages } from './config';
2 | import acceptLanguageParser from 'accept-language-parser';
3 | import { I18N_LOCALE_COOKIE_NAME } from '@/lib/constants';
4 | import deepmerge from 'deepmerge';
5 |
6 | export const resolveLocale = (request: Request) => {
7 | const intlCookie = request.headers
8 | .get('cookie')
9 | ?.split(';')
10 | .find((c) => c.trim().startsWith(`${I18N_LOCALE_COOKIE_NAME}=`))
11 | ?.split('=')[1]
12 | ?.trim();
13 |
14 | const locale =
15 | intlCookie && locales.includes(intlCookie as Locale)
16 | ? intlCookie
17 | : acceptLanguageParser.pick(
18 | locales,
19 | request.headers.get('accept-language') || defaultLocale,
20 | ) || defaultLocale;
21 | return locale as Locale;
22 | };
23 |
24 | const allLocales = import.meta.glob('../locales/*.json');
25 |
26 | export const getMessages = async (locale: string) => {
27 | const messages = (await allLocales[`../locales/${locale}.json`]?.()) ?? null;
28 | if (!messages) throw new Error(`Messages not found for locale: ${locale}`);
29 | const defaultMessages = (await allLocales['../locales/en.json']()) as IntlMessages;
30 | return deepmerge(defaultMessages, messages);
31 | };
32 |
--------------------------------------------------------------------------------
/apps/mail/instrument.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/react';
2 |
3 | if (!import.meta.env.DEV) {
4 | Sentry.init({
5 | dsn: 'https://03f6397c0eb458bf1e37c4776a31797c@o4509328786915328.ingest.us.sentry.io/4509328795303936',
6 | tunnel: '/monitoring',
7 | integrations: [Sentry.replayIntegration()],
8 | tracesSampleRate: 1,
9 | replaysSessionSampleRate: 0.1,
10 | replaysOnErrorSampleRate: 1.0,
11 | debug: false,
12 | });
13 | }
14 |
--------------------------------------------------------------------------------
/apps/mail/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { phoneNumberClient } from 'better-auth/client/plugins';
2 | import { createAuthClient } from 'better-auth/react';
3 | import type { Auth } from '@zero/server/auth';
4 |
5 | export const authClient = createAuthClient({
6 | baseURL: import.meta.env.VITE_PUBLIC_BACKEND_URL,
7 | fetchOptions: {
8 | credentials: 'include',
9 | },
10 | plugins: [phoneNumberClient()],
11 | });
12 |
13 | export const { signIn, signUp, signOut, useSession, getSession, $fetch } = authClient;
14 | export type Session = Awaited>;
15 |
--------------------------------------------------------------------------------
/apps/mail/lib/auth-proxy.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from 'better-auth/client';
2 |
3 | const authClient = createAuthClient({
4 | baseURL: import.meta.env.VITE_PUBLIC_BACKEND_URL,
5 | fetchOptions: {
6 | credentials: 'include',
7 | },
8 | plugins: [],
9 | });
10 |
11 | export const authProxy = {
12 | api: {
13 | getSession: async ({ headers }: { headers: Headers }) => {
14 | const session = await authClient.getSession({
15 | fetchOptions: { headers, credentials: 'include' },
16 | });
17 | if (session.error) {
18 | console.error(`Failed to get session: ${session.error}`, session);
19 | return null;
20 | }
21 | return session.data;
22 | },
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/apps/mail/lib/countries.ts:
--------------------------------------------------------------------------------
1 | export const EU_COUNTRIES = [
2 | "AUT", // Austria
3 | "BEL", // Belgium
4 | "BGR", // Bulgaria
5 | "HRV", // Croatia
6 | "CYP", // Cyprus
7 | "CZE", // Czech Republic
8 | "DNK", // Denmark
9 | "EST", // Estonia
10 | "FIN", // Finland
11 | "FRA", // France
12 | "DEU", // Germany
13 | "GRC", // Greece
14 | "HUN", // Hungary
15 | "IRL", // Ireland
16 | "ITA", // Italy
17 | "LVA", // Latvia
18 | "LTU", // Lithuania
19 | "LUX", // Luxembourg
20 | "MLT", // Malta
21 | "NLD", // Netherlands
22 | "POL", // Poland
23 | "PRT", // Portugal
24 | "ROU", // Romania
25 | "SVK", // Slovakia
26 | "SVN", // Slovenia
27 | "ESP", // Spain
28 | "SWE", // Sweden
29 | "ENG", // United Kingdom (included for cookie consent though not in EU)
30 | "GBR", // United Kingdom (included for cookie consent though not in EU)
31 | ];
32 |
--------------------------------------------------------------------------------
/apps/mail/lib/hotkeys/compose-hotkeys.tsx:
--------------------------------------------------------------------------------
1 | import { keyboardShortcuts } from '@/config/shortcuts';
2 | import { useShortcuts } from './use-hotkey-utils';
3 | import { useQueryState } from 'nuqs';
4 |
5 | export function ComposeHotkeys() {
6 | const scope = 'compose';
7 | const [isComposeOpen, setIsComposeOpen] = useQueryState('isComposeOpen');
8 |
9 | const handlers = {
10 | closeCompose: () => {
11 | if (isComposeOpen === 'true') {
12 | setIsComposeOpen('false');
13 | } else {
14 | setIsComposeOpen('true');
15 | }
16 | },
17 | };
18 |
19 | const composeShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
20 |
21 | useShortcuts(composeShortcuts, handlers, { scope });
22 |
23 | return null;
24 | }
25 |
--------------------------------------------------------------------------------
/apps/mail/lib/hotkeys/global-hotkeys.tsx:
--------------------------------------------------------------------------------
1 | import { useCommandPalette } from '@/components/context/command-palette-context';
2 | import { keyboardShortcuts } from '@/config/shortcuts';
3 | import { useShortcuts } from './use-hotkey-utils';
4 | import { useQueryState } from 'nuqs';
5 |
6 | export function GlobalHotkeys() {
7 | const [composeOpen, setComposeOpen] = useQueryState('isComposeOpen');
8 | const { openModal, clearAllFilters } = useCommandPalette();
9 | const scope = 'global';
10 |
11 | const handlers = {
12 | newEmail: () => setComposeOpen('true'),
13 | commandPalette: () => openModal(),
14 | clearAllFilters: () => clearAllFilters(),
15 | };
16 |
17 | const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
18 |
19 | useShortcuts(globalShortcuts, handlers, { scope });
20 |
21 | return null;
22 | }
23 |
--------------------------------------------------------------------------------
/apps/mail/lib/hotkeys/navigation-hotkeys.tsx:
--------------------------------------------------------------------------------
1 | import { keyboardShortcuts } from '@/config/shortcuts';
2 | import { useShortcuts } from './use-hotkey-utils';
3 | import { useNavigate } from 'react-router';
4 |
5 | export function NavigationHotkeys() {
6 | const navigate = useNavigate();
7 | const scope = 'navigation';
8 |
9 | const handlers = {
10 | goToDrafts: () => navigate('/mail/draft'),
11 | inbox: () => navigate('/mail/inbox'),
12 | sentMail: () => navigate('/mail/sent'),
13 | goToArchive: () => navigate('/mail/archive'),
14 | goToBin: () => navigate('/mail/bin'),
15 | goToSettings: () => navigate('/settings'),
16 | helpWithShortcuts: () => navigate('/settings/shortcuts'),
17 | };
18 |
19 | const globalShortcuts = keyboardShortcuts.filter((shortcut) => shortcut.scope === scope);
20 |
21 | useShortcuts(globalShortcuts, handlers, { scope });
22 |
23 | return null;
24 | }
25 |
--------------------------------------------------------------------------------
/apps/mail/lib/label-colors.ts:
--------------------------------------------------------------------------------
1 | export const LABEL_COLORS = [
2 | { textColor: '#FFFFFF', backgroundColor: '#202020' },
3 | { textColor: '#D1F0D9', backgroundColor: '#12341D' },
4 | { textColor: '#FDECCE', backgroundColor: '#413111' },
5 | { textColor: '#FDD9DF', backgroundColor: '#411D23' },
6 | { textColor: '#D8E6FD', backgroundColor: '#1C2A41' },
7 | { textColor: '#E8DEFD', backgroundColor: '#2C2341' },
8 | ] as const;
9 |
10 | export type LabelColor = (typeof LABEL_COLORS)[number];
11 |
12 | export function isValidLabelColor(color: { backgroundColor: string; textColor: string }): boolean {
13 | return LABEL_COLORS.some(
14 | (labelColor) =>
15 | labelColor.backgroundColor === color.backgroundColor &&
16 | labelColor.textColor === color.textColor,
17 | );
18 | }
19 |
20 | export const LABEL_BACKGROUND_COLORS = LABEL_COLORS.map((color) => color.backgroundColor);
21 |
--------------------------------------------------------------------------------
/apps/mail/lib/posthog-provider.tsx:
--------------------------------------------------------------------------------
1 | // app/providers.tsx
2 |
3 | import { PostHogProvider as PHProvider } from 'posthog-js/react';
4 | import { useSession } from '@/lib/auth-client';
5 | import { useEffect } from 'react';
6 | import posthog from 'posthog-js';
7 |
8 | export function PostHogProvider({ children }: { children: React.ReactNode }) {
9 | const { data: session } = useSession();
10 |
11 | useEffect(() => {
12 | if (!import.meta.env.VITE_PUBLIC_POSTHOG_KEY) return;
13 | try {
14 | posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY as string, {
15 | api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
16 | capture_pageview: true,
17 | });
18 | } catch (error) {
19 | console.error('Error initializing PostHog:', error);
20 | }
21 | }, []);
22 |
23 | useEffect(() => {
24 | if (session?.user) {
25 | posthog.identify(session.user.id, {
26 | email: session.user.email,
27 | name: session.user.name,
28 | });
29 | }
30 | }, [session]);
31 |
32 | return {children};
33 | }
34 |
--------------------------------------------------------------------------------
/apps/mail/lib/redis.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/lib/redis.ts
--------------------------------------------------------------------------------
/apps/mail/lib/sanitize-tip-tap-html.tsx:
--------------------------------------------------------------------------------
1 | import { renderToString } from 'react-dom/server';
2 | import { Html } from '@react-email/components';
3 | import sanitizeHtml from 'sanitize-html';
4 |
5 | export const sanitizeTipTapHtml = async (html: string) => {
6 | const clean = sanitizeHtml(html);
7 |
8 | return renderToString(
9 |
10 |
11 | ,
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/apps/mail/lib/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const serializedFileSchema = z.object({
4 | name: z.string(),
5 | type: z.string(),
6 | size: z.number(),
7 | lastModified: z.number(),
8 | base64: z.string(),
9 | });
10 |
11 | export const serializeFiles = async (files: File[]) => {
12 | return await Promise.all(
13 | files.map(async (file) => {
14 | const reader = new FileReader();
15 | const base64 = await new Promise((resolve) => {
16 | reader.onloadend = () => {
17 | const base64String = reader.result as string;
18 | resolve(base64String.split(',')[1]!); // Remove the data URL prefix
19 | };
20 | reader.readAsDataURL(file);
21 | });
22 |
23 | return {
24 | name: file.name,
25 | type: file.type,
26 | size: file.size,
27 | lastModified: file.lastModified,
28 | base64,
29 | };
30 | }),
31 | );
32 | };
33 |
34 | export const deserializeFiles = async (serializedFiles: z.infer[]) => {
35 | return await Promise.all(
36 | serializedFiles.map((data) => {
37 | const file = Buffer.from(data.base64, 'base64');
38 | const blob = new Blob([file], { type: data.type });
39 | const newFile = new File([blob], data.name, {
40 | type: data.type,
41 | lastModified: data.lastModified,
42 | });
43 | return newFile;
44 | }),
45 | );
46 | };
47 |
48 | export const createDraftData = z.object({
49 | to: z.string(),
50 | cc: z.string().optional(),
51 | bcc: z.string().optional(),
52 | subject: z.string(),
53 | message: z.string(),
54 | attachments: z.array(serializedFileSchema).transform(deserializeFiles).optional(),
55 | id: z.string().nullable(),
56 | });
57 |
58 | export type CreateDraftData = z.infer;
59 |
--------------------------------------------------------------------------------
/apps/mail/lib/site-config.ts:
--------------------------------------------------------------------------------
1 | const TITLE = 'Zero';
2 | const DESCRIPTION =
3 | 'Experience email the way you want with 0 - the first open source email app that puts your privacy and safety first.';
4 |
5 | export const siteConfig = {
6 | title: TITLE,
7 | description: DESCRIPTION,
8 | icons: {
9 | icon: '/favicon.ico',
10 | },
11 | applicationName: 'Zero',
12 | creator: '@nizzyabi @bruvimtired @ripgrim @needleXO @dakdevs @mrgsub',
13 | openGraph: {
14 | title: TITLE,
15 | description: DESCRIPTION,
16 | images: [
17 | {
18 | url: `${import.meta.env.VITE_PUBLIC_APP_URL}/og-api/home`,
19 | width: 1200,
20 | height: 630,
21 | alt: TITLE,
22 | },
23 | ],
24 | },
25 | category: 'Email Client',
26 | alternates: {
27 | canonical: import.meta.env.VITE_PUBLIC_APP_URL,
28 | },
29 | keywords: [
30 | 'Mail',
31 | 'Email',
32 | 'Open Source',
33 | 'Email Client',
34 | 'Gmail Alternative',
35 | 'Webmail',
36 | 'Secure Email',
37 | 'Email Management',
38 | 'Email Platform',
39 | 'Communication Tool',
40 | 'Productivity',
41 | 'Business Email',
42 | 'Personal Email',
43 | 'Mail Server',
44 | 'Email Software',
45 | 'Collaboration',
46 | 'Message Management',
47 | 'Digital Communication',
48 | 'Email Service',
49 | 'Web Application',
50 | ],
51 | // metadataBase: new URL(import.meta.env.VITE_PUBLIC_APP_URL!),
52 | };
53 |
--------------------------------------------------------------------------------
/apps/mail/lib/timezones.ts:
--------------------------------------------------------------------------------
1 | export const getBrowserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
2 |
3 | export const isValidTimezone = (timezone: string) => {
4 | try {
5 | return Intl.supportedValuesOf('timeZone').includes(timezone);
6 | } catch (error) {
7 | return false;
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/apps/mail/lib/trpc.server.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCClient, httpBatchLink } from '@trpc/client';
2 | import type { AppRouter } from '@zero/server/trpc';
3 | import { env } from 'cloudflare:workers';
4 | import superjson from 'superjson';
5 |
6 | const getUrl = () => env.VITE_PUBLIC_BACKEND_URL + '/api/trpc';
7 |
8 | export const getServerTrpc = (req: Request) =>
9 | createTRPCClient({
10 | links: [
11 | httpBatchLink({
12 | url: getUrl(),
13 | transformer: superjson,
14 | headers: req.headers,
15 | }),
16 | ],
17 | });
18 |
--------------------------------------------------------------------------------
/apps/mail/middleware.ts:
--------------------------------------------------------------------------------
1 | // import { type NextRequest, NextResponse } from 'next/server';
2 | // import { navigationConfig } from '@/config/navigation';
3 | // import { geolocation } from '@vercel/functions';
4 | // import { EU_COUNTRIES } from './lib/countries';
5 |
6 | // const disabledRoutes = Object.values(navigationConfig)
7 | // .flatMap((section) => section.sections)
8 | // .flatMap((group) => group.items)
9 | // .filter((item) => item.disabled && item.url !== '#')
10 | // .map((item) => item.url);
11 |
12 | // export function middleware(request: NextRequest) {
13 | // const response = NextResponse.next();
14 | // const geo = geolocation(request);
15 | // const country = geo.countryRegion || '';
16 |
17 | // response.headers.set('x-user-country', country);
18 |
19 | // const isEuRegion = EU_COUNTRIES.includes(country);
20 | // response.headers.set('x-user-eu-region', String(isEuRegion));
21 |
22 | // if (process.env.NODE_ENV === 'development') {
23 | // response.headers.set('x-user-eu-region', 'true');
24 | // }
25 |
26 | // const pathname = request.nextUrl.pathname;
27 | // if (disabledRoutes.some((route) => pathname.startsWith(route))) {
28 | // return NextResponse.redirect(new URL('/mail/inbox', request.url));
29 | // }
30 |
31 | // return response;
32 | // }
33 |
34 | // export const config = {
35 | // matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
36 | // };
37 |
--------------------------------------------------------------------------------
/apps/mail/providers/client-providers.tsx:
--------------------------------------------------------------------------------
1 | import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
2 | import { SidebarProvider } from '@/components/ui/sidebar';
3 | import { PostHogProvider } from '@/lib/posthog-provider';
4 | import { useSettings } from '@/hooks/use-settings';
5 | import CustomToaster from '@/components/ui/toast';
6 | import { Provider as JotaiProvider } from 'jotai';
7 | import type { PropsWithChildren } from 'react';
8 | import { ThemeProvider } from 'next-themes';
9 |
10 | export function ClientProviders({ children }: PropsWithChildren) {
11 | const { data } = useSettings();
12 |
13 | const theme = data?.settings.colorTheme || 'system';
14 |
15 | return (
16 |
17 |
18 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/apps/mail/providers/server-providers.tsx:
--------------------------------------------------------------------------------
1 | import type { IntlMessages, Locale } from '@/i18n/config';
2 | import { QueryProvider } from './query-provider';
3 | import { AutumnProvider } from 'autumn-js/react';
4 | import type { PropsWithChildren } from 'react';
5 | import { IntlProvider } from 'use-intl';
6 |
7 | export function ServerProviders({
8 | children,
9 | messages,
10 | locale,
11 | connectionId,
12 | }: PropsWithChildren<{ messages: IntlMessages; locale: Locale; connectionId: string | null }>) {
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/apps/mail/public/adam.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/adam.jpg
--------------------------------------------------------------------------------
/apps/mail/public/ahmet.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/ahmet.jpg
--------------------------------------------------------------------------------
/apps/mail/public/ai-chat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/ai-chat.png
--------------------------------------------------------------------------------
/apps/mail/public/ai-summary.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/ai-summary.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 rounded edges.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/m0 rounded edges.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 rounded edges.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 w lines rounded edges.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/m0 w lines rounded edges.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 w lines rounded edges.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 w lines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/m0 w lines.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0 w lines.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/m0.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/m0.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/apps/mail/public/assets/mail.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/mail/public/assets/mail0.io - Text + logo w lines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/mail0.io - Text + logo w lines.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/mail0.io - Text w lines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/assets/mail0.io - Text w lines.png
--------------------------------------------------------------------------------
/apps/mail/public/assets/rocket.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/mail/public/black-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/mail/public/claude.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/claude.png
--------------------------------------------------------------------------------
/apps/mail/public/compose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/compose.png
--------------------------------------------------------------------------------
/apps/mail/public/dudu.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/dudu.jpg
--------------------------------------------------------------------------------
/apps/mail/public/email-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/email-preview.png
--------------------------------------------------------------------------------
/apps/mail/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/favicon.ico
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Black.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Bold.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-ExtraBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-ExtraBold.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-ExtraLight.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-ExtraLight.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Light.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Medium.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Regular.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-SemiBold.ttf
--------------------------------------------------------------------------------
/apps/mail/public/fonts/geist/Geist-Thin.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/fonts/geist/Geist-Thin.ttf
--------------------------------------------------------------------------------
/apps/mail/public/gradient.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/apps/mail/public/homepage-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/homepage-image.png
--------------------------------------------------------------------------------
/apps/mail/public/icons-pwa/icon-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/icons-pwa/icon-180.png
--------------------------------------------------------------------------------
/apps/mail/public/icons-pwa/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/icons-pwa/icon-192.png
--------------------------------------------------------------------------------
/apps/mail/public/icons-pwa/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/icons-pwa/icon-512.png
--------------------------------------------------------------------------------
/apps/mail/public/mail-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/mail-list.png
--------------------------------------------------------------------------------
/apps/mail/public/nizzy.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/nizzy.jpg
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/coming-soon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/coming-soon.png
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/get-started.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/get-started.png
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/ready.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/ready.png
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/step1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/step1.gif
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/step2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/step2.gif
--------------------------------------------------------------------------------
/apps/mail/public/onboarding/step3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/onboarding/step3.gif
--------------------------------------------------------------------------------
/apps/mail/public/openai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/openai.png
--------------------------------------------------------------------------------
/apps/mail/public/pricing-gradient.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/pricing-gradient.png
--------------------------------------------------------------------------------
/apps/mail/public/purple-gradient.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/purple-gradient.png
--------------------------------------------------------------------------------
/apps/mail/public/ryan.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/ryan.jpg
--------------------------------------------------------------------------------
/apps/mail/public/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/search.png
--------------------------------------------------------------------------------
/apps/mail/public/small-pixel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/small-pixel.png
--------------------------------------------------------------------------------
/apps/mail/public/snooze-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/snooze-home.png
--------------------------------------------------------------------------------
/apps/mail/public/star-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/star-home.png
--------------------------------------------------------------------------------
/apps/mail/public/star.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/apps/mail/public/verified-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/apps/mail/public/verified-home.png
--------------------------------------------------------------------------------
/apps/mail/public/white-icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/apps/mail/public/yc-small.svg:
--------------------------------------------------------------------------------
1 |
2 |
8 |
--------------------------------------------------------------------------------
/apps/mail/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from '@react-router/dev/config';
2 |
3 | export default {
4 | ssr: true,
5 | buildDirectory: 'build',
6 | future: {
7 | unstable_viteEnvironmentApi: true,
8 | },
9 | } satisfies Config;
10 |
--------------------------------------------------------------------------------
/apps/mail/store/backgroundQueue.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai';
2 |
3 | const baseBackgroundQueueAtom = atom>(new Set());
4 |
5 | export const backgroundQueueAtom = atom(
6 | (get) => Array.from(get(baseBackgroundQueueAtom)),
7 | (get, set, action: { type: 'add' | 'delete' | 'clear'; threadId?: string }) => {
8 | const currentQueue = get(baseBackgroundQueueAtom);
9 | if (action.type === 'add' && action.threadId && !currentQueue.has(action.threadId)) {
10 | set(baseBackgroundQueueAtom, new Set([...currentQueue, action.threadId]));
11 | } else if (action.type === 'delete' && action.threadId) {
12 | const newQueue = new Set(currentQueue);
13 | newQueue.delete(action.threadId);
14 | set(baseBackgroundQueueAtom, newQueue);
15 | } else if (action.type === 'clear') {
16 | set(baseBackgroundQueueAtom, new Set());
17 | }
18 | },
19 | );
20 |
21 | export const isThreadInBackgroundQueueAtom = atom(
22 | (get) => (threadId: string) => get(baseBackgroundQueueAtom).has(threadId),
23 | );
24 |
--------------------------------------------------------------------------------
/apps/mail/store/draftStates.ts:
--------------------------------------------------------------------------------
1 | import { atomWithStorage } from "jotai/utils";
2 | import { atom } from "jotai";
3 |
4 | export interface DraftType {
5 | id: string;
6 | recipient?: string;
7 | cc?: string;
8 | bcc?: string;
9 | subject?: string;
10 | message?: string;
11 | }
12 | export const draftsAtom = atomWithStorage("emailDrafts", []);
13 | export const draftCountAtom = atom((get) => get(draftsAtom).length);
14 |
--------------------------------------------------------------------------------
/apps/mail/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base",
3 | "compilerOptions": {
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "types": ["node", "vite/client"],
6 | "paths": {
7 | "@/*": ["./*"]
8 | },
9 | "rootDirs": [".", "./.react-router/types"]
10 | },
11 | "include": ["**/*.ts", "**/*.tsx", "worker-configuration.d.ts"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/apps/mail/types/speech-recognition.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | SpeechRecognition: typeof SpeechRecognition;
3 | webkitSpeechRecognition: typeof SpeechRecognition;
4 | }
5 |
6 | interface SpeechRecognitionEvent extends Event {
7 | results: SpeechRecognitionResultList;
8 | resultIndex: number;
9 | interpretation: any;
10 | }
11 |
12 | interface SpeechRecognitionResultList {
13 | length: number;
14 | item(index: number): SpeechRecognitionResult;
15 | [index: number]: SpeechRecognitionResult;
16 | }
17 |
18 | interface SpeechRecognitionResult {
19 | length: number;
20 | item(index: number): SpeechRecognitionAlternative;
21 | [index: number]: SpeechRecognitionAlternative;
22 | isFinal: boolean;
23 | }
24 |
25 | interface SpeechRecognitionAlternative {
26 | transcript: string;
27 | confidence: number;
28 | }
29 |
30 | interface SpeechRecognition extends EventTarget {
31 | continuous: boolean;
32 | interimResults: boolean;
33 | lang: string;
34 | maxAlternatives: number;
35 | start(): void;
36 | stop(): void;
37 | abort(): void;
38 | onresult: (event: SpeechRecognitionEvent) => void;
39 | onerror: (event: Event) => void;
40 | onend: (event: Event) => void;
41 | onstart: (event: Event) => void;
42 | onspeechend: (event: Event) => void;
43 | onsoundstart: (event: Event) => void;
44 | onsoundend: (event: Event) => void;
45 | onaudiostart: (event: Event) => void;
46 | onaudioend: (event: Event) => void;
47 | onnomatch: (event: Event) => void;
48 | }
49 |
50 | declare var SpeechRecognition: {
51 | prototype: SpeechRecognition;
52 | new(): SpeechRecognition;
53 | };
--------------------------------------------------------------------------------
/apps/mail/types/tailwind.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'tailwindcss/lib/util/flattenColorPalette' {
2 | const flattenColorPalette: (colors: Record) => Record;
3 | export default flattenColorPalette;
4 | }
5 |
--------------------------------------------------------------------------------
/apps/mail/types/tools.ts:
--------------------------------------------------------------------------------
1 | export enum Tools {
2 | GetThread = 'getThread',
3 | ComposeEmail = 'composeEmail',
4 | ListThreads = 'listThreads',
5 | DeleteEmail = 'deleteEmail',
6 | MarkThreadsRead = 'markThreadsRead',
7 | MarkThreadsUnread = 'markThreadsUnread',
8 | ModifyLabels = 'modifyLabels',
9 | GetUserLabels = 'getUserLabels',
10 | SendEmail = 'sendEmail',
11 | CreateLabel = 'createLabel',
12 | BulkDelete = 'bulkDelete',
13 | BulkArchive = 'bulkArchive',
14 | DeleteLabel = 'deleteLabel',
15 | AskZeroMailbox = 'askZeroMailbox',
16 | AskZeroThread = 'askZeroThread',
17 | WebSearch = 'webSearch',
18 | }
19 |
--------------------------------------------------------------------------------
/apps/mail/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { cloudflare } from '@cloudflare/vite-plugin';
2 | import { reactRouter } from '@react-router/dev/vite';
3 | import tsconfigPaths from 'vite-tsconfig-paths';
4 | import tailwindcss from 'tailwindcss';
5 | import { defineConfig } from 'vite';
6 | import dedent from 'dedent';
7 |
8 | export default defineConfig({
9 | plugins: [
10 | cloudflare({ viteEnvironment: { name: 'ssr' } }),
11 | reactRouter(),
12 | tsconfigPaths(),
13 | {
14 | name: 'add-headers',
15 | applyToEnvironment: (env) => env.name === 'client',
16 | generateBundle() {
17 | this.emitFile({
18 | fileName: '_headers',
19 | type: 'asset',
20 | source: dedent`
21 | # Autogenerated
22 |
23 | /assets/*
24 | ! Cache-Control
25 | Cache-Control: public, immutable, max-age=31536000
26 | `,
27 | });
28 | },
29 | },
30 | ],
31 | server: {
32 | port: 3000,
33 | warmup: {
34 | clientFiles: ['./app/**/*', './components/**/*'],
35 | ssrFiles: ['./app/**/*', './components/**/*'],
36 | },
37 | },
38 | css: {
39 | postcss: {
40 | plugins: [tailwindcss()],
41 | },
42 | },
43 | ssr: {
44 | optimizeDeps: {
45 | include: ['novel', '@tiptap/extension-placeholder'],
46 | },
47 | },
48 | build: {
49 | sourcemap: true,
50 | },
51 | resolve: {
52 | alias: {
53 | tslib: 'tslib/tslib.es6.js',
54 | },
55 | },
56 | });
57 |
--------------------------------------------------------------------------------
/apps/mail/worker.ts:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from 'react-router';
2 |
3 | declare global {
4 | interface CloudflareEnvironment extends Env {}
5 | }
6 |
7 | declare module 'react-router' {
8 | export interface AppLoadContext {
9 | cloudflare: {
10 | env: CloudflareEnvironment;
11 | ctx: ExecutionContext;
12 | };
13 | }
14 | }
15 |
16 | const requestHandler = createRequestHandler(
17 | // @ts-ignore, virtual module
18 | () => import('virtual:react-router/server-build'),
19 | import.meta.env.MODE,
20 | );
21 |
22 | const sentryUrl = `https://o4509328786915328.ingest.us.sentry.io/api/4509328795303936/envelope/?sentry_version=7&sentry_key=03f6397c0eb458bf1e37c4776a31797c&sentry_client=sentry.javascript.react%2F9.19.0`;
23 |
24 | export default {
25 | async fetch(request, env, ctx) {
26 | const url = new URL(request.url);
27 | if (url.pathname.startsWith('/monitoring')) {
28 | const sentryRequest = new Request(sentryUrl, {
29 | method: request.method,
30 | headers: request.headers,
31 | body: request.body,
32 | });
33 | return await fetch(sentryRequest);
34 | }
35 | return requestHandler(request, {
36 | cloudflare: { env, ctx },
37 | });
38 | },
39 | } satisfies ExportedHandler;
40 |
--------------------------------------------------------------------------------
/apps/mail/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "node_modules/wrangler/config-schema.json",
3 | "name": "zero",
4 | "compatibility_date": "2025-05-01",
5 | "compatibility_flags": ["nodejs_compat"],
6 | "main": "./worker.ts",
7 | "observability": {
8 | "enabled": true,
9 | },
10 | "env": {
11 | "local": {
12 | "vars": {
13 | "VITE_PUBLIC_BACKEND_URL": "http://localhost:8787",
14 | "VITE_PUBLIC_APP_URL": "http://localhost:3000",
15 | },
16 | },
17 | "staging": {
18 | "vars": {
19 | "VITE_PUBLIC_BACKEND_URL": "https://sapi.0.email",
20 | "VITE_PUBLIC_APP_URL": "https://staging.0.email",
21 | },
22 | },
23 | "production": {
24 | "vars": {
25 | "VITE_PUBLIC_BACKEND_URL": "https://api.0.email",
26 | "VITE_PUBLIC_APP_URL": "https://0.email",
27 | },
28 | },
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/apps/server/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from 'drizzle-kit';
2 |
3 | export default {
4 | schema: './src/db/schema.ts',
5 | dialect: 'postgresql',
6 | dbCredentials: {
7 | url: process.env.DATABASE_URL!,
8 | },
9 | out: './src/db/migrations',
10 | tablesFilter: ['mail0_*'],
11 | } satisfies Config;
12 |
--------------------------------------------------------------------------------
/apps/server/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import config from '@zero/eslint-config';
2 |
3 | export default config;
4 |
--------------------------------------------------------------------------------
/apps/server/src/ctx.ts:
--------------------------------------------------------------------------------
1 | import type { env } from 'cloudflare:workers';
2 | import type { Autumn } from 'autumn-js';
3 | import type { Auth } from './lib/auth';
4 | import type { DB } from './db';
5 |
6 | export type HonoVariables = {
7 | auth: Auth;
8 | session: Awaited>;
9 | db: DB;
10 | autumn: Autumn;
11 | };
12 |
13 | export type HonoContext = { Variables: HonoVariables; Bindings: typeof env };
14 |
--------------------------------------------------------------------------------
/apps/server/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from 'drizzle-orm/postgres-js';
2 | import * as schema from './schema';
3 | import postgres from 'postgres';
4 |
5 | export const createDb = (url: string) => {
6 | const conn = postgres(url);
7 | const db = drizzle(conn, { schema });
8 | return db;
9 | };
10 |
11 | export type DB = ReturnType;
12 |
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0000_fine_steel_serpent.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_account" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "account_id" text NOT NULL,
4 | "provider_id" text NOT NULL,
5 | "user_id" text NOT NULL,
6 | "access_token" text,
7 | "refresh_token" text,
8 | "id_token" text,
9 | "access_token_expires_at" timestamp,
10 | "refresh_token_expires_at" timestamp,
11 | "scope" text,
12 | "password" text,
13 | "created_at" timestamp NOT NULL,
14 | "updated_at" timestamp NOT NULL
15 | );
16 | --> statement-breakpoint
17 | CREATE TABLE "mail0_session" (
18 | "id" text PRIMARY KEY NOT NULL,
19 | "expires_at" timestamp NOT NULL,
20 | "token" text NOT NULL,
21 | "created_at" timestamp NOT NULL,
22 | "updated_at" timestamp NOT NULL,
23 | "ip_address" text,
24 | "user_agent" text,
25 | "user_id" text NOT NULL,
26 | CONSTRAINT "mail0_session_token_unique" UNIQUE("token")
27 | );
28 | --> statement-breakpoint
29 | CREATE TABLE "mail0_user" (
30 | "id" text PRIMARY KEY NOT NULL,
31 | "name" text NOT NULL,
32 | "email" text NOT NULL,
33 | "email_verified" boolean NOT NULL,
34 | "image" text,
35 | "created_at" timestamp NOT NULL,
36 | "updated_at" timestamp NOT NULL,
37 | CONSTRAINT "mail0_user_email_unique" UNIQUE("email")
38 | );
39 | --> statement-breakpoint
40 | CREATE TABLE "mail0_verification" (
41 | "id" text PRIMARY KEY NOT NULL,
42 | "identifier" text NOT NULL,
43 | "value" text NOT NULL,
44 | "expires_at" timestamp NOT NULL,
45 | "created_at" timestamp,
46 | "updated_at" timestamp
47 | );
48 | --> statement-breakpoint
49 | ALTER TABLE "mail0_account" ADD CONSTRAINT "mail0_account_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
50 | ALTER TABLE "mail0_session" ADD CONSTRAINT "mail0_session_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0001_greedy_darkhawk.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_early_access" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "email" text NOT NULL,
4 | "created_at" timestamp NOT NULL,
5 | "updated_at" timestamp NOT NULL,
6 | CONSTRAINT "mail0_early_access_email_unique" UNIQUE("email")
7 | );
8 |
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0002_flimsy_nightshade.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_google_account" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "user_id" text NOT NULL,
4 | "email" text NOT NULL,
5 | "access_token" text NOT NULL,
6 | "refresh_token" text,
7 | "scope" text NOT NULL,
8 | "expires_at" timestamp NOT NULL,
9 | "created_at" timestamp NOT NULL,
10 | "updated_at" timestamp NOT NULL
11 | );
12 | --> statement-breakpoint
13 | CREATE TABLE "mail0_user_password" (
14 | "id" text PRIMARY KEY NOT NULL,
15 | "user_id" text NOT NULL,
16 | "hash" text NOT NULL,
17 | "created_at" timestamp NOT NULL,
18 | "updated_at" timestamp NOT NULL
19 | );
20 | --> statement-breakpoint
21 | DROP TABLE "mail0_account" CASCADE;--> statement-breakpoint
22 | ALTER TABLE "mail0_google_account" ADD CONSTRAINT "mail0_google_account_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
23 | ALTER TABLE "mail0_user_password" ADD CONSTRAINT "mail0_user_password_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0003_purple_kylun.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_account" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "account_id" text NOT NULL,
4 | "provider_id" text NOT NULL,
5 | "user_id" text NOT NULL,
6 | "access_token" text,
7 | "refresh_token" text,
8 | "id_token" text,
9 | "access_token_expires_at" timestamp,
10 | "refresh_token_expires_at" timestamp,
11 | "scope" text,
12 | "password" text,
13 | "created_at" timestamp NOT NULL,
14 | "updated_at" timestamp NOT NULL
15 | );
16 | --> statement-breakpoint
17 | CREATE TABLE "mail0_google_connection" (
18 | "id" text PRIMARY KEY NOT NULL,
19 | "user_id" text NOT NULL,
20 | "email" text NOT NULL,
21 | "access_token" text NOT NULL,
22 | "refresh_token" text,
23 | "scope" text NOT NULL,
24 | "expires_at" timestamp NOT NULL,
25 | "created_at" timestamp NOT NULL,
26 | "updated_at" timestamp NOT NULL
27 | );
28 | --> statement-breakpoint
29 | DROP TABLE "mail0_google_account" CASCADE;--> statement-breakpoint
30 | DROP TABLE "mail0_user_password" CASCADE;--> statement-breakpoint
31 | ALTER TABLE "mail0_account" ADD CONSTRAINT "mail0_account_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
32 | ALTER TABLE "mail0_google_connection" ADD CONSTRAINT "mail0_google_connection_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0004_quiet_grey_gargoyle.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_google_connection" ADD COLUMN "name" text;--> statement-breakpoint
2 | ALTER TABLE "mail0_google_connection" ADD COLUMN "picture" text;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0005_mature_lady_deathstrike.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_google_connection" RENAME TO "mail0_connection";--> statement-breakpoint
2 | ALTER TABLE "mail0_connection" DROP CONSTRAINT "mail0_google_connection_user_id_mail0_user_id_fk";
3 | --> statement-breakpoint
4 | ALTER TABLE "mail0_connection" ADD COLUMN "provider_id" text NOT NULL;--> statement-breakpoint
5 | ALTER TABLE "mail0_session" ADD COLUMN "connectionId" text;--> statement-breakpoint
6 | ALTER TABLE "mail0_connection" ADD CONSTRAINT "mail0_connection_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
7 | ALTER TABLE "mail0_connection" ADD CONSTRAINT "mail0_connection_email_unique" UNIQUE("email");
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0006_small_unicorn.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_session" RENAME COLUMN "connectionId" TO "connection_id";
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0007_tense_wrecking_crew.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_session" DROP COLUMN "connection_id";
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0008_freezing_hydra.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" ADD COLUMN "default_connection_id" text;--> statement-breakpoint
2 | ALTER TABLE "mail0_user" ADD CONSTRAINT "mail0_user_default_connection_id_mail0_connection_id_fk" FOREIGN KEY ("default_connection_id") REFERENCES "public"."mail0_connection"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0009_boring_big_bertha.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" DROP CONSTRAINT "mail0_user_default_connection_id_mail0_connection_id_fk";
2 |
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0010_dry_hemingway.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" DROP COLUMN "default_connection_id";
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0011_huge_newton_destine.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" ADD COLUMN "default_connection_id" text;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0012_even_johnny_storm.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" ALTER COLUMN "default_connection_id" SET DEFAULT 'Yeah';
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0013_calm_timeslip.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" ALTER COLUMN "default_connection_id" DROP DEFAULT;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0014_cuddly_energizer.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_session" DROP COLUMN "ip_address";--> statement-breakpoint
2 | ALTER TABLE "mail0_session" DROP COLUMN "user_agent";
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0015_minor_mister_sinister.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_session" ADD COLUMN "ip_address" text;--> statement-breakpoint
2 | ALTER TABLE "mail0_session" ADD COLUMN "user_agent" text;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0016_neat_ogun.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_summary" (
2 | "message_id" text PRIMARY KEY NOT NULL,
3 | "content" text NOT NULL,
4 | "created_at" timestamp NOT NULL,
5 | "updated_at" timestamp NOT NULL,
6 | "connection_id" text NOT NULL,
7 | "saved" boolean DEFAULT false NOT NULL,
8 | "tags" text,
9 | "suggested_reply" text
10 | );
11 |
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0017_bouncy_shotgun.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_note" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "user_id" text NOT NULL,
4 | "thread_id" text NOT NULL,
5 | "content" text NOT NULL,
6 | "color" text DEFAULT 'default' NOT NULL,
7 | "is_pinned" boolean DEFAULT false,
8 | "order" integer DEFAULT 0 NOT NULL,
9 | "created_at" timestamp DEFAULT now() NOT NULL,
10 | "updated_at" timestamp DEFAULT now() NOT NULL
11 | );
12 | --> statement-breakpoint
13 | CREATE TABLE "mail0_user_settings" (
14 | "id" text PRIMARY KEY NOT NULL,
15 | "user_id" text NOT NULL,
16 | "settings" jsonb DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true}'::jsonb NOT NULL,
17 | "created_at" timestamp NOT NULL,
18 | "updated_at" timestamp NOT NULL,
19 | CONSTRAINT "mail0_user_settings_user_id_unique" UNIQUE("user_id")
20 | );
21 | --> statement-breakpoint
22 | ALTER TABLE "mail0_note" ADD CONSTRAINT "mail0_note_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
23 | ALTER TABLE "mail0_user_settings" ADD CONSTRAINT "mail0_user_settings_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0018_far_lady_mastermind.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_connection" DROP CONSTRAINT "mail0_connection_email_unique";--> statement-breakpoint
2 | ALTER TABLE "mail0_early_access" ADD COLUMN "is_early_access" boolean DEFAULT false NOT NULL;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0019_mean_war_machine.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","signature":{"enabled":false,"content":"","includeByDefault":true}}'::jsonb;--> statement-breakpoint
2 | ALTER TABLE "mail0_user" ADD COLUMN "custom_prompt" text;--> statement-breakpoint
3 | ALTER TABLE "mail0_connection" ADD CONSTRAINT "mail0_connection_user_id_email_unique" UNIQUE("user_id","email");
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0020_bright_gladiator.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","trustedSenders":[],"isOnboarded":false}'::jsonb;--> statement-breakpoint
2 | ALTER TABLE "mail0_early_access" ADD COLUMN "has_used_ticket" boolean DEFAULT false NOT NULL;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0021_outgoing_mariko_yashida.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_early_access" ALTER COLUMN "has_used_ticket" SET DATA TYPE text;--> statement-breakpoint
2 | ALTER TABLE "mail0_early_access" ALTER COLUMN "has_used_ticket" SET DEFAULT '';--> statement-breakpoint
3 | ALTER TABLE "mail0_early_access" ALTER COLUMN "has_used_ticket" DROP NOT NULL;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0022_round_violations.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_user_hotkeys" (
2 | "user_id" text PRIMARY KEY NOT NULL,
3 | "shortcuts" jsonb NOT NULL,
4 | "created_at" timestamp NOT NULL,
5 | "updated_at" timestamp NOT NULL
6 | );
7 | --> statement-breakpoint
8 | ALTER TABLE "mail0_user_hotkeys" ADD CONSTRAINT "mail0_user_hotkeys_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0023_narrow_maria_hill.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "mail0_writing_style_matrix" (
2 | "connectionId" text NOT NULL,
3 | "numMessages" integer NOT NULL,
4 | "style" jsonb NOT NULL,
5 | "updatedAt" timestamp DEFAULT now() NOT NULL,
6 | CONSTRAINT "mail0_writing_style_matrix_connectionId_pk" PRIMARY KEY("connectionId")
7 | );
8 | --> statement-breakpoint
9 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","trustedSenders":[],"isOnboarded":false,"colorTheme":"system"}'::jsonb;--> statement-breakpoint
10 | ALTER TABLE "mail0_writing_style_matrix" ADD CONSTRAINT "mail0_writing_style_matrix_connectionId_mail0_connection_id_fk" FOREIGN KEY ("connectionId") REFERENCES "public"."mail0_connection"("id") ON DELETE no action ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0024_familiar_wiccan.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":false,"customPrompt":"","trustedSenders":[],"isOnboarded":false,"colorTheme":"system"}'::jsonb;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0025_far_echo.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","trustedSenders":[],"isOnboarded":false,"colorTheme":"system"}'::jsonb;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0025_nervous_paper_doll.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_writing_style_matrix" DROP CONSTRAINT "mail0_writing_style_matrix_connectionId_mail0_connection_id_fk";
2 | --> statement-breakpoint
3 | ALTER TABLE "mail0_user_settings" ALTER COLUMN "settings" SET DEFAULT '{"language":"en","timezone":"UTC","dynamicContent":false,"externalImages":true,"customPrompt":"","trustedSenders":[],"isOnboarded":false,"colorTheme":"system"}'::jsonb;--> statement-breakpoint
4 | ALTER TABLE "mail0_writing_style_matrix" ADD CONSTRAINT "mail0_writing_style_matrix_connectionId_mail0_connection_id_fk" FOREIGN KEY ("connectionId") REFERENCES "public"."mail0_connection"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0026_smooth_norrin_radd.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_connection" ALTER COLUMN "access_token" DROP NOT NULL;
--------------------------------------------------------------------------------
/apps/server/src/db/migrations/0027_vengeful_golden_guardian.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "mail0_user" ADD COLUMN "phone_number" text;--> statement-breakpoint
2 | ALTER TABLE "mail0_user" ADD COLUMN "phone_number_verified" boolean;--> statement-breakpoint
3 | ALTER TABLE "mail0_user" ADD CONSTRAINT "mail0_user_phone_number_unique" UNIQUE("phone_number");
--------------------------------------------------------------------------------
/apps/server/src/lib/driver/google-label-color-map.ts:
--------------------------------------------------------------------------------
1 | type ColorMapping = {
2 | backgroundColor: string;
3 | textColor: string;
4 | };
5 |
6 | export const GOOGLE_LABEL_COLOR_MAP: Record = {
7 | '#ffffff|#000000': { textColor: '#FFFFFF', backgroundColor: '#202020' },
8 | '#16a766|#ffffff': { textColor: '#D1F0D9', backgroundColor: '#12341D' },
9 | '#ffad47|#ffffff': { textColor: '#FDECCE', backgroundColor: '#413111' },
10 | '#4a86e8|#ffffff': { textColor: '#D8E6FD', backgroundColor: '#1C2A41' },
11 | '#a479e2|#ffffff': { textColor: '#E8DEFD', backgroundColor: '#2C2341' },
12 | '#f691b3|#ffffff': { textColor: '#FDD9DF', backgroundColor: '#411D23' },
13 | };
14 |
15 | export function mapGoogleLabelColor(
16 | googleColor: ColorMapping | undefined,
17 | ): ColorMapping | undefined {
18 | if (!googleColor || !googleColor.backgroundColor || !googleColor.textColor) {
19 | return googleColor;
20 | }
21 |
22 | const key = `${googleColor.backgroundColor}|${googleColor.textColor}`;
23 | const mappedColor = GOOGLE_LABEL_COLOR_MAP[key];
24 |
25 | return mappedColor || googleColor;
26 | }
27 |
28 | export function mapToGoogleLabelColor(
29 | customColor: ColorMapping | undefined,
30 | ): ColorMapping | undefined {
31 | if (!customColor || !customColor.backgroundColor || !customColor.textColor) {
32 | return customColor;
33 | }
34 |
35 | for (const [googleKey, mappedValue] of Object.entries(GOOGLE_LABEL_COLOR_MAP)) {
36 | if (
37 | mappedValue.backgroundColor === customColor.backgroundColor &&
38 | mappedValue.textColor === customColor.textColor
39 | ) {
40 | const parts = googleKey.split('|');
41 | const backgroundColor = parts[0] || '';
42 | const textColor = parts[1] || '';
43 | return { backgroundColor, textColor };
44 | }
45 | }
46 |
47 | return customColor;
48 | }
49 |
--------------------------------------------------------------------------------
/apps/server/src/lib/driver/index.ts:
--------------------------------------------------------------------------------
1 | import type { MailManager, ManagerConfig } from './types';
2 | import { OutlookMailManager } from './microsoft';
3 | import { GoogleMailManager } from './google';
4 |
5 | const supportedProviders = {
6 | google: GoogleMailManager,
7 | microsoft: OutlookMailManager,
8 | };
9 |
10 | export const createDriver = (
11 | provider: keyof typeof supportedProviders | (string & {}),
12 | config: ManagerConfig,
13 | ): MailManager => {
14 | const Provider = supportedProviders[provider as keyof typeof supportedProviders];
15 | if (!Provider) throw new Error('Provider not supported');
16 | return new Provider(config);
17 | };
18 |
--------------------------------------------------------------------------------
/apps/server/src/lib/party.ts:
--------------------------------------------------------------------------------
1 | import { Server, type Connection, type ConnectionContext } from 'partyserver';
2 | import { createSimpleAuth, type SimpleAuth } from './auth';
3 | import { parseHeaders } from './utils';
4 |
5 | export class DurableMailbox extends Server {
6 | auth: SimpleAuth;
7 | constructor(ctx: DurableObjectState, env: Env) {
8 | super(ctx, env);
9 | this.auth = createSimpleAuth();
10 | }
11 |
12 | private async getSession(token: string) {
13 | const session = await this.auth.api.getSession({ headers: parseHeaders(token) });
14 | return session;
15 | }
16 |
17 | async onConnect(connection: Connection, ctx: ConnectionContext) {
18 | const url = new URL(ctx.request.url);
19 | const token = url.searchParams.get('token');
20 | if (token) {
21 | const session = await this.getSession(token);
22 | if (session) {
23 | await this.ctx.storage.put('email', session.user.email);
24 | } else {
25 | console.log('No session', token);
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/server/src/lib/sanitize-tip-tap-html.ts:
--------------------------------------------------------------------------------
1 | import { Html } from '@react-email/components';
2 | import { render } from '@react-email/render';
3 | import sanitizeHtml from 'sanitize-html';
4 | import React from 'react';
5 |
6 | export const sanitizeTipTapHtml = async (html: string) => {
7 | const clean = sanitizeHtml(html);
8 | return render(
9 | React.createElement(
10 | Html,
11 | {},
12 | React.createElement('div', { dangerouslySetInnerHTML: { __html: clean } }),
13 | ),
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/apps/server/src/lib/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const serializedFileSchema = z.object({
4 | name: z.string(),
5 | type: z.string(),
6 | size: z.number(),
7 | lastModified: z.number(),
8 | base64: z.string(),
9 | });
10 |
11 | export const deserializeFiles = async (serializedFiles: z.infer[]) => {
12 | return await Promise.all(
13 | serializedFiles.map((data) => {
14 | const file = Buffer.from(data.base64, 'base64');
15 | const blob = new Blob([file], { type: data.type });
16 | const newFile = new File([blob], data.name, {
17 | type: data.type,
18 | lastModified: data.lastModified,
19 | });
20 | return newFile;
21 | }),
22 | );
23 | };
24 |
25 | export const createDraftData = z.object({
26 | to: z.string(),
27 | cc: z.string().optional(),
28 | bcc: z.string().optional(),
29 | subject: z.string(),
30 | message: z.string(),
31 | attachments: z.array(serializedFileSchema).transform(deserializeFiles).optional(),
32 | id: z.string().nullable(),
33 | });
34 |
35 | export type CreateDraftData = z.infer;
36 |
37 | export const defaultUserSettings = {
38 | language: 'en',
39 | timezone: 'UTC',
40 | dynamicContent: false,
41 | externalImages: true,
42 | customPrompt: '',
43 | trustedSenders: [],
44 | isOnboarded: false,
45 | colorTheme: 'system',
46 | } satisfies UserSettings;
47 |
48 | export const userSettingsSchema = z.object({
49 | language: z.string(),
50 | timezone: z.string(),
51 | dynamicContent: z.boolean().optional(),
52 | externalImages: z.boolean(),
53 | customPrompt: z.string(),
54 | isOnboarded: z.boolean().optional(),
55 | trustedSenders: z.string().array().optional(),
56 | colorTheme: z.enum(['light', 'dark', 'system']).default('system'),
57 | });
58 |
59 | export type UserSettings = z.infer;
60 |
--------------------------------------------------------------------------------
/apps/server/src/lib/server-utils.ts:
--------------------------------------------------------------------------------
1 | import { getContext } from 'hono/context-storage';
2 | import { connection, user } from '../db/schema';
3 | import type { HonoContext } from '../ctx';
4 | import { createDriver } from './driver';
5 | import { and, eq } from 'drizzle-orm';
6 |
7 | export const getActiveConnection = async () => {
8 | const c = getContext();
9 | const { session, db } = c.var;
10 | if (!session?.user) throw new Error('Session Not Found');
11 |
12 | const userData = await db.query.user.findFirst({
13 | where: eq(user.id, session.user.id),
14 | });
15 |
16 | if (userData?.defaultConnectionId) {
17 | const activeConnection = await db.query.connection.findFirst({
18 | where: and(
19 | eq(connection.userId, session.user.id),
20 | eq(connection.id, userData.defaultConnectionId),
21 | ),
22 | });
23 | if (activeConnection) return activeConnection;
24 | }
25 |
26 | const firstConnection = await db.query.connection.findFirst({
27 | where: and(eq(connection.userId, session.user.id)),
28 | });
29 | if (!firstConnection) {
30 | console.error(`No connections found for user ${session.user.id}`);
31 | throw new Error('No connections found for user');
32 | }
33 |
34 | return firstConnection;
35 | };
36 |
37 | export const connectionToDriver = (activeConnection: typeof connection.$inferSelect) => {
38 | if (!activeConnection.accessToken || !activeConnection.refreshToken) {
39 | throw new Error('Invalid connection');
40 | }
41 |
42 | return createDriver(activeConnection.providerId, {
43 | auth: {
44 | userId: activeConnection.userId,
45 | accessToken: activeConnection.accessToken,
46 | refreshToken: activeConnection.refreshToken,
47 | email: activeConnection.email,
48 | },
49 | });
50 | };
51 |
--------------------------------------------------------------------------------
/apps/server/src/lib/services.ts:
--------------------------------------------------------------------------------
1 | import { env } from 'cloudflare:workers';
2 | import { Redis } from '@upstash/redis';
3 | import { Resend } from 'resend';
4 |
5 | export const resend = () =>
6 | env.RESEND_API_KEY
7 | ? new Resend(env.RESEND_API_KEY)
8 | : { emails: { send: async (...args: unknown[]) => console.log(args) } };
9 |
10 | export const redis = () => new Redis({ url: env.REDIS_URL, token: env.REDIS_TOKEN });
11 |
12 | export const twilio = (forceUseRealService = false) => {
13 | if (env.NODE_ENV === 'development' && !forceUseRealService) {
14 | return {
15 | messages: {
16 | send: async (to: string, body: string) =>
17 | console.log(`[TWILIO:MOCK] Sending message to ${to}: ${body}`),
18 | },
19 | };
20 | }
21 |
22 | if (!env.TWILIO_ACCOUNT_SID || !env.TWILIO_AUTH_TOKEN || !env.TWILIO_PHONE_NUMBER) {
23 | throw new Error('Twilio is not configured correctly');
24 | }
25 |
26 | const send = async (to: string, body: string) => {
27 | const response = await fetch(
28 | `https://api.twilio.com/2010-04-01/Accounts/${env.TWILIO_ACCOUNT_SID}/Messages.json`,
29 | {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/x-www-form-urlencoded',
33 | Authorization: `Basic ${btoa(`${env.TWILIO_ACCOUNT_SID}:${env.TWILIO_AUTH_TOKEN}`)}`,
34 | },
35 | body: new URLSearchParams({
36 | To: to,
37 | From: env.TWILIO_PHONE_NUMBER,
38 | Body: body,
39 | }),
40 | },
41 | );
42 |
43 | if (!response.ok) {
44 | const error = await response.text();
45 | throw new Error(`Failed to send OTP: ${error}`);
46 | }
47 | };
48 |
49 | return {
50 | messages: {
51 | send,
52 | },
53 | };
54 | };
55 |
--------------------------------------------------------------------------------
/apps/server/src/lib/timezones.ts:
--------------------------------------------------------------------------------
1 | export const getBrowserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
2 |
3 | export const isValidTimezone = (timezone: string) => {
4 | try {
5 | return Intl.supportedValuesOf('timeZone').includes(timezone);
6 | } catch (error) {
7 | return false;
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/apps/server/src/overrides.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Cloudflare {
2 | declare interface Env {
3 | zero: Fetcher & {
4 | subscribe: (data: { connectionId: string; providerId: string }) => Promise;
5 | unsubscribe: (data: { connectionId: string; providerId: string }) => Promise;
6 | };
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/server/src/routes/agent/shared.ts:
--------------------------------------------------------------------------------
1 | export const APPROVAL = {
2 | YES: 'Yes, confirmed.',
3 | NO: 'No, denied.',
4 | } as const;
5 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/index.ts:
--------------------------------------------------------------------------------
1 | import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
2 | import { cookiePreferencesRouter } from './routes/cookies';
3 | import { connectionsRouter } from './routes/connections';
4 | import { shortcutRouter } from './routes/shortcut';
5 | import { settingsRouter } from './routes/settings';
6 | import { getContext } from 'hono/context-storage';
7 | import { draftsRouter } from './routes/drafts';
8 | import { labelsRouter } from './routes/label';
9 | import { brainRouter } from './routes/brain';
10 | import { notesRouter } from './routes/notes';
11 | import { mailRouter } from './routes/mail';
12 | import { userRouter } from './routes/user';
13 | import type { HonoContext } from '../ctx';
14 | import { aiRouter } from './routes/ai';
15 | import { router } from './trpc';
16 |
17 | export const appRouter = router({
18 | ai: aiRouter,
19 | brain: brainRouter,
20 | connections: connectionsRouter,
21 | cookiePreferences: cookiePreferencesRouter,
22 | drafts: draftsRouter,
23 | labels: labelsRouter,
24 | mail: mailRouter,
25 | notes: notesRouter,
26 | shortcut: shortcutRouter,
27 | settings: settingsRouter,
28 | user: userRouter,
29 | });
30 |
31 | export type AppRouter = typeof appRouter;
32 |
33 | export type Inputs = inferRouterInputs;
34 | export type Outputs = inferRouterOutputs;
35 |
36 | export const serverTrpc = () => {
37 | const c = getContext();
38 | return appRouter.createCaller({
39 | c,
40 | session: c.var.session,
41 | db: c.var.db,
42 | auth: c.var.auth,
43 | autumn: c.var.autumn,
44 | });
45 | };
46 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/routes/ai/index.ts:
--------------------------------------------------------------------------------
1 | import { compose, generateEmailSubject } from './compose';
2 | import { generateSearchQuery } from './search';
3 | import { router } from '../../trpc';
4 |
5 | export const aiRouter = router({
6 | generateSearchQuery: generateSearchQuery,
7 | compose: compose,
8 | generateEmailSubject: generateEmailSubject,
9 | });
10 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/routes/ai/search.ts:
--------------------------------------------------------------------------------
1 | import { type CoreMessage, generateText, tool, generateObject } from 'ai';
2 | import { GmailSearchAssistantSystemPrompt } from '../../../lib/prompts';
3 | import { activeDriverProcedure } from '../../trpc';
4 | import type { gmail_v1 } from '@googleapis/gmail';
5 | import { TRPCError } from '@trpc/server';
6 | import { openai } from '@ai-sdk/openai';
7 | import dedent from 'dedent';
8 | import { z } from 'zod';
9 |
10 | export const generateSearchQuery = activeDriverProcedure
11 | .input(z.object({ query: z.string() }))
12 | .mutation(async ({ input, ctx }) => {
13 | const result = await generateObject({
14 | model: openai('gpt-4o'),
15 | system: GmailSearchAssistantSystemPrompt(),
16 | prompt: input.query,
17 | schema: z.object({
18 | query: z.string(),
19 | }),
20 | });
21 |
22 | return result.object;
23 | });
24 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/routes/drafts.ts:
--------------------------------------------------------------------------------
1 | import { activeDriverProcedure, router } from '../trpc';
2 | import { createDraftData } from '../../lib/schemas';
3 | import { z } from 'zod';
4 |
5 | export const draftsRouter = router({
6 | create: activeDriverProcedure.input(createDraftData).mutation(async ({ input, ctx }) => {
7 | const { driver } = ctx;
8 | return driver.createDraft(input);
9 | }),
10 | get: activeDriverProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
11 | const { driver } = ctx;
12 | const { id } = input;
13 | return driver.getDraft(id);
14 | }),
15 | list: activeDriverProcedure
16 | .input(
17 | z.object({
18 | q: z.string().optional(),
19 | max: z.number().optional(),
20 | pageToken: z.string().optional(),
21 | }),
22 | )
23 | .query(async ({ input, ctx }) => {
24 | const { driver } = ctx;
25 | const { q, max, pageToken } = input;
26 | return driver.listDrafts({ q, maxResults: max, pageToken });
27 | }),
28 | });
29 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/routes/shortcut.ts:
--------------------------------------------------------------------------------
1 | import { shortcutSchema } from '../../lib/shortcuts';
2 | import { privateProcedure, router } from '../trpc';
3 | import { userHotkeys } from '../../db/schema';
4 | import { z } from 'zod';
5 |
6 | export const shortcutRouter = router({
7 | update: privateProcedure
8 | .input(
9 | z.object({
10 | shortcuts: z.array(shortcutSchema),
11 | }),
12 | )
13 | .mutation(async ({ ctx, input }) => {
14 | const { db, session } = ctx;
15 | const { shortcuts } = input;
16 | await db
17 | .insert(userHotkeys)
18 | .values({
19 | userId: session.user.id,
20 | shortcuts,
21 | createdAt: new Date(),
22 | updatedAt: new Date(),
23 | })
24 | .onConflictDoUpdate({
25 | target: userHotkeys.userId,
26 | set: {
27 | shortcuts,
28 | updatedAt: new Date(),
29 | },
30 | });
31 | }),
32 | });
33 |
--------------------------------------------------------------------------------
/apps/server/src/trpc/routes/user.ts:
--------------------------------------------------------------------------------
1 | import { privateProcedure, router } from '../trpc';
2 |
3 | export const userRouter = router({
4 | delete: privateProcedure.mutation(async ({ ctx }) => {
5 | const { success, message } = await ctx.c.var.auth.api.deleteUser({
6 | body: {
7 | callbackURL: '/',
8 | },
9 | headers: ctx.c.req.raw.headers,
10 | request: ctx.c.req.raw,
11 | });
12 | return { success, message };
13 | }),
14 | });
15 |
--------------------------------------------------------------------------------
/apps/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base",
3 | "include": ["src/**/*.ts", "src/overrides.d.ts", "worker-configuration.d.ts", "drizzle.config.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | project_id_env: CROWDIN_ID
2 | api_token_env: CROWDIN_KEY
3 | base_path: .
4 | base_url: 'https://api.crowdin.com'
5 | preserve_hierarchy: 1
6 | files:
7 | - source: /apps/mail/locales/en.json
8 | translation: /apps/mail/locales/%two_letters_code%.json
9 |
--------------------------------------------------------------------------------
/docker-compose.db.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | db:
3 | container_name: zerodotemail-db
4 | image: postgres:17
5 | restart: unless-stopped
6 | environment:
7 | POSTGRES_USER: postgres
8 | POSTGRES_PASSWORD: postgres
9 | POSTGRES_DB: zerodotemail
10 | PGDATA: /var/lib/postgresql/data/pgdata
11 | ports:
12 | - 5432:5432
13 | volumes:
14 | - postgres-data:/var/lib/postgresql/data
15 |
16 | valkey:
17 | container_name: zerodotemail-redis
18 | image: docker.io/bitnami/valkey:8.0
19 | environment:
20 | - ALLOW_EMPTY_PASSWORD=yes
21 | - VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
22 | ports:
23 | - 6379:6379
24 | volumes:
25 | - valkey-data:/bitnami/valkey/data
26 |
27 | upstash-proxy:
28 | container_name: zerodotemail-upstash-proxy
29 | image: hiett/serverless-redis-http:latest
30 | environment:
31 | SRH_MODE: env
32 | SRH_TOKEN: upstash-local-token
33 | SRH_CONNECTION_STRING: 'redis://valkey:6379'
34 | ports:
35 | - 8079:80
36 |
37 | volumes:
38 | valkey-data:
39 | postgres-data:
40 |
--------------------------------------------------------------------------------
/docker/db/Dockerfile:
--------------------------------------------------------------------------------
1 | # ========================================
2 | # Dependencies Stage: Install Dependencies
3 | # ========================================
4 | FROM oven/bun:alpine AS deps
5 | WORKDIR /app
6 |
7 | # Copy only package files needed for migrations
8 | COPY package.json bun.lock turbo.json ./
9 | COPY packages/db/package.json ./packages/db/
10 | COPY packages/tsconfig/base.json ./packages/tsconfig/base.json
11 | COPY packages/tsconfig/package.json ./packages/tsconfig/
12 |
13 | # Install minimal dependencies in one layer
14 | RUN bun install --omit dev --ignore-scripts && \
15 | bun install --omit dev --ignore-scripts drizzle-kit drizzle-orm postgres
16 |
17 | # ========================================
18 | # Runner Stage: Production Environment
19 | # ========================================
20 | FROM oven/bun:alpine AS runner
21 | WORKDIR /app
22 |
23 | # Copy only the necessary files from deps
24 | COPY --from=deps /app/node_modules ./node_modules
25 | COPY packages/db/drizzle.config.ts ./packages/db/drizzle.config.ts
26 | COPY packages/db/src ./packages/db/src
27 | COPY packages/db/migrations ./packages/db/migrations
28 | COPY packages/db/package.json ./packages/db/package.json
29 |
30 | WORKDIR /app/packages/db
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import config from "./packages/eslint-config/eslint.config.mjs";
2 | import { fileURLToPath } from "url";
3 | import { resolve } from "path";
4 |
5 | const __dirname = fileURLToPath(new URL(".", import.meta.url));
6 |
7 | export default [
8 | ...config,
9 | {
10 | files: ["**/*.ts", "**/*.tsx"],
11 | languageOptions: {
12 | parserOptions: {
13 | project: ["./tsconfig.json", "./apps/*/tsconfig.json", "./packages/*/tsconfig.json"],
14 | tsconfigRootDir: __dirname,
15 | },
16 | },
17 | },
18 | ];
19 |
--------------------------------------------------------------------------------
/i18n.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://lingo.dev/schema/i18n.json",
3 | "version": 1.5,
4 | "locale": {
5 | "source": "en",
6 | "targets": [
7 | "ar",
8 | "ca",
9 | "cs",
10 | "de",
11 | "es",
12 | "fr",
13 | "hi",
14 | "nl",
15 | "ja",
16 | "ko",
17 | "lv",
18 | "pl",
19 | "pt",
20 | "ru",
21 | "tr",
22 | "hu",
23 | "fa",
24 | "vi",
25 | "zh_CN",
26 | "zh_TW"
27 | ]
28 | },
29 | "buckets": {
30 | "json": {
31 | "include": ["apps/mail/locales/[locale].json"]
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zero",
3 | "version": "0.1.0",
4 | "private": true,
5 | "packageManager": "pnpm@10.11.0",
6 | "scripts": {
7 | "prepare": "husky",
8 | "nizzy": "tsx ./packages/cli/src/cli.ts",
9 | "postinstall": "pnpm nizzy sync",
10 | "dev": "turbo run dev",
11 | "build": "turbo run build",
12 | "build:frontend": "pnpm run --filter=@zero/mail build",
13 | "deploy:frontend": "pnpm run --filter=@zero/mail deploy",
14 | "deploy:backend": "pnpm run --filter=@zero/server deploy",
15 | "start": "turbo run start",
16 | "lint": "turbo run lint",
17 | "format": "prettier --write apps/**/*.{ts,tsx} --log-level silent",
18 | "check": "pnpm run check:format && pnpm run lint",
19 | "check:format": "prettier . --check",
20 | "lint-staged": "prettier --write --ignore-unknown",
21 | "docker:db:up": "docker compose -f docker-compose.db.yaml up -d",
22 | "docker:db:down": "docker compose -f docker-compose.db.yaml down",
23 | "docker:db:clean": "docker compose -f docker-compose.db.yaml down -v",
24 | "db:generate": "dotenv -- pnpm run -C apps/server db:generate",
25 | "db:migrate": "dotenv -- pnpm run -C apps/server db:migrate",
26 | "db:push": "dotenv -- pnpm run -C apps/server db:push",
27 | "db:studio": "dotenv -- pnpm run -C apps/server db:studio",
28 | "sentry:sourcemaps": "sentry-cli sourcemaps inject --org zero-7y --project nextjs ./apps/mail/.next && sentry-cli sourcemaps upload --org zero-7y --project nextjs ./apps/mail/.next",
29 | "scripts": "dotenv -- pnpx tsx ./scripts/run.ts"
30 | },
31 | "devDependencies": {
32 | "@types/node": "22.15.29",
33 | "@zero/tsconfig": "workspace:*",
34 | "dotenv-cli": "^8.0.0",
35 | "husky": "9.1.7",
36 | "prettier": "3.5.3",
37 | "prettier-plugin-sort-imports": "1.8.8",
38 | "prettier-plugin-tailwindcss": "0.6.12",
39 | "tsx": "4.19.4",
40 | "turbo": "^2.5.4",
41 | "typescript": "catalog:"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zero/cli",
3 | "type": "module",
4 | "private": true,
5 | "author": "BlankParticle",
6 | "packageManager": "pnpm@10.11.0",
7 | "devDependencies": {
8 | "@clack/prompts": "0.10.1",
9 | "@zero/tsconfig": "workspace:*",
10 | "tiny-glob": "0.2.9"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/cli/src/cli.ts:
--------------------------------------------------------------------------------
1 | import { intro, select, isCancel, outro, log } from '@clack/prompts';
2 | import * as commands from './commands';
3 |
4 | let args = [];
5 | if (process.argv.slice(2).length === 0) {
6 | intro(`Welcome to the Nizzy CLI`);
7 |
8 | const command = await select({
9 | message: `Hey ${process.env.USER}, what do you want to do?`,
10 | options: Object.values(commands).map((command) => ({
11 | label: command.description,
12 | value: command.id,
13 | })),
14 | maxItems: 5,
15 | });
16 |
17 | if (isCancel(command)) {
18 | outro('No worries, come back anytime!');
19 | process.exit(0);
20 | }
21 |
22 | args = [command];
23 | } else {
24 | intro(`Nizzy CLI`);
25 | args = process.argv.slice(2);
26 | }
27 |
28 | if (['help', '-h', '--help'].includes(args[0])) {
29 | log.message('Available commands:');
30 | log.message(
31 | Object.values(commands)
32 | .map((command) => ` ${command.id.padStart(15)} ${command.description}`)
33 | .join('\n'),
34 | );
35 | outro('Run `pnpm nizzy` for an interactive experience\n');
36 | process.exit(0);
37 | }
38 |
39 | const command = Object.values(commands).find((command) => command.id === args[0]);
40 |
41 | if (!command) {
42 | outro("Umm, I don't know how to do that yet");
43 | process.exit(0);
44 | }
45 |
46 | await command.run();
47 | outro(`Done!`);
48 | process.exit(0);
49 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/index.ts:
--------------------------------------------------------------------------------
1 | export type Command = {
2 | id: string;
3 | description: string;
4 | run: () => Promise;
5 | };
6 |
7 | export { command as fixEnv } from './fix-env';
8 | export { command as reinstallNodeModules } from './reinstall-node-modules';
9 | export { command as sync } from './sync';
10 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/reinstall-node-modules.ts:
--------------------------------------------------------------------------------
1 | import { getProjectRoot, runCommand } from '../utils';
2 | import { log, spinner } from '@clack/prompts';
3 | import { rm } from 'fs/promises';
4 | import type { Command } from '.';
5 | import glob from 'tiny-glob';
6 | import { join } from 'path';
7 |
8 | export const command: Command = {
9 | id: 'reinstall',
10 | description: 'Reinstall node modules',
11 | run: async () => {
12 | const root = await getProjectRoot();
13 | const removePackagesSpinner = spinner();
14 | removePackagesSpinner.start('Removing node_modules');
15 | const nodeModuleFolders = await glob('**/*/node_modules', { cwd: root });
16 | await Promise.all(
17 | nodeModuleFolders.map((folder) => rm(join(root, folder), { recursive: true, force: true })),
18 | );
19 | removePackagesSpinner.stop('Removed node_modules');
20 | log.step('Reinstalling node_modules');
21 | await runCommand('pnpm', ['install']);
22 | log.step('Reinstalled node_modules');
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/sync.ts:
--------------------------------------------------------------------------------
1 | import { getProjectRoot, runCommand } from '../utils';
2 | import { cp, readFile } from 'fs/promises';
3 | import { log } from '@clack/prompts';
4 | import type { Command } from '.';
5 | import { join } from 'path';
6 |
7 | export const command: Command = {
8 | id: 'sync',
9 | description: 'Sync your environment variables and types',
10 | run: async () => {
11 | const root = await getProjectRoot();
12 | const envFile = await readFile(join(root, '.env'), 'utf8').catch(() => null);
13 |
14 | if (!envFile) {
15 | log.step('No .env file exists, creating one using `pnpm nizzy env`');
16 | process.exit(0);
17 | }
18 |
19 | log.step('Syncing environment variables');
20 | cp(join(root, '.env'), join(root, 'apps/mail/.dev.vars'));
21 | cp(join(root, '.env'), join(root, 'apps/mail/.env'));
22 | cp(join(root, '.env'), join(root, 'apps/server/.dev.vars'));
23 |
24 | log.step('Syncing frontend types');
25 | await runCommand('pnpm', ['run', 'types'], { cwd: join(root, 'apps/mail') });
26 | log.step('Syncing backend types');
27 | await runCommand('pnpm', ['run', 'types'], { cwd: join(root, 'apps/server') });
28 | log.success('Synced environment variables and types');
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/packages/cli/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { spawn, type SpawnOptions } from 'child_process';
2 | import { readFile } from 'fs/promises';
3 | import { log } from '@clack/prompts';
4 | import { join } from 'path';
5 |
6 | export const getProjectRoot = async () => {
7 | const cwd = process.cwd();
8 | const packageJson = await readFile(join(cwd, 'package.json'), 'utf8').catch(() => '{}');
9 | const packageJsonObject = JSON.parse(packageJson);
10 | const rootName = packageJsonObject.name;
11 | if (!rootName || rootName !== 'zero') {
12 | log.error(`Please run this command from the root of the project.`);
13 | process.exit(0);
14 | }
15 | return cwd;
16 | };
17 |
18 | export const runCommand = async (command: string, args: string[], options: SpawnOptions = {}) => {
19 | const child = spawn(command, args, { stdio: 'inherit', ...options });
20 | await new Promise((resolve, reject) => {
21 | child.once('close', resolve);
22 | child.once('error', reject);
23 | });
24 | };
25 |
26 | export const parseEnv = (env: string) => {
27 | return env
28 | .split('\n')
29 | .map((line) => line.trim())
30 | .filter((line) => line && !line.startsWith('#'))
31 | .map((line) => {
32 | const equalIndex = line.indexOf('=');
33 | if (equalIndex === -1) return null;
34 |
35 | const key = line.slice(0, equalIndex).trim();
36 | let value = line.slice(equalIndex + 1).trim();
37 |
38 | if (
39 | (value.startsWith('"') && value.endsWith('"')) ||
40 | (value.startsWith("'") && value.endsWith("'"))
41 | ) {
42 | value = value.slice(1, -1);
43 | }
44 |
45 | return { key, value };
46 | })
47 | .filter((entry): entry is { key: string; value: string } => entry !== null);
48 | };
49 |
--------------------------------------------------------------------------------
/packages/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/eslint-config/config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, globalIgnores } from 'eslint/config';
2 | import reactHooksPlugin from 'eslint-plugin-react-hooks';
3 | import reactPlugin from 'eslint-plugin-react';
4 | import tseslint from 'typescript-eslint';
5 |
6 | export default defineConfig([
7 | globalIgnores([
8 | '**/node_modules/**',
9 | '**/dist/**',
10 | '**/build/**',
11 | '**/.react-router/**',
12 | '**/.well-known/**',
13 | ]),
14 | // @ts-expect-error
15 | tseslint.configs.recommended,
16 | //
17 | reactPlugin.configs.flat.recommended,
18 | reactPlugin.configs.flat['jsx-runtime'],
19 | reactHooksPlugin.configs['recommended-latest'],
20 | ]);
21 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zero/eslint-config",
3 | "private": true,
4 | "type": "module",
5 | "exports": {
6 | ".": "./config.ts"
7 | },
8 | "devDependencies": {
9 | "eslint": "^9.27.0",
10 | "eslint-plugin-import": "^2.31.0",
11 | "eslint-plugin-react": "^7.37.5",
12 | "eslint-plugin-react-hooks": "^5.2.0",
13 | "typescript-eslint": "8.32.1"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/eslint-config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "noEmit": true
7 | },
8 | "include": ["**/*.ts"],
9 | "exclude": ["node_modules"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/tailwind-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zero/tailwind-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | ".": "./tailwind.config.ts"
7 | },
8 | "devDependencies": {
9 | "@zero/tsconfig": "workspace:*",
10 | "tailwindcss": "3.4.17"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/tailwind-config/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import * as defaultTheme from "tailwindcss/defaultTheme";
2 | import type { Config } from "tailwindcss";
3 |
4 | export default {
5 | darkMode: ["class"],
6 | content: [
7 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
10 | ],
11 | theme: {
12 | extend: {
13 | fontFamily: {
14 | sans: ["Geist", ...defaultTheme.fontFamily.sans],
15 | mono: ["Geist_Mono", ...defaultTheme.fontFamily.mono],
16 | },
17 | },
18 | },
19 | } satisfies Config;
20 |
--------------------------------------------------------------------------------
/packages/tailwind-config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base",
3 | "include": ["."],
4 | "exclude": ["dist", "build", "node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/tsconfig/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "lib": ["EsNext"],
6 | "allowJs": true,
7 | "checkJs": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "verbatimModuleSyntax": true,
19 | "allowImportingTsExtensions": true,
20 | "allowArbitraryExtensions": true,
21 | "types": ["node"]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zero/tsconfig",
3 | "private": true,
4 | "exports": {
5 | "./base": "./base.json"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - apps/*
3 | - packages/*
4 | - scripts/*
5 | catalog:
6 | zod: ^3.25.42
7 | better-auth: ^1.2.8
8 | autumn-js: ^0.0.48
9 | superjson: ^2.2.2
10 | '@trpc/server': ^11.1.4
11 | '@trpc/client': ^11.1.4
12 | '@trpc/tanstack-react-query': ^11.1.4
13 | wrangler: ^4.18.0
14 | typescript: ^5.8.3
15 | drizzle-orm: ^0.43.1
16 | drizzle-kit: ^0.31.1
17 | '@types/node': ^22.15.21
18 | onlyBuiltDependencies:
19 | - core-js
20 | - core-js-pure
21 | - esbuild
22 | - sharp
23 | - unrs-resolver
24 | - workerd
25 | patchedDependencies:
26 | novel: patches/novel.patch
27 |
--------------------------------------------------------------------------------
/public/better-auth.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/public/better-auth.png
--------------------------------------------------------------------------------
/public/coderabbit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/public/coderabbit.png
--------------------------------------------------------------------------------
/public/drizzle-orm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/public/drizzle-orm.png
--------------------------------------------------------------------------------
/public/vercel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mail-0/Zero/13bae8fdcb0324339a08b911c2c55b439ff39fb6/public/vercel.png
--------------------------------------------------------------------------------
/scripts/docker/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -x
3 |
4 | # Replacing placeholder urls to runtime variables, since we're using rewrites in nextjs, this is required.
5 | # Everything else which doesn't compile URLs at build should already be able to use runtime variables.
6 |
7 | /app/scripts/replace-placeholder.sh "http://REPLACE-BACKEND-URL.com" "$NEXT_PUBLIC_BACKEND_URL"
8 | /app/scripts/replace-placeholder.sh "http://REPLACE-APP-URL.com" "$NEXT_PUBLIC_APP_URL"
9 |
10 | exec bun /app/apps/mail/server.js
--------------------------------------------------------------------------------
/scripts/docker/replace-placeholder.sh:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | #
3 | # Copyright (c) Cal.com
4 | #
5 | # Permission is hereby granted, free of charge, to any person obtaining a copy
6 | # of this software and associated documentation files (the "Software"), to deal
7 | # in the Software without restriction, including without limitation the rights
8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | # copies of the Software, and to permit persons to whom the Software is
10 | # furnished to do so, subject to the following conditions:
11 | #
12 | # The above copyright notice and this permission notice shall be included in all
13 | # copies or substantial portions of the Software.
14 | #
15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | # SOFTWARE.
22 |
23 | FROM=$1
24 | TO=$2
25 |
26 | if [ "${FROM}" = "${TO}" ]; then
27 | echo "Nothing to replace, the value is already set to ${TO}."
28 | exit 0
29 | fi
30 |
31 | # Only peform action if $FROM and $TO are different.
32 | echo "Replacing all statically built instances of $FROM with $TO."
33 |
34 | for file in $(egrep -r -l "${FROM}" /app/apps/mail); do
35 | sed -i -e "s|$FROM|$TO|g" "$file"
36 | done
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scripts",
3 | "version": "0.0.1",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {},
7 | "dependencies": {
8 | "@faker-js/faker": "9.8.0",
9 | "@inquirer/prompts": "7.5.1",
10 | "cmd-ts": "^0.13.0",
11 | "resend": "4.5.1"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/scripts/run.ts:
--------------------------------------------------------------------------------
1 | import { sendEmailsCommand } from './send-emails/index';
2 | import { seedStyleCommand } from './seed-style/seeder';
3 | import { subcommands, run } from 'cmd-ts';
4 |
5 | const app = subcommands({
6 | name: 'scripts',
7 | cmds: {
8 | 'seed-style': seedStyleCommand,
9 | 'send-emails': sendEmailsCommand,
10 | },
11 | });
12 |
13 | await run(app, process.argv.slice(2));
14 | process.exit(0);
15 |
--------------------------------------------------------------------------------
/scripts/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "module": "ESNext",
5 | "moduleResolution": "bundler"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@zero/tsconfig/base"
3 | }
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "envMode": "loose",
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build", "sentry:sourcemaps"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
8 | "outputs": ["build/**", "!.react-router/**", "dist/**"]
9 | },
10 | "dev": {
11 | "persistent": true,
12 | "cache": false
13 | },
14 | "start": {
15 | "cache": false
16 | },
17 | "lint": {
18 | "outputs": []
19 | },
20 | "sentry:sourcemaps": {
21 | "cache": false
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------