├── .cursor ├── environment.json └── rules │ └── general.mdc ├── .dockerignore ├── .env.example ├── .env.selfhost.example ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── marketing.yml │ └── smtp.yml └── workflows │ ├── publish.yml │ └── release-js-package.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── .windsurfrules ├── CLAUDE.md ├── CONTRIBUTION.md ├── LICENSE ├── README.md ├── apps ├── docs │ ├── README.md │ ├── api-reference │ │ ├── contacts │ │ │ ├── create-contact.mdx │ │ │ ├── delete-contact.mdx │ │ │ ├── get-contact.mdx │ │ │ ├── get-contacts.mdx │ │ │ ├── update-contact.mdx │ │ │ └── upsert-contact.mdx │ │ ├── domains │ │ │ ├── create-domain.mdx │ │ │ ├── get-domain.mdx │ │ │ └── verify-domain.mdx │ │ ├── emails │ │ │ ├── batch-email.mdx │ │ │ ├── cancel-schedule.mdx │ │ │ ├── get-email.mdx │ │ │ ├── list-emails.mdx │ │ │ ├── send-email.mdx │ │ │ └── update-schedule.mdx │ │ ├── introduction.mdx │ │ └── openapi.json │ ├── community-sdk │ │ ├── go.mdx │ │ └── python.mdx │ ├── favicon.svg │ ├── get-started │ │ ├── create-aws-credentials.mdx │ │ ├── local.mdx │ │ ├── nodejs.mdx │ │ ├── self-hosting.mdx │ │ ├── set-up-docker.mdx │ │ └── smtp.mdx │ ├── guides │ │ └── use-with-react-email.mdx │ ├── images │ │ ├── aws │ │ │ ├── key-1.png │ │ │ ├── key-2.png │ │ │ ├── key-3.png │ │ │ ├── key-4.png │ │ │ ├── key-5.png │ │ │ └── key-6.png │ │ ├── github-callback.png │ │ └── ses-settings │ │ │ ├── add-ses-settings.png │ │ │ └── sandbox.png │ ├── introduction.mdx │ ├── logo │ │ ├── Logo-wordmark-dark.png │ │ ├── Logo-wordmark.png │ │ └── logo.svg │ └── mint.json ├── marketing │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── Logo-bold.png │ │ ├── app.webp │ │ ├── favicon.ico │ │ ├── logo-full-wordmark.svg │ │ ├── logo.svg │ │ └── og_banner.png │ ├── src │ │ ├── app │ │ │ ├── IntegrationCode.tsx │ │ │ ├── editor │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── privacy │ │ │ │ └── page.tsx │ │ │ └── terms │ │ │ │ └── page.tsx │ │ └── components │ │ │ ├── landind-page.tsx │ │ │ └── ui │ │ │ ├── background-beams.tsx │ │ │ └── styled-input.tsx │ ├── tailwind.config.ts │ └── tsconfig.json ├── smtp-server │ ├── Dockerfile │ ├── docker-compose.yml │ ├── package.json │ ├── src │ │ ├── server.ts │ │ └── usage.js │ ├── tsconfig.json │ └── tsup.config.ts └── web │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.cjs │ ├── prisma │ ├── migrations │ │ ├── 20240626213358_init │ │ │ └── migration.sql │ │ ├── 20240809235907_add_campaign │ │ │ └── migration.sql │ │ ├── 20240820093529_add_schedule │ │ │ └── migration.sql │ │ ├── 20240820093931_add_cancelled │ │ │ └── migration.sql │ │ ├── 20240914003754_add_emoji_for_contact_book │ │ │ └── migration.sql │ │ ├── 20250201051912_add_indexes │ │ │ └── migration.sql │ │ ├── 20250201131024_add_daily_usage │ │ │ └── migration.sql │ │ ├── 20250201131959_add_sent_to_daily_usage │ │ │ └── migration.sql │ │ ├── 20250202115917_add_payments │ │ │ └── migration.sql │ │ ├── 20250207104036_add_template │ │ │ └── migration.sql │ │ ├── 20250215074030_added_apiid_to_email_table │ │ │ └── migration.sql │ │ ├── 20250317104401_add_unsubscribe_reason │ │ │ └── migration.sql │ │ ├── 20250321093643_add_is_active │ │ │ └── migration.sql │ │ ├── 20250323114242_add_team_invites │ │ │ └── migration.sql │ │ ├── 20250323115457_add_created_at_for_users │ │ │ └── migration.sql │ │ ├── 20250325113154_add_team_invites_foreign_key_to_team │ │ │ └── migration.sql │ │ ├── 20250425130849_update_email_status │ │ │ └── migration.sql │ │ ├── 20250425141139_add_hard_bounce │ │ │ └── migration.sql │ │ ├── 20250425141529_add_hard_bounce_to_campaign │ │ │ └── migration.sql │ │ ├── 20250510052850_add_cumulated_metrics │ │ │ └── migration.sql │ │ ├── 20250510235405_compute_cumulated_metrics │ │ │ └── migration.sql │ │ ├── 20250517053539_add_team_api_rate_limit │ │ │ └── migration.sql │ │ ├── 20250523232535_add_in_reply_to │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── public │ ├── Logo-rounded.png │ ├── favicon.ico │ ├── favicon_io │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── site.webmanifest │ ├── logo-dark.png │ ├── logo-dark.svg │ ├── logo-full-wordmark.svg │ ├── logo-light.png │ └── logo-light.svg │ ├── src │ ├── app │ │ ├── (dashboard) │ │ │ ├── admin │ │ │ │ ├── add-ses-configuration.tsx │ │ │ │ ├── edit-ses-configuration.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── ses-configurations.tsx │ │ │ ├── campaigns │ │ │ │ ├── [campaignId] │ │ │ │ │ ├── edit │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── campaign-list.tsx │ │ │ │ ├── create-campaign.tsx │ │ │ │ ├── delete-campaign.tsx │ │ │ │ ├── duplicate-campaign.tsx │ │ │ │ └── page.tsx │ │ │ ├── contacts │ │ │ │ ├── [contactBookId] │ │ │ │ │ ├── add-contact.tsx │ │ │ │ │ ├── contact-list.tsx │ │ │ │ │ ├── delete-contact.tsx │ │ │ │ │ ├── edit-contact.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── add-contact-book.tsx │ │ │ │ ├── contact-books-list.tsx │ │ │ │ ├── delete-contact-book.tsx │ │ │ │ ├── edit-contact-book.tsx │ │ │ │ └── page.tsx │ │ │ ├── dasboard-layout.tsx │ │ │ ├── dashboard │ │ │ │ ├── dashboard-filters.tsx │ │ │ │ ├── email-chart.tsx │ │ │ │ ├── hooks │ │ │ │ │ └── useColors.ts │ │ │ │ ├── page.tsx │ │ │ │ └── reputation-metrics.tsx │ │ │ ├── dev-settings │ │ │ │ ├── api-keys │ │ │ │ │ ├── add-api-key.tsx │ │ │ │ │ ├── api-list.tsx │ │ │ │ │ ├── delete-api-key.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── settings-nav-button.tsx │ │ │ │ └── smtp │ │ │ │ │ └── page.tsx │ │ │ ├── domains │ │ │ │ ├── [domainId] │ │ │ │ │ ├── delete-domain.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── send-test-mail.tsx │ │ │ │ ├── add-domain.tsx │ │ │ │ ├── domain-badge.tsx │ │ │ │ ├── domain-list.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── status-indicator.tsx │ │ │ ├── emails │ │ │ │ ├── cancel-email.tsx │ │ │ │ ├── edit-schedule.tsx │ │ │ │ ├── email-details.tsx │ │ │ │ ├── email-list.tsx │ │ │ │ ├── email-status-badge.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── payments │ │ │ │ └── page.tsx │ │ │ ├── settings │ │ │ │ ├── billing │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── team │ │ │ │ │ ├── delete-team-invite.tsx │ │ │ │ │ ├── delete-team-member.tsx │ │ │ │ │ ├── edit-team-member.tsx │ │ │ │ │ ├── invite-team-member.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── resend-team-invite.tsx │ │ │ │ │ └── team-members-list.tsx │ │ │ │ └── usage │ │ │ │ │ └── usage.tsx │ │ │ └── templates │ │ │ │ ├── [templateId] │ │ │ │ └── edit │ │ │ │ │ └── page.tsx │ │ │ │ ├── create-template.tsx │ │ │ │ ├── delete-template.tsx │ │ │ │ ├── duplicate-template.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── template-list.tsx │ │ ├── api │ │ │ ├── auth │ │ │ │ └── [...nextauth] │ │ │ │ │ └── route.ts │ │ │ ├── health │ │ │ │ └── route.ts │ │ │ ├── ses_callback │ │ │ │ └── route.ts │ │ │ ├── to-html │ │ │ │ └── route.ts │ │ │ ├── trpc │ │ │ │ └── [trpc] │ │ │ │ │ └── route.ts │ │ │ ├── v1 │ │ │ │ └── [[...route]] │ │ │ │ │ └── route.ts │ │ │ └── webhook │ │ │ │ └── stripe │ │ │ │ └── route.ts │ │ ├── join-team │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ ├── login-page.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── signup │ │ │ └── page.tsx │ │ ├── unsubscribe │ │ │ ├── page.tsx │ │ │ └── re-subscribe.tsx │ │ └── wait-list │ │ │ └── page.tsx │ ├── components │ │ ├── AppSideBar.tsx │ │ ├── FullScreenLoading.tsx │ │ ├── payments │ │ │ ├── PlanDetails.tsx │ │ │ └── UpgradeButton.tsx │ │ ├── settings │ │ │ └── AddSesSettings.tsx │ │ ├── team │ │ │ ├── CreateTeam.tsx │ │ │ └── JoinTeam.tsx │ │ └── theme │ │ │ └── ThemeSwitcher.tsx │ ├── env.js │ ├── hooks │ │ ├── useInterval.ts │ │ └── useUrlState.ts │ ├── instrumentation.ts │ ├── lib │ │ ├── constants │ │ │ ├── colors.ts │ │ │ ├── example-codes.ts │ │ │ ├── index.ts │ │ │ ├── payments.ts │ │ │ └── ses-errors.ts │ │ ├── usage.ts │ │ └── zod │ │ │ └── domain-schema.ts │ ├── providers │ │ ├── dashboard-provider.tsx │ │ ├── next-auth.tsx │ │ └── team-context.tsx │ ├── server │ │ ├── api │ │ │ ├── root.ts │ │ │ ├── routers │ │ │ │ ├── admin.ts │ │ │ │ ├── api.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── campaign.ts │ │ │ │ ├── contacts.ts │ │ │ │ ├── dashboard.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── email.ts │ │ │ │ ├── invitiation.ts │ │ │ │ ├── team.ts │ │ │ │ └── template.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ ├── aws │ │ │ ├── ses.ts │ │ │ └── sns.ts │ │ ├── billing │ │ │ ├── payments.ts │ │ │ └── usage.ts │ │ ├── crypto.ts │ │ ├── db.ts │ │ ├── jobs │ │ │ └── usage-job.ts │ │ ├── mailer.ts │ │ ├── nanoid.ts │ │ ├── public-api │ │ │ ├── api-error.ts │ │ │ ├── api-utils.ts │ │ │ ├── api │ │ │ │ ├── contacts │ │ │ │ │ ├── add-contact.ts │ │ │ │ │ ├── delete-contact.ts │ │ │ │ │ ├── get-contact.ts │ │ │ │ │ ├── get-contacts.ts │ │ │ │ │ ├── update-contact.ts │ │ │ │ │ └── upsert-contact.ts │ │ │ │ ├── domains │ │ │ │ │ ├── create-domain.ts │ │ │ │ │ ├── get-domains.ts │ │ │ │ │ └── verify-domain.ts │ │ │ │ └── emails │ │ │ │ │ ├── batch-email.ts │ │ │ │ │ ├── cancel-email.ts │ │ │ │ │ ├── get-email.ts │ │ │ │ │ ├── list-emails.ts │ │ │ │ │ ├── send-email.ts │ │ │ │ │ └── update-email.ts │ │ │ ├── auth.ts │ │ │ ├── hono.ts │ │ │ ├── index.ts │ │ │ └── schemas │ │ │ │ └── email-schema.ts │ │ ├── queue │ │ │ └── queue-constants.ts │ │ ├── redis.ts │ │ └── service │ │ │ ├── api-service.ts │ │ │ ├── campaign-service.ts │ │ │ ├── contact-service.ts │ │ │ ├── domain-service.ts │ │ │ ├── email-queue-service.ts │ │ │ ├── email-service.ts │ │ │ ├── notification-service.ts │ │ │ ├── ses-hook-parser.ts │ │ │ ├── ses-settings-service.ts │ │ │ └── storage-service.ts │ ├── trpc │ │ ├── react.tsx │ │ └── server.ts │ ├── types │ │ ├── aws-types.ts │ │ └── index.ts │ └── utils │ │ ├── client.ts │ │ ├── common.ts │ │ ├── gravatar-utils.ts │ │ └── ses-utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json ├── docker ├── Dockerfile ├── README.md ├── build.sh ├── dev │ └── compose.yml ├── prod │ └── compose.yml └── start.sh ├── package.json ├── packages ├── email-editor │ ├── .eslintrc.cjs │ ├── package.json │ ├── postcss.config.cjs │ ├── src │ │ ├── components │ │ │ ├── panels │ │ │ │ ├── LinkEditorPanel.tsx │ │ │ │ ├── LinkPreviewPanel.tsx │ │ │ │ └── TextEditorPanel.tsx │ │ │ └── ui │ │ │ │ ├── ColorPicker.tsx │ │ │ │ └── icons │ │ │ │ ├── AlignmentIcon.tsx │ │ │ │ └── BorderWidth.tsx │ │ ├── editor.tsx │ │ ├── extensions │ │ │ ├── ButtonExtension.ts │ │ │ ├── ImageExtension.tsx │ │ │ ├── SlashCommand.tsx │ │ │ ├── UnsubsubscribeExtension.tsx │ │ │ ├── VariableExtension.ts │ │ │ ├── dragHandle.ts │ │ │ └── index.ts │ │ ├── hooks │ │ │ └── useEvent.ts │ │ ├── index.ts │ │ ├── menus │ │ │ ├── LinkMenu.tsx │ │ │ ├── TextMenu.tsx │ │ │ └── TextMenuButton.tsx │ │ ├── nodes │ │ │ ├── button.tsx │ │ │ ├── image-resize.tsx │ │ │ ├── unsubscribe-footer.tsx │ │ │ └── variable.tsx │ │ ├── renderer.tsx │ │ ├── styles │ │ │ └── index.css │ │ └── types.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── tsconfig.lint.json │ └── tsup.config.ts ├── eslint-config │ ├── README.md │ ├── library.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── sdk │ ├── .eslintrc.js │ ├── .npmignore │ ├── LICENSE │ ├── README.md │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── contact.ts │ │ ├── domain.ts │ │ ├── email.ts │ │ └── unsend.ts │ ├── tsconfig.json │ └── types │ │ ├── index.ts │ │ └── schema.d.ts ├── tailwind-config │ ├── package.json │ ├── tailwind.config.ts │ └── tsconfig.json ├── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── .eslintrc.cjs │ ├── code-theme.ts │ ├── index.ts │ ├── lib │ └── utils.ts │ ├── package.json │ ├── src │ ├── accordion.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── card.tsx │ ├── charts.tsx │ ├── code.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hooks │ │ └── use-mobile.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── logo.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── spinner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── text-with-copy.tsx │ ├── textarea.tsx │ ├── toaster.tsx │ └── tooltip.tsx │ ├── styles │ └── globals.css │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── tsconfig.lint.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── turbo.json /.cursor/environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": "pnpm i", 3 | "start": "pnpm d" 4 | } -------------------------------------------------------------------------------- /.cursor/rules/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. 7 | 8 | - Follow the user's requirements carefully & to the letter. 9 | - First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. 10 | - Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . 11 | - Focus on easy and readability code, over being performant. 12 | - Fully implement all requested functionality. 13 | - Leave NO todo's, placeholders or missing pieces. 14 | - Ensure code is complete! Verify thoroughly finalised. 15 | - Include all required imports, and ensure proper naming of key components. 16 | - Be concise Minimize any other prose. 17 | - If you think there might not be a correct answer, you say so. 18 | - If you do not know the answer, say so, instead of guessing. 19 | - never, install any libraries without asking 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 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 | 9 | 10 | # next.js 11 | .next/ 12 | out/ 13 | build 14 | 15 | # misc 16 | .DS_Store 17 | 18 | # debug 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # local env files 24 | .env 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | 30 | # turbo 31 | .turbo 32 | 33 | # vercel 34 | .vercel -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://unsend:password@localhost:54320/unsend" 2 | REDIS_URL="redis://localhost:6379" 3 | 4 | 5 | 6 | NEXTAUTH_URL="http://localhost:3000" 7 | 8 | SMTP_HOST=smtp.mailtrap.io # Example SMTP host 9 | SMTP_USER=test_userdadad@example.com # Example SMTP user 10 | 11 | 12 | AWS_DEFAULT_REGION="us-east-1" 13 | AWS_SECRET_KEY="some-secret-key" 14 | AWS_ACCESS_KEY="some-access-key" 15 | AWS_SES_ENDPOINT="http://localhost:3003/api/ses" 16 | AWS_SNS_ENDPOINT="http://localhost:3003/api/sns" 17 | 18 | NEXTAUTH_SECRET="" 19 | 20 | FROM_EMAIL="hello@unsend.dev" 21 | 22 | API_RATE_LIMIT=2 23 | 24 | NEXT_PUBLIC_IS_CLOUD=true 25 | -------------------------------------------------------------------------------- /.env.selfhost.example: -------------------------------------------------------------------------------- 1 | # Redis container name - required 2 | REDIS_URL="redis://redis:6379" 3 | 4 | # Postgres - required for docker-compose, not needed for just docker 5 | POSTGRES_USER="postgres" 6 | POSTGRES_PASSWORD="postgres" 7 | POSTGRES_DB="unsend" 8 | # Postgres - required 9 | DATABASE_URL="postgresql://postgres:postgres@postgres:5432/unsend" 10 | 11 | # NextAuth - required 12 | NEXTAUTH_URL="http://localhost:3000" 13 | NEXTAUTH_SECRET= 14 | 15 | #SMTP 16 | SMTP_HOST=smtp.mailtrap.io # Example SMTP host 17 | SMTP_USER= "unsend" # Example SMTP user 18 | 19 | ## Auth providers any one is required 20 | # Github login - required 21 | GITHUB_ID="" 22 | GITHUB_SECRET="" 23 | 24 | # Google login - required 25 | GOOGLE_CLIENT_ID="" 26 | GOOGLE_CLIENT_SECRET="" 27 | 28 | # AWS details - required 29 | AWS_DEFAULT_REGION="us-east-1" 30 | AWS_SECRET_KEY="" 31 | AWS_ACCESS_KEY="" 32 | 33 | 34 | 35 | DOCKER_OUTPUT=1 36 | API_RATE_LIMIT=1 37 | 38 | # used to send important error notification - optional 39 | DISCORD_WEBHOOK_URL="" 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // This configuration only applies to the package manager root. 2 | /** @type {import("eslint").Linter.Config} */ 3 | module.exports = { 4 | ignorePatterns: ["apps/**", "packages/**"], 5 | extends: ["@unsend/eslint-config/library.js"], 6 | parser: "@typescript-eslint/parser", 7 | parserOptions: { 8 | project: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ['KMKoushik'] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | labels: ["bug"] 4 | title: "🐞 - " 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: what-happened 12 | attributes: 13 | label: What happened? 14 | placeholder: Tell us what you see! 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: replication 19 | attributes: 20 | label: Replication Steps 21 | description: Provide clear steps as to how this happened 22 | value: "1. First click this \n2. Navigate to this \n3. Click this \n4. See this" 23 | validations: 24 | required: false 25 | - type: dropdown 26 | id: kind 27 | attributes: 28 | label: Self hosted or Cloud? 29 | description: Does this happen on app.unsend.dev or on your own instance? 30 | options: 31 | - Cloud 32 | - Self hosted 33 | default: 0 34 | validations: 35 | required: true 36 | - type: dropdown 37 | id: browsers 38 | attributes: 39 | label: What browsers are you seeing the problem on? 40 | multiple: true 41 | options: 42 | - Firefox 43 | - Chrome (or chrome based like Brave, Arc, etc) 44 | - Safari -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/marketing.yml: -------------------------------------------------------------------------------- 1 | name: Marketing Issue 2 | description: Issue relataed to Marketing 3 | labels: ["marketing"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to create this issue 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: What happened? 13 | placeholder: Tell us what you see! 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: replication 18 | attributes: 19 | label: Replication Steps 20 | description: Provide clear steps as to how this happened 21 | value: "1. First click this \n2. Navigate to this \n3. Click this \n4. See this" 22 | validations: 23 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/smtp.yml: -------------------------------------------------------------------------------- 1 | name: SMTP Issue 2 | description: Issue relataed to SMTP 3 | labels: ["smtp"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to create this issue 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: What happened? 13 | placeholder: Tell us what you see! 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: replication 18 | attributes: 19 | label: Replication Steps 20 | description: Provide clear steps as to how this happened 21 | value: "1. First click this \n2. Navigate to this \n3. Click this \n4. See this" 22 | validations: 23 | required: false -------------------------------------------------------------------------------- /.github/workflows/release-js-package.yml: -------------------------------------------------------------------------------- 1 | name: Release JS Packages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "packages/sdk/**" # Trigger only changes in packages 9 | - ".github/workflows/release-js-package.yml" 10 | 11 | concurrency: ${{ github.workflow }}-${{ github.ref }} 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | defaults: 17 | run: 18 | working-directory: packages/sdk 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up Node.js 20.x 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 20.x 27 | 28 | - name: Set up pnpm 29 | uses: pnpm/action-setup@v4 30 | 31 | - name: Install dependencies 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: Create .npmrc file 35 | run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc 36 | env: 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | 39 | - name: Publish 40 | run: pnpm publish-sdk 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /.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 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | .env.selfhost 15 | 16 | # Testing 17 | coverage 18 | .idea 19 | 20 | # Turbo 21 | .turbo 22 | 23 | # Vercel 24 | .vercel 25 | 26 | # Build Outputs 27 | .next/ 28 | out/ 29 | build 30 | dist 31 | 32 | 33 | # Debug 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | 38 | # Misc 39 | .DS_Store 40 | *.pem 41 | prod_db.tar 42 | 43 | bin -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/.npmrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | You are a Staff Engineer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS, NodeJS, Prisma, Postgres, and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are also great at scalling things. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. 2 | 3 | - Follow the user’s requirements carefully & to the letter. 4 | - First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. 5 | - Always write correct, best practice, bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . 6 | - Focus on easy and readability code, over being performant. 7 | - Fully implement all requested functionality. 8 | - Leave NO todo’s, placeholders or missing pieces. 9 | - Ensure code is complete! Verify thoroughly finalised. 10 | - Include all required imports, and ensure proper naming of key components. 11 | - Be concise Minimize any other prose. 12 | - If you think there might not be a correct answer, you say so. 13 | - If you do not know the answer, say so, instead of guessing. 14 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Unsend Project Guidelines 2 | 3 | ## Commands 4 | - **Build**: `pnpm build` (specific: `pnpm build:web`, `pnpm build:editor`) 5 | - **Lint**: `pnpm lint` 6 | - **Dev**: `pnpm dev` (or `pnpm d` for setup + dev) 7 | - **DB**: `pnpm db:migrate-dev`, `pnpm db:studio`, `pnpm db:push` 8 | - **Test**: Run single test with `pnpm test --filter=web -- -t "test name"` 9 | - **Format**: `pnpm format` 10 | 11 | ## Code Style 12 | - **Formatting**: Prettier with tailwind plugin 13 | - **Imports**: Group by source (internal/external), alphabetize 14 | - **TypeScript**: Strong typing, avoid `any`, use Zod for validation 15 | - **Naming**: camelCase for variables/functions, PascalCase for components/classes 16 | - **React**: Functional components with hooks, group related hooks 17 | - **Component Structure**: Props at top, hooks next, helper functions, then JSX 18 | - **Error Handling**: Use try/catch with specific error types 19 | - **API**: Use tRPC for internal, Hono for public API endpoints 20 | 21 | Follow Vercel style guides with strict TypeScript. Be thoughtful, write readable code over premature optimization. -------------------------------------------------------------------------------- /apps/docs/README.md: -------------------------------------------------------------------------------- 1 | # Mintlify Starter Kit 2 | 3 | Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including 4 | 5 | - Guide pages 6 | - Navigation 7 | - Customizations 8 | - API Reference pages 9 | - Use of popular components 10 | 11 | ### Development 12 | 13 | Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command 14 | 15 | ``` 16 | npm i -g mintlify 17 | ``` 18 | 19 | Run the following command at the root of your documentation (where mint.json is) 20 | 21 | ``` 22 | mintlify dev 23 | ``` 24 | 25 | ### Publishing Changes 26 | 27 | Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard. 28 | 29 | #### Troubleshooting 30 | 31 | - Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies. 32 | - Page loads as a 404 - Make sure you are running in a folder with `mint.json` 33 | -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/create-contact.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: post /v1/contactBooks/{contactBookId}/contacts 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/delete-contact.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: delete /v1/contactBooks/{contactBookId}/contacts/{contactId} 3 | --- -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/get-contact.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: get /v1/contactBooks/{contactBookId}/contacts/{contactId} 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/get-contacts.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: get /v1/contactBooks/{contactBookId}/contacts 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/update-contact.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: patch /v1/contactBooks/{contactBookId}/contacts/{contactId} 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/contacts/upsert-contact.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: put /v1/contactBooks/{contactBookId}/contacts/{contactId} 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/domains/create-domain.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: post /v1/domains 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/domains/get-domain.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: get /v1/domains 3 | --- -------------------------------------------------------------------------------- /apps/docs/api-reference/domains/verify-domain.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: put /v1/domains/{id}/verify 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/batch-email.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: post /v1/emails/batch 3 | --- 4 | 5 | Send up to 100 emails in a single request. 6 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/cancel-schedule.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: post /v1/emails/{emailId}/cancel 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/get-email.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: get /v1/emails/{emailId} 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/list-emails.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: get /v1/emails 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/send-email.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: post /v1/emails 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/emails/update-schedule.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: patch /v1/emails/{emailId} 3 | --- 4 | -------------------------------------------------------------------------------- /apps/docs/api-reference/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: "Fundamental concepts of Usend's API." 4 | --- 5 | 6 | ## Base URL 7 | 8 | Unsend's API is built on REST principles and is served over HTTPS. To ensure data privacy, unencrypted HTTP is not supported. 9 | 10 | The Base URL for all API endpoints is: 11 | 12 | ```sh Terminal 13 | https://app.unsend.dev/api/ 14 | ``` 15 | 16 | ## Authentication 17 | 18 | Authentication to Usend's API is performed via the Authorization header with a Bearer token. To authenticate, you need to include the Authorization header with the word Bearer followed by your token in your API requests like so: 19 | 20 | ```sh Terminal 21 | Authorization: Bearer us_12345 22 | ``` 23 | 24 | You can create a new token/API key under your Unsend [Developer Settings](https://app.unsend.dev/dev-settings/api-keys). 25 | -------------------------------------------------------------------------------- /apps/docs/get-started/create-aws-credentials.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Create AWS credentials 3 | description: Step by step guide to create AWS credentials to self-host Unsend. 4 | --- 5 | 6 | 7 | 8 | Login to your AWS console and go to IAM > Users > Create user. Type in user name, in this case `unsend` 9 | 10 | ![create user](/images/aws/key-1.png) 11 | 12 | 13 | 14 | Search for `AmazonSNSFullAccess` and `AmazonSESFullAccess` and check the checkboxes. Then proceed to create the user. 15 | 16 | ![set permission](/images/aws/key-2.png) 17 | 18 | 19 | 20 | Click on the created user and click on the `Create access key` button. 21 | 22 | ![create access key](/images/aws/key-3.png) 23 | ![option and create](/images/aws/key-4.png) 24 | ![description](/images/aws/key-5.png) 25 | 26 | 27 | 28 | Copy the access key ID and secret access key to your `.env` file. 29 | 30 | ```env 31 | AWS_ACCESS_KEY= 32 | AWS_SECRET_KEY= 33 | ``` 34 | 35 | ![create access key](/images/aws/key-6.png) 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /apps/docs/get-started/smtp.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: SMTP support 3 | description: "A guide to integrate Unsend with SMTP" 4 | icon: envelope 5 | --- 6 | 7 | ## Prerequisites 8 | 9 | You will need an API key and a verified domain to get the most out of this guide: 10 | 11 | - [API Key](https://app.unsend.dev/dev-settings/api-keys) 12 | - [Verified Domain](https://app.unsend.dev/domains) 13 | 14 | ## SMTP credentials 15 | 16 | To set up your SMTP integration, you'll need to provide the following credentials: 17 | 18 | - **Host:** ```smtp.unsend.dev``` 19 | - **Port:** ```465```, ```587```, ```2465```, or ```2587``` 20 | - **Username:** ```unsend``` 21 | - **Password:** ```YOUR-API-KEY``` 22 | 23 | ## Example with Nodemailer 24 | 25 | Following example with Nodemailer shows how you can send mails with SMTP support from Unsend and Nodemailer. 26 | 27 | ```javascript 28 | const nodemailer = require("nodemailer"); 29 | 30 | const transporter = nodemailer.createTransport({ 31 | host: "smtp.unsend.dev", 32 | port: 465, 33 | secure: false, 34 | auth: { 35 | user: "unsend", 36 | pass: "us_123", 37 | }, 38 | tls: { 39 | rejectUnauthorized: false, 40 | }, 41 | }); 42 | 43 | const mailOptions = { 44 | to: "sender@example.com", 45 | from: "hello@example.com", 46 | subject: "Testing SMTP", 47 | html: "THIS IS USING SMTP,

