├── .editorconfig ├── .env.local.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── autofix.yml │ └── check_and_build_pull_requests.yaml ├── .gitignore ├── .husky └── pre-commit ├── .infisical.json ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .vscode ├── cspell.json ├── extensions.json ├── settings.json └── uninbox-code-snippets.code-snippets ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── apps ├── mail-bridge │ ├── app.ts │ ├── ctx.ts │ ├── env.ts │ ├── package.json │ ├── postal-db │ │ ├── functions.ts │ │ ├── generators.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── postal-routes │ │ ├── events.ts │ │ ├── inbound.ts │ │ └── signature-middleware.ts │ ├── queue │ │ └── mail-processor.ts │ ├── scripts │ │ ├── email-stress-test.ts │ │ ├── email-templates.ts │ │ └── mock-incoming.ts │ ├── smtp │ │ ├── auth.ts │ │ └── sendEmail.ts │ ├── tracing.ts │ ├── trpc │ │ ├── index.ts │ │ ├── routers │ │ │ ├── domainRouter.ts │ │ │ ├── orgRouter.ts │ │ │ ├── sendMailRouter.ts │ │ │ └── smtpRouter.ts │ │ └── trpc.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── types.ts │ └── utils │ │ ├── contactParsing.ts │ │ ├── purify.ts │ │ ├── queue-helpers.ts │ │ ├── realtime.ts │ │ ├── spaceUtils.ts │ │ ├── tiptap-utils.ts │ │ └── validatePostalWebhookSignature.ts ├── platform │ ├── app.ts │ ├── ctx.ts │ ├── env.ts │ ├── middlewares.ts │ ├── package.json │ ├── routes │ │ ├── auth.ts │ │ ├── realtime.ts │ │ └── services.ts │ ├── storage.ts │ ├── tracing.ts │ ├── trpc │ │ ├── index.ts │ │ ├── ratelimit.ts │ │ ├── routers │ │ │ ├── authRouter │ │ │ │ ├── passkeyRouter.ts │ │ │ │ ├── passwordRouter.ts │ │ │ │ ├── recoveryRouter.ts │ │ │ │ ├── signupRouter.ts │ │ │ │ └── twoFactorRouter.ts │ │ │ ├── contactRouter │ │ │ │ └── contactRouter.ts │ │ │ ├── convoRouter │ │ │ │ ├── convoRouter.ts │ │ │ │ └── entryRouter.ts │ │ │ ├── orgRouter │ │ │ │ ├── iCanHaz │ │ │ │ │ └── iCanHazRouter.ts │ │ │ │ ├── mail │ │ │ │ │ ├── domainsRouter.ts │ │ │ │ │ ├── emailIdentityExternalRouter.ts │ │ │ │ │ └── emailIdentityRouter.ts │ │ │ │ ├── orgCrudRouter.ts │ │ │ │ ├── orgStoreRouter.ts │ │ │ │ ├── setup │ │ │ │ │ ├── billingRouter.ts │ │ │ │ │ └── profileRouter.ts │ │ │ │ └── users │ │ │ │ │ ├── invitesRouter.ts │ │ │ │ │ ├── membersRouter.ts │ │ │ │ │ ├── teamsHandler.ts │ │ │ │ │ └── teamsRouter.ts │ │ │ ├── spaceRouter │ │ │ │ ├── membersRouter.ts │ │ │ │ ├── spaceRouter.ts │ │ │ │ ├── spaceSettingsRouter.ts │ │ │ │ ├── tagsRouter.ts │ │ │ │ ├── utils.ts │ │ │ │ └── workflowsRouter.ts │ │ │ └── userRouter │ │ │ │ ├── addressRouter.ts │ │ │ │ ├── profileRouter.ts │ │ │ │ └── securityRouter.ts │ │ └── trpc.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── utils │ │ ├── account.ts │ │ ├── auth.ts │ │ ├── auth │ │ ├── adapter.ts │ │ ├── passkeyUtils.ts │ │ └── passkeys.ts │ │ ├── cookieNames.ts │ │ ├── mail │ │ ├── inviteTemplate.ts │ │ ├── passwordRecoveryEmailTemplate.ts │ │ ├── setRecoveryEmailTemplate.ts │ │ └── transactional.ts │ │ ├── orgShortcode.ts │ │ ├── realtime.ts │ │ ├── session.ts │ │ ├── signup.ts │ │ ├── tRPCServerClients.ts │ │ ├── tiptap-utils.ts │ │ └── updateDnsRecords.ts ├── storage │ ├── api │ │ ├── avatar.ts │ │ ├── deleteAttachments.ts │ │ ├── deleteOrg.ts │ │ ├── internalPresign.ts │ │ ├── mailfetch.ts │ │ └── presign.ts │ ├── app.ts │ ├── ctx.ts │ ├── env.ts │ ├── middlewares.ts │ ├── package.json │ ├── proxy │ │ ├── attachment.ts │ │ ├── avatars.ts │ │ └── inline-proxy.ts │ ├── s3.ts │ ├── storage.ts │ ├── tracing.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── web │ ├── .gitignore │ ├── components.json │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── favicon.ico │ │ ├── inbox-zero.svg │ │ └── logo.png │ ├── src │ │ ├── app │ │ │ ├── (login) │ │ │ │ ├── _components │ │ │ │ │ └── two-factor-dialog.tsx │ │ │ │ └── page.tsx │ │ │ ├── [orgShortcode] │ │ │ │ ├── [spaceShortcode] │ │ │ │ │ ├── convo │ │ │ │ │ │ ├── [convoId] │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── new │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── welcome │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── top-bar.tsx │ │ │ │ │ │ │ ├── welcome-message.tsx │ │ │ │ │ │ │ └── welcome-messages.tsx │ │ │ │ │ │ │ ├── _data │ │ │ │ │ │ │ └── welcomeMessages.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── route.ts │ │ │ │ │ └── settings │ │ │ │ │ │ ├── _components │ │ │ │ │ │ └── settingsTitle.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── atoms.ts │ │ │ │ │ ├── bottom-nav.tsx │ │ │ │ │ ├── claim-email-identity.tsx │ │ │ │ │ ├── new-space-modal.tsx │ │ │ │ │ ├── sidebar-content.tsx │ │ │ │ │ ├── sidebar-nav-button.tsx │ │ │ │ │ └── sidebar.tsx │ │ │ │ ├── convo │ │ │ │ │ ├── [convoId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── context-panel.tsx │ │ │ │ │ │ │ ├── convo-views.tsx │ │ │ │ │ │ │ ├── messages-panel.tsx │ │ │ │ │ │ │ ├── original-message-view.tsx │ │ │ │ │ │ │ ├── participants.tsx │ │ │ │ │ │ │ ├── reply-box.tsx │ │ │ │ │ │ │ └── top-bar.tsx │ │ │ │ │ │ ├── atoms.ts │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── convo-list-base.tsx │ │ │ │ │ │ ├── convo-list-item.tsx │ │ │ │ │ │ ├── convo-list.tsx │ │ │ │ │ │ ├── create-convo-form.tsx │ │ │ │ │ │ ├── delete-convos-modal.tsx │ │ │ │ │ │ ├── new-convo-sheet.tsx │ │ │ │ │ │ └── org-issue-alerts.tsx │ │ │ │ │ ├── atoms.ts │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── new │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── layout.tsx │ │ │ │ ├── route.ts │ │ │ │ └── settings │ │ │ │ │ ├── _components │ │ │ │ │ ├── page-title.tsx │ │ │ │ │ └── settings-sidebar.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── org │ │ │ │ │ ├── mail │ │ │ │ │ │ ├── addresses │ │ │ │ │ │ │ ├── [addressId] │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ │ │ ├── add │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── domains │ │ │ │ │ │ │ ├── [domainId] │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── add-domain-modal.tsx │ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── setup │ │ │ │ │ │ └── billing │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ └── plans-table.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── users │ │ │ │ │ │ ├── invites │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ │ └── invite-modal.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── members │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── teams │ │ │ │ │ │ ├── [teamId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── add-new-member.tsx │ │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ │ └── member-editor.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ │ └── new-team-modal.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── user │ │ │ │ │ ├── addresses │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── claim-address-modal.tsx │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── profile │ │ │ │ │ └── page.tsx │ │ │ │ │ └── security │ │ │ │ │ ├── _components │ │ │ │ │ ├── elevated-modal.tsx │ │ │ │ │ ├── passkey-modals.tsx │ │ │ │ │ ├── password-modals.tsx │ │ │ │ │ ├── recovery-email-modals.tsx │ │ │ │ │ ├── recovery-email-section.tsx │ │ │ │ │ ├── recovery-modals.tsx │ │ │ │ │ ├── session-modal.tsx │ │ │ │ │ └── two-factor-modals.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── join │ │ │ │ ├── _components │ │ │ │ │ └── stepper.tsx │ │ │ │ ├── invite │ │ │ │ │ └── [code] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ └── invite-card.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── org │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── create-org.tsx │ │ │ │ │ │ └── join-org.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── profile │ │ │ │ │ ├── _components │ │ │ │ │ │ └── profile-card.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── secure │ │ │ │ │ ├── _components │ │ │ │ │ └── secure-cards.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── recovery │ │ │ │ └── reset-password │ │ │ │ ├── request │ │ │ │ └── page.tsx │ │ │ │ ├── reset │ │ │ │ └── page.tsx │ │ │ │ └── verify │ │ │ │ └── page.tsx │ │ ├── components │ │ │ ├── avatar-crop.tsx │ │ │ ├── avatar-plus.tsx │ │ │ ├── avatar.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── editor │ │ │ │ ├── index.tsx │ │ │ │ ├── selectors.tsx │ │ │ │ └── slash-commands.tsx │ │ │ ├── modifier-class-provider.tsx │ │ │ ├── password-input.tsx │ │ │ ├── posthog-page-view.tsx │ │ │ ├── shadcn-ui │ │ │ │ ├── accordion.tsx │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── hover-card.tsx │ │ │ │ ├── input-otp.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── toggle-group.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── shared │ │ │ │ ├── attachments.tsx │ │ │ │ ├── editable-text.tsx │ │ │ │ ├── multiselect.tsx │ │ │ │ ├── strength-meter.tsx │ │ │ │ └── table.tsx │ │ │ ├── smart-date-time.tsx │ │ │ └── turnstile.tsx │ │ ├── env.ts │ │ ├── fonts │ │ │ └── CalSans-SemiBold.woff2 │ │ ├── hooks │ │ │ ├── use-avatar-uploader.ts │ │ │ ├── use-inline-uploader.ts │ │ │ ├── use-is-mobile.ts │ │ │ ├── use-page-title.ts │ │ │ ├── use-params.ts │ │ │ └── use-time-ago.ts │ │ ├── instrumentation.ts │ │ ├── lib │ │ │ ├── middleware-utils.ts │ │ │ ├── trpc.tsx │ │ │ ├── upload.ts │ │ │ └── utils.ts │ │ ├── middleware.ts │ │ ├── providers │ │ │ ├── posthog-provider.tsx │ │ │ └── realtime-provider.tsx │ │ ├── stores │ │ │ ├── draft-store.ts │ │ │ └── preferences-store.ts │ │ └── styles │ │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json └── worker │ ├── app.ts │ ├── ctx.ts │ ├── env.ts │ ├── functions │ ├── check-dns.ts │ └── cleanup-expired-sessions.ts │ ├── middlewares.ts │ ├── package.json │ ├── services │ ├── dns-check-queue.ts │ └── expired-session-cleanup.ts │ ├── tracing.ts │ ├── trpc │ ├── index.ts │ ├── routers │ │ └── jobs-router.ts │ └── trpc.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── utils │ └── queue-helpers.ts ├── ee ├── LICENSE ├── README.md └── apps │ ├── billing │ ├── app.ts │ ├── ctx.ts │ ├── env.ts │ ├── middlewares.ts │ ├── package.json │ ├── routes │ │ └── stripe.ts │ ├── scripts │ │ └── sync-stripe-db.ts │ ├── stripe.ts │ ├── trpc │ │ ├── index.ts │ │ ├── routers │ │ │ ├── iCanHazRouter.ts │ │ │ ├── stripeLinksRouter.ts │ │ │ └── subscriptionsRouter.ts │ │ └── trpc.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── validateLicenseKey.ts │ └── command │ ├── components.json │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ └── favicon.ico │ ├── src │ ├── app │ │ ├── api │ │ │ └── trpc │ │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── password-reset │ │ │ └── page.tsx │ │ ├── remove-expired-sessions │ │ │ └── page.tsx │ │ ├── skiff │ │ │ └── page.tsx │ │ └── unin │ │ │ └── page.tsx │ ├── components │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ └── sonner.tsx │ ├── lib │ │ ├── get-account.ts │ │ ├── trpc.tsx │ │ └── utils.ts │ ├── middleware.ts │ ├── server │ │ └── trpc │ │ │ ├── index.ts │ │ │ ├── routers │ │ │ ├── accountRouter.ts │ │ │ ├── internalRouter.ts │ │ │ └── orgRoutes.ts │ │ │ └── trpc.ts │ └── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── package.json ├── packages ├── database │ ├── dbClean.ts │ ├── dbSeed.ts │ ├── drizzle.config.ts │ ├── env.ts │ ├── index.ts │ ├── migrate.ts │ ├── migrations │ │ ├── 0000_cultured_cable.sql │ │ ├── 0001_flimsy_mercury.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ └── _journal.json │ ├── orm.ts │ ├── package.json │ ├── schema.eraserdiagram │ ├── schema.ts │ └── tsconfig.json ├── eslint-plugin │ ├── index.js │ ├── package.json │ └── rules │ │ └── table-needs-org-id.js ├── hono │ ├── package.json │ ├── src │ │ ├── helpers.ts │ │ └── index.ts │ └── tsconfig.json ├── local-docker │ └── docker-compose.yml ├── otel │ ├── env.ts │ ├── exports.ts │ ├── helpers.ts │ ├── hono.ts │ ├── index.ts │ ├── logger.ts │ ├── package.json │ ├── setup.ts │ └── tsconfig.json ├── realtime │ ├── client.ts │ ├── events.ts │ ├── package.json │ ├── server.ts │ └── tsconfig.json ├── tiptap │ ├── components │ │ ├── editor-bubble-item.tsx │ │ ├── editor-bubble.tsx │ │ ├── editor-command-item.tsx │ │ ├── editor-command.tsx │ │ ├── editor.tsx │ │ └── index.ts │ ├── extensions │ │ ├── image-uploader.ts │ │ ├── index.ts │ │ └── slash-command.ts │ ├── index.ts │ ├── package.json │ ├── react.ts │ ├── tsconfig.json │ └── utils │ │ ├── atoms.ts │ │ └── store.ts └── utils │ ├── discord.ts │ ├── dns │ ├── index.ts │ ├── resolver.ts │ ├── txtParsers.ts │ └── verifier.ts │ ├── ms.ts │ ├── package.json │ ├── password.ts │ ├── sanitizers.ts │ ├── spaces.ts │ ├── tsconfig.json │ ├── typeid.ts │ ├── uiColors.ts │ └── zodSchemas.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── predev.ts ├── prettier.config.cjs ├── tsconfig.json └── turbo.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # other artefacts 2 | .output 3 | node_modules 4 | dist 5 | public 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('eslint').Linter.Config} 3 | */ 4 | module.exports = { 5 | root: true, 6 | parser: '@typescript-eslint/parser', 7 | plugins: ['@typescript-eslint', 'drizzle'], 8 | parserOptions: { 9 | project: true 10 | }, 11 | extends: [ 12 | 'plugin:drizzle/all', 13 | 'plugin:@typescript-eslint/recommended-type-checked', 14 | 'plugin:@typescript-eslint/stylistic-type-checked' 15 | ], 16 | rules: { 17 | '@typescript-eslint/array-type': 'off', 18 | '@typescript-eslint/consistent-type-definitions': 'off', 19 | '@typescript-eslint/consistent-type-imports': [ 20 | 'warn', 21 | { prefer: 'type-imports', fixStyle: 'inline-type-imports' } 22 | ], 23 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 24 | '@typescript-eslint/require-await': 'off', 25 | '@typescript-eslint/no-misused-promises': [ 26 | 'error', 27 | { checksVoidReturn: { attributes: false } } 28 | ], 29 | 'no-console': ['error', { allow: ['info', 'warn', 'trace', 'error'] }] 30 | }, 31 | overrides: [ 32 | { 33 | files: ['./packages/database/**/*'], 34 | plugins: ['@u22n/custom'], 35 | rules: { '@u22n/custom/table-needs-org-id': 'error' } 36 | }, 37 | { 38 | files: ['./apps/web/**/*'], 39 | extends: ['next/core-web-vitals'], 40 | rules: { 41 | 'react/no-children-prop': ['warn', { allowFunctions: true }], 42 | '@next/next/no-img-element': 'off' 43 | } 44 | } 45 | ] 46 | }; 47 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 2 | 3 | # These owners will be the default owners for everything in 4 | # the repo. Unless a later match takes precedence, 5 | * @mcpizza0 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | 4 | 5 | Fixes # (issue) 6 | 7 | _If there is not an issue for this, please create one first. This is used to tracking purposes and also helps use understand why this PR exists_ 8 | 9 | 10 | 11 | ## Type of change 12 | 13 | 14 | 15 | - [ ] Bug fix (non-breaking change which fixes an issue) 16 | - [ ] Chore (refactoring code, technical debt, workflow improvements) 17 | - [ ] Enhancement (small improvements) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | - [ ] This change requires a documentation update 21 | 22 | ## Checklist 23 | 24 | 25 | 26 | ### Required 27 | 28 | - [ ] Read [Contributing Guide](https://github.com/un/inbox/blob/main/CONTRIBUTING.md) 29 | - [ ] Self-reviewed my own code 30 | - [ ] Tested my code in a local environment 31 | - [ ] Commented on my code in hard-to-understand areas 32 | - [ ] Checked for warnings, there are none 33 | - [ ] Removed all `console.logs` 34 | - [ ] Merged the latest changes from main onto my branch with `git pull origin main` 35 | - [ ] My changes don't cause any responsiveness issues 36 | 37 | ### Appreciated 38 | 39 | - [ ] If a UI change was made: Added a screen recording or screenshots to this PR 40 | - [ ] Updated the UnInbox Docs if changes were necessary 41 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci 2 | on: 3 | pull_request: 4 | types: [review_requested, ready_for_review] 5 | push: 6 | branches: ['main'] 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | autofix: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v3 18 | 19 | - name: Install Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | cache: 'pnpm' 23 | node-version: '20' 24 | 25 | - run: pnpm install 26 | 27 | - name: Run ESLint 28 | run: pnpm fix 29 | 30 | - name: Run Prettier 31 | run: pnpm format 32 | 33 | - uses: autofix-ci/action@dd55f44df8f7cdb7a6bf74c78677eb8acd40cd0a 34 | -------------------------------------------------------------------------------- /.github/workflows/check_and_build_pull_requests.yaml: -------------------------------------------------------------------------------- 1 | name: check-and-build-pull-requests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | concurrency: 13 | cancel-in-progress: true 14 | group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha }} 15 | 16 | jobs: 17 | check: 18 | name: Check and Build 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 15 21 | steps: 22 | - name: Checkout Code 🛎 23 | uses: actions/checkout@v4 24 | 25 | - name: Cache turbo build setup 🚀 26 | uses: actions/cache@v4 27 | with: 28 | path: .turbo 29 | key: ${{ runner.os }}-turbo-${{ github.sha }} 30 | restore-keys: | 31 | ${{ runner.os }}-turbo- 32 | 33 | - name: Setup pnpm 📦 34 | uses: pnpm/action-setup@v3 35 | 36 | - name: Setup Node.js 🟩 37 | uses: actions/setup-node@v4 38 | with: 39 | cache: 'pnpm' 40 | node-version: '20' 41 | 42 | - name: Install Dependencies 📦 43 | run: pnpm install 44 | 45 | - name: Copy Example Env 📝 46 | run: cp .env.local.example .env.local 47 | 48 | - name: Check 🚨 49 | run: pnpm check 50 | 51 | - name: Build 🏗 52 | run: pnpm build:all 53 | -------------------------------------------------------------------------------- /.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.js 7 | package-lock.json 8 | 9 | # common build outputs 10 | .cache 11 | .output 12 | dist 13 | 14 | # misc 15 | .DS_Store 16 | *.pem 17 | .idea 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .pnpm-debug.log* 24 | *.log* 25 | 26 | # local env files 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # turbo 34 | .turbo 35 | 36 | # next.js 37 | .next 38 | out 39 | next-env.d.ts 40 | 41 | # typescript 42 | *.tsbuildinfo -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged -------------------------------------------------------------------------------- /.infisical.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspaceId": "6488789573de3b388fec7d44", 3 | "defaultEnvironment": "local", 4 | "gitBranchToEnvironmentMapping": null 5 | } 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.15 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .turbo 3 | .pnpm-store 4 | pnpm-lock.yaml 5 | .next 6 | packages/database/migrations -------------------------------------------------------------------------------- /.vscode/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": ["package.json"], 4 | "dictionaryDefinitions": [], 5 | "dictionaries": [], 6 | "words": [ 7 | "Authed", 8 | "bullmq", 9 | "Commenters", 10 | "composables", 11 | "convo", 12 | "convos", 13 | "dmarc", 14 | "domainkey", 15 | "hono", 16 | "hookform", 17 | "MAILBRIDGE", 18 | "mailfetch", 19 | "mailserver", 20 | "mediumint", 21 | "opentelemetry", 22 | "otel", 23 | "partialize", 24 | "planetscale", 25 | "posthog", 26 | "presign", 27 | "presigner", 28 | "ratelimit", 29 | "Ratelimiter", 30 | "RPID", 31 | "shadcn", 32 | "Shortcode", 33 | "Shortcodes", 34 | "simplewebauthn", 35 | "sonner", 36 | "starttls", 37 | "superjson", 38 | "tanstack", 39 | "tinyint", 40 | "tiptap", 41 | "totp", 42 | "trpc", 43 | "Typesafe", 44 | "unboarding", 45 | "Unin", 46 | "uninbox", 47 | "unkey", 48 | "unstorage", 49 | "unvalidated", 50 | "waitlist", 51 | "zustand", 52 | "zxcvbn" 53 | ], 54 | "ignoreWords": ["ABCDEFGHJKMNPQRSTVWXYZ"], 55 | "import": [] 56 | } 57 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "amirha.better-comments-2", // Comment Highlighting for important/explainer comments 4 | "yzhang.markdown-all-in-one", // Markdown support 5 | "dbaeumer.vscode-eslint", // eslint plugin 6 | "streetsidesoftware.code-spell-checker", // Spell checker with custom dict 7 | "esbenp.prettier-vscode" // Prettier plugin 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": {}, 3 | "better-comments.tags": [ 4 | { 5 | "tag": "!", 6 | "color": "#FF2D00", 7 | "strikethrough": false, 8 | "underline": false, 9 | "backgroundColor": "transparent", 10 | "bold": false, 11 | "italic": false 12 | }, 13 | { 14 | "tag": "?", 15 | "color": "#3498DB", 16 | "strikethrough": false, 17 | "underline": false, 18 | "backgroundColor": "transparent", 19 | "bold": false, 20 | "italic": false 21 | }, 22 | { 23 | "tag": "//", 24 | "color": "#474747", 25 | "strikethrough": true, 26 | "underline": false, 27 | "backgroundColor": "transparent", 28 | "bold": false, 29 | "italic": false 30 | }, 31 | { 32 | "tag": "todo", 33 | "color": "#FF8C00", 34 | "strikethrough": false, 35 | "underline": false, 36 | "backgroundColor": "transparent", 37 | "bold": false, 38 | "italic": false 39 | }, 40 | { 41 | "tag": "self", 42 | "color": "#FF8C00", 43 | "strikethrough": false, 44 | "underline": false, 45 | "backgroundColor": "transparent", 46 | "bold": false, 47 | "italic": true 48 | }, 49 | { 50 | "tag": "fixme", 51 | "color": "#FF2D00", 52 | "strikethrough": false, 53 | "underline": true, 54 | "backgroundColor": "transparent", 55 | "bold": true, 56 | "italic": false 57 | }, 58 | { 59 | "tag": "*", 60 | "color": "#98C379", 61 | "strikethrough": false, 62 | "underline": false, 63 | "backgroundColor": "transparent", 64 | "bold": false, 65 | "italic": false 66 | } 67 | ], 68 | "typescript.tsdk": "node_modules/typescript/lib", 69 | "typescript.preferences.autoImportSpecifierExcludeRegexes": [ 70 | "^@radix-ui/.*$", 71 | "^next/router$", 72 | "^next/dist.*$", 73 | "^next/font.*$", 74 | "^@phosphor-icons/react/dist/.*$" 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /.vscode/uninbox-code-snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "DrizzleORM Ops": { 3 | "prefix": "orm", 4 | "description": "Import the conditional operators from Drizzle ORM via @u22n/database", 5 | "scope": "", 6 | "body": ["import { $1 } from '@u22n/database/orm'"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/mail-bridge/ctx.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '@u22n/hono/helpers'; 2 | import type { HonoContext } from '@u22n/hono'; 3 | import type { db } from '@u22n/database'; 4 | import type { env } from './env'; 5 | 6 | export type Ctx = HonoContext; 7 | 8 | export type TRPCContext = { 9 | auth: boolean; 10 | db: typeof db; 11 | config: typeof env; 12 | context: Context; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/mail-bridge/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u22n/mail-bridge", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "dev": "tsx watch --clear-screen=false --import ./tracing.ts app.ts", 8 | "start": "node --import ./.output/tracing.js .output/app.js", 9 | "build": "tsup", 10 | "check": "tsc --noEmit", 11 | "mock:incoming-mail": "tsx ./scripts/mock-incoming.ts", 12 | "stress:email": "tsx ./scripts/email-stress-test.ts" 13 | }, 14 | "exports": { 15 | "./trpc": { 16 | "types": "./trpc/index.ts" 17 | } 18 | }, 19 | "dependencies": { 20 | "@t3-oss/env-core": "^0.11.0", 21 | "@trpc/client": "11.0.0-rc.485", 22 | "@trpc/server": "11.0.0-rc.485", 23 | "@u22n/database": "workspace:*", 24 | "@u22n/hono": "workspace:^", 25 | "@u22n/mailtools": "^0.1.2", 26 | "@u22n/otel": "workspace:^", 27 | "@u22n/realtime": "workspace:^", 28 | "@u22n/tiptap": "workspace:^", 29 | "@u22n/utils": "workspace:*", 30 | "bullmq": "^5.12.10", 31 | "dompurify": "^3.1.6", 32 | "drizzle-orm": "^0.33.0", 33 | "jsdom": "^24.1.1", 34 | "mailauth": "^4.6.9", 35 | "mailparser": "^3.7.1", 36 | "mime": "^4.0.4", 37 | "mysql2": "^3.11.0", 38 | "nanoid": "^5.0.7", 39 | "nodemailer": "^6.9.14", 40 | "superjson": "^2.2.1", 41 | "zod": "^3.23.8" 42 | }, 43 | "devDependencies": { 44 | "@clack/prompts": "^0.7.0", 45 | "@types/dompurify": "^3.0.5", 46 | "@types/jsdom": "^21.1.7", 47 | "@types/mailparser": "^3.4.4", 48 | "@types/nodemailer": "^6.4.15", 49 | "tsup": "^8.2.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/mail-bridge/postal-db/index.ts: -------------------------------------------------------------------------------- 1 | import { activePostalServer, env } from '../env'; 2 | import { drizzle } from 'drizzle-orm/mysql2'; 3 | import mysql from 'mysql2/promise'; 4 | import * as schema from './schema'; 5 | 6 | const isLocal = env.MAILBRIDGE_LOCAL_MODE; 7 | export const connection = mysql.createPool({ 8 | uri: isLocal 9 | ? // we actually don't use the db in local mode, it is set to the local docker db to avoid throwing connection errors 10 | env.DB_MYSQL_MIGRATION_URL 11 | : `${activePostalServer.dbConnectionString}/postal`, 12 | multipleStatements: true 13 | }); 14 | 15 | export const postalDB = drizzle(connection, { schema, mode: 'default' }); 16 | -------------------------------------------------------------------------------- /apps/mail-bridge/postal-routes/events.ts: -------------------------------------------------------------------------------- 1 | import { createHonoApp } from '@u22n/hono'; 2 | 3 | export const eventApi = createHonoApp().post( 4 | '/events/:params{.+}', 5 | async (c) => { 6 | return c.json({ error: 'Not implemented' }, 400); 7 | } 8 | ); 9 | -------------------------------------------------------------------------------- /apps/mail-bridge/postal-routes/inbound.ts: -------------------------------------------------------------------------------- 1 | import { postalMessageSchema, mailParamsSchema } from '../queue/mail-processor'; 2 | import { mailProcessorQueue } from '../queue/mail-processor'; 3 | import { zValidator } from '@u22n/hono/helpers'; 4 | import { createHonoApp } from '@u22n/hono'; 5 | import type { Ctx } from '../ctx'; 6 | 7 | export const inboundApi = createHonoApp(); 8 | 9 | inboundApi.post( 10 | '/mail/inbound/:orgId/:mailserverId', 11 | zValidator('json', postalMessageSchema), 12 | zValidator('param', mailParamsSchema), 13 | async (c) => { 14 | await mailProcessorQueue.add(`mail-processor`, { 15 | rawMessage: c.req.valid('json'), 16 | params: c.req.valid('param') 17 | }); 18 | return c.text('OK', 200); 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /apps/mail-bridge/postal-routes/signature-middleware.ts: -------------------------------------------------------------------------------- 1 | import { validatePostalWebhookSignature } from '../utils/validatePostalWebhookSignature'; 2 | import { createMiddleware } from '@u22n/hono/helpers'; 3 | import { getTracer } from '@u22n/otel/helpers'; 4 | import { flatten } from '@u22n/otel/exports'; 5 | import type { Ctx } from '../ctx'; 6 | import { env } from '../env'; 7 | 8 | const middlewareTracer = getTracer('mail-bridge/hono/middleware'); 9 | 10 | export const signatureMiddleware = createMiddleware(async (c, next) => 11 | middlewareTracer.startActiveSpan( 12 | 'Postal Signature Middleware', 13 | async (span) => { 14 | if (c.req.method !== 'POST') { 15 | span?.recordException(new Error(`Method not allowed, ${c.req.method}`)); 16 | return c.json({ message: 'Method not allowed' }, 405); 17 | } 18 | const body = (await c.req.json().catch(() => ({}))) as unknown; 19 | const signature = c.req.header('x-postal-signature'); 20 | if (!signature) { 21 | span?.recordException(new Error('Missing signature')); 22 | return c.json({ message: 'Missing signature' }, 401); 23 | } 24 | const publicKeys = env.MAILBRIDGE_POSTAL_SERVERS.map( 25 | (server) => server.webhookPubKey 26 | ); 27 | const valid = await validatePostalWebhookSignature( 28 | body, 29 | signature, 30 | publicKeys 31 | ); 32 | if (!valid) { 33 | span?.setAttributes( 34 | flatten({ 35 | 'req.signature.meta': { 36 | valid: false, 37 | body: body, 38 | signature: signature 39 | } 40 | }) 41 | ); 42 | span?.recordException(new Error('Invalid signature')); 43 | return c.json({ message: 'Invalid signature' }, 401); 44 | } 45 | span?.setAttribute('req.signature.meta.valid', true); 46 | 47 | await next(); 48 | } 49 | ) 50 | ); 51 | -------------------------------------------------------------------------------- /apps/mail-bridge/smtp/auth.ts: -------------------------------------------------------------------------------- 1 | import { createTransport } from 'nodemailer'; 2 | 3 | export type AuthOptions = { 4 | host: string; 5 | port?: number; 6 | username: string; 7 | password: string; 8 | encryption: 'ssl' | 'tls' | 'starttls' | 'none'; 9 | authMethod: 'plain' | 'login'; 10 | }; 11 | 12 | export async function validateSmtpCredentials({ 13 | host, 14 | port, 15 | username, 16 | password, 17 | encryption, 18 | authMethod 19 | }: AuthOptions) { 20 | const transport = createTransport({ 21 | host, 22 | port, 23 | secure: encryption === 'ssl' || encryption === 'tls', 24 | auth: { 25 | user: username, 26 | pass: password, 27 | method: authMethod.toUpperCase() 28 | } 29 | }); 30 | const status = await transport 31 | .verify() 32 | .then(() => ({ valid: true, error: null }) as const) 33 | .catch( 34 | (e: Error) => 35 | ({ 36 | valid: false, 37 | error: e.message 38 | }) as const 39 | ); 40 | transport.close(); 41 | return status; 42 | } 43 | -------------------------------------------------------------------------------- /apps/mail-bridge/smtp/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import { createTransport } from 'nodemailer'; 2 | import type { AuthOptions } from './auth'; 3 | 4 | export type SendEmailOptions = { 5 | to: string[]; 6 | from: string; 7 | raw: string; 8 | }; 9 | 10 | export async function sendEmail({ 11 | auth: { host, port, username, password, encryption, authMethod }, 12 | email: { to, from, raw } 13 | }: { 14 | auth: AuthOptions; 15 | email: SendEmailOptions; 16 | }) { 17 | const transport = createTransport({ 18 | host, 19 | port, 20 | secure: encryption === 'ssl' || encryption === 'tls', 21 | auth: { 22 | user: username, 23 | pass: password, 24 | method: authMethod.toUpperCase() 25 | } 26 | }); 27 | const res = await transport.sendMail({ 28 | envelope: { to, from }, 29 | raw 30 | }); 31 | return res; 32 | } 33 | -------------------------------------------------------------------------------- /apps/mail-bridge/tracing.ts: -------------------------------------------------------------------------------- 1 | import { opentelemetryEnabled } from '@u22n/otel'; 2 | import { name, version } from './package.json'; 3 | 4 | if (opentelemetryEnabled) { 5 | const { setupOpentelemetry } = await import('@u22n/otel/setup'); 6 | setupOpentelemetry({ name, version }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/mail-bridge/trpc/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@trpc/server'; 2 | import { sendMailRouter } from './routers/sendMailRouter'; 3 | import { domainRouter } from './routers/domainRouter'; 4 | import { smtpRouter } from './routers/smtpRouter'; 5 | import { orgRouter } from './routers/orgRouter'; 6 | import { router } from './trpc'; 7 | 8 | export const trpcMailBridgePostalRouter = router({ 9 | org: orgRouter, 10 | domains: domainRouter 11 | }); 12 | export const trpcMailBridgeMailRouter = router({ 13 | send: sendMailRouter 14 | }); 15 | export const trpcMailBridgeRouter = router({ 16 | mail: trpcMailBridgeMailRouter, 17 | postal: trpcMailBridgePostalRouter, 18 | smtp: smtpRouter 19 | }); 20 | 21 | export type TrpcMailBridgeRouter = typeof trpcMailBridgeRouter; 22 | -------------------------------------------------------------------------------- /apps/mail-bridge/trpc/routers/smtpRouter.ts: -------------------------------------------------------------------------------- 1 | import { validateSmtpCredentials } from '../../smtp/auth'; 2 | import { protectedProcedure, router } from '../trpc'; 3 | import { z } from 'zod'; 4 | 5 | export const smtpRouter = router({ 6 | validateSmtpCredentials: protectedProcedure 7 | .input( 8 | z.object({ 9 | host: z.string(), 10 | port: z.number(), 11 | username: z.string(), 12 | password: z.string(), 13 | encryption: z.enum(['none', 'ssl', 'tls', 'starttls']), 14 | authMethod: z.enum(['plain', 'login']) 15 | }) 16 | ) 17 | .query(async ({ input }) => { 18 | const result = await validateSmtpCredentials(input); 19 | 20 | return { result }; 21 | }) 22 | }); 23 | -------------------------------------------------------------------------------- /apps/mail-bridge/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import { getTracer } from '@u22n/otel/helpers'; 3 | import { flatten } from '@u22n/otel/exports'; 4 | import type { TRPCContext } from '../ctx'; 5 | import superjson from 'superjson'; 6 | 7 | export const trpcContext = initTRPC 8 | .context() 9 | .create({ transformer: superjson }); 10 | 11 | const isServiceAuthenticated = trpcContext.middleware(({ next, ctx }) => { 12 | if (!ctx.auth) { 13 | throw new TRPCError({ code: 'UNAUTHORIZED' }); 14 | } 15 | return next({ ctx }); 16 | }); 17 | 18 | const trpcTracer = getTracer('mail-bridge/trpc'); 19 | export const publicProcedure = trpcContext.procedure.use( 20 | async ({ type, path, next }) => 21 | trpcTracer.startActiveSpan(`TRPC ${type} ${path}`, async (span) => { 22 | const result = await next(); 23 | if (span) { 24 | span.setAttributes( 25 | flatten({ 26 | trpc: { 27 | type: type, 28 | path: path, 29 | ok: result.ok 30 | } 31 | }) 32 | ); 33 | } 34 | return result; 35 | }) 36 | ); 37 | export const protectedProcedure = publicProcedure.use(isServiceAuthenticated); 38 | export const router = trpcContext.router; 39 | export const middleware = trpcContext.middleware; 40 | -------------------------------------------------------------------------------- /apps/mail-bridge/tsconfig.json: -------------------------------------------------------------------------------- 1 | { "extends": "@u22n/tsconfig" } 2 | -------------------------------------------------------------------------------- /apps/mail-bridge/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['app.ts', 'tracing.ts'], 5 | outDir: '.output', 6 | format: 'esm', 7 | target: 'esnext', 8 | clean: true, 9 | bundle: true, 10 | treeshake: true, 11 | noExternal: [/^@u22n\/.*/], 12 | minify: false, 13 | keepNames: true, 14 | banner: { 15 | js: [ 16 | `import { createRequire } from 'module';`, 17 | `const require = createRequire(import.meta.url);` 18 | ].join('\n') 19 | }, 20 | esbuildOptions: (options) => { 21 | options.legalComments = 'none'; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /apps/mail-bridge/types.ts: -------------------------------------------------------------------------------- 1 | // exported types 2 | import { type trpcMailBridgeRouter } from './trpc'; 3 | export type TrpcMailBridgeRouter = typeof trpcMailBridgeRouter; 4 | 5 | export interface postalEmailPayload { 6 | id: number; 7 | rcpt_to: string; 8 | mail_from: string; 9 | message: string; 10 | base64: boolean; 11 | size: number; 12 | } 13 | 14 | export interface MessageParseAddressPlatformObject { 15 | id: number; 16 | type: 'contact' | 'emailIdentity'; 17 | publicId: string; 18 | email: string; 19 | contactType: 20 | | 'person' 21 | | 'product' 22 | | 'newsletter' 23 | | 'marketing' 24 | | 'unknown' 25 | | null; 26 | ref: 'to' | 'cc' | 'from'; 27 | } 28 | 29 | // Runtime Config Types 30 | export interface MailDomains { 31 | free: string[]; 32 | premium: string[]; 33 | fwd: string[]; 34 | } 35 | 36 | export interface EnvPostalServersObject { 37 | url: string; 38 | controlPanelSubDomain: string; 39 | ipv4: string; 40 | ipv6: string; 41 | webhookPubKey: string; 42 | dbConnectionString: string; 43 | defaultNewPool: string; 44 | active: boolean; 45 | routesDomain: string; 46 | } 47 | 48 | export interface EnvPostalServerPersonalCredentials { 49 | apiUrl: string; 50 | apiKey: string; 51 | } 52 | 53 | export interface EnvPostalServerLimits { 54 | messageRetentionDays: number; 55 | outboundSpamThreshold: number; 56 | rawMessageRetentionDays: number; 57 | rawMessageRetentionSize: number; 58 | } 59 | 60 | export interface EnvPostalWebhookDestinations { 61 | events: string; 62 | messages: string; 63 | } 64 | 65 | export type PostalConfig = { 66 | servers: EnvPostalServersObject[]; 67 | activeServers: EnvPostalServersObject; 68 | personalServerCredentials: EnvPostalServerPersonalCredentials; 69 | dnsRootUrl: string; 70 | webhookDestinations: EnvPostalWebhookDestinations; 71 | limits: EnvPostalServerLimits; 72 | localMode: boolean; 73 | }; 74 | -------------------------------------------------------------------------------- /apps/mail-bridge/utils/purify.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | import { JSDOM } from 'jsdom'; 3 | 4 | const window = new JSDOM('').window; 5 | const purify = DOMPurify(window); 6 | 7 | export const sanitize = (html: string) => 8 | purify.sanitize(html, { USE_PROFILES: { html: true } }); 9 | -------------------------------------------------------------------------------- /apps/mail-bridge/utils/queue-helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Queue, 3 | Worker, 4 | type Job, 5 | type QueueOptions, 6 | type WorkerOptions 7 | } from 'bullmq'; 8 | import { env } from '../env'; 9 | 10 | const { host, username, password, port } = new URL( 11 | env.DB_REDIS_CONNECTION_STRING 12 | ); 13 | 14 | const connection = { 15 | host: host.split(':')[0], 16 | port: Number(port), 17 | username, 18 | password 19 | }; 20 | 21 | export function createQueue( 22 | name: string, 23 | options: Omit = {} 24 | ) { 25 | const queue = new Queue(name, { 26 | connection, 27 | ...options 28 | }); 29 | return queue; 30 | } 31 | 32 | export function createWorker( 33 | name: string, 34 | jobHandler: (job: Job) => Promise, 35 | options: Omit = {} 36 | ) { 37 | const worker = new Worker(name, jobHandler, { 38 | connection, 39 | ...options 40 | }); 41 | worker.on('error', (error) => { 42 | console.error(`Worker for queue ${name} encountered an error:`, error); 43 | }); 44 | return worker; 45 | } 46 | -------------------------------------------------------------------------------- /apps/mail-bridge/utils/tiptap-utils.ts: -------------------------------------------------------------------------------- 1 | import type { JSONContent } from '@u22n/tiptap/react'; 2 | import { validateTypeId } from '@u22n/utils/typeid'; 3 | 4 | export function walkAndReplaceImages( 5 | jsonContent: JSONContent, 6 | callback: (url: string) => string 7 | ) { 8 | for (const element of jsonContent.content ?? []) { 9 | if (element.type === 'image' && typeof element.attrs?.src === 'string') { 10 | const newUrl = callback(element.attrs.src); 11 | if (element.attrs) { 12 | element.attrs.src = newUrl; 13 | } 14 | } 15 | if (element.content) { 16 | walkAndReplaceImages(element, callback); 17 | } 18 | } 19 | } 20 | 21 | export function tryParseInlineAttachmentUrl(url: string) { 22 | try { 23 | const urlObject = new URL(url); 24 | const [base, orgShortcode, attachmentPublicId, fileName] = 25 | urlObject.pathname.split('/').splice(1); 26 | 27 | if ( 28 | base !== 'attachment' || 29 | !orgShortcode || 30 | !validateTypeId('convoAttachments', attachmentPublicId) || 31 | !fileName 32 | ) 33 | return null; 34 | 35 | return { 36 | orgShortcode, 37 | attachmentPublicId, 38 | fileName 39 | }; 40 | } catch (e) { 41 | return null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /apps/platform/app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createHonoApp, 3 | setupCors, 4 | setupErrorHandlers, 5 | setupHealthReporting, 6 | setupHonoListener, 7 | setupRouteLogger, 8 | setupRuntime, 9 | setupTrpcHandler 10 | } from '@u22n/hono'; 11 | import { authMiddleware, serviceMiddleware } from './middlewares'; 12 | import { realtimeApi } from './routes/realtime'; 13 | import { servicesApi } from './routes/services'; 14 | import { opentelemetry } from '@u22n/otel/hono'; 15 | import type { Ctx, TrpcContext } from './ctx'; 16 | import { trpcPlatformRouter } from './trpc'; 17 | import { authApi } from './routes/auth'; 18 | import { db } from '@u22n/database'; 19 | import { env } from './env'; 20 | 21 | const app = createHonoApp(); 22 | 23 | app.use(opentelemetry('platform/hono')); 24 | 25 | setupRouteLogger(app, env.NODE_ENV === 'development'); 26 | setupCors(app, { origin: [env.WEBAPP_URL], exposeHeaders: ['Location'] }); 27 | setupHealthReporting(app, { service: 'Platform' }); 28 | setupErrorHandlers(app); 29 | 30 | // Auth middleware 31 | app.use('*', authMiddleware); 32 | 33 | setupTrpcHandler( 34 | app, 35 | trpcPlatformRouter, 36 | (_, c) => 37 | ({ 38 | db, 39 | account: c.get('account'), 40 | org: null, 41 | event: c, 42 | selfHosted: !env.EE_LICENSE_KEY 43 | }) satisfies TrpcContext 44 | ); 45 | 46 | // Routes 47 | app.route('/auth', authApi); 48 | app.route('/realtime', realtimeApi); 49 | // Service Endpoints 50 | app.use('/services/*', serviceMiddleware); 51 | app.route('/services', servicesApi); 52 | 53 | const cleanup = setupHonoListener(app, { port: env.PORT }); 54 | setupRuntime([cleanup]); 55 | -------------------------------------------------------------------------------- /apps/platform/ctx.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '@u22n/hono/helpers'; 2 | import type { TypeId } from '@u22n/utils/typeid'; 3 | import type { HonoContext } from '@u22n/hono'; 4 | import type { DBType } from '@u22n/database'; 5 | import type { DatabaseSession } from 'lucia'; 6 | 7 | export type Ctx = HonoContext<{ 8 | account: AccountContext; 9 | }>; 10 | 11 | export type OrgContext = { 12 | id: number; 13 | publicId: TypeId<'org'>; 14 | name: string; 15 | memberId?: number; 16 | members: { 17 | id: number; 18 | accountId: number | null; 19 | // Refer to DB schema orgMembers.role and orgMembers.status 20 | status: 'invited' | 'active' | 'removed'; 21 | role: 'admin' | 'member'; 22 | }[]; 23 | } | null; 24 | 25 | export type AccountContext = { 26 | id: number; 27 | session: DatabaseSession; 28 | } | null; 29 | 30 | export type TrpcContext = { 31 | db: DBType; 32 | account: AccountContext; 33 | org: OrgContext; 34 | event: Context; 35 | selfHosted: boolean; 36 | }; 37 | -------------------------------------------------------------------------------- /apps/platform/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { createMiddleware, getCookie } from '@u22n/hono/helpers'; 2 | import { COOKIE_SESSION } from './utils/cookieNames'; 3 | import { getTracer } from '@u22n/otel/helpers'; 4 | import { flatten } from '@u22n/otel/exports'; 5 | import { storage } from './storage'; 6 | import type { Ctx } from './ctx'; 7 | import { env } from './env'; 8 | 9 | const middlewareTracer = getTracer('platform/hono/middleware'); 10 | 11 | export const authMiddleware = createMiddleware(async (c, next) => 12 | middlewareTracer.startActiveSpan('Auth Middleware', async (span) => { 13 | const sessionCookie = getCookie(c, COOKIE_SESSION); 14 | span?.setAttribute('req.auth.meta.has_cookie', !!sessionCookie); 15 | if (!sessionCookie) { 16 | c.set('account', null); 17 | } else { 18 | const sessionObject = await storage.session.getItem(sessionCookie); 19 | 20 | if (sessionObject) { 21 | span?.setAttributes( 22 | flatten({ 23 | 'req.auth.meta': { 24 | account_public_id: sessionObject.attributes.account.publicId, 25 | account_username: sessionObject?.attributes.account.username 26 | } 27 | }) 28 | ); 29 | } 30 | 31 | c.set( 32 | 'account', 33 | !sessionObject 34 | ? null 35 | : { 36 | id: sessionObject.attributes.account.id, 37 | session: sessionObject 38 | } 39 | ); 40 | } 41 | 42 | return next(); 43 | }) 44 | ); 45 | 46 | export const serviceMiddleware = createMiddleware(async (c, next) => 47 | middlewareTracer.startActiveSpan('Service Middleware', async (span) => { 48 | const authToken = c.req.header('Authorization'); 49 | span?.setAttribute('req.service.meta.has_header', !!authToken); 50 | if (authToken !== env.WORKER_ACCESS_KEY) { 51 | return c.text('Unauthorized', 401); 52 | } 53 | await next(); 54 | }) 55 | ); 56 | -------------------------------------------------------------------------------- /apps/platform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u22n/platform", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "dev": "tsx watch --clear-screen=false --import ./tracing.ts app.ts", 8 | "start": "node --import ./.output/tracing.js .output/app.js", 9 | "build": "tsup", 10 | "check": "tsc --noEmit" 11 | }, 12 | "exports": { 13 | "./trpc": { 14 | "types": "./trpc/index.ts" 15 | } 16 | }, 17 | "dependencies": { 18 | "@simplewebauthn/server": "^9.0.3", 19 | "@t3-oss/env-core": "^0.11.0", 20 | "@trpc/client": "11.0.0-rc.485", 21 | "@trpc/server": "11.0.0-rc.485", 22 | "@u22n/database": "workspace:^", 23 | "@u22n/hono": "workspace:^", 24 | "@u22n/otel": "workspace:^", 25 | "@u22n/realtime": "workspace:^", 26 | "@u22n/tiptap": "workspace:^", 27 | "@u22n/utils": "workspace:^", 28 | "@unkey/ratelimit": "^0.4.3", 29 | "lucia": "^3.2.0", 30 | "oslo": "^1.2.1", 31 | "superjson": "^2.2.1", 32 | "ua-parser-js": "2.0.0-beta.3", 33 | "unstorage": "^1.10.2", 34 | "zod": "^3.23.8" 35 | }, 36 | "devDependencies": { 37 | "@simplewebauthn/types": "^9.0.1", 38 | "@u22n/mail-bridge": "workspace:^", 39 | "@uninbox-ee/billing": "workspace:^", 40 | "tsup": "^8.2.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/platform/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { accounts } from '@u22n/database/schema'; 2 | import { setCookie } from '@u22n/hono/helpers'; 3 | import { lucia } from '~platform/utils/auth'; 4 | import { createHonoApp } from '@u22n/hono'; 5 | import type { Ctx } from '~platform/ctx'; 6 | import { eq } from '@u22n/database/orm'; 7 | import { db } from '@u22n/database'; 8 | 9 | export const authApi = createHonoApp(); 10 | 11 | authApi.get('/status', async (c) => { 12 | const account = c.get('account'); 13 | if (!account) { 14 | return c.json({ authStatus: 'unauthenticated' }); 15 | } 16 | return c.json({ authStatus: 'authenticated' }); 17 | }); 18 | 19 | authApi.get('/redirection', async (c) => { 20 | const account = c.get('account'); 21 | if (!account) { 22 | return c.json({ defaultOrgShortcode: null }, 401); 23 | } 24 | 25 | const accountId = account.id; 26 | const accountResponse = await db.query.accounts.findFirst({ 27 | where: eq(accounts.id, accountId), 28 | columns: {}, 29 | with: { 30 | orgMemberships: { 31 | columns: {}, 32 | with: { 33 | org: { 34 | columns: { 35 | shortcode: true 36 | } 37 | } 38 | } 39 | } 40 | } 41 | }); 42 | 43 | if (!accountResponse) { 44 | return c.json({ error: 'User not found' }, 403); 45 | } 46 | 47 | return c.json({ 48 | defaultOrgShortcode: 49 | accountResponse?.orgMemberships[0]?.org?.shortcode ?? null 50 | }); 51 | }); 52 | 53 | authApi.post('/logout', async (c) => { 54 | const account = c.get('account'); 55 | if (!account) { 56 | return c.json({ ok: true }); 57 | } 58 | const sessionId = account.session.id; 59 | await lucia.invalidateSession(sessionId); 60 | const cookie = lucia.createBlankSessionCookie(); 61 | setCookie(c, cookie.name, cookie.value, cookie.attributes); 62 | return c.json({ ok: true }); 63 | }); 64 | -------------------------------------------------------------------------------- /apps/platform/routes/services.ts: -------------------------------------------------------------------------------- 1 | import { updateDnsRecords } from '~platform/utils/updateDnsRecords'; 2 | import { typeIdValidator } from '@u22n/utils/typeid'; 3 | import { zValidator } from '@u22n/hono/helpers'; 4 | import { createHonoApp } from '@u22n/hono'; 5 | import type { Ctx } from '~platform/ctx'; 6 | import { db } from '@u22n/database'; 7 | import { z } from 'zod'; 8 | 9 | export const servicesApi = createHonoApp(); 10 | 11 | servicesApi.post( 12 | '/dns-check', 13 | zValidator( 14 | 'json', 15 | z.object({ 16 | orgId: z.number(), 17 | domainPublicId: typeIdValidator('domains') 18 | }) 19 | ), 20 | async (c) => { 21 | const { orgId, domainPublicId } = c.req.valid('json'); 22 | const results = await updateDnsRecords({ domainPublicId, orgId }, db).catch( 23 | (e: Error) => ({ error: e.message }) 24 | ); 25 | return c.json({ results }); 26 | } 27 | ); 28 | -------------------------------------------------------------------------------- /apps/platform/tracing.ts: -------------------------------------------------------------------------------- 1 | import { opentelemetryEnabled } from '@u22n/otel'; 2 | import { name, version } from './package.json'; 3 | 4 | if (opentelemetryEnabled) { 5 | const { setupOpentelemetry } = await import('@u22n/otel/setup'); 6 | setupOpentelemetry({ name, version }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/platform/trpc/routers/contactRouter/contactRouter.ts: -------------------------------------------------------------------------------- 1 | import { router, orgProcedure } from '~platform/trpc/trpc'; 2 | import { contacts } from '@u22n/database/schema'; 3 | import { eq } from '@u22n/database/orm'; 4 | 5 | export const contactsRouter = router({ 6 | getOrgContacts: orgProcedure.query(async ({ ctx }) => { 7 | const { db, org } = ctx; 8 | const orgId = org.id; 9 | 10 | const orgContactsResponse = await db.query.contacts.findMany({ 11 | where: eq(contacts.orgId, orgId), 12 | columns: { 13 | publicId: true, 14 | avatarTimestamp: true, 15 | emailUsername: true, 16 | emailDomain: true, 17 | name: true, 18 | setName: true, 19 | screenerStatus: true 20 | } 21 | }); 22 | 23 | return { contacts: orgContactsResponse }; 24 | }) 25 | }); 26 | -------------------------------------------------------------------------------- /apps/platform/trpc/routers/orgRouter/iCanHaz/iCanHazRouter.ts: -------------------------------------------------------------------------------- 1 | import { router, orgProcedure, createCallerFactory } from '~platform/trpc/trpc'; 2 | import { billingTrpcClient } from '~platform/utils/tRPCServerClients'; 3 | 4 | export const iCanHazRouter = router({ 5 | billing: orgProcedure.query(async ({ ctx }) => { 6 | const { selfHosted } = ctx; 7 | if (selfHosted) { 8 | return false; 9 | } 10 | return await billingTrpcClient.iCanHaz.billing.query(); 11 | }), 12 | domain: orgProcedure.query(async ({ ctx }) => { 13 | const { org, selfHosted } = ctx; 14 | if (selfHosted) { 15 | return true; 16 | } 17 | return await billingTrpcClient.iCanHaz.domain.query({ orgId: org.id }); 18 | }), 19 | team: orgProcedure.query(async ({ ctx }) => { 20 | const { org, selfHosted } = ctx; 21 | if (selfHosted) { 22 | return true; 23 | } 24 | return await billingTrpcClient.iCanHaz.team.query({ orgId: org.id }); 25 | }), 26 | space: orgProcedure.query(async ({ ctx }) => { 27 | const { org, selfHosted } = ctx; 28 | if (selfHosted) { 29 | return { 30 | open: true, 31 | private: true 32 | }; 33 | } 34 | return await billingTrpcClient.iCanHaz.space.query({ orgId: org.id }); 35 | }), 36 | spaceWorkflow: orgProcedure.query(async ({ ctx }) => { 37 | const { org, selfHosted } = ctx; 38 | if (selfHosted) { 39 | return { 40 | open: 8, 41 | active: 8, 42 | closed: 8 43 | }; 44 | } 45 | return await billingTrpcClient.iCanHaz.spaceWorkflow.query({ 46 | orgId: org.id 47 | }); 48 | }), 49 | spaceTag: orgProcedure.query(async ({ ctx }) => { 50 | const { org, selfHosted } = ctx; 51 | if (selfHosted) { 52 | return { 53 | open: true, 54 | private: true 55 | }; 56 | } 57 | return await billingTrpcClient.iCanHaz.spaceTag.query({ orgId: org.id }); 58 | }) 59 | }); 60 | 61 | export const iCanHazCallerFactory = createCallerFactory(iCanHazRouter); 62 | -------------------------------------------------------------------------------- /apps/platform/trpc/routers/orgRouter/setup/profileRouter.ts: -------------------------------------------------------------------------------- 1 | import { router, orgProcedure, orgAdminProcedure } from '~platform/trpc/trpc'; 2 | import { refreshOrgShortcodeCache } from '~platform/utils/orgShortcode'; 3 | import { typeIdValidator } from '@u22n/utils/typeid'; 4 | import { orgs } from '@u22n/database/schema'; 5 | import { TRPCError } from '@trpc/server'; 6 | import { eq } from '@u22n/database/orm'; 7 | import { z } from 'zod'; 8 | 9 | export const orgProfileRouter = router({ 10 | getOrgProfile: orgProcedure 11 | .input( 12 | z.object({ 13 | orgPublicId: typeIdValidator('org').optional() 14 | }) 15 | ) 16 | .query(async ({ ctx, input }) => { 17 | const { db, org, account } = ctx; 18 | const orgId = org.id; 19 | const { orgPublicId } = input; 20 | 21 | const orgProfileQuery = await db.query.orgs.findFirst({ 22 | columns: { 23 | publicId: true, 24 | avatarTimestamp: true, 25 | name: true, 26 | ownerId: true 27 | }, 28 | where: orgPublicId ? eq(orgs.publicId, orgPublicId) : eq(orgs.id, orgId) 29 | }); 30 | 31 | if (!orgProfileQuery?.publicId) { 32 | throw new TRPCError({ 33 | code: 'NOT_FOUND', 34 | message: 'Organization profile not found' 35 | }); 36 | } 37 | 38 | return { 39 | orgProfile: orgProfileQuery, 40 | isOwner: orgProfileQuery.ownerId === account.id 41 | }; 42 | }), 43 | 44 | setOrgProfile: orgAdminProcedure 45 | .input( 46 | z.object({ 47 | orgName: z.string().min(3).max(32) 48 | }) 49 | ) 50 | .mutation(async ({ ctx, input }) => { 51 | const { db, org } = ctx; 52 | const orgId = org.id; 53 | const { orgName } = input; 54 | 55 | await db 56 | .update(orgs) 57 | .set({ 58 | name: orgName 59 | }) 60 | .where(eq(orgs.id, orgId)); 61 | 62 | await refreshOrgShortcodeCache(orgId); 63 | 64 | return { 65 | success: true 66 | }; 67 | }) 68 | }); 69 | -------------------------------------------------------------------------------- /apps/platform/trpc/routers/spaceRouter/membersRouter.ts: -------------------------------------------------------------------------------- 1 | import { router } from '~platform/trpc/trpc'; 2 | 3 | export const spaceMembersRouter = router({}); 4 | -------------------------------------------------------------------------------- /apps/platform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@u22n/tsconfig", 3 | "compilerOptions": { 4 | "paths": { 5 | "~platform/*": ["./*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/platform/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['app.ts', 'tracing.ts'], 5 | outDir: '.output', 6 | format: 'esm', 7 | target: 'esnext', 8 | clean: true, 9 | bundle: true, 10 | treeshake: true, 11 | noExternal: [/^@u22n\/.*/], 12 | minify: false, 13 | keepNames: true, 14 | banner: { 15 | js: [ 16 | `import { createRequire } from 'module';`, 17 | `const require = createRequire(import.meta.url);` 18 | ].join('\n') 19 | }, 20 | esbuildOptions: (options) => { 21 | options.legalComments = 'none'; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /apps/platform/utils/account.ts: -------------------------------------------------------------------------------- 1 | import type { OrgContext } from '~platform/ctx'; 2 | 3 | export async function isAccountAdminOfOrg(orgContext: OrgContext) { 4 | if (!orgContext?.memberId) return false; 5 | const accountOrgMembership = orgContext?.members.find((member) => { 6 | return member.id === orgContext?.memberId; 7 | }); 8 | if (!accountOrgMembership) { 9 | return false; 10 | } 11 | if ( 12 | accountOrgMembership.role !== 'admin' || 13 | accountOrgMembership.status !== 'active' 14 | ) { 15 | return false; 16 | } 17 | return true; 18 | } 19 | -------------------------------------------------------------------------------- /apps/platform/utils/cookieNames.ts: -------------------------------------------------------------------------------- 1 | export const COOKIE_SESSION = 'un-session'; 2 | export const COOKIE_ELEVATED_TOKEN = 'un-elevated-token'; 3 | export const COOKIE_PASSKEY_CHALLENGE = 'un-passkey-challenge'; 4 | export const COOKIE_TWO_FACTOR_RESET_CHALLENGE = 'un-2fa-reset-challenge'; 5 | export const COOKIE_TWO_FACTOR_LOGIN_CHALLENGE = 'un-2fa-login-challenge'; 6 | -------------------------------------------------------------------------------- /apps/platform/utils/session.ts: -------------------------------------------------------------------------------- 1 | import { setCookie, type Context } from '@u22n/hono/helpers'; 2 | import { accounts } from '@u22n/database/schema'; 3 | import type { TypeId } from '@u22n/utils/typeid'; 4 | import { eq } from '@u22n/database/orm'; 5 | import { UAParser } from 'ua-parser-js'; 6 | import { db } from '@u22n/database'; 7 | import { lucia } from './auth'; 8 | 9 | type SessionInfo = { 10 | accountId: number; 11 | username: string; 12 | publicId: TypeId<'account'>; 13 | }; 14 | 15 | /** 16 | * Create a Lucia session cookie for given session info, set the cookie in for the event, update last login and return the cookie. 17 | */ 18 | export async function createLuciaSessionCookie( 19 | event: Context, 20 | info: SessionInfo 21 | ) { 22 | const { device, os, browser } = UAParser(event.req.header('User-Agent')); 23 | const userDevice = 24 | device.type === 'mobile' 25 | ? device.toString() 26 | : (device.vendor ?? device.model ?? device.type ?? 'Unknown'); 27 | const { accountId, username, publicId } = info; 28 | const accountSession = await lucia.createSession(accountId, { 29 | account: { 30 | id: accountId, 31 | username, 32 | publicId 33 | }, 34 | device: userDevice, 35 | os: `${browser.toString()} ${os.name ?? 'Unknown'}` 36 | }); 37 | const cookie = lucia.createSessionCookie(accountSession.id); 38 | setCookie(event, cookie.name, cookie.value, cookie.attributes); 39 | await db 40 | .update(accounts) 41 | .set({ lastLoginAt: new Date() }) 42 | .where(eq(accounts.id, accountId)); 43 | return cookie; 44 | } 45 | -------------------------------------------------------------------------------- /apps/platform/utils/tRPCServerClients.ts: -------------------------------------------------------------------------------- 1 | import type { TrpcMailBridgeRouter } from '@u22n/mail-bridge/trpc'; 2 | import type { TrpcBillingRouter } from '@uninbox-ee/billing/trpc'; 3 | import { createTRPCClient, httpBatchLink } from '@trpc/client'; 4 | import { loggerLink } from '@trpc/client'; 5 | import { env } from '~platform/env'; 6 | import SuperJSON from 'superjson'; 7 | 8 | export const mailBridgeTrpcClient = createTRPCClient({ 9 | links: [ 10 | loggerLink({ 11 | enabled: (opts) => 12 | env.NODE_ENV === 'development' && 13 | opts.direction === 'down' && 14 | opts.result instanceof Error 15 | }), 16 | httpBatchLink({ 17 | url: `${env.MAILBRIDGE_URL}/trpc`, 18 | transformer: SuperJSON, 19 | maxURLLength: 2083, 20 | headers() { 21 | return { 22 | Authorization: env.MAILBRIDGE_KEY 23 | }; 24 | } 25 | }) 26 | ] 27 | }); 28 | 29 | // TODO: Make this conditional on EE license. If no EE then it should not be available. 30 | export const billingTrpcClient = createTRPCClient({ 31 | links: [ 32 | loggerLink({ 33 | enabled: (opts) => 34 | env.NODE_ENV === 'development' || 35 | (opts.direction === 'down' && opts.result instanceof Error) 36 | }), 37 | httpBatchLink({ 38 | url: `${env.BILLING_URL}/trpc`, 39 | transformer: SuperJSON, 40 | maxURLLength: 2083, 41 | headers() { 42 | if (!env.BILLING_KEY) { 43 | throw new Error('Tried to use billing client without key'); 44 | } 45 | return { 46 | Authorization: env.BILLING_KEY 47 | }; 48 | } 49 | }) 50 | ] 51 | }); 52 | -------------------------------------------------------------------------------- /apps/platform/utils/tiptap-utils.ts: -------------------------------------------------------------------------------- 1 | import type { JSONContent } from '@u22n/tiptap/react'; 2 | import { validateTypeId } from '@u22n/utils/typeid'; 3 | 4 | export function walkAndReplaceImages( 5 | jsonContent: JSONContent, 6 | callback: (url: string) => string 7 | ) { 8 | for (const element of jsonContent.content ?? []) { 9 | if (element.type === 'image' && typeof element.attrs?.src === 'string') { 10 | const newUrl = callback(element.attrs.src); 11 | if (element.attrs) { 12 | element.attrs.src = newUrl; 13 | } 14 | } 15 | if (element.content) { 16 | walkAndReplaceImages(element, callback); 17 | } 18 | } 19 | } 20 | 21 | export function tryParseInlineProxyUrl(url: string) { 22 | try { 23 | const urlObject = new URL(url); 24 | const [base, orgShortcode, attachmentPublicId, fileName] = 25 | urlObject.pathname.split('/').splice(1); 26 | if ( 27 | base !== 'inline-proxy' || 28 | !orgShortcode || 29 | !validateTypeId('convoAttachments', attachmentPublicId) || 30 | !fileName 31 | ) 32 | return null; 33 | const fileType = decodeURIComponent( 34 | urlObject.searchParams.get('type') ?? 'image/png' 35 | ); 36 | const size = Number(urlObject.searchParams.get('size') ?? 0) || 0; 37 | 38 | return { 39 | orgShortcode, 40 | attachmentPublicId, 41 | fileName, 42 | fileType, 43 | size, 44 | inline: true 45 | }; 46 | } catch (e) { 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/storage/api/deleteAttachments.ts: -------------------------------------------------------------------------------- 1 | import { DeleteObjectsCommand } from '@aws-sdk/client-s3'; 2 | import { checkAuthorizedService } from '../middlewares'; 3 | import { zValidator } from '@u22n/hono/helpers'; 4 | import { createHonoApp } from '@u22n/hono'; 5 | import type { Ctx } from '../ctx'; 6 | import { s3Client } from '../s3'; 7 | import { env } from '../env'; 8 | import { z } from 'zod'; 9 | 10 | export const deleteAttachmentsApi = createHonoApp().post( 11 | '/attachments/deleteAttachments', 12 | checkAuthorizedService, 13 | zValidator( 14 | 'json', 15 | z.object({ 16 | attachments: z.string().array() 17 | }) 18 | ), 19 | async (c) => { 20 | const { attachments } = c.req.valid('json'); 21 | const attachmentKeys = attachments.map((attachment) => ({ 22 | Key: attachment 23 | })); 24 | const command = new DeleteObjectsCommand({ 25 | Bucket: env.STORAGE_S3_BUCKET_ATTACHMENTS, 26 | Delete: { 27 | Objects: attachmentKeys 28 | } 29 | }); 30 | 31 | await s3Client.send(command).catch((err: Error) => { 32 | console.error('Error while deleting some attachments', { 33 | attachments, 34 | err 35 | }); 36 | }); 37 | 38 | return c.json({ message: 'ok' }); 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /apps/storage/api/internalPresign.ts: -------------------------------------------------------------------------------- 1 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 2 | import { checkAuthorizedService } from '../middlewares'; 3 | import { PutObjectCommand } from '@aws-sdk/client-s3'; 4 | import { typeIdGenerator } from '@u22n/utils/typeid'; 5 | import { zValidator } from '@u22n/hono/helpers'; 6 | import { createHonoApp } from '@u22n/hono'; 7 | import type { Ctx } from '../ctx'; 8 | import { s3Client } from '../s3'; 9 | import { env } from '../env'; 10 | import { z } from 'zod'; 11 | 12 | export const internalPresignApi = createHonoApp().post( 13 | '/attachments/internalPresign', 14 | checkAuthorizedService, 15 | zValidator( 16 | 'json', 17 | z.object({ 18 | orgPublicId: z.string(), 19 | filename: z.string() 20 | }) 21 | ), 22 | async (c) => { 23 | const { filename, orgPublicId } = c.req.valid('json'); 24 | const attachmentPublicId = typeIdGenerator('convoAttachments'); 25 | 26 | const command = new PutObjectCommand({ 27 | Bucket: env.STORAGE_S3_BUCKET_ATTACHMENTS, 28 | Key: `${orgPublicId}/${attachmentPublicId}/${filename}` 29 | }); 30 | const signedUrl = await getSignedUrl(s3Client, command, { 31 | expiresIn: 3600 32 | }); 33 | return c.json({ publicId: attachmentPublicId, signedUrl }); 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /apps/storage/api/mailfetch.ts: -------------------------------------------------------------------------------- 1 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 2 | import { checkAuthorizedService } from '../middlewares'; 3 | import { GetObjectCommand } from '@aws-sdk/client-s3'; 4 | import { zValidator } from '@u22n/hono/helpers'; 5 | import { createHonoApp } from '@u22n/hono'; 6 | import type { Ctx } from '../ctx'; 7 | import { s3Client } from '../s3'; 8 | import { env } from '../env'; 9 | import { z } from 'zod'; 10 | 11 | export const mailfetchApi = createHonoApp().post( 12 | '/attachments/mailfetch', 13 | checkAuthorizedService, 14 | zValidator( 15 | 'json', 16 | z.object({ 17 | orgPublicId: z.string(), 18 | attachmentPublicId: z.string(), 19 | filename: z.string() 20 | }) 21 | ), 22 | async (c) => { 23 | const { orgPublicId, attachmentPublicId, filename } = c.req.valid('json'); 24 | const command = new GetObjectCommand({ 25 | Bucket: env.STORAGE_S3_BUCKET_ATTACHMENTS, 26 | Key: `${orgPublicId}/${attachmentPublicId}/${filename}` 27 | }); 28 | const url = await getSignedUrl(s3Client, command, { expiresIn: 300 }); 29 | return c.json({ url }); 30 | } 31 | ); 32 | -------------------------------------------------------------------------------- /apps/storage/app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createHonoApp, 3 | setupCors, 4 | setupErrorHandlers, 5 | setupHealthReporting, 6 | setupHonoListener, 7 | setupRouteLogger, 8 | setupRuntime 9 | } from '@u22n/hono'; 10 | import { deleteAttachmentsApi } from './api/deleteAttachments'; 11 | import { internalPresignApi } from './api/internalPresign'; 12 | import { attachmentProxy } from './proxy/attachment'; 13 | import { inlineProxy } from './proxy/inline-proxy'; 14 | import { deleteOrgsApi } from './api/deleteOrg'; 15 | import { opentelemetry } from '@u22n/otel/hono'; 16 | import { mailfetchApi } from './api/mailfetch'; 17 | import { authMiddleware } from './middlewares'; 18 | import { avatarProxy } from './proxy/avatars'; 19 | import { presignApi } from './api/presign'; 20 | import { avatarApi } from './api/avatar'; 21 | import type { Ctx } from './ctx'; 22 | import { env } from './env'; 23 | 24 | const app = createHonoApp(); 25 | 26 | app.use(opentelemetry('storage/hono')); 27 | 28 | setupRouteLogger(app, env.NODE_ENV === 'development'); 29 | setupCors(app, { origin: [env.WEBAPP_URL] }); 30 | setupHealthReporting(app, { service: 'Storage' }); 31 | setupErrorHandlers(app); 32 | 33 | // Auth middleware 34 | app.use('*', authMiddleware); 35 | // Proxies 36 | app.route('/avatar', avatarProxy); 37 | app.route('/attachment', attachmentProxy); 38 | app.route('/inline-proxy', inlineProxy); 39 | 40 | // APIs 41 | app.route('/api', avatarApi); 42 | app.route('/api', presignApi); 43 | app.route('/api', internalPresignApi); 44 | app.route('/api', mailfetchApi); 45 | app.route('/api', deleteAttachmentsApi); 46 | app.route('/api', deleteOrgsApi); 47 | 48 | const cleanup = setupHonoListener(app, { port: env.PORT }); 49 | setupRuntime([cleanup]); 50 | -------------------------------------------------------------------------------- /apps/storage/ctx.ts: -------------------------------------------------------------------------------- 1 | import type { HonoContext } from '@u22n/hono'; 2 | import type { Session } from './storage'; 3 | 4 | export type Ctx = HonoContext<{ 5 | account: { 6 | id: number; 7 | session: Session; 8 | } | null; 9 | }>; 10 | -------------------------------------------------------------------------------- /apps/storage/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-core'; 2 | import { z } from 'zod'; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | WEBAPP_URL: z.string().url(), 7 | STORAGE_KEY: z.string().min(1), 8 | STORAGE_S3_ENDPOINT: z.string().min(1), 9 | STORAGE_S3_REGION: z.string().min(1), 10 | STORAGE_S3_ACCESS_KEY_ID: z.string().min(1), 11 | STORAGE_S3_SECRET_ACCESS_KEY: z.string().min(1), 12 | STORAGE_S3_BUCKET_ATTACHMENTS: z.string().min(1), 13 | STORAGE_S3_BUCKET_AVATARS: z.string().min(1), 14 | DB_REDIS_CONNECTION_STRING: z.string().min(1), 15 | PORT: z.coerce.number().int().min(1).max(65535).default(3200), 16 | NODE_ENV: z.enum(['development', 'production']).default('development') 17 | }, 18 | client: {}, 19 | clientPrefix: '_', // Not used, just for making TS happy 20 | runtimeEnv: process.env 21 | }); 22 | -------------------------------------------------------------------------------- /apps/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u22n/storage", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "dev": "tsx watch --clear-screen=false --import ./tracing.ts app.ts", 8 | "start": "node --import ./.output/tracing.js .output/app.js", 9 | "build": "tsup", 10 | "check": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@aws-sdk/client-s3": "^3.637.0", 14 | "@aws-sdk/s3-request-presigner": "^3.637.0", 15 | "@t3-oss/env-core": "^0.11.0", 16 | "@u22n/database": "workspace:*", 17 | "@u22n/hono": "workspace:^", 18 | "@u22n/otel": "workspace:^", 19 | "@u22n/utils": "workspace:*", 20 | "sharp": "^0.33.5", 21 | "unstorage": "^1.10.2", 22 | "zod": "^3.23.8" 23 | }, 24 | "devDependencies": { 25 | "tsup": "^8.2.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/storage/proxy/avatars.ts: -------------------------------------------------------------------------------- 1 | import { createHonoApp } from '@u22n/hono'; 2 | import type { Ctx } from '../ctx'; 3 | import { env } from '../env'; 4 | 5 | // Proxy to `${process.env.STORAGE_S3_ENDPOINT}/${process.env.STORAGE_S3_BUCKET_AVATARS}/${proxy}` 6 | export const avatarProxy = createHonoApp().get( 7 | '/:proxy{.+}', 8 | async (c) => { 9 | const proxy = c.req.param('proxy'); 10 | const res = await fetch( 11 | `${env.STORAGE_S3_ENDPOINT}/${env.STORAGE_S3_BUCKET_AVATARS}/${proxy}` 12 | ); 13 | if (res.status === 404) { 14 | return c.json({ error: 'Not Found' }, 404); 15 | } 16 | // Avatars are immutable so we can cache them for a long time 17 | c.header( 18 | 'Cache-Control', 19 | 'public, immutable, max-age=86400, stale-while-revalidate=604800' 20 | ); 21 | return c.body(res.body, res); 22 | } 23 | ); 24 | -------------------------------------------------------------------------------- /apps/storage/s3.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3'; 2 | import { env } from './env'; 3 | 4 | export const s3Client = new S3Client({ 5 | region: env.STORAGE_S3_REGION, 6 | endpoint: env.STORAGE_S3_ENDPOINT, 7 | forcePathStyle: true, 8 | credentials: { 9 | accessKeyId: env.STORAGE_S3_ACCESS_KEY_ID, 10 | secretAccessKey: env.STORAGE_S3_SECRET_ACCESS_KEY 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /apps/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import { createStorage, type Driver, type StorageValue } from 'unstorage'; 2 | import redisDriver from 'unstorage/drivers/redis'; 3 | import { ms } from '@u22n/utils/ms'; 4 | import { env } from './env'; 5 | 6 | export type Session = { 7 | attributes: { 8 | account: { id: number; publicId: string; username: string }; 9 | }; 10 | }; 11 | 12 | const createCachedStorage = ( 13 | base: string, 14 | ttl: number 15 | ) => 16 | createStorage({ 17 | driver: redisDriver({ 18 | url: env.DB_REDIS_CONNECTION_STRING, 19 | ttl, 20 | base 21 | }) as Driver 22 | }); 23 | 24 | export const storage = { 25 | session: createCachedStorage( 26 | 'sessions', 27 | env.NODE_ENV === 'development' ? ms('12 hours') : ms('30 days') 28 | ) 29 | }; 30 | -------------------------------------------------------------------------------- /apps/storage/tracing.ts: -------------------------------------------------------------------------------- 1 | import { opentelemetryEnabled } from '@u22n/otel'; 2 | import { name, version } from './package.json'; 3 | 4 | if (opentelemetryEnabled) { 5 | const { setupOpentelemetry } = await import('@u22n/otel/setup'); 6 | setupOpentelemetry({ name, version }); 7 | } 8 | -------------------------------------------------------------------------------- /apps/storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { "extends": "@u22n/tsconfig" } 2 | -------------------------------------------------------------------------------- /apps/storage/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['app.ts', 'tracing.ts'], 5 | outDir: '.output', 6 | format: 'esm', 7 | target: 'esnext', 8 | clean: true, 9 | bundle: true, 10 | treeshake: true, 11 | noExternal: [/^@u22n\/.*/], 12 | minify: false, 13 | keepNames: true, 14 | banner: { 15 | js: [ 16 | `import { createRequire } from 'module';`, 17 | `const require = createRequire(import.meta.url);` 18 | ].join('\n') 19 | }, 20 | esbuildOptions: (options) => { 21 | options.legalComments = 'none'; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/.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.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 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 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/src/components", 15 | "utils": "@/src/lib/utils", 16 | "ui": "@/src/components/shadcn-ui" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | /** @type {import("next").NextConfig} */ 4 | const config = { 5 | // Checked in CI anyways 6 | typescript: { 7 | ignoreBuildErrors: true 8 | }, 9 | eslint: { 10 | ignoreDuringBuilds: true 11 | }, 12 | output: 'standalone', 13 | experimental: { 14 | outputFileTracingRoot: join( 15 | new URL('.', import.meta.url).pathname, 16 | '../../' 17 | ), 18 | outputFileTracingIncludes: { 19 | '/': ['./public/*'] 20 | }, 21 | instrumentationHook: true 22 | }, 23 | productionBrowserSourceMaps: true, 24 | // https://posthog.com/docs/advanced/proxy/nextjs 25 | async rewrites() { 26 | return [ 27 | { 28 | source: '/ingest/static/:path*', 29 | destination: 'https://us-assets.i.posthog.com/static/:path*' 30 | }, 31 | { 32 | source: '/ingest/:path*', 33 | destination: 'https://us.i.posthog.com/:path*' 34 | }, 35 | { 36 | source: '/ingest/decide', 37 | destination: 'https://us.i.posthog.com/decide' 38 | } 39 | ]; 40 | }, 41 | // This is required to support PostHog trailing slash API requests 42 | skipTrailingSlashRedirect: true 43 | }; 44 | 45 | export default config; 46 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {} 4 | } 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /apps/web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un/inbox/ee35c84be35d514c60dc16c13358c66408dfd6a5/apps/web/public/logo.png -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/[convoId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Page from '../../../convo/[convoId]/page'; 3 | export default Page; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Layout from '../../convo/layout'; 3 | export default Layout; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/new/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Page from '../../../convo/new/page'; 3 | export default Page; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Page from '../../convo/page'; 3 | export default Page; 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/welcome/_components/welcome-message.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface WelcomeMessageProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function WelcomeMessage({ children }: WelcomeMessageProps) { 8 | return ( 9 |
10 | UnInbox Team 15 |
16 |

UnInbox Team

17 | {children} 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/welcome/_components/welcome-messages.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { welcomeMessages } from '../_data/welcomeMessages'; 4 | import { useOrgShortcode } from '@/src/hooks/use-params'; 5 | import { WelcomeMessage } from './welcome-message'; 6 | import React, { useState, useEffect } from 'react'; 7 | 8 | export function WelcomeMessages() { 9 | const orgShortcode = useOrgShortcode(); 10 | const [messageIndex, setMessageIndex] = useState(0); 11 | 12 | useEffect(() => { 13 | const timer = setInterval(() => { 14 | setMessageIndex((prev) => { 15 | if (prev < welcomeMessages.length - 1) { 16 | return prev + 1; 17 | } 18 | clearInterval(timer); 19 | return prev; 20 | }); 21 | }, 1000); 22 | 23 | return () => clearInterval(timer); 24 | }, []); 25 | 26 | return ( 27 |
28 | {welcomeMessages.slice(0, messageIndex + 1).map((Message, index) => ( 29 | 30 | 31 | 32 | ))} 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/convo/welcome/_data/welcomeMessages.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | interface WelcomeMessageProps { 5 | orgShortcode: string; 6 | } 7 | 8 | const WelcomeMessage1 = () =>

Hey Welcome to Uninbox!

; 9 | 10 | const WelcomeMessage2 = ({ orgShortcode }: WelcomeMessageProps) => ( 11 |

12 | If you're just trying this out, you can claim your uninbox.me email 13 | address in{' '} 14 | 17 | Settings > User > Address 18 | 19 | . 20 |

21 | ); 22 | 23 | const WelcomeMessage3 = () => ( 24 |

25 | To get the best of Uninbox for your team, add your own domain. This will 26 | unlock unlimited email addresses (not aliases), the full power of spaces, 27 | and the ability to scale your org communications. 28 |

29 | ); 30 | 31 | const WelcomeMessage4 = ({ orgShortcode }: WelcomeMessageProps) => ( 32 |

33 | You can add your own email domain by going to{' '} 34 | 37 | Settings > Mail > Domains 38 | 39 | . 40 |

41 | ); 42 | 43 | const WelcomeMessage5 = () => ( 44 |

45 | Need extra help? Check out our guide on{' '} 46 | 51 | using your own domain 52 | 53 | . 54 |

55 | ); 56 | 57 | export const welcomeMessages = [ 58 | WelcomeMessage1, 59 | WelcomeMessage2, 60 | WelcomeMessage3, 61 | WelcomeMessage4, 62 | WelcomeMessage5 63 | ]; 64 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | export function GET( 5 | _: NextRequest, 6 | { params }: { params: { orgShortcode: string; spaceShortcode: string } } 7 | ) { 8 | redirect(`/${params.orgShortcode}/${params.spaceShortcode}/convo`); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/[spaceShortcode]/settings/_components/settingsTitle.tsx: -------------------------------------------------------------------------------- 1 | export function SettingsTitle({ title }: { title: string }) { 2 | return {title}; 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/_components/atoms.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | 3 | export const sidebarSubmenuOpenAtom = atom(false); 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/_components/claim-email-identity.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogClose, 4 | DialogContent, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle 8 | } from '@/src/components/shadcn-ui/dialog'; 9 | import { useOrgScopedRouter } from '@/src/hooks/use-params'; 10 | import { Button } from '@/src/components/shadcn-ui/button'; 11 | import { DialogDescription } from '@radix-ui/react-dialog'; 12 | import Link from 'next/link'; 13 | 14 | export function ClaimEmailIdentity() { 15 | const { scopedUrl } = useOrgScopedRouter(); 16 | return ( 17 | 18 | 19 | 20 | No Associated Email found for your account 21 | 22 | 23 | You don't have any email addresses assigned to your account. 24 | Do you want to claim a free @uninbox.me email address? 25 | 26 | 27 | You can also ask your Organization admin to assign a email to you 28 | 29 | 30 | 31 | 32 | 33 | 38 | 39 | 40 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/convo/[convoId]/atoms.ts: -------------------------------------------------------------------------------- 1 | import { type TypeId } from '@u22n/utils/typeid'; 2 | import { atom } from 'jotai'; 3 | 4 | export const replyToMessageAtom = atom | null>(null); 5 | export const emailIdentityAtom = atom | null>(null); 6 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/convo/[convoId]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ConvoView, ConvoNotFound } from './_components/convo-views'; 4 | import { useCurrentConvoId } from '@/src/hooks/use-params'; 5 | 6 | export default function ConvoPage() { 7 | const convoId = useCurrentConvoId(); 8 | if (!convoId) return ; 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/app/[orgShortcode]/convo/_components/convo-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | useOrgScopedRouter, 5 | useOrgShortcode, 6 | useSpaceShortcode 7 | } from '@/src/hooks/use-params'; 8 | import { ConvoListBase } from './convo-list-base'; 9 | import { useEffect, useMemo } from 'react'; 10 | import { platform } from '@/src/lib/trpc'; 11 | import { ms } from '@u22n/utils/ms'; 12 | 13 | // type Props = { 14 | // hidden: boolean; 15 | // }; 16 | 17 | export function ConvoList(/*{hidden} : Props*/) { 18 | const orgShortcode = useOrgShortcode(); 19 | const spaceShortcode = useSpaceShortcode(); 20 | const { scopedRedirect, scopedUrl } = useOrgScopedRouter(); 21 | 22 | const { 23 | data: convos, 24 | fetchNextPage, 25 | isLoading, 26 | hasNextPage, 27 | isFetchingNextPage, 28 | error 29 | } = platform.spaces.getSpaceConvos.useInfiniteQuery( 30 | { 31 | orgShortcode, 32 | spaceShortcode: spaceShortcode ?? 'all' 33 | // includeHidden: hidden 34 | }, 35 | { 36 | getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, 37 | staleTime: ms('1 hour') 38 | } 39 | ); 40 | 41 | const allConvos = useMemo( 42 | () => (convos ? convos?.pages.flatMap((page) => page.data) : []), 43 | [convos] 44 | ); 45 | 46 | useEffect(() => { 47 | if (error?.data?.code === 'FORBIDDEN') { 48 | scopedRedirect('/personal/convo'); 49 | } 50 | }, [error, scopedRedirect]); 51 | 52 | return ( 53 |