Unsend is the best open source sending platform

check out unsend.dev", 48 | text: "hello,\n\nUnsend is the best open source sending platform", 49 | }; 50 | 51 | transporter.sendMail(mailOptions, (error, info) => { 52 | if (error) { 53 | console.error("Error sending email:", error); 54 | } else { 55 | console.log("Email sent successfully:", info.response); 56 | } 57 | }); 58 | ``` -------------------------------------------------------------------------------- /apps/docs/guides/use-with-react-email.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Use with React Email 3 | description: "A guide on how to use Unsend with React Email" 4 | --- 5 | 6 | ## Introduction 7 | 8 | [React Email](https://react.email/docs/introduction) is a library for building emails with React. In this guide, we will show you how to use Unsend with React Email. 9 | 10 | ## Install dependencies 11 | 12 | 13 | ```sh npm 14 | npm install unsend @react-email/render 15 | ``` 16 | 17 | ```sh yarn 18 | yarn add unsend @react-email/render 19 | ``` 20 | 21 | ```sh pnpm 22 | pnpm add unsend @react-email/render 23 | ``` 24 | 25 | ```sh bun 26 | bun add unsend @react-email/render 27 | ``` 28 | 29 | 30 | 31 | ## Create an email template 32 | 33 | ```tsx 34 | import * as React from "react"; 35 | import { Html } from "@react-email/html"; 36 | import { Button } from "@react-email/button"; 37 | 38 | export function Email(props) { 39 | const { url } = props; 40 | 41 | return ( 42 | 43 | 44 | 45 | ); 46 | } 47 | ``` 48 | 49 | ## Send an email using Unsend 50 | 51 | ```ts 52 | import { Unsend } from "unsend"; 53 | import { render } from "@react-email/render"; 54 | import { Email } from "./email"; 55 | 56 | const unsend = new Unsend("us_your_unsend_api_key"); 57 | 58 | const html = await render(); 59 | 60 | const response = await unsend.emails.send({ 61 | to: "hello@unsend.dev", 62 | from: "hello@unsend.dev", 63 | subject: "Unsend email", 64 | html, 65 | }); 66 | ``` 67 | 68 | ## Build your project 69 | 70 | ### JavaScript 71 | 72 | If you're using nodejs, importing `email.jsx` might fail. make sure to add these to your babel config: 73 | 74 | ```js 75 | { 76 | "plugins": ["@babel/plugin-proposal-class-properties"] 77 | } 78 | ``` 79 | 80 | Checkout this [example](https://github.com/unsend-dev/unsend-js-examples/tree/main/react-email-js) 81 | 82 | ### TypeScript 83 | 84 | Just add `jsx` to your `tsconfig.json` 85 | 86 | ```json 87 | { 88 | "compilerOptions": { "jsx": "react-jsx" } 89 | } 90 | ``` 91 | 92 | Checkout this [example](https://github.com/unsend-dev/unsend-js-examples/tree/main/react-email-ts) 93 | -------------------------------------------------------------------------------- /apps/docs/images/aws/key-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-1.png -------------------------------------------------------------------------------- /apps/docs/images/aws/key-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-2.png -------------------------------------------------------------------------------- /apps/docs/images/aws/key-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-3.png -------------------------------------------------------------------------------- /apps/docs/images/aws/key-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-4.png -------------------------------------------------------------------------------- /apps/docs/images/aws/key-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-5.png -------------------------------------------------------------------------------- /apps/docs/images/aws/key-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/aws/key-6.png -------------------------------------------------------------------------------- /apps/docs/images/github-callback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/github-callback.png -------------------------------------------------------------------------------- /apps/docs/images/ses-settings/add-ses-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/ses-settings/add-ses-settings.png -------------------------------------------------------------------------------- /apps/docs/images/ses-settings/sandbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/images/ses-settings/sandbox.png -------------------------------------------------------------------------------- /apps/docs/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: "Unsend is Open source alternative to Resend, Sendgrid, Mailgun and Postmark etc." 4 | icon: rocket 5 | --- 6 | 7 | ## Setting up 8 | 9 | Quicklinks to set up your account and get started 10 | 11 | 12 | 17 | Add domains to send emails 18 | 19 | 20 | 25 | Generate API key to send emails from your app. 26 | 27 | 32 | Learn how to use our API to send emails programmatically. 33 | 34 | 39 | Learn how to use our SDK using NodeJS to send emails programmatically. 40 | 41 | 46 | Send emails with SMTP server instead of REST API. 47 | 48 | 49 | -------------------------------------------------------------------------------- /apps/docs/logo/Logo-wordmark-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/logo/Logo-wordmark-dark.png -------------------------------------------------------------------------------- /apps/docs/logo/Logo-wordmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/docs/logo/Logo-wordmark.png -------------------------------------------------------------------------------- /apps/marketing/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@unsend/eslint-config/next.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /apps/marketing/.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /apps/marketing/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /apps/marketing/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "export", 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /apps/marketing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marketing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3001", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "^2.2.0", 13 | "@unsend/email-editor": "workspace:*", 14 | "@unsend/ui": "workspace:*", 15 | "date-fns": "^4.1.0", 16 | "framer-motion": "^12.9.2", 17 | "lucide-react": "^0.503.0", 18 | "next": "15.3.1", 19 | "react": "^19.1.0", 20 | "react-dom": "^19.1.0" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.15.2", 24 | "@types/react": "^19.1.2", 25 | "@types/react-dom": "^19.1.2", 26 | "@unsend/eslint-config": "workspace:*", 27 | "@unsend/tailwind-config": "workspace:*", 28 | "autoprefixer": "^10.4.21", 29 | "eslint": "^9.25.1", 30 | "eslint-config-next": "15.3.1", 31 | "postcss": "^8.5.3", 32 | "tailwindcss": "^3.4.1", 33 | "typescript": "^5.8.3" 34 | } 35 | } -------------------------------------------------------------------------------- /apps/marketing/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/marketing/public/Logo-bold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/marketing/public/Logo-bold.png -------------------------------------------------------------------------------- /apps/marketing/public/app.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/marketing/public/app.webp -------------------------------------------------------------------------------- /apps/marketing/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/marketing/public/favicon.ico -------------------------------------------------------------------------------- /apps/marketing/public/og_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/marketing/public/og_banner.png -------------------------------------------------------------------------------- /apps/marketing/src/app/editor/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Editor } from "@unsend/email-editor"; 4 | import { Button } from "@unsend/ui/src/button"; 5 | import { useState } from "react"; 6 | 7 | export default function EditorPage() { 8 | const [json, setJson] = useState>({ 9 | type: "doc", 10 | content: [], 11 | }); 12 | 13 | const onConvertToHtml = async () => { 14 | console.log(json) 15 | const resp = await fetch("http://localhost:3000/api/to-html", { 16 | method: "POST", 17 | body: JSON.stringify(json), 18 | }); 19 | 20 | const respJson = await resp.json(); 21 | console.log(respJson); 22 | }; 23 | 24 | return ( 25 |

26 |

27 | Try out unsend's email editor 28 |

29 |
30 | 33 | 34 | setJson(editor.getJSON())} /> 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/marketing/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | @layer utilities { 30 | .text-balance { 31 | text-wrap: balance; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/marketing/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import sharedConfig from "@unsend/tailwind-config/tailwind.config"; 3 | import path from "path"; 4 | 5 | export default { 6 | ...sharedConfig, 7 | content: [ 8 | "./src/**/*.tsx", 9 | `${path.join(require.resolve("@unsend/ui"), "..")}/**/*.{ts,tsx}`, 10 | ], 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /apps/marketing/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/nextjs.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "~/*": ["./src/*"] 7 | }, 8 | "plugins": [ 9 | { 10 | "name": "next" 11 | } 12 | ], 13 | "strictNullChecks": true 14 | }, 15 | "include": [ 16 | "next-env.d.ts", 17 | "**/*.ts", 18 | "**/*.tsx", 19 | "**/*.cjs", 20 | "**/*.js", 21 | ".next/types/**/*.ts" 22 | ], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/smtp-server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build stage 2 | FROM node:20-alpine AS builder 3 | 4 | # Install pnpm (package manager) globally 5 | RUN npm install -g pnpm 6 | 7 | # Set working directory for the application 8 | WORKDIR /app 9 | 10 | # Copy configuration files first 11 | COPY package.json tsconfig.json tsup.config.ts ./ 12 | 13 | # Install dependencies (including devDependencies) 14 | RUN pnpm install 15 | 16 | # Copy the source code 17 | COPY src/ ./src/ 18 | 19 | # Build the application 20 | RUN pnpm run build 21 | 22 | 23 | # Remove development dependencies to reduce size 24 | RUN pnpm prune --prod 25 | 26 | # Stage 2: Production stage 27 | FROM node:20-alpine AS production 28 | 29 | # Set working directory in the final image 30 | WORKDIR /app 31 | 32 | # Copy necessary files from the builder stage: production node_modules, build output, and package definition 33 | COPY --from=builder /app/node_modules ./node_modules 34 | 35 | # Copy only the necessary files from builder 36 | COPY --from=builder /app/dist ./dist 37 | COPY --from=builder /app/package.json ./package.json 38 | 39 | 40 | # Expose SMTP ports (standard SMTP, SMTPS, and alternative ports) 41 | EXPOSE 25 465 587 2465 2587 42 | 43 | 44 | # Run the SMTP server 45 | CMD ["node", "dist/server.js"] 46 | -------------------------------------------------------------------------------- /apps/smtp-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: unsend-smtp-server 2 | 3 | services: 4 | smtp-server: 5 | container_name: unsend-smtp-server 6 | image: unsend/smtp-proxy:latest 7 | # Pass necessary environment variables 8 | environment: 9 | SMTP_AUTH_USERNAME: "unsend" # can be anything, just use the same while sending emails 10 | UNSEND_BASE_URL: "https://app.unsend.dev" # your self hosted unsend instance url 11 | 12 | # Uncomment this if you have SSL certificates. port 465 and 2465 will be using SSL 13 | # UNSEND_API_KEY_PATH: "/certs/server.key" 14 | # UNSEND_API_CERT_PATH: "/certs/server.crt" 15 | # If you have SSL certificates, mount them here (read-only recommended) 16 | 17 | # volumes: 18 | # - ./certs/server.key:/certs/server.key:ro 19 | # - ./certs/server.crt:/certs/server.crt:ro 20 | 21 | # Expose the SMTP ports 22 | ports: 23 | - "25:25" 24 | - "587:587" 25 | - "2587:2587" 26 | - "465:465" 27 | - "2465:2465" 28 | # Restart always or on-failure, depending on preference 29 | restart: unless-stopped 30 | -------------------------------------------------------------------------------- /apps/smtp-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smtp-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "tsup", 9 | "start": "node dist/server.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@types/mailparser": "^3.4.5", 16 | "@types/smtp-server": "^3.5.10", 17 | "dotenv": "^16.5.0", 18 | "mailparser": "^3.7.2", 19 | "nodemailer": "^6.10.1", 20 | "smtp-server": "^3.13.6" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^22.15.2", 24 | "@types/nodemailer": "^6.4.17", 25 | "tsup": "^8.4.0", 26 | "typescript": "^5.8.3" 27 | } 28 | } -------------------------------------------------------------------------------- /apps/smtp-server/src/usage.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require("nodemailer"); 2 | 3 | const transporter = nodemailer.createTransport({ 4 | host: "localhost", 5 | port: 25, 6 | secure: false, 7 | auth: { 8 | user: "unsend", 9 | pass: "us_123", 10 | }, 11 | tls: { 12 | rejectUnauthorized: false, 13 | }, 14 | }); 15 | 16 | const mailOptions = { 17 | to: "sender@example.com", 18 | from: "hello@example.com", 19 | subject: "Testing SMTP", 20 | html: "THIS IS USING SMTP,

Unsend is the best open source sending platform

check out unsend.dev", 21 | text: "hello,\n\nUnsend is the best open source sending platform", 22 | }; 23 | 24 | transporter.sendMail(mailOptions, (error, info) => { 25 | if (error) { 26 | console.error("Error sending email:", error); 27 | } else { 28 | console.log("Email sent successfully:", info.response); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /apps/smtp-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Language and Environment */ 4 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 5 | "module": "commonjs", /* Specify what module code is generated. */ 6 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */ 7 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 8 | 9 | /* Modules */ 10 | "rootDir": "./src", /* Specify the root folder within your source files. */ 11 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 12 | 13 | /* Emit */ 14 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 15 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 16 | "removeComments": true, /* Disable emitting comments. */ 17 | 18 | /* Type Checking */ 19 | "strict": true, /* Enable all strict type-checking options. */ 20 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 21 | }, 22 | "include": ["src/**/*.ts"], /* Include all TypeScript files in the src directory. */ 23 | "exclude": ["node_modules"] /* Exclude node_modules from compilation. */ 24 | } 25 | -------------------------------------------------------------------------------- /apps/smtp-server/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig, Options } from "tsup"; 3 | 4 | // eslint-disable-next-line import/no-default-export 5 | export default defineConfig((options: Options) => ({ 6 | entry: ["src/server.ts"], 7 | format: ["cjs"], 8 | dts: true, 9 | minify: true, 10 | clean: true, 11 | injectStyle: true, 12 | ...options, 13 | })); 14 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@unsend/eslint-config/next.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /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/README.md: -------------------------------------------------------------------------------- 1 | # Create T3 App 2 | 3 | This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. 4 | 5 | ## What's next? How do I make an app with this? 6 | 7 | We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. 8 | 9 | If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. 10 | 11 | - [Next.js](https://nextjs.org) 12 | - [NextAuth.js](https://next-auth.js.org) 13 | - [Prisma](https://prisma.io) 14 | - [Drizzle](https://orm.drizzle.team) 15 | - [Tailwind CSS](https://tailwindcss.com) 16 | - [tRPC](https://trpc.io) 17 | 18 | ## Learn More 19 | 20 | To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: 21 | 22 | - [Documentation](https://create.t3.gg/) 23 | - [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials 24 | 25 | You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! 26 | 27 | ## How do I deploy this? 28 | 29 | Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. 30 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.js"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | output: process.env.DOCKER_OUTPUT ? "standalone" : undefined, 10 | experimental: { 11 | instrumentationHook: true, 12 | serverComponentsExternalPackages: ["bullmq"], 13 | esmExternals: "loose", 14 | }, 15 | images: { 16 | remotePatterns: [ 17 | { 18 | protocol: "https", 19 | hostname: "www.gravatar.com", 20 | }, 21 | ], 22 | }, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240820093529_add_schedule/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "EmailStatus" ADD VALUE 'SCHEDULED'; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Email" ADD COLUMN "scheduledAt" TIMESTAMP(3); 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240820093931_add_cancelled/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "EmailStatus" ADD VALUE 'CANCELLED'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20240914003754_add_emoji_for_contact_book/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "ContactBook" ADD COLUMN "emoji" TEXT NOT NULL DEFAULT '📙'; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250201051912_add_indexes/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "Campaign_createdAt_idx" ON "Campaign"("createdAt" DESC); 3 | 4 | -- CreateIndex 5 | CREATE INDEX "Email_createdAt_idx" ON "Email"("createdAt" DESC); 6 | 7 | -- CreateIndex 8 | CREATE INDEX "EmailEvent_emailId_idx" ON "EmailEvent"("emailId"); 9 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250201131024_add_daily_usage/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "EmailUsageType" AS ENUM ('TRANSACTIONAL', 'MARKETING'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "DailyEmailUsage" ( 6 | "teamId" INTEGER NOT NULL, 7 | "date" TEXT NOT NULL, 8 | "type" "EmailUsageType" NOT NULL, 9 | "domainId" INTEGER NOT NULL, 10 | "delivered" INTEGER NOT NULL, 11 | "opened" INTEGER NOT NULL, 12 | "clicked" INTEGER NOT NULL, 13 | "bounced" INTEGER NOT NULL, 14 | "complained" INTEGER NOT NULL, 15 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" TIMESTAMP(3) NOT NULL, 17 | 18 | CONSTRAINT "DailyEmailUsage_pkey" PRIMARY KEY ("teamId","domainId","date","type") 19 | ); 20 | 21 | -- AddForeignKey 22 | ALTER TABLE "DailyEmailUsage" ADD CONSTRAINT "DailyEmailUsage_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; 23 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250201131959_add_sent_to_daily_usage/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "DailyEmailUsage" ADD COLUMN "sent" INTEGER NOT NULL DEFAULT 0, 3 | ALTER COLUMN "delivered" SET DEFAULT 0, 4 | ALTER COLUMN "opened" SET DEFAULT 0, 5 | ALTER COLUMN "clicked" SET DEFAULT 0, 6 | ALTER COLUMN "bounced" SET DEFAULT 0, 7 | ALTER COLUMN "complained" SET DEFAULT 0; 8 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250202115917_add_payments/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[stripeCustomerId]` on the table `Team` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateEnum 8 | CREATE TYPE "Plan" AS ENUM ('FREE', 'BASIC'); 9 | 10 | -- AlterTable 11 | ALTER TABLE "Team" ADD COLUMN "plan" "Plan" NOT NULL DEFAULT 'FREE', 12 | ADD COLUMN "stripeCustomerId" TEXT; 13 | 14 | -- CreateTable 15 | CREATE TABLE "Subscription" ( 16 | "id" TEXT NOT NULL, 17 | "teamId" INTEGER NOT NULL, 18 | "status" TEXT NOT NULL, 19 | "priceId" TEXT NOT NULL, 20 | "currentPeriodEnd" TIMESTAMP(3), 21 | "currentPeriodStart" TIMESTAMP(3), 22 | "cancelAtPeriodEnd" TIMESTAMP(3), 23 | "paymentMethod" TEXT, 24 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "updatedAt" TIMESTAMP(3) NOT NULL, 26 | 27 | CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") 28 | ); 29 | 30 | -- CreateIndex 31 | CREATE UNIQUE INDEX "Team_stripeCustomerId_key" ON "Team"("stripeCustomerId"); 32 | 33 | -- AddForeignKey 34 | ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; 35 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250207104036_add_template/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Template" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "teamId" INTEGER NOT NULL, 6 | "subject" TEXT NOT NULL, 7 | "html" TEXT, 8 | "content" TEXT, 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "Template_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateIndex 16 | CREATE INDEX "Template_createdAt_idx" ON "Template"("createdAt" DESC); 17 | 18 | -- AddForeignKey 19 | ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; 20 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250215074030_added_apiid_to_email_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Email" ADD COLUMN "apiId" INTEGER; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250317104401_add_unsubscribe_reason/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "UnsubscribeReason" AS ENUM ('BOUNCED', 'COMPLAINED', 'UNSUBSCRIBED'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "Contact" ADD COLUMN "unsubscribeReason" "UnsubscribeReason"; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250321093643_add_is_active/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Team" ADD COLUMN "billingEmail" TEXT, 3 | ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true; 4 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250323114242_add_team_invites/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "TeamInvite" ( 3 | "id" TEXT NOT NULL, 4 | "teamId" INTEGER NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "role" "Role" NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "TeamInvite_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "TeamInvite_teamId_email_key" ON "TeamInvite"("teamId", "email"); 15 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250323115457_add_created_at_for_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250325113154_add_team_invites_foreign_key_to_team/migration.sql: -------------------------------------------------------------------------------- 1 | -- AddForeignKey 2 | ALTER TABLE "TeamInvite" ADD CONSTRAINT "TeamInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250425130849_update_email_status/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | BEGIN; 3 | 4 | CREATE TYPE "EmailStatusV2" AS ENUM ('SCHEDULED', 'CANCELLED', 'RENDERING_FAILURE', 'QUEUED', 'SENT', 'REJECTED', 'DELIVERY_DELAYED', 'DELIVERED', 'BOUNCED', 'OPENED', 'CLICKED', 'COMPLAINED', 'FAILED'); 5 | 6 | ALTER TABLE "EmailEvent" 7 | ALTER COLUMN "status" drop default, 8 | ALTER COLUMN "status" type text using "status"::text; 9 | 10 | ALTER TABLE "Email" 11 | ALTER COLUMN "latestStatus" drop default, 12 | ALTER COLUMN "latestStatus" type text using "latestStatus"::text; 13 | 14 | DROP TYPE "EmailStatus"; 15 | 16 | ALTER TYPE "EmailStatusV2" RENAME TO "EmailStatus"; 17 | 18 | ALTER TABLE "EmailEvent" 19 | ALTER COLUMN "status" TYPE "EmailStatus" USING "status"::text::"EmailStatus", 20 | ALTER COLUMN "status" set default 'QUEUED'; 21 | 22 | ALTER TABLE "Email" 23 | ALTER COLUMN "latestStatus" TYPE "EmailStatus" USING "latestStatus"::text::"EmailStatus", 24 | ALTER COLUMN "latestStatus" set default 'QUEUED'; 25 | 26 | COMMIT; 27 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250425141139_add_hard_bounce/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "DailyEmailUsage" ADD COLUMN "hardBounced" INTEGER NOT NULL DEFAULT 0; 3 | 4 | -- AlterTable 5 | ALTER TABLE "EmailEvent" ALTER COLUMN "status" DROP DEFAULT; 6 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250425141529_add_hard_bounce_to_campaign/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Campaign" ADD COLUMN "hardBounced" INTEGER NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250510052850_add_cumulated_metrics/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CumulatedMetrics" ( 3 | "teamId" INTEGER NOT NULL, 4 | "domainId" INTEGER NOT NULL, 5 | "delivered" BIGINT NOT NULL DEFAULT 0, 6 | "hardBounced" BIGINT NOT NULL DEFAULT 0, 7 | "complained" BIGINT NOT NULL DEFAULT 0, 8 | 9 | CONSTRAINT "CumulatedMetrics_pkey" PRIMARY KEY ("teamId","domainId") 10 | ); 11 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250510235405_compute_cumulated_metrics/migration.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | -- 1) Populate or update cumulated totals 4 | INSERT INTO "CumulatedMetrics" ( 5 | "teamId", 6 | "domainId", 7 | "delivered", 8 | "hardBounced", 9 | "complained" 10 | ) 11 | SELECT 12 | du."teamId", 13 | du."domainId", 14 | SUM(du.delivered)::BIGINT AS delivered, 15 | SUM(du."hardBounced")::BIGINT AS hardBounced, 16 | SUM(du.complained)::BIGINT AS complained 17 | FROM public."DailyEmailUsage" du 18 | GROUP BY 19 | du."teamId", 20 | du."domainId" 21 | ON CONFLICT ("teamId","domainId") DO UPDATE 22 | SET 23 | "delivered" = EXCLUDED."delivered", 24 | "hardBounced" = EXCLUDED."hardBounced", 25 | "complained" = EXCLUDED."complained"; 26 | 27 | COMMIT; -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250517053539_add_team_api_rate_limit/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Team" ADD COLUMN "apiRateLimit" INTEGER NOT NULL DEFAULT 2; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/20250523232535_add_in_reply_to/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Email" ADD COLUMN "inReplyToId" TEXT; 3 | -------------------------------------------------------------------------------- /apps/web/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /apps/web/public/Logo-rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/Logo-rounded.png -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/favicon_io/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/android-chrome-192x192.png -------------------------------------------------------------------------------- /apps/web/public/favicon_io/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/android-chrome-512x512.png -------------------------------------------------------------------------------- /apps/web/public/favicon_io/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/apple-touch-icon.png -------------------------------------------------------------------------------- /apps/web/public/favicon_io/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/favicon-16x16.png -------------------------------------------------------------------------------- /apps/web/public/favicon_io/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/favicon-32x32.png -------------------------------------------------------------------------------- /apps/web/public/favicon_io/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/favicon_io/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/favicon_io/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /apps/web/public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/logo-dark.png -------------------------------------------------------------------------------- /apps/web/public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unsend-dev/unsend/4f5b35868f65607cb872ab7994679c5a73d1bc9e/apps/web/public/logo-light.png -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@unsend/ui/src/button"; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@unsend/ui/src/dialog"; 11 | 12 | import { Plus } from "lucide-react"; 13 | import { useState } from "react"; 14 | import { AddSesSettingsForm } from "~/components/settings/AddSesSettings"; 15 | 16 | export default function AddSesConfiguration() { 17 | const [open, setOpen] = useState(false); 18 | 19 | return ( 20 |

(_open !== open ? setOpen(_open) : null)} 23 | > 24 | 25 | 29 | 30 | 31 | 32 | Add a new SES configuration 33 | 34 |
35 | setOpen(false)} /> 36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import AddSesConfiguration from "./add-ses-configuration"; 4 | import SesConfigurations from "./ses-configurations"; 5 | 6 | export default function ApiKeysPage() { 7 | return ( 8 |
9 |
10 |

Admin

11 | 12 |
13 |
14 |

SES Configurations

15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/campaigns/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import CampaignList from "./campaign-list"; 4 | import CreateCampaign from "./create-campaign"; 5 | 6 | export default function ContactsPage() { 7 | return ( 8 |
9 |
10 |

Campaigns

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/contacts/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import AddContactBook from "./add-contact-book"; 4 | import ContactBooksList from "./contact-books-list"; 5 | 6 | export default function ContactsPage() { 7 | return ( 8 |
9 |
10 |

Contact books

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dasboard-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AppSidebar } from "~/components/AppSideBar"; 4 | import { SidebarInset, SidebarTrigger } from "@unsend/ui/src/sidebar"; 5 | import { SidebarProvider } from "@unsend/ui/src/sidebar"; 6 | import { useIsMobile } from "@unsend/ui/src/hooks/use-mobile"; 7 | 8 | export function DashboardLayout({ children }: { children: React.ReactNode }) { 9 | const isMobile = useIsMobile(); 10 | 11 | return ( 12 |
13 | 14 | 15 | 16 |
17 | {isMobile ? ( 18 | 19 | ) : null} 20 | {children} 21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dashboard/dashboard-filters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs"; 3 | import { useUrlState } from "~/hooks/useUrlState"; 4 | import { 5 | Select, 6 | SelectContent, 7 | SelectItem, 8 | SelectTrigger, 9 | } from "@unsend/ui/src/select"; 10 | import { api } from "~/trpc/react"; 11 | 12 | interface DashboardFiltersProps { 13 | days: string; 14 | setDays: (days: string) => void; 15 | domain: string | null; 16 | setDomain: (domain: string | null) => void; 17 | } 18 | 19 | export default function DashboardFilters({ 20 | days, 21 | setDays, 22 | domain, 23 | setDomain, 24 | }: DashboardFiltersProps) { 25 | const { data: domainsQuery } = api.domain.domains.useQuery(); 26 | 27 | const handleDomain = (val: string) => { 28 | setDomain(val === "All Domain" ? null : val); 29 | }; 30 | 31 | return ( 32 |
33 | 54 | setDays(value)}> 55 | 56 | 7 Days 57 | 30 Days 58 | 59 | 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dashboard/hooks/useColors.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@unsend/ui"; 2 | 3 | export function useColors() { 4 | const { resolvedTheme } = useTheme(); 5 | 6 | const lightColors = { 7 | delivered: "#40a02b", 8 | bounced: "#d20f39", 9 | complained: "#df8e1d", 10 | opened: "#8839ef", 11 | clicked: "#04a5e5", 12 | xaxis: "#6D6F84", 13 | }; 14 | 15 | const darkColors = { 16 | delivered: "#a6e3a1", 17 | bounced: "#f38ba8", 18 | complained: "#F9E2AF", 19 | opened: "#cba6f7", 20 | clicked: "#93c5fd", 21 | xaxis: "#AAB1CD", 22 | }; 23 | 24 | const currentColors = resolvedTheme === "dark" ? darkColors : lightColors; 25 | 26 | return currentColors; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmailChart from "./email-chart"; 4 | import DashboardFilters from "./dashboard-filters"; 5 | import { useUrlState } from "~/hooks/useUrlState"; 6 | import { ReputationMetrics } from "./reputation-metrics"; 7 | 8 | export default function Dashboard() { 9 | const [days, setDays] = useUrlState("days", "7"); 10 | const [domain, setDomain] = useUrlState("domain"); 11 | 12 | return ( 13 |
14 |
15 |
16 |

Analytics

17 | 23 |
24 |
25 | 26 | 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dev-settings/api-keys/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import AddApiKey from "./add-api-key"; 4 | import ApiList from "./api-list"; 5 | 6 | export default function ApiKeysPage() { 7 | return ( 8 |
9 |
10 |

API Keys

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dev-settings/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SettingsNavButton } from "./settings-nav-button"; 4 | 5 | export const dynamic = "force-static"; 6 | 7 | export default function ApiKeysPage({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | return ( 13 |
14 |

Developer settings

15 |
16 | API Keys 17 | SMTP 18 |
19 |
{children}
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dev-settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import AddApiKey from "./api-keys/add-api-key"; 4 | import ApiList from "./api-keys/api-list"; 5 | 6 | export default function ApiKeysPage() { 7 | return ( 8 |
9 |
10 |

API Keys

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import React from "react"; 6 | 7 | export const SettingsNavButton: React.FC<{ 8 | href: string; 9 | children: React.ReactNode; 10 | comingSoon?: boolean; 11 | }> = ({ href, children, comingSoon }) => { 12 | const pathname = usePathname(); 13 | 14 | const isActive = pathname === href; 15 | 16 | if (comingSoon) { 17 | return ( 18 |
19 |
22 | {children} 23 |
24 |
25 | soon 26 |
27 |
28 | ); 29 | } 30 | 31 | return ( 32 | 36 | {children} 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/domains/domain-badge.tsx: -------------------------------------------------------------------------------- 1 | import { DomainStatus } from "@prisma/client"; 2 | 3 | export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({ 4 | status, 5 | }) => { 6 | let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color 7 | switch (status) { 8 | case DomainStatus.SUCCESS: 9 | badgeColor = 10 | "bg-green-500/15 dark:bg-green-600/10 text-green-700 dark:text-green-600/90 border border-green-500/25 dark:border-green-700/25"; 11 | break; 12 | case DomainStatus.FAILED: 13 | badgeColor = 14 | "bg-red-500/10 text-red-600 dark:text-red-700/90 border border-red-600/10"; 15 | break; 16 | case DomainStatus.TEMPORARY_FAILURE: 17 | case DomainStatus.PENDING: 18 | badgeColor = 19 | "bg-yellow-500/20 dark:bg-yellow-500/10 text-yellow-600 border border-yellow-600/10"; 20 | break; 21 | default: 22 | badgeColor = 23 | "bg-gray-200/70 dark:bg-gray-400/10 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-400/20"; 24 | } 25 | 26 | return ( 27 |
30 | 31 | {status === "SUCCESS" ? "Verified" : status.toLowerCase()} 32 | 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/domains/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import DomainsList from "./domain-list"; 4 | import AddDomain from "./add-domain"; 5 | 6 | export default function DomainsPage() { 7 | return ( 8 |
9 |
10 |

Domains

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/domains/status-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { DomainStatus } from "@prisma/client"; 2 | 3 | export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({ 4 | status, 5 | }) => { 6 | let badgeColor = "bg-gray-400"; // Default color 7 | switch (status) { 8 | case DomainStatus.NOT_STARTED: 9 | badgeColor = "bg-gray-400"; 10 | break; 11 | case DomainStatus.SUCCESS: 12 | badgeColor = "bg-emerald-500"; 13 | break; 14 | case DomainStatus.FAILED: 15 | badgeColor = "bg-red-500"; 16 | break; 17 | case DomainStatus.TEMPORARY_FAILURE: 18 | case DomainStatus.PENDING: 19 | badgeColor = "bg-yellow-500"; 20 | break; 21 | default: 22 | badgeColor = "bg-gray-400"; 23 | } 24 | 25 | return
; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/emails/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmailList from "./email-list"; 4 | 5 | export default function EmailsPage() { 6 | return ( 7 |
8 |
9 |

Emails

10 |
11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardProvider } from "~/providers/dashboard-provider"; 2 | import { NextAuthProvider } from "~/providers/next-auth"; 3 | import { DashboardLayout } from "./dasboard-layout"; 4 | 5 | export const dynamic = "force-static"; 6 | 7 | export default function AuthenticatedDashboardLayout({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | return ( 13 | 14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/payments/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@unsend/ui/src/button"; 4 | import Spinner from "@unsend/ui/src/spinner"; 5 | import { CheckCircle2 } from "lucide-react"; 6 | import Link from "next/link"; 7 | import { useSearchParams } from "next/navigation"; 8 | import { api } from "~/trpc/react"; 9 | 10 | export default function PaymentsPage() { 11 | const searchParams = useSearchParams(); 12 | 13 | const success = searchParams.get("success"); 14 | const canceled = searchParams.get("canceled"); 15 | 16 | return ( 17 |
18 |

19 | Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"} 20 |

21 | {canceled ? ( 22 | 23 | 24 | 25 | ) : null} 26 | {success ? : null} 27 |
28 | ); 29 | } 30 | 31 | function VerifySuccess() { 32 | const { data: teams, isLoading } = api.team.getTeams.useQuery(undefined, { 33 | refetchInterval: 3000, 34 | }); 35 | 36 | if (teams?.[0]?.plan !== "FREE") { 37 | return ( 38 |
39 |
40 | 41 |

Your account has been upgraded to the paid plan.

42 |
43 | 44 | 45 | 46 |
47 | ); 48 | } 49 | 50 | return ( 51 |
52 | 56 |

Verifying payment

57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTeam } from "~/providers/team-context"; 4 | import { SettingsNavButton } from "../dev-settings/settings-nav-button"; 5 | import { isCloud } from "~/utils/common"; 6 | 7 | export const dynamic = "force-static"; 8 | 9 | export default function ApiKeysPage({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | const { currentIsAdmin } = useTeam(); 15 | 16 | return ( 17 |
18 |

Settings

19 |
20 | {isCloud() ? ( 21 | Usage 22 | ) : null} 23 | {currentIsAdmin && isCloud() ? ( 24 | 25 | Billing 26 | 27 | ) : null} 28 | Team 29 |
30 |
{children}
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { isCloud } from "~/utils/common"; 4 | import UsagePage from "./usage/usage"; 5 | import InviteTeamMember from "./team/invite-team-member"; 6 | import TeamMembersList from "./team/team-members-list"; 7 | 8 | export default function SettingsPage() { 9 | if (!isCloud()) { 10 | return ( 11 |
12 |
13 |
14 | 15 |
16 | 17 |
18 |
19 | ); 20 | } 21 | 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/settings/team/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import InviteTeamMember from "./invite-team-member"; 4 | import TeamMembersList from "./team-members-list"; 5 | 6 | export default function TeamsPage() { 7 | return ( 8 |
9 |
10 | 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/app/(dashboard)/templates/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import TemplateList from "./template-list"; 4 | import CreateTemplate from "./create-template"; 5 | 6 | export default function TemplatesPage() { 7 | return ( 8 |
9 |
10 |

Templates

11 | 12 |
13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | 3 | import { authOptions } from "~/server/auth"; 4 | 5 | const handler = NextAuth(authOptions); 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /apps/web/src/app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | export const dynamic = "force-dynamic"; 2 | 3 | export async function GET() { 4 | return Response.json({ data: "Healthy" }); 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/src/app/api/to-html/route.ts: -------------------------------------------------------------------------------- 1 | import { EmailRenderer } from "@unsend/email-editor/src/renderer"; 2 | 3 | export const dynamic = "force-dynamic"; 4 | 5 | export async function POST(req: Request) { 6 | const data = await req.json(); 7 | 8 | try { 9 | const renderer = new EmailRenderer(data); 10 | const time = Date.now(); 11 | const html = await renderer.render({ 12 | shouldReplaceVariableValues: true, 13 | linkValues: { 14 | "{{unsend_unsubscribe_url}}": "https://unsend.com/unsubscribe", 15 | }, 16 | }); 17 | console.log(`Time taken: ${Date.now() - time}ms`); 18 | return new Response(JSON.stringify({ data: html }), { 19 | headers: { 20 | "Content-Type": "application/json", 21 | "Access-Control-Allow-Origin": "*", 22 | "Access-Control-Allow-Methods": "POST, OPTIONS", 23 | "Access-Control-Allow-Headers": "Content-Type", 24 | }, 25 | }); 26 | } catch (e) { 27 | console.error(e); 28 | return new Response( 29 | JSON.stringify({ data: "Error in converting to html" }), 30 | { 31 | headers: { 32 | "Content-Type": "application/json", 33 | "Access-Control-Allow-Origin": "*", 34 | "Access-Control-Allow-Methods": "POST, OPTIONS", 35 | "Access-Control-Allow-Headers": "Content-Type", 36 | }, 37 | } 38 | ); 39 | } 40 | } 41 | 42 | export function OPTIONS() { 43 | return new Response(null, { 44 | headers: { 45 | "Access-Control-Allow-Origin": "*", 46 | "Access-Control-Allow-Methods": "POST, OPTIONS", 47 | "Access-Control-Allow-Headers": "Content-Type", 48 | }, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { type NextRequest } from "next/server"; 3 | 4 | import { env } from "~/env"; 5 | import { appRouter } from "~/server/api/root"; 6 | import { createTRPCContext } from "~/server/api/trpc"; 7 | 8 | /** 9 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 10 | * handling a HTTP request (e.g. when you make requests from Client Components). 11 | */ 12 | const createContext = async (req: NextRequest) => { 13 | return createTRPCContext({ 14 | headers: req.headers, 15 | }); 16 | }; 17 | 18 | const handler = (req: NextRequest) => 19 | fetchRequestHandler({ 20 | endpoint: "/api/trpc", 21 | req, 22 | router: appRouter, 23 | createContext: () => createContext(req), 24 | onError: 25 | env.NODE_ENV === "development" 26 | ? ({ path, error }) => { 27 | console.error( 28 | `❌ tRPC failed on ${path ?? ""}: ${error.message}` 29 | ); 30 | } 31 | : undefined, 32 | }); 33 | 34 | export { handler as GET, handler as POST }; 35 | -------------------------------------------------------------------------------- /apps/web/src/app/api/v1/[[...route]]/route.ts: -------------------------------------------------------------------------------- 1 | import { handle } from "hono/vercel"; 2 | import { app } from "~/server/public-api"; 3 | 4 | export const GET = handle(app); 5 | export const POST = handle(app); 6 | export const PUT = handle(app); 7 | export const DELETE = handle(app); 8 | export const PATCH = handle(app); 9 | -------------------------------------------------------------------------------- /apps/web/src/app/join-team/page.tsx: -------------------------------------------------------------------------------- 1 | import JoinTeam from "~/components/team/JoinTeam"; 2 | import { Suspense } from "react"; 3 | import Spinner from "@unsend/ui/src/spinner"; 4 | import { getServerAuthSession } from "~/server/auth"; 5 | import { redirect } from "next/navigation"; 6 | 7 | export default async function CreateTeam() { 8 | const session = await getServerAuthSession(); 9 | 10 | if (!session) { 11 | redirect("/login"); 12 | } 13 | 14 | return ( 15 |
16 |
17 | 20 | 21 |
22 | } 23 | > 24 | 25 | 26 |
27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@unsend/ui/styles/globals.css"; 2 | 3 | import { Inter } from "next/font/google"; 4 | import { ThemeProvider } from "@unsend/ui"; 5 | import { Toaster } from "@unsend/ui/src/toaster"; 6 | 7 | import { TRPCReactProvider } from "~/trpc/react"; 8 | import { Metadata } from "next"; 9 | 10 | const inter = Inter({ 11 | subsets: ["latin"], 12 | variable: "--font-sans", 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Unsend", 17 | description: "Open source sending infrastructure for developers", 18 | icons: [{ rel: "icon", url: "/favicon.ico" }], 19 | }; 20 | 21 | export default async function RootLayout({ 22 | children, 23 | }: { 24 | children: React.ReactNode; 25 | }) { 26 | return ( 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getServerAuthSession } from "~/server/auth"; 3 | import LoginPage from "./login-page"; 4 | import { getProviders } from "next-auth/react"; 5 | 6 | export default async function Login() { 7 | const session = await getServerAuthSession(); 8 | 9 | if (session) { 10 | redirect("/dashboard"); 11 | } 12 | 13 | const providers = await getProviders(); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerAuthSession } from "~/server/auth"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default async function Home() { 5 | const session = await getServerAuthSession(); 6 | 7 | if (!session?.user) { 8 | redirect("/login"); 9 | } 10 | 11 | if (!session.user.isBetaUser) { 12 | redirect("/wait-list"); 13 | } else { 14 | redirect("/dashboard"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { getServerAuthSession } from "~/server/auth"; 3 | import LoginPage from "../login/login-page"; 4 | import { getProviders } from "next-auth/react"; 5 | 6 | export default async function Login() { 7 | const session = await getServerAuthSession(); 8 | 9 | if (session) { 10 | redirect("/dashboard"); 11 | } 12 | 13 | const providers = await getProviders(); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/app/unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import { unsubscribeContactFromLink } from "~/server/service/campaign-service"; 2 | import ReSubscribe from "./re-subscribe"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | 6 | async function UnsubscribePage({ 7 | searchParams, 8 | }: { 9 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 10 | }) { 11 | const params = await searchParams; 12 | 13 | const id = params.id as string; 14 | const hash = params.hash as string; 15 | 16 | if (!id || !hash) { 17 | return ( 18 |
19 |
20 |

21 | Unsubscribe 22 |

23 |

24 | Invalid unsubscribe link. Please check your URL and try again. 25 |

26 |
27 |
28 | ); 29 | } 30 | 31 | const contact = await unsubscribeContactFromLink(id, hash); 32 | 33 | return ( 34 |
35 | 36 | 37 |
38 |

39 | Powered by{" "} 40 | 41 | Unsend 42 | 43 |

44 |
45 |
46 | ); 47 | } 48 | 49 | export default UnsubscribePage; 50 | -------------------------------------------------------------------------------- /apps/web/src/app/unsubscribe/re-subscribe.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Contact } from "@prisma/client"; 4 | import { Button } from "@unsend/ui/src/button"; 5 | import Spinner from "@unsend/ui/src/spinner"; 6 | import { toast } from "@unsend/ui/src/toaster"; 7 | import { useState } from "react"; 8 | import { api } from "~/trpc/react"; 9 | 10 | export default function ReSubscribe({ 11 | id, 12 | hash, 13 | contact, 14 | }: { 15 | id: string; 16 | hash: string; 17 | contact: Contact; 18 | }) { 19 | const [subscribed, setSubscribed] = useState(false); 20 | 21 | const reSubscribe = api.campaign.reSubscribeContact.useMutation({ 22 | onSuccess: () => { 23 | toast.success("You have been subscribed again"); 24 | setSubscribed(true); 25 | }, 26 | onError: (e) => { 27 | toast.error(e.message); 28 | }, 29 | }); 30 | 31 | return ( 32 |
33 |

34 | {subscribed ? "You have subscribed again" : "You have unsubscribed"} 35 |

36 |
37 | {subscribed 38 | ? "You have been added to our mailing list and will receive all emails at" 39 | : "You have been removed from our mailing list and won't receive any emails at"}{" "} 40 | {contact.email}. 41 |
42 | 43 |
44 | {!subscribed ? ( 45 | 56 | ) : null} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/src/app/wait-list/page.tsx: -------------------------------------------------------------------------------- 1 | import { Rocket } from "lucide-react"; 2 | 3 | export default async function Home() { 4 | return ( 5 |
6 |
7 | 8 |

You're on the Waitlist!

9 |

10 | Hang tight, we'll get to you as soon as possible. 11 |

12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/src/components/FullScreenLoading.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@unsend/ui"; 2 | import Image from "next/image"; 3 | 4 | export const FullScreenLoading = () => { 5 | const { resolvedTheme } = useTheme(); 6 | return ( 7 |
8 | Unsend 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/src/components/payments/PlanDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Plan } from "@prisma/client"; 2 | import { PLAN_PERKS } from "~/lib/constants/payments"; 3 | import { CheckCircle2 } from "lucide-react"; 4 | import { api } from "~/trpc/react"; 5 | import Spinner from "@unsend/ui/src/spinner"; 6 | import { useTeam } from "~/providers/team-context"; 7 | import { Badge } from "@unsend/ui/src/badge"; 8 | import { format } from "date-fns"; 9 | export const PlanDetails = () => { 10 | const subscriptionQuery = api.billing.getSubscriptionDetails.useQuery(); 11 | const { currentTeam } = useTeam(); 12 | 13 | if (subscriptionQuery.isLoading || !currentTeam) { 14 | return null; 15 | } 16 | 17 | const planKey = currentTeam.plan as keyof typeof PLAN_PERKS; 18 | const perks = PLAN_PERKS[planKey] || []; 19 | 20 | return ( 21 |
22 |
23 | {subscriptionQuery.data?.status === "active" 24 | ? planKey.toLowerCase() 25 | : "free"} 26 |
27 |
28 |
Current plan
29 | {subscriptionQuery.data?.cancelAtPeriodEnd && ( 30 | 31 | Cancels {format(subscriptionQuery.data.cancelAtPeriodEnd, "MMM dd")} 32 | 33 | )} 34 |
35 |
    36 | {perks.map((perk, index) => ( 37 |
  • 38 | 39 | {perk} 40 |
  • 41 | ))} 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /apps/web/src/components/payments/UpgradeButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@unsend/ui/src/button"; 2 | import Spinner from "@unsend/ui/src/spinner"; 3 | import { api } from "~/trpc/react"; 4 | 5 | export const UpgradeButton = () => { 6 | const checkoutMutation = api.billing.createCheckoutSession.useMutation(); 7 | 8 | const onClick = async () => { 9 | const url = await checkoutMutation.mutateAsync(); 10 | if (url) { 11 | window.location.href = url; 12 | } 13 | }; 14 | 15 | return ( 16 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useInterval(callback: () => void, delay: number | null) { 4 | const savedCallback = useRef<(() => void) | null>(null); 5 | 6 | // Remember the latest callback. 7 | useEffect(() => { 8 | savedCallback.current = callback; 9 | }, [callback]); 10 | 11 | // Set up the interval. 12 | useEffect(() => { 13 | function tick() { 14 | if (savedCallback.current) { 15 | savedCallback.current(); 16 | } 17 | } 18 | if (delay !== null) { 19 | const id = setInterval(tick, delay); 20 | return () => clearInterval(id); 21 | } 22 | }, [delay]); 23 | } 24 | 25 | export default useInterval; 26 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useUrlState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import qs from "query-string"; 3 | 4 | /** 5 | * A custom hook to use URL as state 6 | * @param key The query parameter key. 7 | */ 8 | export function useUrlState(key: string, defaultValue: string | null = null) { 9 | const [state, setState] = useState(() => { 10 | if (typeof window === "undefined") return null; 11 | const queryValue = qs.parse(window.location.search)[key]; 12 | if (queryValue !== undefined) { 13 | return (Array.isArray(queryValue) ? queryValue[0] : queryValue) ?? null; 14 | } 15 | return defaultValue; 16 | }); 17 | 18 | // Update URL when state changes 19 | const setUrlState = useCallback( 20 | (newValue: string | null) => { 21 | setState(newValue); 22 | const newQuery = { 23 | ...qs.parse(window.location.search), 24 | [key]: newValue, 25 | }; 26 | const newUrl = qs.stringifyUrl({ 27 | url: window.location.href, 28 | query: newQuery, 29 | }); 30 | window.history.replaceState({}, "", newUrl); 31 | }, 32 | [key] 33 | ); 34 | 35 | return [state, setUrlState] as const; 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./env"; 2 | import { isCloud } from "./utils/common"; 3 | 4 | let initialized = false; 5 | 6 | /** 7 | * Add things here to be executed during server startup. 8 | * 9 | * more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation 10 | */ 11 | export async function register() { 12 | // eslint-disable-next-line turbo/no-undeclared-env-vars 13 | if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) { 14 | console.log("Registering instrumentation"); 15 | 16 | const { EmailQueueService } = await import( 17 | "~/server/service/email-queue-service" 18 | ); 19 | await EmailQueueService.init(); 20 | 21 | /** 22 | * Send usage data to Stripe 23 | */ 24 | if (isCloud()) { 25 | await import("~/server/jobs/usage-job"); 26 | } 27 | 28 | initialized = true; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants/colors.ts: -------------------------------------------------------------------------------- 1 | import { EmailStatus } from "@prisma/client"; 2 | 3 | export const EMAIL_COLORS: Record = { 4 | total: "bg-gray-400 dark:bg-gray-400", 5 | DELIVERED: "bg-[#40a02b] dark:bg-[#a6e3a1]", 6 | BOUNCED: "bg-[#d20f39] dark:bg-[#f38ba8]", 7 | FAILED: "bg-[#d20f39] dark:bg-[#f38ba8]", 8 | CLICKED: "bg-[#04a5e5] dark:bg-[#93c5fd]", 9 | OPENED: "bg-[#8839ef] dark:bg-[#cba6f7]", 10 | COMPLAINED: "bg-[#df8e1d] dark:bg-[#F9E2AF]", 11 | DELIVERY_DELAYED: "bg-[#df8e1d] dark:bg-[#F9E2AF]", 12 | SENT: "bg-gray-200 dark:bg-gray-400", 13 | SCHEDULED: "bg-gray-200 dark:bg-gray-400", 14 | QUEUED: "bg-gray-200 dark:bg-gray-400", 15 | REJECTED: "bg-[#d20f39] dark:bg-[#f38ba8]", 16 | RENDERING_FAILURE: "bg-[#d20f39] dark:bg-[#f38ba8]", 17 | CANCELLED: "bg-gray-200 dark:bg-gray-400", 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_QUERY_LIMIT = 50; 2 | 3 | /* Reputation constants */ 4 | export const HARD_BOUNCE_WARNING_RATE = 5; 5 | export const HARD_BOUNCE_RISK_RATE = 10; 6 | export const COMPLAINED_WARNING_RATE = 0.1; 7 | export const COMPLAINED_RISK_RATE = 0.5; 8 | -------------------------------------------------------------------------------- /apps/web/src/lib/constants/payments.ts: -------------------------------------------------------------------------------- 1 | export const PLAN_PERKS = { 2 | FREE: [ 3 | "Send up to 3000 emails per month", 4 | "Send up to 100 emails per day", 5 | "Can have 1 contact book", 6 | "Can have 1 domain", 7 | "Can have 1 team member", 8 | ], 9 | BASIC: [ 10 | "Includes $10 of usage monthly", 11 | "Send transactional emails at $0.0004 per email", 12 | "Send marketing emails at $0.001 per email", 13 | "Can have unlimited contact books", 14 | "Can have unlimited domains", 15 | "Can have unlimited team members", 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/src/lib/zod/domain-schema.ts: -------------------------------------------------------------------------------- 1 | import { DomainStatus } from "@prisma/client"; 2 | import { z } from "zod"; 3 | 4 | export const DomainStatusSchema = z.nativeEnum(DomainStatus); 5 | 6 | export const DomainSchema = z.object({ 7 | id: z.number().openapi({ description: "The ID of the domain", example: 1 }), 8 | name: z 9 | .string() 10 | .openapi({ description: "The name of the domain", example: "example.com" }), 11 | teamId: z.number().openapi({ description: "The ID of the team", example: 1 }), 12 | status: DomainStatusSchema, 13 | region: z.string().default("us-east-1"), 14 | clickTracking: z.boolean().default(false), 15 | openTracking: z.boolean().default(false), 16 | publicKey: z.string(), 17 | dkimStatus: z.string().optional().nullish(), 18 | spfDetails: z.string().optional().nullish(), 19 | createdAt: z.string(), 20 | updatedAt: z.string(), 21 | dmarcAdded: z.boolean().default(false), 22 | isVerifying: z.boolean().default(false), 23 | errorMessage: z.string().optional().nullish(), 24 | subdomain: z.string().optional().nullish(), 25 | }); 26 | -------------------------------------------------------------------------------- /apps/web/src/providers/dashboard-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "next-auth/react"; 4 | import { FullScreenLoading } from "~/components/FullScreenLoading"; 5 | import { AddSesSettings } from "~/components/settings/AddSesSettings"; 6 | import CreateTeam from "~/components/team/CreateTeam"; 7 | import { env } from "~/env"; 8 | import { api } from "~/trpc/react"; 9 | import { TeamProvider } from "./team-context"; 10 | 11 | export const DashboardProvider = ({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) => { 16 | const { data: session } = useSession(); 17 | const { data: teams, status } = api.team.getTeams.useQuery(); 18 | const { data: settings, status: settingsStatus } = 19 | api.admin.getSesSettings.useQuery(undefined, { 20 | enabled: !env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin, 21 | }); 22 | 23 | if ( 24 | status === "pending" || 25 | (settingsStatus === "pending" && !env.NEXT_PUBLIC_IS_CLOUD) 26 | ) { 27 | return ; 28 | } 29 | 30 | if ( 31 | settings?.length === 0 && 32 | (!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin) 33 | ) { 34 | return ; 35 | } 36 | 37 | if (!teams || teams.length === 0) { 38 | return ; 39 | } 40 | 41 | return {children}; 42 | }; 43 | -------------------------------------------------------------------------------- /apps/web/src/providers/next-auth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import type { Session } from "next-auth"; 6 | import { SessionProvider, useSession } from "next-auth/react"; 7 | import LoginPage from "~/app/login/login-page"; 8 | import { Rocket } from "lucide-react"; 9 | import { FullScreenLoading } from "~/components/FullScreenLoading"; 10 | 11 | export type NextAuthProviderProps = { 12 | session?: Session | null | undefined; 13 | children: React.ReactNode; 14 | }; 15 | 16 | export const NextAuthProvider = ({ 17 | session, 18 | children, 19 | }: NextAuthProviderProps) => { 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | }; 26 | 27 | const AppAuthProvider = ({ children }: { children: React.ReactNode }) => { 28 | const { data: session, status } = useSession({ required: true }); 29 | 30 | if (status === "loading") { 31 | return ; 32 | } 33 | 34 | if (!session) { 35 | return ; 36 | } 37 | 38 | if (!session.user.isBetaUser) { 39 | return ( 40 |
41 |
42 | 43 |

You're on the Waitlist!

44 |

45 | Hang tight, we'll get to you as soon as possible. 46 |

47 |
48 |
49 | ); 50 | } 51 | 52 | return <>{children}; 53 | }; 54 | -------------------------------------------------------------------------------- /apps/web/src/providers/team-context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, useContext, useState, useEffect } from "react"; 4 | import { api } from "~/trpc/react"; 5 | 6 | // Define the Team type based on the Prisma schema 7 | type Team = { 8 | id: number; 9 | name: string; 10 | createdAt: Date; 11 | updatedAt: Date; 12 | plan: "FREE" | "BASIC"; 13 | stripeCustomerId?: string | null; 14 | billingEmail?: string | null; 15 | }; 16 | 17 | interface TeamContextType { 18 | currentTeam: Team | null; 19 | teams: Team[]; 20 | isLoading: boolean; 21 | currentRole: "ADMIN" | "MEMBER"; 22 | currentIsAdmin: boolean; 23 | } 24 | 25 | const TeamContext = createContext(undefined); 26 | 27 | export function TeamProvider({ children }: { children: React.ReactNode }) { 28 | const { data: teams, status } = api.team.getTeams.useQuery(); 29 | 30 | const currentTeam = teams?.[0] ?? null; 31 | 32 | const value = { 33 | currentTeam, 34 | teams: teams || [], 35 | isLoading: status === "pending", 36 | currentRole: currentTeam?.teamUsers[0]?.role ?? "MEMBER", 37 | currentIsAdmin: currentTeam?.teamUsers[0]?.role === "ADMIN", 38 | }; 39 | 40 | return {children}; 41 | } 42 | 43 | export function useTeam() { 44 | const context = useContext(TeamContext); 45 | if (context === undefined) { 46 | throw new Error("useTeam must be used within a TeamProvider"); 47 | } 48 | return context; 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { domainRouter } from "~/server/api/routers/domain"; 2 | import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; 3 | import { apiRouter } from "./routers/api"; 4 | import { emailRouter } from "./routers/email"; 5 | import { teamRouter } from "./routers/team"; 6 | import { adminRouter } from "./routers/admin"; 7 | import { contactsRouter } from "./routers/contacts"; 8 | import { campaignRouter } from "./routers/campaign"; 9 | import { templateRouter } from "./routers/template"; 10 | import { billingRouter } from "./routers/billing"; 11 | import { invitationRouter } from "./routers/invitiation"; 12 | import { dashboardRouter } from "./routers/dashboard"; 13 | 14 | /** 15 | * This is the primary router for your server. 16 | * 17 | * All routers added in /api/routers should be manually added here. 18 | */ 19 | export const appRouter = createTRPCRouter({ 20 | domain: domainRouter, 21 | apiKey: apiRouter, 22 | email: emailRouter, 23 | team: teamRouter, 24 | admin: adminRouter, 25 | contacts: contactsRouter, 26 | campaign: campaignRouter, 27 | template: templateRouter, 28 | billing: billingRouter, 29 | invitation: invitationRouter, 30 | dashboard: dashboardRouter, 31 | }); 32 | 33 | // export type definition of API 34 | export type AppRouter = typeof appRouter; 35 | 36 | /** 37 | * Create a server-side caller for the tRPC API. 38 | * @example 39 | * const trpc = createCaller(createContext); 40 | * const res = await trpc.post.all(); 41 | * ^? Post[] 42 | */ 43 | export const createCaller = createCallerFactory(appRouter); 44 | -------------------------------------------------------------------------------- /apps/web/src/server/api/routers/admin.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { env } from "~/env"; 3 | 4 | import { createTRPCRouter, adminProcedure } from "~/server/api/trpc"; 5 | import { SesSettingsService } from "~/server/service/ses-settings-service"; 6 | import { getAccount } from "~/server/aws/ses"; 7 | 8 | export const adminRouter = createTRPCRouter({ 9 | getSesSettings: adminProcedure.query(async () => { 10 | return SesSettingsService.getAllSettings(); 11 | }), 12 | 13 | getQuotaForRegion: adminProcedure 14 | .input( 15 | z.object({ 16 | region: z.string(), 17 | }) 18 | ) 19 | .query(async ({ input }) => { 20 | const acc = await getAccount(input.region); 21 | return acc.SendQuota?.MaxSendRate; 22 | }), 23 | 24 | addSesSettings: adminProcedure 25 | .input( 26 | z.object({ 27 | region: z.string(), 28 | unsendUrl: z.string().url(), 29 | sendRate: z.number(), 30 | transactionalQuota: z.number(), 31 | }) 32 | ) 33 | .mutation(async ({ input }) => { 34 | return SesSettingsService.createSesSetting({ 35 | region: input.region, 36 | unsendUrl: input.unsendUrl, 37 | sendingRateLimit: input.sendRate, 38 | transactionalQuota: input.transactionalQuota, 39 | }); 40 | }), 41 | 42 | updateSesSettings: adminProcedure 43 | .input( 44 | z.object({ 45 | settingsId: z.string(), 46 | sendRate: z.number(), 47 | transactionalQuota: z.number(), 48 | }) 49 | ) 50 | .mutation(async ({ input }) => { 51 | return SesSettingsService.updateSesSetting({ 52 | id: input.settingsId, 53 | sendingRateLimit: input.sendRate, 54 | transactionalQuota: input.transactionalQuota, 55 | }); 56 | }), 57 | 58 | getSetting: adminProcedure 59 | .input( 60 | z.object({ 61 | region: z.string().optional().nullable(), 62 | }) 63 | ) 64 | .query(async ({ input }) => { 65 | return SesSettingsService.getSetting( 66 | input.region ?? env.AWS_DEFAULT_REGION 67 | ); 68 | }), 69 | }); 70 | -------------------------------------------------------------------------------- /apps/web/src/server/api/routers/api.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { 4 | apiKeyProcedure, 5 | createTRPCRouter, 6 | teamProcedure, 7 | } from "~/server/api/trpc"; 8 | import { addApiKey, deleteApiKey } from "~/server/service/api-service"; 9 | 10 | export const apiRouter = createTRPCRouter({ 11 | createToken: teamProcedure 12 | .input( 13 | z.object({ name: z.string(), permission: z.enum(["FULL", "SENDING"]) }) 14 | ) 15 | .mutation(async ({ ctx, input }) => { 16 | return addApiKey({ 17 | name: input.name, 18 | permission: input.permission, 19 | teamId: ctx.team.id, 20 | }); 21 | }), 22 | 23 | getApiKeys: teamProcedure.query(async ({ ctx }) => { 24 | const keys = await ctx.db.apiKey.findMany({ 25 | where: { 26 | teamId: ctx.team.id, 27 | }, 28 | select: { 29 | id: true, 30 | name: true, 31 | permission: true, 32 | partialToken: true, 33 | lastUsed: true, 34 | createdAt: true, 35 | }, 36 | }); 37 | 38 | return keys; 39 | }), 40 | 41 | deleteApiKey: apiKeyProcedure.mutation(async ({ input }) => { 42 | return deleteApiKey(input.id); 43 | }), 44 | }); 45 | -------------------------------------------------------------------------------- /apps/web/src/server/api/routers/invitiation.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { z } from "zod"; 3 | import { env } from "~/env"; 4 | 5 | import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; 6 | 7 | export const invitationRouter = createTRPCRouter({ 8 | getUserInvites: protectedProcedure 9 | .input( 10 | z.object({ 11 | inviteId: z.string().optional().nullable(), 12 | }) 13 | ) 14 | .query(async ({ ctx, input }) => { 15 | if (!ctx.session.user.email) { 16 | return []; 17 | } 18 | 19 | const invites = await ctx.db.teamInvite.findMany({ 20 | where: { 21 | ...(input.inviteId 22 | ? { id: input.inviteId } 23 | : { email: ctx.session.user.email }), 24 | }, 25 | include: { 26 | team: true, 27 | }, 28 | }); 29 | 30 | return invites; 31 | }), 32 | 33 | getInvite: protectedProcedure 34 | .input(z.object({ inviteId: z.string() })) 35 | .query(async ({ ctx, input }) => { 36 | const invite = await ctx.db.teamInvite.findUnique({ 37 | where: { 38 | id: input.inviteId, 39 | }, 40 | }); 41 | 42 | return invite; 43 | }), 44 | 45 | acceptTeamInvite: protectedProcedure 46 | .input(z.object({ inviteId: z.string() })) 47 | .mutation(async ({ ctx, input }) => { 48 | const invite = await ctx.db.teamInvite.findUnique({ 49 | where: { 50 | id: input.inviteId, 51 | }, 52 | }); 53 | 54 | if (!invite) { 55 | throw new TRPCError({ 56 | code: "NOT_FOUND", 57 | message: "Invite not found", 58 | }); 59 | } 60 | 61 | await ctx.db.teamUser.create({ 62 | data: { 63 | teamId: invite.teamId, 64 | userId: ctx.session.user.id, 65 | role: invite.role, 66 | }, 67 | }); 68 | 69 | await ctx.db.teamInvite.delete({ 70 | where: { 71 | id: input.inviteId, 72 | }, 73 | }); 74 | 75 | return true; 76 | }), 77 | }); 78 | -------------------------------------------------------------------------------- /apps/web/src/server/aws/sns.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SNSClient, 3 | CreateTopicCommand, 4 | SubscribeCommand, 5 | DeleteTopicCommand, 6 | } from "@aws-sdk/client-sns"; 7 | import { env } from "~/env"; 8 | 9 | function getSnsClient(region: string) { 10 | return new SNSClient({ 11 | endpoint: env.AWS_SNS_ENDPOINT, 12 | region: region, 13 | credentials: { 14 | accessKeyId: env.AWS_ACCESS_KEY, 15 | secretAccessKey: env.AWS_SECRET_KEY, 16 | }, 17 | }); 18 | } 19 | 20 | export async function createTopic(topic: string, region: string) { 21 | const client = getSnsClient(region); 22 | const command = new CreateTopicCommand({ 23 | Name: topic, 24 | }); 25 | 26 | const data = await client.send(command); 27 | return data.TopicArn; 28 | } 29 | 30 | export async function deleteTopic(topicArn: string, region: string) { 31 | const client = getSnsClient(region); 32 | await client.send(new DeleteTopicCommand({ TopicArn: topicArn })); 33 | } 34 | 35 | export async function subscribeEndpoint( 36 | topicArn: string, 37 | endpointUrl: string, 38 | region: string 39 | ) { 40 | const subscribeCommand = new SubscribeCommand({ 41 | Protocol: "https", 42 | TopicArn: topicArn, 43 | Endpoint: endpointUrl, 44 | }); 45 | const client = getSnsClient(region); 46 | 47 | const data = await client.send(subscribeCommand); 48 | return data.SubscriptionArn; 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/src/server/billing/usage.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import { env } from "~/env"; 3 | import { getUsageTimestamp } from "~/lib/usage"; 4 | 5 | const METER_EVENT_NAME = "unsend_usage"; 6 | 7 | export async function sendUsageToStripe(customerId: string, usage: number) { 8 | const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { 9 | apiVersion: "2025-03-31.basil", 10 | }); 11 | 12 | const meterEvent = await stripe.billing.meterEvents.create({ 13 | event_name: METER_EVENT_NAME, 14 | payload: { 15 | value: usage.toString(), 16 | stripe_customer_id: customerId, 17 | }, 18 | timestamp: getUsageTimestamp(), 19 | }); 20 | 21 | return meterEvent; 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/server/crypto.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes, scryptSync } from "crypto"; 2 | 3 | export const createSecureHash = async (key: string) => { 4 | const data = new TextEncoder().encode(key); 5 | const salt = randomBytes(16).toString("hex"); 6 | 7 | const derivedKey = scryptSync(data, salt, 64); 8 | 9 | return `${salt}:${derivedKey.toString("hex")}`; 10 | }; 11 | 12 | export const verifySecureHash = async (key: string, hash: string) => { 13 | const data = new TextEncoder().encode(key); 14 | 15 | const [salt, storedHash] = hash.split(":"); 16 | const derivedKey = scryptSync(data, String(salt), 64); 17 | 18 | return storedHash === derivedKey.toString("hex"); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/web/src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { env } from "~/env"; 3 | 4 | const createPrismaClient = () => { 5 | const client = new PrismaClient({ 6 | log: 7 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 8 | }); 9 | 10 | return client; 11 | }; 12 | 13 | // eslint-disable-next-line no-undef 14 | const globalForPrisma = globalThis as unknown as { 15 | prisma: ReturnType | undefined; 16 | }; 17 | 18 | export const db = globalForPrisma.prisma ?? createPrismaClient(); 19 | 20 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; 21 | -------------------------------------------------------------------------------- /apps/web/src/server/nanoid.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | 3 | export const smallNanoid = customAlphabet( 4 | "1234567890abcdefghijklmnopqrstuvwxyz", 5 | 10 6 | ); 7 | 8 | export const nanoid = customAlphabet( 9 | "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 10 | 21 11 | ); 12 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api-utils.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../db"; 3 | import { UnsendApiError } from "./api-error"; 4 | 5 | export const getContactBook = async (c: Context, teamId: number) => { 6 | const contactBookId = c.req.param("contactBookId"); 7 | 8 | if (!contactBookId) { 9 | throw new UnsendApiError({ 10 | code: "BAD_REQUEST", 11 | message: "contactBookId is mandatory", 12 | }); 13 | } 14 | 15 | const contactBook = await db.contactBook.findUnique({ 16 | where: { id: contactBookId, teamId }, 17 | }); 18 | 19 | if (!contactBook) { 20 | throw new UnsendApiError({ 21 | code: "NOT_FOUND", 22 | message: "Contact book not found for this team", 23 | }); 24 | } 25 | 26 | return contactBook; 27 | }; 28 | 29 | export const checkIsValidEmailId = async (emailId: string, teamId: number) => { 30 | const email = await db.email.findUnique({ where: { id: emailId, teamId } }); 31 | 32 | if (!email) { 33 | throw new UnsendApiError({ code: "NOT_FOUND", message: "Email not found" }); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/contacts/add-contact.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { addOrUpdateContact } from "~/server/service/contact-service"; 5 | import { getContactBook } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "post", 9 | path: "/v1/contactBooks/{contactBookId}/contacts", 10 | request: { 11 | params: z.object({ 12 | contactBookId: z 13 | .string() 14 | .min(3) 15 | .openapi({ 16 | param: { 17 | name: "contactBookId", 18 | in: "path", 19 | }, 20 | example: "cuiwqdj74rygf74", 21 | }), 22 | }), 23 | body: { 24 | required: true, 25 | content: { 26 | "application/json": { 27 | schema: z.object({ 28 | email: z.string(), 29 | firstName: z.string().optional(), 30 | lastName: z.string().optional(), 31 | properties: z.record(z.string()).optional(), 32 | subscribed: z.boolean().optional(), 33 | }), 34 | }, 35 | }, 36 | }, 37 | }, 38 | responses: { 39 | 200: { 40 | content: { 41 | "application/json": { 42 | schema: z.object({ contactId: z.string().optional() }), 43 | }, 44 | }, 45 | description: "Retrieve the user", 46 | }, 47 | }, 48 | }); 49 | 50 | function addContact(app: PublicAPIApp) { 51 | app.openapi(route, async (c) => { 52 | const team = c.var.team; 53 | 54 | const contactBook = await getContactBook(c, team.id); 55 | 56 | const contact = await addOrUpdateContact( 57 | contactBook.id, 58 | c.req.valid("json") 59 | ); 60 | 61 | return c.json({ contactId: contact.id }); 62 | }); 63 | } 64 | 65 | export default addContact; 66 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/contacts/delete-contact.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { deleteContact } from "~/server/service/contact-service"; 5 | import { getContactBook } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "delete", 9 | path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}", 10 | request: { 11 | params: z.object({ 12 | contactBookId: z.string().openapi({ 13 | param: { 14 | name: "contactBookId", 15 | in: "path", 16 | }, 17 | example: "cuiwqdj74rygf74", 18 | }), 19 | contactId: z.string().openapi({ 20 | param: { 21 | name: "contactId", 22 | in: "path", 23 | }, 24 | example: "cuiwqdj74rygf74", 25 | }), 26 | }), 27 | }, 28 | responses: { 29 | 200: { 30 | content: { 31 | "application/json": { 32 | schema: z.object({ success: z.boolean() }), 33 | }, 34 | }, 35 | description: "Contact deleted successfully", 36 | }, 37 | }, 38 | }); 39 | 40 | function deleteContactHandler(app: PublicAPIApp) { 41 | app.openapi(route, async (c) => { 42 | const team = c.var.team; 43 | 44 | await getContactBook(c, team.id); 45 | const contactId = c.req.param("contactId"); 46 | 47 | await deleteContact(contactId); 48 | 49 | return c.json({ success: true }); 50 | }); 51 | } 52 | 53 | export default deleteContactHandler; 54 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/contacts/update-contact.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { updateContact } from "~/server/service/contact-service"; 5 | import { getContactBook } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "patch", 9 | path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}", 10 | request: { 11 | params: z.object({ 12 | contactBookId: z.string().openapi({ 13 | param: { 14 | name: "contactBookId", 15 | in: "path", 16 | }, 17 | example: "cuiwqdj74rygf74", 18 | }), 19 | contactId: z.string().openapi({ 20 | param: { 21 | name: "contactId", 22 | in: "path", 23 | }, 24 | example: "cuiwqdj74rygf74", 25 | }), 26 | }), 27 | body: { 28 | required: true, 29 | content: { 30 | "application/json": { 31 | schema: z.object({ 32 | firstName: z.string().optional(), 33 | lastName: z.string().optional(), 34 | properties: z.record(z.string()).optional(), 35 | subscribed: z.boolean().optional(), 36 | }), 37 | }, 38 | }, 39 | }, 40 | }, 41 | responses: { 42 | 200: { 43 | content: { 44 | "application/json": { 45 | schema: z.object({ contactId: z.string().optional() }), 46 | }, 47 | }, 48 | description: "Retrieve the user", 49 | }, 50 | }, 51 | }); 52 | 53 | function updateContactInfo(app: PublicAPIApp) { 54 | app.openapi(route, async (c) => { 55 | const team = c.var.team; 56 | 57 | await getContactBook(c, team.id); 58 | const contactId = c.req.param("contactId"); 59 | 60 | const contact = await updateContact(contactId, c.req.valid("json")); 61 | 62 | return c.json({ contactId: contact.id }); 63 | }); 64 | } 65 | 66 | export default updateContactInfo; 67 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/contacts/upsert-contact.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { addOrUpdateContact } from "~/server/service/contact-service"; 5 | import { getContactBook } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "put", 9 | path: "/v1/contactBooks/{contactBookId}/contacts/{contactId}", 10 | request: { 11 | params: z.object({ 12 | contactBookId: z 13 | .string() 14 | .min(3) 15 | .openapi({ 16 | param: { 17 | name: "contactBookId", 18 | in: "path", 19 | }, 20 | example: "cuiwqdj74rygf74", 21 | }), 22 | }), 23 | body: { 24 | required: true, 25 | content: { 26 | "application/json": { 27 | schema: z.object({ 28 | email: z.string(), 29 | firstName: z.string().optional(), 30 | lastName: z.string().optional(), 31 | properties: z.record(z.string()).optional(), 32 | subscribed: z.boolean().optional(), 33 | }), 34 | }, 35 | }, 36 | }, 37 | }, 38 | responses: { 39 | 200: { 40 | content: { 41 | "application/json": { 42 | schema: z.object({ contactId: z.string() }), 43 | }, 44 | }, 45 | description: "Contact upserted successfully", 46 | }, 47 | }, 48 | }); 49 | 50 | function upsertContact(app: PublicAPIApp) { 51 | app.openapi(route, async (c) => { 52 | const team = c.var.team; 53 | 54 | const contactBook = await getContactBook(c, team.id); 55 | 56 | const contact = await addOrUpdateContact( 57 | contactBook.id, 58 | c.req.valid("json") 59 | ); 60 | 61 | return c.json({ contactId: contact.id }); 62 | }); 63 | } 64 | 65 | export default upsertContact; 66 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/domains/create-domain.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { DomainSchema } from "~/lib/zod/domain-schema"; 3 | import { PublicAPIApp } from "~/server/public-api/hono"; 4 | import { createDomain as createDomainService } from "~/server/service/domain-service"; 5 | import { getTeamFromToken } from "~/server/public-api/auth"; 6 | 7 | const route = createRoute({ 8 | method: "post", 9 | path: "/v1/domains", 10 | request: { 11 | body: { 12 | required: true, 13 | content: { 14 | "application/json": { 15 | schema: z.object({ 16 | name: z.string(), 17 | region: z.string(), 18 | }), 19 | }, 20 | }, 21 | }, 22 | }, 23 | responses: { 24 | 200: { 25 | content: { 26 | "application/json": { 27 | schema: DomainSchema, 28 | }, 29 | }, 30 | description: "Create a new domain", 31 | }, 32 | }, 33 | }); 34 | 35 | function createDomain(app: PublicAPIApp) { 36 | app.openapi(route, async (c) => { 37 | const team = c.var.team; 38 | const body = c.req.valid("json"); 39 | const response = await createDomainService(team.id, body.name, body.region); 40 | 41 | return c.json(response); 42 | }); 43 | } 44 | 45 | export default createDomain; 46 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/domains/get-domains.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { DomainSchema } from "~/lib/zod/domain-schema"; 3 | import { PublicAPIApp } from "~/server/public-api/hono"; 4 | import { db } from "~/server/db"; 5 | import { getTeamFromToken } from "~/server/public-api/auth"; 6 | 7 | const route = createRoute({ 8 | method: "get", 9 | path: "/v1/domains", 10 | responses: { 11 | 200: { 12 | content: { 13 | "application/json": { 14 | schema: z.array(DomainSchema), 15 | }, 16 | }, 17 | description: "Retrieve the user", 18 | }, 19 | }, 20 | }); 21 | 22 | function getDomains(app: PublicAPIApp) { 23 | app.openapi(route, async (c) => { 24 | const team = c.var.team; 25 | 26 | const domains = await db.domain.findMany({ where: { teamId: team.id } }); 27 | 28 | return c.json(domains); 29 | }); 30 | } 31 | 32 | export default getDomains; 33 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/domains/verify-domain.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { db } from "~/server/db"; 5 | 6 | const route = createRoute({ 7 | method: "put", 8 | path: "/v1/domains/{id}/verify", 9 | request: { 10 | params: z.object({ 11 | id: z.coerce.number().openapi({ 12 | param: { 13 | name: "id", 14 | in: "path", 15 | }, 16 | example: 1, 17 | }), 18 | }), 19 | }, 20 | responses: { 21 | 200: { 22 | content: { 23 | "application/json": { 24 | schema: z.object({ 25 | message: z.string(), 26 | }), 27 | }, 28 | }, 29 | description: "Create a new domain", 30 | }, 31 | }, 32 | }); 33 | 34 | function verifyDomain(app: PublicAPIApp) { 35 | app.openapi(route, async (c) => { 36 | await db.domain.update({ 37 | where: { id: c.req.valid("param").id }, 38 | data: { isVerifying: true }, 39 | }); 40 | 41 | return c.json({ 42 | message: "Domain verification started", 43 | }); 44 | }); 45 | } 46 | 47 | export default verifyDomain; 48 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/emails/cancel-email.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { cancelEmail } from "~/server/service/email-service"; 5 | import { checkIsValidEmailId } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "post", 9 | path: "/v1/emails/{emailId}/cancel", 10 | request: { 11 | params: z.object({ 12 | emailId: z 13 | .string() 14 | .min(3) 15 | .openapi({ 16 | param: { 17 | name: "emailId", 18 | in: "path", 19 | }, 20 | example: "cuiwqdj74rygf74", 21 | }), 22 | }), 23 | }, 24 | responses: { 25 | 200: { 26 | content: { 27 | "application/json": { 28 | schema: z.object({ emailId: z.string().optional() }), 29 | }, 30 | }, 31 | description: "Retrieve the user", 32 | }, 33 | }, 34 | }); 35 | 36 | function cancelScheduledEmail(app: PublicAPIApp) { 37 | app.openapi(route, async (c) => { 38 | const team = c.var.team; 39 | const emailId = c.req.param("emailId"); 40 | await checkIsValidEmailId(emailId, team.id); 41 | 42 | await cancelEmail(emailId); 43 | 44 | return c.json({ emailId }); 45 | }); 46 | } 47 | 48 | export default cancelScheduledEmail; 49 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/emails/send-email.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { sendEmail } from "~/server/service/email-service"; 4 | import { emailSchema } from "../../schemas/email-schema"; 5 | 6 | const route = createRoute({ 7 | method: "post", 8 | path: "/v1/emails", 9 | request: { 10 | body: { 11 | required: true, 12 | content: { 13 | "application/json": { 14 | schema: emailSchema, 15 | }, 16 | }, 17 | }, 18 | }, 19 | responses: { 20 | 200: { 21 | content: { 22 | "application/json": { 23 | schema: z.object({ emailId: z.string().optional() }), 24 | }, 25 | }, 26 | description: "Retrieve the user", 27 | }, 28 | }, 29 | }); 30 | 31 | function send(app: PublicAPIApp) { 32 | app.openapi(route, async (c) => { 33 | const team = c.var.team; 34 | 35 | let html = undefined; 36 | 37 | const _html = c.req.valid("json")?.html?.toString(); 38 | 39 | if (_html && _html !== "true" && _html !== "false") { 40 | html = _html; 41 | } 42 | 43 | const email = await sendEmail({ 44 | ...c.req.valid("json"), 45 | teamId: team.id, 46 | apiKeyId: team.apiKeyId, 47 | text: c.req.valid("json").text ?? undefined, 48 | html: html, 49 | }); 50 | 51 | return c.json({ emailId: email?.id }); 52 | }); 53 | } 54 | 55 | export default send; 56 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/api/emails/update-email.ts: -------------------------------------------------------------------------------- 1 | import { createRoute, z } from "@hono/zod-openapi"; 2 | import { PublicAPIApp } from "~/server/public-api/hono"; 3 | import { getTeamFromToken } from "~/server/public-api/auth"; 4 | import { updateEmail } from "~/server/service/email-service"; 5 | import { checkIsValidEmailId } from "../../api-utils"; 6 | 7 | const route = createRoute({ 8 | method: "patch", 9 | path: "/v1/emails/{emailId}", 10 | request: { 11 | params: z.object({ 12 | emailId: z 13 | .string() 14 | .min(3) 15 | .openapi({ 16 | param: { 17 | name: "emailId", 18 | in: "path", 19 | }, 20 | example: "cuiwqdj74rygf74", 21 | }), 22 | }), 23 | body: { 24 | required: true, 25 | content: { 26 | "application/json": { 27 | schema: z.object({ 28 | scheduledAt: z.string().datetime(), 29 | }), 30 | }, 31 | }, 32 | }, 33 | }, 34 | responses: { 35 | 200: { 36 | content: { 37 | "application/json": { 38 | schema: z.object({ emailId: z.string().optional() }), 39 | }, 40 | }, 41 | description: "Retrieve the user", 42 | }, 43 | }, 44 | }); 45 | 46 | function updateEmailScheduledAt(app: PublicAPIApp) { 47 | app.openapi(route, async (c) => { 48 | const team = c.var.team; 49 | const emailId = c.req.param("emailId"); 50 | 51 | await checkIsValidEmailId(emailId, team.id); 52 | 53 | await updateEmail(emailId, { 54 | scheduledAt: c.req.valid("json").scheduledAt, 55 | }); 56 | 57 | return c.json({ emailId }); 58 | }); 59 | } 60 | 61 | export default updateEmailScheduledAt; 62 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/auth.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { db } from "../db"; 3 | import { UnsendApiError } from "./api-error"; 4 | import { getTeamAndApiKey } from "../service/api-service"; 5 | import { isSelfHosted } from "~/utils/common"; 6 | 7 | /** 8 | * Gets the team from the token. Also will check if the token is valid. 9 | */ 10 | export const getTeamFromToken = async (c: Context) => { 11 | const authHeader = c.req.header("Authorization"); 12 | 13 | if (!authHeader) { 14 | throw new UnsendApiError({ 15 | code: "UNAUTHORIZED", 16 | message: "No Authorization header provided", 17 | }); 18 | } 19 | 20 | const token = authHeader.split(" ")[1]; 21 | 22 | if (!token) { 23 | throw new UnsendApiError({ 24 | code: "UNAUTHORIZED", 25 | message: "No Authorization header provided", 26 | }); 27 | } 28 | 29 | const teamAndApiKey = await getTeamAndApiKey(token); 30 | 31 | if (!teamAndApiKey) { 32 | throw new UnsendApiError({ 33 | code: "FORBIDDEN", 34 | message: "Invalid API token", 35 | }); 36 | } 37 | 38 | const { team, apiKey } = teamAndApiKey; 39 | 40 | if (!team) { 41 | throw new UnsendApiError({ 42 | code: "FORBIDDEN", 43 | message: "Invalid API token", 44 | }); 45 | } 46 | 47 | // No await so it won't block the request. Need to be moved to a queue in future 48 | db.apiKey 49 | .update({ 50 | where: { 51 | id: apiKey.id, 52 | }, 53 | data: { 54 | lastUsed: new Date(), 55 | }, 56 | }) 57 | .catch(console.error); 58 | 59 | return { ...team, apiKeyId: apiKey.id }; 60 | }; 61 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/index.ts: -------------------------------------------------------------------------------- 1 | import { getApp } from "./hono"; 2 | import getDomains from "./api/domains/get-domains"; 3 | import sendEmail from "./api/emails/send-email"; 4 | import getEmail from "./api/emails/get-email"; 5 | import listEmails from "./api/emails/list-emails"; 6 | import addContact from "./api/contacts/add-contact"; 7 | import updateContactInfo from "./api/contacts/update-contact"; 8 | import getContact from "./api/contacts/get-contact"; 9 | import updateEmailScheduledAt from "./api/emails/update-email"; 10 | import cancelScheduledEmail from "./api/emails/cancel-email"; 11 | import getContacts from "./api/contacts/get-contacts"; 12 | import upsertContact from "./api/contacts/upsert-contact"; 13 | import createDomain from "./api/domains/create-domain"; 14 | import deleteContact from "./api/contacts/delete-contact"; 15 | import verifyDomain from "./api/domains/verify-domain"; 16 | import sendBatch from "./api/emails/batch-email"; 17 | 18 | export const app = getApp(); 19 | 20 | /**Domain related APIs */ 21 | getDomains(app); 22 | createDomain(app); 23 | verifyDomain(app); 24 | 25 | /**Email related APIs */ 26 | getEmail(app); 27 | listEmails(app); 28 | sendEmail(app); 29 | sendBatch(app); 30 | updateEmailScheduledAt(app); 31 | cancelScheduledEmail(app); 32 | 33 | /**Contact related APIs */ 34 | addContact(app); 35 | updateContactInfo(app); 36 | getContact(app); 37 | getContacts(app); 38 | upsertContact(app); 39 | deleteContact(app); 40 | 41 | export default app; 42 | -------------------------------------------------------------------------------- /apps/web/src/server/public-api/schemas/email-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "@hono/zod-openapi"; 2 | 3 | /** 4 | * Reusable Zod schema for a single email payload used in public API requests. 5 | */ 6 | export const emailSchema = z 7 | .object({ 8 | to: z.string().or(z.array(z.string())), 9 | from: z.string(), 10 | subject: z.string().min(1).optional().openapi({ 11 | description: "Optional when templateId is provided", 12 | }), 13 | templateId: z.string().optional().openapi({ 14 | description: "ID of a template from the dashboard", 15 | }), 16 | variables: z.record(z.string()).optional(), 17 | replyTo: z.string().email().or(z.array(z.string().email())).optional(), 18 | cc: z.string().email().or(z.array(z.string().email())).optional(), 19 | bcc: z.string().email().or(z.array(z.string().email())).optional(), 20 | text: z.string().min(1).optional().nullable(), 21 | html: z.coerce.string().min(1).optional().nullable(), 22 | attachments: z 23 | .array( 24 | z.object({ 25 | filename: z.string().min(1), 26 | content: z.string().min(1), // Consider base64 validation if needed 27 | }) 28 | ) 29 | .max(10) // Limit attachments array size if desired 30 | .optional(), 31 | scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset 32 | inReplyToId: z.string().optional().nullable(), 33 | }) 34 | .refine( 35 | (data) => !!data.subject || !!data.templateId, 36 | "Either subject or templateId must be provided." 37 | ) 38 | .refine( 39 | (data) => !!data.text || !!data.html, 40 | "Either text or html content must be provided." 41 | ); 42 | -------------------------------------------------------------------------------- /apps/web/src/server/queue/queue-constants.ts: -------------------------------------------------------------------------------- 1 | export const SES_WEBHOOK_QUEUE = "ses-webhook"; 2 | export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing"; 3 | 4 | export const DEFAULT_QUEUE_OPTIONS = { 5 | removeOnComplete: true, 6 | removeOnFail: { 7 | age: 30 * 24 * 3600, // 30 days 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web/src/server/redis.ts: -------------------------------------------------------------------------------- 1 | import IORedis from "ioredis"; 2 | import { env } from "~/env"; 3 | 4 | export let connection: IORedis | null = null; 5 | 6 | export const getRedis = () => { 7 | if (!connection) { 8 | connection = new IORedis(`${env.REDIS_URL}?family=0`, { 9 | maxRetriesPerRequest: null, 10 | }); 11 | } 12 | return connection; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/web/src/server/service/api-service.ts: -------------------------------------------------------------------------------- 1 | import { ApiPermission } from "@prisma/client"; 2 | import { db } from "../db"; 3 | import { randomBytes } from "crypto"; 4 | import { smallNanoid } from "../nanoid"; 5 | import { createSecureHash, verifySecureHash } from "../crypto"; 6 | 7 | export async function addApiKey({ 8 | name, 9 | permission, 10 | teamId, 11 | }: { 12 | name: string; 13 | permission: ApiPermission; 14 | teamId: number; 15 | }) { 16 | try { 17 | const clientId = smallNanoid(10); 18 | const token = randomBytes(16).toString("hex"); 19 | const hashedToken = await createSecureHash(token); 20 | 21 | const apiKey = `us_${clientId}_${token}`; 22 | 23 | await db.apiKey.create({ 24 | data: { 25 | name, 26 | permission: permission, 27 | teamId, 28 | tokenHash: hashedToken, 29 | partialToken: `${apiKey.slice(0, 6)}...${apiKey.slice(-3)}`, 30 | clientId, 31 | }, 32 | }); 33 | return apiKey; 34 | } catch (error) { 35 | console.error("Error adding API key:", error); 36 | throw error; 37 | } 38 | } 39 | 40 | export async function getTeamAndApiKey(apiKey: string) { 41 | const [, clientId, token] = apiKey.split("_") as [string, string, string]; 42 | 43 | const apiKeyRow = await db.apiKey.findUnique({ 44 | where: { 45 | clientId, 46 | }, 47 | }); 48 | 49 | if (!apiKeyRow) { 50 | return null; 51 | } 52 | 53 | try { 54 | const isValid = await verifySecureHash(token, apiKeyRow.tokenHash); 55 | if (!isValid) { 56 | return null; 57 | } 58 | 59 | const team = await db.team.findUnique({ 60 | where: { 61 | id: apiKeyRow.teamId, 62 | }, 63 | }); 64 | 65 | return { team, apiKey: apiKeyRow }; 66 | } catch (error) { 67 | console.error("Error verifying API key:", error); 68 | return null; 69 | } 70 | } 71 | 72 | export async function deleteApiKey(id: number) { 73 | try { 74 | await db.apiKey.delete({ 75 | where: { 76 | id, 77 | }, 78 | }); 79 | } catch (error) { 80 | console.error("Error deleting API key:", error); 81 | throw error; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /apps/web/src/server/service/notification-service.ts: -------------------------------------------------------------------------------- 1 | import { env } from "~/env"; 2 | 3 | export async function sendToDiscord(message: string) { 4 | if (!env.DISCORD_WEBHOOK_URL) { 5 | console.error( 6 | "Discord webhook URL is not defined in the environment variables. So printing the message to the console." 7 | ); 8 | console.log("Message: ", message); 9 | return; 10 | } 11 | 12 | const webhookUrl = env.DISCORD_WEBHOOK_URL; 13 | const response = await fetch(webhookUrl, { 14 | method: "POST", 15 | headers: { 16 | "Content-Type": "application/json", 17 | }, 18 | body: JSON.stringify({ content: message }), 19 | }); 20 | 21 | if (response.ok) { 22 | console.log("Message sent to Discord successfully."); 23 | } else { 24 | console.error("Failed to send message to Discord:", response.statusText); 25 | } 26 | 27 | return; 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/server/service/storage-service.ts: -------------------------------------------------------------------------------- 1 | import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; 2 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 3 | import { env } from "~/env"; 4 | 5 | let S3: S3Client | null = null; 6 | export const DEFAULT_BUCKET = "unsend"; 7 | 8 | export const isStorageConfigured = () => 9 | !!( 10 | env.S3_COMPATIBLE_ACCESS_KEY && 11 | env.S3_COMPATIBLE_API_URL && 12 | env.S3_COMPATIBLE_PUBLIC_URL && 13 | env.S3_COMPATIBLE_SECRET_KEY 14 | ); 15 | 16 | const getClient = () => { 17 | if ( 18 | !S3 && 19 | env.S3_COMPATIBLE_ACCESS_KEY && 20 | env.S3_COMPATIBLE_API_URL && 21 | env.S3_COMPATIBLE_PUBLIC_URL && 22 | env.S3_COMPATIBLE_SECRET_KEY 23 | ) { 24 | S3 = new S3Client({ 25 | region: "auto", 26 | endpoint: env.S3_COMPATIBLE_API_URL, 27 | credentials: { 28 | accessKeyId: env.S3_COMPATIBLE_ACCESS_KEY, 29 | secretAccessKey: env.S3_COMPATIBLE_SECRET_KEY, 30 | }, 31 | forcePathStyle: true, // needed for minio 32 | }); 33 | } 34 | 35 | return S3; 36 | }; 37 | 38 | export const getDocumentUploadUrl = async ( 39 | key: string, 40 | fileType: string, 41 | bucket: string = DEFAULT_BUCKET 42 | ) => { 43 | const s3Client = getClient(); 44 | 45 | if (!s3Client) { 46 | throw new Error("R2 is not configured"); 47 | } 48 | 49 | const url = await getSignedUrl( 50 | s3Client, 51 | new PutObjectCommand({ 52 | Bucket: bucket, 53 | Key: key, 54 | ContentType: fileType, 55 | }), 56 | { 57 | expiresIn: 3600, 58 | signableHeaders: new Set(["content-type"]), 59 | } 60 | ); 61 | 62 | return url; 63 | }; 64 | -------------------------------------------------------------------------------- /apps/web/src/trpc/react.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; 5 | import { createTRPCReact } from "@trpc/react-query"; 6 | import { useState } from "react"; 7 | import SuperJSON from "superjson"; 8 | 9 | import { type AppRouter } from "~/server/api/root"; 10 | 11 | const createQueryClient = () => new QueryClient(); 12 | 13 | let clientQueryClientSingleton: QueryClient | undefined = undefined; 14 | const getQueryClient = () => { 15 | if (typeof window === "undefined") { 16 | // Server: always make a new query client 17 | return createQueryClient(); 18 | } 19 | // Browser: use singleton pattern to keep the same query client 20 | return (clientQueryClientSingleton ??= createQueryClient()); 21 | }; 22 | 23 | export const api = createTRPCReact(); 24 | 25 | export function TRPCReactProvider(props: { children: React.ReactNode }) { 26 | const queryClient = getQueryClient(); 27 | 28 | const [trpcClient] = useState(() => 29 | api.createClient({ 30 | links: [ 31 | loggerLink({ 32 | enabled: (op) => 33 | process.env.NODE_ENV === "development" || 34 | (op.direction === "down" && op.result instanceof Error), 35 | }), 36 | unstable_httpBatchStreamLink({ 37 | transformer: SuperJSON, 38 | url: getBaseUrl() + "/api/trpc", 39 | headers: () => { 40 | const headers = new Headers(); 41 | headers.set("x-trpc-source", "nextjs-react"); 42 | return headers; 43 | }, 44 | }), 45 | ], 46 | }) 47 | ); 48 | 49 | return ( 50 | 51 | 52 | {props.children} 53 | 54 | 55 | ); 56 | } 57 | 58 | function getBaseUrl() { 59 | if (typeof window !== "undefined") return window.location.origin; 60 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 61 | return `http://localhost:${process.env.PORT ?? 3000}`; 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/src/trpc/server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { headers } from "next/headers"; 4 | import { cache } from "react"; 5 | 6 | import { createCaller } from "~/server/api/root"; 7 | import { createTRPCContext } from "~/server/api/trpc"; 8 | 9 | /** 10 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 11 | * handling a tRPC call from a React Server Component. 12 | */ 13 | const createContext = cache(async () => { 14 | const heads = new Headers(await headers()); 15 | heads.set("x-trpc-source", "rsc"); 16 | 17 | return createTRPCContext({ 18 | headers: heads, 19 | }); 20 | }); 21 | 22 | export const api = createCaller(createContext); 23 | -------------------------------------------------------------------------------- /apps/web/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type EmailContent = { 2 | to: string | string[]; 3 | from: string; 4 | subject?: string; 5 | templateId?: string; 6 | variables?: Record; 7 | text?: string; 8 | html?: string; 9 | replyTo?: string | string[]; 10 | cc?: string | string[]; 11 | bcc?: string | string[]; 12 | attachments?: Array; 13 | unsubUrl?: string; 14 | scheduledAt?: string; 15 | inReplyToId?: string | null; 16 | }; 17 | 18 | export type EmailAttachment = { 19 | filename: string; 20 | content: string; 21 | }; 22 | -------------------------------------------------------------------------------- /apps/web/src/utils/client.ts: -------------------------------------------------------------------------------- 1 | export const isLocalhost = () => { 2 | return location.hostname === "localhost"; 3 | }; 4 | -------------------------------------------------------------------------------- /apps/web/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { env } from "~/env"; 2 | 3 | export function isCloud() { 4 | return env.NEXT_PUBLIC_IS_CLOUD; 5 | } 6 | 7 | export function isSelfHosted() { 8 | return !isCloud(); 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/utils/gravatar-utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * Possible Gravatar rating values: 'g' (general), 'pg' (parental guidance), 5 | * 'r' (restricted), or 'x' (explicit). 6 | */ 7 | export type GravatarRating = "g" | "pg" | "r" | "x"; 8 | 9 | /** 10 | * Common default image options in Gravatar. 11 | * Can also be a URL (string) for a custom default image. 12 | */ 13 | export type GravatarDefaultImage = 14 | | "404" 15 | | "mp" 16 | | "identicon" 17 | | "monsterid" 18 | | "wavatar" 19 | | "retro" 20 | | "robohash" 21 | | "blank"; 22 | 23 | export interface GravatarOptions { 24 | size?: number; // specify the size (in pixels) 25 | defaultImage?: GravatarDefaultImage; // default image 26 | rating?: GravatarRating; // image rating 27 | } 28 | 29 | export function getGravatarUrl( 30 | email: string, 31 | options: GravatarOptions = { 32 | size: 80, 33 | defaultImage: "identicon", 34 | rating: "g", 35 | } 36 | ) { 37 | const trimmedEmail = email.trim().toLowerCase(); 38 | const hash = crypto.createHash("sha256").update(trimmedEmail).digest("hex"); 39 | 40 | // Base Gravatar URL 41 | const baseUrl = "https://www.gravatar.com/avatar/"; 42 | 43 | // Use URLSearchParams to build query string 44 | const queryParams = new URLSearchParams(); 45 | 46 | if (options.size) { 47 | queryParams.set("s", options.size.toString()); 48 | } 49 | if (options.defaultImage) { 50 | queryParams.set("d", options.defaultImage); 51 | } 52 | if (options.rating) { 53 | queryParams.set("r", options.rating); 54 | } 55 | 56 | const queryString = queryParams.toString(); 57 | const finalUrl = queryString 58 | ? `${baseUrl}${hash}?${queryString}` 59 | : `${baseUrl}${hash}`; 60 | 61 | return finalUrl; 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/src/utils/ses-utils.ts: -------------------------------------------------------------------------------- 1 | import { SesSettingsService } from "~/server/service/ses-settings-service"; 2 | 3 | export async function getConfigurationSetName( 4 | clickTracking: boolean, 5 | openTracking: boolean, 6 | region: string 7 | ) { 8 | const setting = await SesSettingsService.getSetting(region); 9 | 10 | if (!setting) { 11 | throw new Error(`No SES setting found for region: ${region}`); 12 | } 13 | 14 | if (clickTracking && openTracking) { 15 | return setting.configFull; 16 | } 17 | if (clickTracking) { 18 | return setting.configClick; 19 | } 20 | if (openTracking) { 21 | return setting.configOpen; 22 | } 23 | 24 | return setting.configGeneral; 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import sharedConfig from "@unsend/tailwind-config/tailwind.config"; 3 | import path from "path"; 4 | 5 | export default { 6 | ...sharedConfig, 7 | content: [ 8 | "./src/**/*.tsx", 9 | `${path.join(require.resolve("@unsend/ui"), "..")}/**/*.{ts,tsx}`, 10 | `${path.join(require.resolve("@unsend/email-editor"), "..")}/**/*.{ts,tsx}`, 11 | ], 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/nextjs.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "~/*": ["./src/*"] 7 | }, 8 | "plugins": [ 9 | { 10 | "name": "next" 11 | } 12 | ], 13 | "strictNullChecks": true 14 | }, 15 | "include": [ 16 | "next-env.d.ts", 17 | "**/*.ts", 18 | "**/*.tsx", 19 | "**/*.cjs", 20 | "**/*.js", 21 | ".next/types/**/*.ts", 22 | ".eslintrc.cjs" 23 | ], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | command -v docker >/dev/null 2>&1 || { 4 | echo "Docker is not running. Please start Docker and try again." 5 | exit 1 6 | } 7 | 8 | SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" 9 | MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" 10 | 11 | APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" 12 | GIT_SHA="$(git rev-parse HEAD)" 13 | 14 | echo "Building docker image for monorepo at $MONOREPO_ROOT" 15 | echo "App version: $APP_VERSION" 16 | echo "Git SHA: $GIT_SHA" 17 | 18 | docker build -f "$SCRIPT_DIR/Dockerfile" \ 19 | --progress=plain \ 20 | -t "unsend/unsend:latest" \ 21 | -t "unsend/unsend:$GIT_SHA" \ 22 | -t "unsend/unsend:$APP_VERSION" \ 23 | -t "ghcr.io/unsend-dev/unsend:latest" \ 24 | -t "ghcr.io/unsend-dev/unsend:$GIT_SHA" \ 25 | -t "ghcr.io/unsend-dev/unsend:$APP_VERSION" \ 26 | "$MONOREPO_ROOT" -------------------------------------------------------------------------------- /docker/dev/compose.yml: -------------------------------------------------------------------------------- 1 | name: unsend-dev 2 | 3 | services: 4 | postgres: 5 | image: postgres:16 6 | container_name: unsend-db-dev 7 | restart: always 8 | environment: 9 | - POSTGRES_USER=unsend 10 | - POSTGRES_PASSWORD=password 11 | - POSTGRES_DB=unsend 12 | volumes: 13 | - database:/var/lib/postgresql/data 14 | ports: 15 | - "54320:5432" 16 | 17 | redis: 18 | image: redis:7 19 | container_name: unsend-redis-dev 20 | restart: always 21 | ports: 22 | - "6379:6379" 23 | volumes: 24 | - redis:/data 25 | command: ["redis-server", "--maxmemory-policy", "noeviction"] 26 | 27 | local-sen-sns: 28 | image: unsend/local-ses-sns:latest 29 | container_name: local-ses-sns 30 | restart: always 31 | ports: 32 | - "5350:3000" 33 | environment: 34 | WEBHOOK_URL: http://host.docker.internal:3000/api/ses_callback 35 | extra_hosts: 36 | - "host.docker.internal:host-gateway" 37 | 38 | minio: 39 | image: minio/minio 40 | container_name: unsend-storage-dev 41 | ports: 42 | - 9002:9002 43 | - 9001:9001 44 | volumes: 45 | - minio:/data 46 | environment: 47 | MINIO_ROOT_USER: unsend 48 | MINIO_ROOT_PASSWORD: password 49 | entrypoint: sh 50 | command: -c 'mkdir -p /data/unsend && minio server /data --console-address ":9001" --address ":9002"' 51 | 52 | volumes: 53 | database: 54 | redis: 55 | minio: 56 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -x 4 | 5 | echo "Deploying prisma migrations" 6 | 7 | pnpx prisma migrate deploy --schema ./apps/web/prisma/schema.prisma 8 | 9 | echo "Starting web server" 10 | 11 | node apps/web/server.js 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unsend", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build", 6 | "build:editor": "turbo build --filter=@unsend/email-editor", 7 | "build:web": "turbo build --filter=@unsend/email-editor --filter=web ", 8 | "build:marketing": "turbo build --filter=@unsend/email-editor --filter=marketing", 9 | "build:smtp": "turbo build --filter=smtp-server", 10 | "build:web:local": "pnpm load-env -- turbo build --filter=@unsend/email-editor --filter=web ", 11 | "start:web:local": "pnpm load-env -- cd apps/web && pnpm start", 12 | "dev": "pnpm load-env -- turbo dev", 13 | "dev:docs": "cd apps/docs && mintlify dev", 14 | "dev:marketing": "cd apps/marketing && turbo dev", 15 | "lint": "turbo lint", 16 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 17 | "db:generate": "pnpm db db:generate", 18 | "db:push": "pnpm db db:push", 19 | "db:migrate-dev": "pnpm db db:migrate-dev", 20 | "db:migrate-deploy": "pnpm db db:migrate-deploy", 21 | "db:migrate-reset": "pnpm db db:migrate-reset", 22 | "db:studio": "pnpm db db:studio", 23 | "db": "pnpm load-env -- pnpm --filter=web", 24 | "load-env": "dotenv -e .env", 25 | "d": "pnpm dx && pnpm dev", 26 | "dx": "pnpm i && pnpm dx:up && pnpm db:migrate-dev", 27 | "dx:up": "docker compose -f docker/dev/compose.yml up -d", 28 | "dx:down": "docker compose -f docker/dev/compose.yml down" 29 | }, 30 | "devDependencies": { 31 | "@unsend/eslint-config": "workspace:*", 32 | "@unsend/typescript-config": "workspace:*", 33 | "dotenv-cli": "^8.0.0", 34 | "mintlify": "4.0.510", 35 | "prettier": "^3.5.3" 36 | }, 37 | "packageManager": "pnpm@8.9.0", 38 | "engines": { 39 | "node": ">=20" 40 | }, 41 | "dependencies": { 42 | "turbo": "^2.5.2" 43 | } 44 | } -------------------------------------------------------------------------------- /packages/email-editor/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@unsend/eslint-config/react-internal.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: "./tsconfig.lint.json", 8 | tsconfigRootDir: __dirname, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/email-editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsend/email-editor", 3 | "version": "0.0.1", 4 | "description": "Email editor used by unsend", 5 | "main": "./src/index.ts", 6 | "types": "./src/index.ts", 7 | "files": [ 8 | "src" 9 | ], 10 | "scripts": { 11 | "build": "tsup", 12 | "dev": "tsup --watch", 13 | "clean": "rm -rf dist", 14 | "lint": "eslint . --max-warnings 0", 15 | "lint:fix": "eslint . --fix" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "@types/eslint": "^9.6.1", 22 | "@types/react": "^19.1.2", 23 | "@unsend/eslint-config": "workspace:*", 24 | "@unsend/tailwind-config": "workspace:*", 25 | "@unsend/typescript-config": "workspace:*", 26 | "@unsend/ui": "workspace:*", 27 | "postcss": "^8.5.3", 28 | "prettier": "^3.5.3", 29 | "prettier-plugin-tailwindcss": "^0.6.11", 30 | "react": "^19.1.0", 31 | "tailwindcss": "^3.4.1", 32 | "tsup": "^8.4.0", 33 | "typescript": "^5.8.3" 34 | }, 35 | "peerDependencies": { 36 | "react": "^19.1.0" 37 | }, 38 | "dependencies": { 39 | "@tiptap/core": "^2.11.7", 40 | "@tiptap/extension-code-block": "^2.11.7", 41 | "@tiptap/extension-color": "^2.11.7", 42 | "@tiptap/extension-heading": "^2.11.7", 43 | "@tiptap/extension-image": "^2.11.7", 44 | "@tiptap/extension-link": "^2.11.7", 45 | "@tiptap/extension-paragraph": "^2.11.7", 46 | "@tiptap/extension-placeholder": "^2.11.7", 47 | "@tiptap/extension-task-item": "^2.11.7", 48 | "@tiptap/extension-task-list": "^2.11.7", 49 | "@tiptap/extension-text-align": "^2.11.7", 50 | "@tiptap/extension-text-style": "^2.11.7", 51 | "@tiptap/extension-underline": "^2.11.7", 52 | "@tiptap/pm": "^2.11.7", 53 | "@tiptap/react": "^2.11.7", 54 | "@tiptap/starter-kit": "^2.11.7", 55 | "@tiptap/suggestion": "^2.11.7", 56 | "eslint": "^9.25.1", 57 | "jsx-email": "^2.7.1", 58 | "lucide-react": "^0.503.0", 59 | "react-colorful": "^5.6.1", 60 | "shiki": "^3.3.0", 61 | "tippy.js": "^6.3.7", 62 | "tiptap-extension-global-drag-handle": "^0.1.18" 63 | }, 64 | "engines": { 65 | "node": ">=18.0.0" 66 | } 67 | } -------------------------------------------------------------------------------- /packages/email-editor/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /packages/email-editor/src/components/panels/LinkEditorPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@unsend/ui/src/button"; 2 | import { CheckIcon } from "lucide-react"; 3 | import { useState, useCallback, useMemo } from "react"; 4 | 5 | export type LinkEditorPanelProps = { 6 | initialUrl?: string; 7 | onSetLink: (url: string) => void; 8 | }; 9 | 10 | export const useLinkEditorState = ({ 11 | initialUrl, 12 | onSetLink, 13 | }: LinkEditorPanelProps) => { 14 | const [url, setUrl] = useState(initialUrl || ""); 15 | 16 | const onChange = useCallback((event: React.ChangeEvent) => { 17 | setUrl(event.target.value); 18 | }, []); 19 | 20 | const handleSubmit = useCallback( 21 | (e: React.FormEvent) => { 22 | e.preventDefault(); 23 | onSetLink(url); 24 | }, 25 | [url, onSetLink] 26 | ); 27 | 28 | return { 29 | url, 30 | setUrl, 31 | onChange, 32 | handleSubmit, 33 | }; 34 | }; 35 | 36 | export const LinkEditorPanel = ({ 37 | onSetLink, 38 | initialUrl, 39 | }: LinkEditorPanelProps) => { 40 | const state = useLinkEditorState({ 41 | onSetLink, 42 | initialUrl, 43 | }); 44 | 45 | return ( 46 |
47 |
51 | 59 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /packages/email-editor/src/components/panels/LinkPreviewPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@unsend/ui/src/button"; 2 | import { Edit2Icon, EditIcon, Trash2Icon } from "lucide-react"; 3 | 4 | export type LinkPreviewPanelProps = { 5 | url: string; 6 | onEdit: () => void; 7 | onClear: () => void; 8 | }; 9 | 10 | export const LinkPreviewPanel = ({ 11 | onClear, 12 | onEdit, 13 | url, 14 | }: LinkPreviewPanelProps) => { 15 | return ( 16 |
17 | 23 | {url} 24 | 25 | 28 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/email-editor/src/components/panels/TextEditorPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@unsend/ui/src/button"; 2 | import { CheckIcon } from "lucide-react"; 3 | import { useState, useCallback, useMemo } from "react"; 4 | 5 | export type TextEditorPanelProps = { 6 | initialText?: string; 7 | onSetInitialText: (url: string) => void; 8 | }; 9 | 10 | export const useTextEditorState = ({ 11 | initialText, 12 | onSetInitialText, 13 | }: TextEditorPanelProps) => { 14 | const [url, setUrl] = useState(initialText || ""); 15 | 16 | const onChange = useCallback((event: React.ChangeEvent) => { 17 | setUrl(event.target.value); 18 | }, []); 19 | 20 | const handleSubmit = useCallback( 21 | (e: React.FormEvent) => { 22 | e.preventDefault(); 23 | onSetInitialText(url); 24 | }, 25 | [url, onSetInitialText] 26 | ); 27 | 28 | return { 29 | url, 30 | setUrl, 31 | onChange, 32 | handleSubmit, 33 | }; 34 | }; 35 | 36 | export const TextEditorPanel = ({ 37 | onSetInitialText, 38 | initialText, 39 | }: TextEditorPanelProps) => { 40 | const state = useTextEditorState({ 41 | onSetInitialText, 42 | initialText, 43 | }); 44 | 45 | return ( 46 |
47 |
51 | 59 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /packages/email-editor/src/components/ui/icons/AlignmentIcon.tsx: -------------------------------------------------------------------------------- 1 | import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react"; 2 | import { AllowedAlignments } from "../../../types"; 3 | 4 | export const AlignmentIcon = ({ 5 | alignment, 6 | }: { 7 | alignment: AllowedAlignments; 8 | }) => { 9 | if (alignment === "left") { 10 | return ; 11 | } else if (alignment === "center") { 12 | return ; 13 | } else if (alignment === "right") { 14 | return ; 15 | } 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/email-editor/src/components/ui/icons/BorderWidth.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { SVGProps } from "../../../types"; 4 | 5 | export const BorderWidth: React.FC = (props) => { 6 | return ( 7 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/email-editor/src/extensions/ButtonExtension.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | import { AllowedAlignments } from "../types"; 4 | 5 | import { ButtonComponent } from "../nodes/button"; 6 | // import { AllowedLogoAlignment } from '../nodes/logo'; 7 | 8 | declare module "@tiptap/core" { 9 | interface Commands { 10 | button: { 11 | setButton: () => ReturnType; 12 | }; 13 | } 14 | } 15 | 16 | export const ButtonExtension = Node.create({ 17 | name: "button", 18 | group: "block", 19 | atom: true, 20 | draggable: true, 21 | 22 | addAttributes() { 23 | return { 24 | component: { 25 | default: "button", 26 | }, 27 | text: { 28 | default: "Button", 29 | }, 30 | url: { 31 | default: "", 32 | }, 33 | alignment: { 34 | default: "left", 35 | }, 36 | borderRadius: { 37 | default: "4", 38 | }, 39 | borderWidth: { 40 | default: "1", 41 | }, 42 | buttonColor: { 43 | default: "rgb(0, 0, 0)", 44 | }, 45 | borderColor: { 46 | default: "rgb(0, 0, 0)", 47 | }, 48 | textColor: { 49 | default: "rgb(255, 255, 255)", 50 | }, 51 | }; 52 | }, 53 | 54 | parseHTML() { 55 | return [ 56 | { 57 | tag: `a[data-unsend-component="${this.name}"]`, 58 | }, 59 | ]; 60 | }, 61 | 62 | renderHTML({ HTMLAttributes }) { 63 | return [ 64 | "a", 65 | mergeAttributes( 66 | { 67 | "data-unsend-component": this.name, 68 | }, 69 | HTMLAttributes 70 | ), 71 | ]; 72 | }, 73 | 74 | addCommands() { 75 | return { 76 | setButton: 77 | () => 78 | ({ commands }) => { 79 | return commands.insertContent({ 80 | type: this.name, 81 | attrs: { 82 | unsendComponent: this.name, 83 | }, 84 | }); 85 | }, 86 | }; 87 | }, 88 | 89 | addNodeView() { 90 | return ReactNodeViewRenderer(ButtonComponent); 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /packages/email-editor/src/extensions/UnsubsubscribeExtension.tsx: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from "@tiptap/core"; 2 | import { ReactNodeViewRenderer } from "@tiptap/react"; 3 | 4 | import { UnsubscribeFooterComponent } from "../nodes/unsubscribe-footer"; 5 | 6 | declare module "@tiptap/core" { 7 | interface Commands { 8 | unsubscribeFooter: { 9 | setUnsubscribeFooter: () => ReturnType; 10 | }; 11 | } 12 | } 13 | 14 | export const UnsubscribeFooterExtension = Node.create({ 15 | name: "unsubscribeFooter", 16 | group: "block", 17 | content: "inline*", 18 | 19 | addAttributes() { 20 | return { 21 | component: { 22 | default: "unsubscribeFooter", 23 | }, 24 | }; 25 | }, 26 | 27 | parseHTML() { 28 | return [ 29 | { 30 | tag: `unsub`, 31 | }, 32 | ]; 33 | }, 34 | 35 | renderHTML({ HTMLAttributes }) { 36 | return [ 37 | "unsub", 38 | mergeAttributes( 39 | { 40 | "data-unsend-component": this.name, 41 | class: "footer", 42 | contenteditable: "true", 43 | }, 44 | HTMLAttributes 45 | ), 46 | 0, 47 | ]; 48 | }, 49 | 50 | addNodeView() { 51 | return ReactNodeViewRenderer(UnsubscribeFooterComponent); 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /packages/email-editor/src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useRef } from "react"; 2 | 3 | export const useEvent = any>(handler: T): T => { 4 | const handlerRef = useRef(null); 5 | 6 | useLayoutEffect(() => { 7 | handlerRef.current = handler; 8 | }, [handler]); 9 | 10 | return useCallback((...args: Parameters): ReturnType => { 11 | if (handlerRef.current === null) { 12 | throw new Error("Handler is not assigned"); 13 | } 14 | return handlerRef.current(...args); 15 | }, []) as T; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/email-editor/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./styles/index.css"; 2 | 3 | export * from "./editor"; 4 | -------------------------------------------------------------------------------- /packages/email-editor/src/menus/TextMenuButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@unsend/ui/src/button"; 2 | import { cn } from "@unsend/ui/lib/utils"; 3 | 4 | import { TextMenuItem } from "./TextMenu"; 5 | 6 | export function TextMenuButton(item: TextMenuItem) { 7 | return ( 8 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/email-editor/src/nodes/unsubscribe-footer.tsx: -------------------------------------------------------------------------------- 1 | import { NodeViewProps, NodeViewWrapper, NodeViewContent } from "@tiptap/react"; 2 | import { cn } from "@unsend/ui/lib/utils"; 3 | 4 | export function UnsubscribeFooterComponent(props: NodeViewProps) { 5 | return ( 6 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/email-editor/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/react"; 2 | 3 | export interface MenuProps { 4 | editor: Editor; 5 | appendTo?: React.RefObject; 6 | shouldHide?: boolean; 7 | } 8 | 9 | export type AllowedAlignments = "left" | "center" | "right"; 10 | 11 | export interface ButtonOptions { 12 | text: string; 13 | url: string; 14 | alignment: AllowedAlignments; 15 | borderRadius: string; 16 | borderColor: string; 17 | borderWidth: string; 18 | buttonColor: string; 19 | textColor: string; 20 | HTMLAttributes: Record; 21 | } 22 | 23 | export interface ImageOptions { 24 | altText: string; 25 | url: string; 26 | alignment: AllowedAlignments; 27 | borderRadius: string; 28 | borderColor: string; 29 | borderWidth: string; 30 | HTMLAttributes: Record; 31 | } 32 | 33 | export type SVGProps = React.SVGProps; 34 | -------------------------------------------------------------------------------- /packages/email-editor/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import sharedConfig from "@unsend/tailwind-config/tailwind.config"; 3 | 4 | export default { 5 | ...sharedConfig, 6 | content: ["./src/**/*.tsx", "./src/**/*.ts"], 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /packages/email-editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["**/*.tsx", "**/*.ts"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/email-editor/tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src", "**/*.ts", "**/*.tsx"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/email-editor/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { defineConfig, Options } from "tsup"; 3 | 4 | // eslint-disable-next-line import/no-default-export 5 | export default defineConfig((options: Options) => ({ 6 | entry: ["src/index.ts"], 7 | format: ["esm", "cjs"], 8 | banner: { 9 | js: "'use client'", 10 | }, 11 | dts: true, 12 | minify: true, 13 | clean: true, 14 | external: ["react", "react-dom"], 15 | injectStyle: true, 16 | ...options, 17 | })); 18 | -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 8 | plugins: ["only-warn"], 9 | globals: { 10 | React: true, 11 | JSX: true, 12 | }, 13 | env: { 14 | node: true, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | ".*.js", 26 | "node_modules/", 27 | "dist/", 28 | ], 29 | overrides: [ 30 | { 31 | files: ["*.js?(x)", "*.ts?(x)"], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: [ 8 | "eslint:recommended", 9 | "prettier", 10 | require.resolve("@vercel/style-guide/eslint/next"), 11 | "eslint-config-turbo", 12 | ], 13 | globals: { 14 | React: true, 15 | JSX: true, 16 | }, 17 | env: { 18 | node: true, 19 | browser: true, 20 | }, 21 | plugins: ["only-warn"], 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | ], 34 | overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }], 35 | }; 36 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsend/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "library.js", 7 | "next.js", 8 | "react-internal.js" 9 | ], 10 | "devDependencies": { 11 | "@next/eslint-plugin-next": "^15.3.1", 12 | "@typescript-eslint/eslint-plugin": "^8.31.0", 13 | "@typescript-eslint/parser": "^8.31.0", 14 | "@vercel/style-guide": "^6.0.0", 15 | "eslint-config-prettier": "^10.1.2", 16 | "eslint-config-turbo": "^2.5.2", 17 | "eslint-plugin-only-warn": "^1.1.0", 18 | "typescript": "^5.8.3" 19 | } 20 | } -------------------------------------------------------------------------------- /packages/eslint-config/react-internal.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * internal (bundled by their consumer) libraries 8 | * that utilize React. 9 | * 10 | * This config extends the Vercel Engineering Style Guide. 11 | * For more information, see https://github.com/vercel/style-guide 12 | * 13 | */ 14 | 15 | /** @type {import("eslint").Linter.Config} */ 16 | module.exports = { 17 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 18 | plugins: ["only-warn"], 19 | globals: { 20 | React: true, 21 | JSX: true, 22 | }, 23 | env: { 24 | browser: true, 25 | }, 26 | settings: { 27 | "import/resolver": { 28 | typescript: { 29 | project, 30 | }, 31 | }, 32 | }, 33 | ignorePatterns: [ 34 | // Ignore dotfiles 35 | ".*.js", 36 | "node_modules/", 37 | "dist/", 38 | ], 39 | overrides: [ 40 | // Force ESLint to detect .tsx files 41 | { files: ["*.js?(x)", "*.ts?(x)"] }, 42 | ], 43 | rules: { 44 | "no-redeclare": "off", 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /packages/sdk/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@unsend/eslint-config/library.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: "./tsconfig.json", 8 | tsconfigRootDir: __dirname, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/sdk/.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore all TypeScript files 2 | tsconfig.json 3 | .eslintrc.js 4 | tsup.config.json 5 | index.ts 6 | 7 | # Ignore specific directories 8 | src/ 9 | types/ 10 | .turbo/ -------------------------------------------------------------------------------- /packages/sdk/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Unsend 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 | -------------------------------------------------------------------------------- /packages/sdk/README.md: -------------------------------------------------------------------------------- 1 | # Unsend SDK 2 | 3 | ## Prerequisites 4 | 5 | - [Unsend API key](https://app.unsend.dev/dev-settings/api-keys) 6 | - [Verified domain](https://app.unsend.dev/domains) 7 | 8 | ## Installation 9 | 10 | ### NPM 11 | 12 | ```bash 13 | npm install unsend 14 | ``` 15 | 16 | ### Yarn 17 | 18 | ```bash 19 | yarn add unsend 20 | ``` 21 | 22 | ### PNPM 23 | 24 | ```bash 25 | pnpm add unsend 26 | ``` 27 | 28 | ### Bun 29 | 30 | ```bash 31 | bun add unsend 32 | ``` 33 | 34 | ## Usage 35 | 36 | ```javascript 37 | import { Unsend } from "unsend"; 38 | 39 | const unsend = new Unsend("us_12345"); 40 | 41 | // for self-hosted installations you can pass your base URL 42 | // const unsend = new Unsend("us_12345", "https://my-unsend-instance.com"); 43 | 44 | unsend.emails.send({ 45 | to: "hello@acme.com", 46 | from: "hello@company.com", 47 | subject: "Unsend email", 48 | html: "

Unsend is the best open source product to send emails

", 49 | text: "Unsend is the best open source product to send emails", 50 | }); 51 | ``` 52 | -------------------------------------------------------------------------------- /packages/sdk/index.ts: -------------------------------------------------------------------------------- 1 | export { Unsend } from "./src/unsend"; 2 | -------------------------------------------------------------------------------- /packages/sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unsend", 3 | "version": "1.5.1", 4 | "description": "", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "lint": "eslint . --max-warnings 0", 11 | "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts", 12 | "publish-sdk": "pnpm run build && pnpm publish --no-git-checks", 13 | "openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/node": "^22.15.2", 20 | "@types/react": "^19.1.2", 21 | "@unsend/eslint-config": "workspace:*", 22 | "@unsend/typescript-config": "workspace:*", 23 | "openapi-typescript": "^7.6.1", 24 | "tsup": "^8.4.0", 25 | "typescript": "^5.8.3" 26 | }, 27 | "dependencies": { 28 | "@react-email/render": "^1.0.6", 29 | "react": "^19.1.0" 30 | } 31 | } -------------------------------------------------------------------------------- /packages/sdk/src/domain.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "../types/schema"; 2 | import { ErrorResponse } from "../types"; 3 | import { Unsend } from "./unsend"; 4 | 5 | type CreateDomainPayload = 6 | paths["/v1/domains"]["post"]["requestBody"]["content"]["application/json"]; 7 | 8 | type CreateDomainResponse = { 9 | data: CreateDomainResponseSuccess | null; 10 | error: ErrorResponse | null; 11 | }; 12 | 13 | type CreateDomainResponseSuccess = 14 | paths["/v1/domains"]["post"]["responses"]["200"]["content"]["application/json"]; 15 | 16 | type GetDomainsResponse = { 17 | data: GetDomainsResponseSuccess | null; 18 | error: ErrorResponse | null; 19 | }; 20 | 21 | type GetDomainsResponseSuccess = 22 | paths["/v1/domains"]["get"]["responses"]["200"]["content"]["application/json"]; 23 | 24 | type VerifyDomainResponse = { 25 | data: VerifyDomainResponseSuccess | null; 26 | error: ErrorResponse | null; 27 | }; 28 | 29 | type VerifyDomainResponseSuccess = 30 | paths["/v1/domains/{id}/verify"]["put"]["responses"]["200"]["content"]["application/json"]; 31 | 32 | export class Domains { 33 | constructor(private readonly unsend: Unsend) { 34 | this.unsend = unsend; 35 | } 36 | 37 | async list(): Promise { 38 | const data = await this.unsend.get("/domains"); 39 | return data; 40 | } 41 | 42 | async create(payload: CreateDomainPayload): Promise { 43 | const data = await this.unsend.post( 44 | "/domains", 45 | payload 46 | ); 47 | return data; 48 | } 49 | 50 | async verify(id: number): Promise { 51 | const data = await this.unsend.put( 52 | `/domains/${id}/verify`, 53 | {} 54 | ); 55 | return data; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/base.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["**/*.ts"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/sdk/types/index.ts: -------------------------------------------------------------------------------- 1 | export type ErrorResponse = { 2 | message: string; 3 | code: string; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/tailwind-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsend/tailwind-config", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "tailwindcss-animate": "^1.0.7" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.15.2", 18 | "autoprefixer": "^10.4.21", 19 | "postcss": "^8.5.3", 20 | "tailwindcss": "^3.4.1" 21 | } 22 | } -------------------------------------------------------------------------------- /packages/tailwind-config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@unsend/typescript-config/base.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "noEmit": true 6 | }, 7 | "include": ["tailwind.config.ts"], 8 | "exclude": ["node_modules", "dist"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "esModuleInterop": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "lib": [ 11 | "es2022", 12 | "DOM", 13 | "DOM.Iterable" 14 | ], 15 | "module": "NodeNext", 16 | "moduleDetection": "force", 17 | "moduleResolution": "NodeNext", 18 | "noUncheckedIndexedAccess": true, 19 | "resolveJsonModule": true, 20 | "skipLibCheck": true, 21 | "strict": true, 22 | "target": "ES2022" 23 | } 24 | } -------------------------------------------------------------------------------- /packages/typescript-config/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [ 7 | { 8 | "name": "next" 9 | } 10 | ], 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "allowJs": true, 14 | "jsx": "preserve", 15 | "noEmit": true 16 | } 17 | } -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsend/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler" 9 | } 10 | } -------------------------------------------------------------------------------- /packages/ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@unsend/eslint-config/react-internal.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: "./tsconfig.lint.json", 8 | tsconfigRootDir: __dirname, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { cn } from "./lib/utils"; 2 | 3 | export { cn }; 4 | export { ThemeProvider, useTheme } from "next-themes"; 5 | -------------------------------------------------------------------------------- /packages/ui/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsend/ui", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./index.ts", 6 | "types": "./index.ts", 7 | "files": [ 8 | "src" 9 | ], 10 | "scripts": { 11 | "lint": "eslint . --max-warnings 0", 12 | "lint:fix": "eslint . --fix" 13 | }, 14 | "devDependencies": { 15 | "@types/eslint": "^9.6.1", 16 | "@types/node": "^22.15.2", 17 | "@types/react": "^19.1.2", 18 | "@types/react-dom": "^19.1.2", 19 | "@types/react-syntax-highlighter": "^15.5.13", 20 | "@unsend/eslint-config": "workspace:*", 21 | "@unsend/tailwind-config": "workspace:*", 22 | "@unsend/typescript-config": "workspace:*", 23 | "eslint": "^9.25.1", 24 | "postcss": "^8.5.3", 25 | "prettier": "^3.5.3", 26 | "prettier-plugin-tailwindcss": "^0.6.11", 27 | "react": "19.1.0", 28 | "tailwindcss": "^3.4.1", 29 | "typescript": "^5.8.3" 30 | }, 31 | "dependencies": { 32 | "@hookform/resolvers": "^5.0.1", 33 | "@radix-ui/react-accordion": "^1.2.8", 34 | "@radix-ui/react-avatar": "^1.1.9", 35 | "@radix-ui/react-dialog": "^1.1.11", 36 | "@radix-ui/react-dropdown-menu": "^2.1.12", 37 | "@radix-ui/react-label": "^2.1.4", 38 | "@radix-ui/react-popover": "^1.1.11", 39 | "@radix-ui/react-progress": "^1.1.4", 40 | "@radix-ui/react-select": "^2.2.2", 41 | "@radix-ui/react-separator": "^1.1.4", 42 | "@radix-ui/react-slot": "^1.2.0", 43 | "@radix-ui/react-switch": "^1.2.2", 44 | "@radix-ui/react-tabs": "^1.1.9", 45 | "@radix-ui/react-tooltip": "^1.2.4", 46 | "add": "^2.0.6", 47 | "class-variance-authority": "^0.7.1", 48 | "clsx": "^2.1.1", 49 | "cmdk": "^1.1.1", 50 | "framer-motion": "^12.9.2", 51 | "input-otp": "^1.4.2", 52 | "lucide-react": "^0.503.0", 53 | "next-themes": "^0.4.6", 54 | "pnpm": "^10.9.0", 55 | "react-hook-form": "^7.56.1", 56 | "react-syntax-highlighter": "^15.6.1", 57 | "recharts": "^2.15.3", 58 | "sonner": "^2.0.3", 59 | "tailwind-merge": "^3.2.0", 60 | "tailwindcss-animate": "^1.0.7", 61 | "zod": "^3.24.3" 62 | } 63 | } -------------------------------------------------------------------------------- /packages/ui/src/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ComponentRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ComponentRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ComponentRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /packages/ui/src/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "../lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-foreground-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /packages/ui/src/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "../lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
14 | )); 15 | Card.displayName = "Card"; 16 | 17 | const CardHeader = React.forwardRef< 18 | HTMLDivElement, 19 | React.HTMLAttributes 20 | >(({ className, ...props }, ref) => ( 21 |
26 | )); 27 | CardHeader.displayName = "CardHeader"; 28 | 29 | const CardTitle = React.forwardRef< 30 | HTMLParagraphElement, 31 | React.HTMLAttributes 32 | >(({ className, ...props }, ref) => ( 33 |

38 | )); 39 | CardTitle.displayName = "CardTitle"; 40 | 41 | const CardDescription = React.forwardRef< 42 | HTMLParagraphElement, 43 | React.HTMLAttributes 44 | >(({ className, ...props }, ref) => ( 45 |

50 | )); 51 | CardDescription.displayName = "CardDescription"; 52 | 53 | const CardContent = React.forwardRef< 54 | HTMLDivElement, 55 | React.HTMLAttributes 56 | >(({ className, ...props }, ref) => ( 57 |

58 | )); 59 | CardContent.displayName = "CardContent"; 60 | 61 | const CardFooter = React.forwardRef< 62 | HTMLDivElement, 63 | React.HTMLAttributes 64 | >(({ className, ...props }, ref) => ( 65 |
70 | )); 71 | CardFooter.displayName = "CardFooter"; 72 | 73 | export { 74 | Card, 75 | CardHeader, 76 | CardFooter, 77 | CardTitle, 78 | CardDescription, 79 | CardContent, 80 | }; 81 | -------------------------------------------------------------------------------- /packages/ui/src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /packages/ui/src/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "../lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /packages/ui/src/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "../lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /packages/ui/src/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Popover = PopoverPrimitive.Root; 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverTrigger, PopoverContent }; 32 | -------------------------------------------------------------------------------- /packages/ui/src/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )); 26 | Progress.displayName = ProgressPrimitive.Root.displayName; 27 | 28 | export { Progress }; 29 | -------------------------------------------------------------------------------- /packages/ui/src/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /packages/ui/src/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /packages/ui/src/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /packages/ui/src/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "../lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /packages/ui/src/text-with-copy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { Button } from "./button"; 4 | import { CheckIcon, ClipboardCopy } from "lucide-react"; 5 | 6 | export const TextWithCopyButton: React.FC<{ 7 | value: string; 8 | className?: string; 9 | alwaysShowCopy?: boolean; 10 | }> = ({ value, className, alwaysShowCopy }) => { 11 | const [isCopied, setIsCopied] = React.useState(false); 12 | 13 | const copyToClipboard = async () => { 14 | try { 15 | await navigator.clipboard.writeText(value); 16 | setIsCopied(true); 17 | setTimeout(() => setIsCopied(false), 2000); // Reset isCopied to false after 2 seconds 18 | } catch (err) { 19 | console.error("Failed to copy: ", err); 20 | } 21 | }; 22 | 23 | return ( 24 |
25 |
{value}
26 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/ui/src/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "../lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |